feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。
- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
(metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)
同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距
依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8013196e3f
commit
ceb66f8c77
265 files changed
+17751
-2483
No files matched your search
@@ -184,6 +184,7 @@ export default function AuditPage() {
|
||||
) : null}
|
||||
</FilterBar>
|
||||
<DataTable<AuditEvent>
|
||||
paginationKey={JSON.stringify({ actorType, action, targetType, targetId })}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={q.isPending}
|
||||
|
||||
@@ -102,6 +102,7 @@ export default function DownloadsPage() {
|
||||
<TransferLine transfer={first?.transfer} />
|
||||
</div>
|
||||
<DataTable
|
||||
paginationKey={state}
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(t) => t.id}
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "~/api/files.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { ContextMenu, type MenuAnchor, type MenuItem } from "~/components/files/context-menu.tsx";
|
||||
import {
|
||||
CopyPathDialog,
|
||||
@@ -91,6 +92,7 @@ import type { WizardEntity } from "~/components/organize-wizard/model.ts";
|
||||
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
@@ -113,6 +115,7 @@ type Dialog =
|
||||
| { kind: "refresh"; items: FileEntry[] }
|
||||
| { kind: "scrape"; items: FileEntry[] }
|
||||
| { kind: "wizard"; source: OrganizeSource }
|
||||
| { kind: "wizardSource"; source: Extract<OrganizeSource, { kind: "files" }> }
|
||||
| { kind: "shortcuts" };
|
||||
|
||||
/** 宽布局:内容区不受全站最大宽度限制,目录树贴着侧边栏(layout.tsx 读 handle.layout) */
|
||||
@@ -325,6 +328,13 @@ export default function FilesPage() {
|
||||
const list = useFileList(listQ);
|
||||
const pages = useMemo(() => list.data?.pages ?? [], [list.data]);
|
||||
const entries = useMemo(() => pages.flatMap((p) => p.entries), [pages]);
|
||||
const paged = useListPagination(entries, {
|
||||
hasMore: list.hasNextPage,
|
||||
onLoadMore: () => list.fetchNextPage(),
|
||||
loadingMore: list.isFetchingNextPage,
|
||||
error: list.error,
|
||||
resetKey: JSON.stringify({ rootId, path, q, sort, order, filter, hidden }),
|
||||
});
|
||||
const first = pages[0];
|
||||
const keyOf = useCallback((e: FileEntry) => refKey({ root: rootId, path: e.path }), [rootId]);
|
||||
|
||||
@@ -668,8 +678,9 @@ export default function FilesPage() {
|
||||
} else {
|
||||
body = (
|
||||
<FileList
|
||||
key={`${rootId}|${path}|${paged.pagination.page}|${paged.pagination.pageSize}`}
|
||||
gridRef={gridRef}
|
||||
entries={entries}
|
||||
entries={paged.items ?? []}
|
||||
keyOf={keyOf}
|
||||
selection={sel}
|
||||
onSelection={setSel}
|
||||
@@ -682,7 +693,7 @@ export default function FilesPage() {
|
||||
opState={(e, op) => (root ? opDisabledReason(op, e, root) : null)}
|
||||
onDragRows={onDragRows}
|
||||
onDropToDir={(dir, ev) => onDropTo({ root: rootId, path: dir.path }, ev)}
|
||||
hasMore={!!list.hasNextPage}
|
||||
hasMore={false}
|
||||
onLoadMore={() => void list.fetchNextPage()}
|
||||
loadingMore={list.isFetchingNextPage}
|
||||
mobile={mobile}
|
||||
@@ -1093,6 +1104,7 @@ export default function FilesPage() {
|
||||
) : null}
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">{body}</div>
|
||||
{!list.isPending && root ? <Pagination {...paged.pagination} /> : null}
|
||||
|
||||
{/* 状态栏 */}
|
||||
{root && !offline && root.health !== "offline" && !mobile ? (
|
||||
|
||||
@@ -25,6 +25,7 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx";
|
||||
import { runTitle } from "~/components/tasks/labels.ts";
|
||||
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
@@ -34,8 +35,7 @@ import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 首页只回答三件事(DEAI-2):在忙什么、要我处理什么、最近入库了什么;顶部一行一句话状态。
|
||||
// 运行状态、计划、缓存参数、收录版本满足率不在这里,只在出问题时写进状态行。
|
||||
// 首页概览:自动化、存储与下载器状态,以及当前工作、待处理事项和最近入库。
|
||||
|
||||
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
|
||||
const DASHBOARD_EVENTS = [
|
||||
@@ -335,6 +335,8 @@ export default function Home() {
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{q.data ? <StatusLine d={q.data} /> : null}
|
||||
<HomeRuntime downloaders={q.data?.sources.downloaders} />
|
||||
{q.isPending ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{["a", "b", "c"].map((k) => (
|
||||
@@ -346,14 +348,11 @@ export default function Home() {
|
||||
) : q.isError ? (
|
||||
<ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
|
||||
) : (
|
||||
<>
|
||||
<StatusLine d={q.data} />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Busy w={q.data.currentWork} />
|
||||
<NeedsMe n={q.data.needsAttention} />
|
||||
<RecentImports d={q.data} />
|
||||
</div>
|
||||
</>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Busy w={q.data.currentWork} />
|
||||
<NeedsMe n={q.data.needsAttention} />
|
||||
<RecentImports d={q.data} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
|
||||
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
|
||||
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
@@ -21,6 +22,7 @@ import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-a
|
||||
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
@@ -444,6 +446,14 @@ export default function InboxPage() {
|
||||
const list = useDecisions({ status: status || undefined, kind: kind || undefined, minSeverity });
|
||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||
|
||||
const paged = useListPagination(rows, {
|
||||
hasMore: list.hasNextPage,
|
||||
onLoadMore: () => list.fetchNextPage(),
|
||||
loadingMore: list.isFetchingNextPage,
|
||||
error: list.error,
|
||||
resetKey: JSON.stringify({ status, kind, minSeverity }),
|
||||
});
|
||||
|
||||
// SSE:decision.created / decision.updated → 刷新(合并 500ms 内的多条事件)
|
||||
const qc = useQueryClient();
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -478,7 +488,7 @@ export default function InboxPage() {
|
||||
// 按问题类型 + 作品 / 来源合并;组内按严重度、时间
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<string, Group>();
|
||||
for (const d of rows ?? []) {
|
||||
for (const d of paged.items ?? []) {
|
||||
const entityId = entityOf(d);
|
||||
const source = entityId ? null : sourceGroupOf(d);
|
||||
const key = `${entityId ?? (source ? `site:${source.siteId}` : `subject:${d.subjectType ?? "none"}`)}|${d.kind}`;
|
||||
@@ -488,7 +498,7 @@ export default function InboxPage() {
|
||||
}
|
||||
const maxSev = (g: Group) => Math.max(...g.items.map((d) => SEV_RANK[d.severity]));
|
||||
return [...m.values()].sort((a, b) => maxSev(b) - maxSev(a) || b.items.length - a.items.length);
|
||||
}, [rows]);
|
||||
}, [paged.items]);
|
||||
|
||||
const isOpenView = !status;
|
||||
// 折叠状态:缺省前 OPEN_GROUPS 组展开;用户点过的组按用户的来
|
||||
@@ -614,20 +624,9 @@ export default function InboxPage() {
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{list.hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => list.fetchNextPage()}
|
||||
disabled={list.isFetchingNextPage}
|
||||
>
|
||||
{list.isFetchingNextPage ? "加载中…" : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{rows?.length || list.hasNextPage ? <Pagination {...paged.pagination} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PaginatedList } from "~/components/console/paginated-list.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { DROPPED_STATUS, droppedReason, PURPOSE_LABEL } from "~/components/downloads/plan-format.ts";
|
||||
@@ -143,8 +144,8 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{bindings.length ? (
|
||||
<ul className="space-y-2">
|
||||
{bindings.map((b) => {
|
||||
<PaginatedList rows={bindings} as="ul" label="目录绑定" resetKey={detail.entity.id} className="space-y-2">
|
||||
{(b) => {
|
||||
const root = rootOf(b.libraryRootId);
|
||||
return (
|
||||
<li key={b.id} className="rounded-lg border border-border/70 px-3 py-2">
|
||||
@@ -161,39 +162,41 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}}
|
||||
</PaginatedList>
|
||||
) : null}
|
||||
{detail.assets.map((a) => (
|
||||
<div key={a.asset.id} className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{deliveryLine(a.asset) || "未知规格"}</span>
|
||||
{a.asset.status !== "active" ? <StatusBadge tone="warning">{a.asset.status}</StatusBadge> : null}
|
||||
{a.asset.verifiedAt ? null : <StatusBadge>未核验</StatusBadge>}
|
||||
<PaginatedList rows={detail.assets} label="文件资产" resetKey={detail.entity.id} className="space-y-4">
|
||||
{(a) => (
|
||||
<div key={a.asset.id} className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{deliveryLine(a.asset) || "未知规格"}</span>
|
||||
{a.asset.status !== "active" ? <StatusBadge tone="warning">{a.asset.status}</StatusBadge> : null}
|
||||
{a.asset.verifiedAt ? null : <StatusBadge>未核验</StatusBadge>}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{a.locations.map((l) => (
|
||||
<li key={l.id} className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{l.locationKind !== "library" ? (
|
||||
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
|
||||
) : null}
|
||||
{l.status !== "available" ? <Tone v={LOCATION_STATUS[l.status]} /> : null}
|
||||
{l.libraryRootId ? (
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{rootOf(l.libraryRootId)?.name ?? "媒体库目录"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 font-mono text-meta break-all text-muted-foreground">
|
||||
{l.absolutePath ?? l.relativePath}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
{a.locations.length === 0 ? <li className="text-meta text-muted-foreground">没有登记位置</li> : null}
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{a.locations.map((l) => (
|
||||
<li key={l.id} className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{l.locationKind !== "library" ? (
|
||||
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
|
||||
) : null}
|
||||
{l.status !== "available" ? <Tone v={LOCATION_STATUS[l.status]} /> : null}
|
||||
{l.libraryRootId ? (
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{rootOf(l.libraryRootId)?.name ?? "媒体库目录"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 font-mono text-meta break-all text-muted-foreground">
|
||||
{l.absolutePath ?? l.relativePath}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
{a.locations.length === 0 ? <li className="text-meta text-muted-foreground">没有登记位置</li> : null}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</PaginatedList>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
@@ -226,11 +229,9 @@ function IdentityPanel({ detail }: { detail: LibraryDetail }) {
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{list.map((i) => (
|
||||
<IdentityRow key={i.id} i={i} />
|
||||
))}
|
||||
</ul>
|
||||
<PaginatedList rows={list} as="ul" label="外部身份" resetKey={detail.entity.id} className="space-y-2">
|
||||
{(i) => <IdentityRow key={i.id} i={i} />}
|
||||
</PaginatedList>
|
||||
)}
|
||||
<IdentityDialog
|
||||
entityId={detail.entity.id}
|
||||
@@ -530,8 +531,13 @@ function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUn
|
||||
const assetOf = (id: string | null) => detail.assets.find((a) => a.asset.id === id);
|
||||
return (
|
||||
<Panel title="收录版本" description="每个目标单独判断硬条件、软偏好和升级">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{detail.slots.map((s) => {
|
||||
<PaginatedList
|
||||
rows={detail.slots}
|
||||
label="收录版本"
|
||||
resetKey={detail.entity.id}
|
||||
className="grid gap-3 xl:grid-cols-2"
|
||||
>
|
||||
{(s) => {
|
||||
const cur = assetOf(s.currentAssetId);
|
||||
const unit = units.find((u) => u.id === s.contentUnitId);
|
||||
return (
|
||||
@@ -543,8 +549,8 @@ function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUn
|
||||
candidates={detail.candidates.filter((c) => c.slotId === s.id).slice(0, 5)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}}
|
||||
</PaginatedList>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -747,8 +753,14 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
||||
}
|
||||
>
|
||||
{rows.length > 1 ? (
|
||||
<fieldset aria-label="按收录版本看状态" className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
|
||||
{rows.map((r, i) => (
|
||||
<PaginatedList
|
||||
rows={rows}
|
||||
as="fieldset"
|
||||
label="按收录版本看状态"
|
||||
resetKey={e.id}
|
||||
className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
|
||||
>
|
||||
{(r, i) => (
|
||||
<button
|
||||
key={r.slotId ?? "any"}
|
||||
type="button"
|
||||
@@ -762,8 +774,8 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
</PaginatedList>
|
||||
) : null}
|
||||
{seasonNums.length === 0 ? (
|
||||
<EmptyState
|
||||
@@ -774,8 +786,8 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{seasonNums.map((n) => {
|
||||
<PaginatedList rows={seasonNums} label="季列表" resetKey={`${e.id}:${rowIdx}`} className="space-y-2">
|
||||
{(n) => {
|
||||
const su = seasonUnitOf(n);
|
||||
const eps = episodes.filter((u) => u.seasonNumber === n);
|
||||
const total = su?.episodeTotal ?? (eps.length || null);
|
||||
@@ -810,8 +822,15 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
||||
{eps.length === 0 ? (
|
||||
<p className="text-meta text-muted-foreground">这一季还没有集信息。</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{eps.map((u) => {
|
||||
<PaginatedList
|
||||
rows={eps}
|
||||
as="ul"
|
||||
compact
|
||||
label={`第 ${n} 季集列表`}
|
||||
resetKey={`${e.id}:${n}:${rowIdx}`}
|
||||
className="divide-y divide-border/60"
|
||||
>
|
||||
{(u) => {
|
||||
const v = (f: string) => finalOf(u, f)?.value;
|
||||
const str = (x: unknown) => (typeof x === "string" && x ? x : null);
|
||||
const num = (x: unknown) => (typeof x === "number" ? x : null);
|
||||
@@ -843,13 +862,13 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}}
|
||||
</PaginatedList>
|
||||
)}
|
||||
</SeasonSection>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}}
|
||||
</PaginatedList>
|
||||
)}
|
||||
<UnplacedVersions versions={versions.data?.unplaced ?? []} />
|
||||
<UnitFieldDialog entityId={e.id} unit={editing} onOpenChange={(o) => !o && setEditing(null)} />
|
||||
@@ -949,8 +968,14 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
{dropped.length ? (
|
||||
<div className={cn("px-4 py-3 sm:px-5", detail.downloads.length ? "border-t border-border/60" : "")}>
|
||||
<p className="mb-2 text-meta text-muted-foreground">没提交到下载器</p>
|
||||
<ul className="space-y-2">
|
||||
{dropped.map(({ plan, releaseTitle }) => {
|
||||
<PaginatedList
|
||||
rows={dropped}
|
||||
as="ul"
|
||||
label="未提交下载计划"
|
||||
resetKey={detail.entity.id}
|
||||
className="space-y-2"
|
||||
>
|
||||
{({ plan, releaseTitle }) => {
|
||||
const st = DROPPED_STATUS[plan.status as keyof typeof DROPPED_STATUS] ?? DROPPED_STATUS.failed;
|
||||
const why = droppedReason(plan.statusReason);
|
||||
return (
|
||||
@@ -977,15 +1002,15 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}}
|
||||
</PaginatedList>
|
||||
</div>
|
||||
) : null}
|
||||
{manual.length ? (
|
||||
<div className="border-t border-border/60 px-4 py-3 sm:px-5">
|
||||
<p className="mb-2 text-meta text-muted-foreground">其他整理记录</p>
|
||||
<ul className="space-y-1.5">
|
||||
{manual.map((r) => (
|
||||
<PaginatedList rows={manual} as="ul" label="其他整理记录" resetKey={detail.entity.id} className="space-y-1.5">
|
||||
{(r) => (
|
||||
<li key={r.id} className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Tone v={ORG_STATUS[r.status]} />
|
||||
<Link
|
||||
@@ -1000,8 +1025,8 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
{r.fileTotal} 个文件 · {formatDateTime(r.createdAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</PaginatedList>
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
@@ -201,6 +201,7 @@ function WorksView() {
|
||||
<FilterSelect label="状态" value={filter ?? ""} onChange={(v) => set("filter", v)} options={FILTERS} />
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
paginationKey={sp.toString()}
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.entity.id}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { mediaServers } from "~/api/instances.ts";
|
||||
import { useMediaServerInventory, useTmdbMedia, useTmdbSeason } from "~/api/search.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
||||
import { PaginatedList } from "~/components/console/paginated-list.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { MEDIA_KIND } from "~/components/library/labels.ts";
|
||||
import { Panel } from "~/components/library/parts.tsx";
|
||||
@@ -284,8 +285,8 @@ function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
|
||||
const seasons = [...d.seasons].sort((a, b) => (a.seasonNumber || 999) - (b.seasonNumber || 999));
|
||||
return (
|
||||
<Panel title="季与集" description={d.localEntityId ? "本地一列是已入库集数" : "不在媒体库"}>
|
||||
<div className="space-y-2">
|
||||
{seasons.map((s) => (
|
||||
<PaginatedList rows={seasons} label="季列表" resetKey={d.externalId} className="space-y-2">
|
||||
{(s) => (
|
||||
<SeasonSection
|
||||
key={s.seasonNumber}
|
||||
s={{
|
||||
@@ -307,8 +308,8 @@ function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
|
||||
>
|
||||
<SeasonEpisodes tvId={d.externalId} season={s.seasonNumber} />
|
||||
</SeasonSection>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PaginatedList>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -319,8 +320,15 @@ function SeasonEpisodes({ tvId, season }: { tvId: string; season: number }) {
|
||||
if (q.isError) return <ErrorState title="季详情加载失败" error={q.error} onRetry={() => q.refetch()} />;
|
||||
if (!q.data.episodes.length) return <EmptyState plain title="TMDB 还没有这一季的集信息" className="py-6" />;
|
||||
return (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{q.data.episodes.map((e) => (
|
||||
<PaginatedList
|
||||
rows={q.data.episodes}
|
||||
as="ul"
|
||||
compact
|
||||
label={`第 ${season} 季集列表`}
|
||||
resetKey={`${tvId}:${season}`}
|
||||
className="divide-y divide-border/60"
|
||||
>
|
||||
{(e) => (
|
||||
<EpisodeRow
|
||||
key={e.episodeNumber}
|
||||
e={{
|
||||
@@ -335,7 +343,7 @@ function SeasonEpisodes({ tvId, season }: { tvId: string; season: number }) {
|
||||
voteAverage: e.voteAverage,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</PaginatedList>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { PaginatedList } from "~/components/console/paginated-list.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { CoverPlaceholder } from "~/components/library/parts.tsx";
|
||||
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
|
||||
import { canReorganize, isRunning } from "~/components/organize/record-parts.ts";
|
||||
import { OrganizeScrapeHistory } from "~/components/organize/organize-scrape-history.tsx";
|
||||
import { canReorganize, isRunning, recordFileProgress } from "~/components/organize/record-parts.ts";
|
||||
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { actorTypeLabel } from "~/lib/labels.ts";
|
||||
@@ -130,7 +132,7 @@ export default function OrganizeRecordPage() {
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/organize">
|
||||
<Link to="/console/organize?tab=records">
|
||||
<ArrowLeft />
|
||||
全部记录
|
||||
</Link>
|
||||
@@ -199,6 +201,12 @@ export default function OrganizeRecordPage() {
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
{recordFileProgress(r) ? (
|
||||
<p className="break-all text-caption text-muted-foreground">
|
||||
已完成 {recordFileProgress(r)?.completed}/{recordFileProgress(r)?.total} 个文件
|
||||
{recordFileProgress(r)?.current ? ` · 当前文件:${recordFileProgress(r)?.current}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{r.reason ? (
|
||||
<p
|
||||
className={cn(
|
||||
@@ -260,17 +268,22 @@ export default function OrganizeRecordPage() {
|
||||
文件明细({r.files?.length ?? 0})
|
||||
</h2>
|
||||
{r.files?.length ? (
|
||||
<ul className="divide-y divide-border/60 border-t border-border/60">
|
||||
{r.files.map((f) => (
|
||||
<FileRow key={f.id} f={f} />
|
||||
))}
|
||||
</ul>
|
||||
<PaginatedList
|
||||
rows={r.files}
|
||||
label="整理文件明细"
|
||||
resetKey={r.id}
|
||||
as="ul"
|
||||
className="divide-y divide-border/60 border-t border-border/60"
|
||||
>
|
||||
{(f) => <FileRow key={f.id} f={f} />}
|
||||
</PaginatedList>
|
||||
) : (
|
||||
<p className="px-4 pb-4 text-caption text-muted-foreground">
|
||||
{r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
<OrganizeScrapeHistory recordId={r.id} />
|
||||
</div>
|
||||
)}
|
||||
{r ? (
|
||||
|
||||
@@ -30,14 +30,18 @@ import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { useQueryState } from "~/components/console/use-query-state.ts";
|
||||
import { Checkbox } from "~/components/files/file-list.tsx";
|
||||
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
|
||||
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
|
||||
import { canReorganize, isRunning, recordTitle } from "~/components/organize/record-parts.ts";
|
||||
import { OrganizeQueue } from "~/components/organize/organize-queue.tsx";
|
||||
import { canReorganize, isRunning, recordFileProgress, recordTitle } from "~/components/organize/record-parts.ts";
|
||||
import { SourceSelection } from "~/components/organize/source-selection.tsx";
|
||||
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { reasonLine } from "~/lib/reasons.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
@@ -65,11 +69,18 @@ const when = (r: Rec) => r.finishedAt ?? r.startedAt ?? r.createdAt;
|
||||
|
||||
/** 状态:成功不加徽章,其余徽章 + 一句原因 */
|
||||
function StatusCell({ r }: { r: Rec }) {
|
||||
const progress = recordFileProgress(r);
|
||||
if (r.status === "succeeded" && !r.reason) return <span className="text-muted-foreground">成功</span>;
|
||||
const line = reasonLine(r.reason);
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
||||
{progress && isRunning(r) ? (
|
||||
<span className="max-w-52 text-meta text-muted-foreground">
|
||||
已完成 {progress.completed}/{progress.total} 个文件
|
||||
{progress.current ? ` · 当前:${progress.current.split("/").pop()}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{line ? (
|
||||
<span className="line-clamp-2 max-w-52 text-meta text-muted-foreground" title={line}>
|
||||
{line}
|
||||
@@ -255,6 +266,17 @@ export default function OrganizePage() {
|
||||
const { set: setFilter, values: v } = f;
|
||||
const [qInput, setQInput] = useState(v.q);
|
||||
const entityId = params.get("mediaEntityId") ?? undefined;
|
||||
const legacyRecords = !!(entityId || params.get("ids") || v.q || v.trigger || v.kind || v.time || v.status);
|
||||
const tab = params.get("tab") === "records" || (!params.get("tab") && legacyRecords) ? "records" : "queue";
|
||||
const setTab = (next: string) =>
|
||||
setParams(
|
||||
(previous) => {
|
||||
const updated = new URLSearchParams(previous);
|
||||
updated.set("tab", next);
|
||||
return updated;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
// 整理向导执行多组后跳来:?ids=<记录 id,…> 只看这几条(执行中每 3 秒刷新)
|
||||
const ids = (params.get("ids") ?? "")
|
||||
.split(",")
|
||||
@@ -270,6 +292,8 @@ export default function OrganizePage() {
|
||||
// 时间筛选换成具体时刻只在切换时算一次,避免每次渲染查询键都变
|
||||
const since = useMemo(() => sinceOf(v.time), [v.time]);
|
||||
const base: Omit<RecordFilters, "status"> = {
|
||||
phase: v.status === "running" || v.status === "queued" ? undefined : "history",
|
||||
organizeOnly: true,
|
||||
trigger: (v.trigger || undefined) as OrganizationTrigger | undefined,
|
||||
kind: (v.kind || undefined) as RecordFilters["kind"],
|
||||
since,
|
||||
@@ -284,18 +308,35 @@ export default function OrganizePage() {
|
||||
if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] });
|
||||
});
|
||||
const pinned = ids.length > 0;
|
||||
const rows = pinned ? byIds.data?.items : records.data?.pages.flatMap((p) => p.items);
|
||||
const allRows = pinned ? byIds.data?.items : records.data?.pages.flatMap((p) => p.items);
|
||||
const paged = useListPagination(allRows, {
|
||||
hasMore: !pinned && records.hasNextPage,
|
||||
onLoadMore: () => records.fetchNextPage(),
|
||||
loadingMore: records.isFetchingNextPage,
|
||||
error: pinned ? byIds.error : records.error,
|
||||
resetKey: JSON.stringify({ ...base, status, ids }),
|
||||
});
|
||||
const rows = paged.items;
|
||||
const filtered = !!(v.status || v.trigger || v.kind || v.time || v.q) || !!entityId || pinned;
|
||||
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
const [choosingSource, setChoosingSource] = useState(false);
|
||||
const [wizard, setWizard] = useState<OrganizeSource | null>(null);
|
||||
const [toDelete, setToDelete] = useState<Rec[] | null>(null);
|
||||
const grouped = v.group === "entity";
|
||||
|
||||
const selRows = (rows ?? []).filter((r) => sel.has(r.id));
|
||||
const allPicked = !!rows?.length && selRows.length === rows.length;
|
||||
const pickAll = () => setSel(allPicked ? new Set() : new Set(rows?.map((r) => r.id)));
|
||||
const selRows = (allRows ?? []).filter((r) => sel.has(r.id));
|
||||
const allPicked = !!rows?.length && rows.every((r) => sel.has(r.id));
|
||||
const pickAll = () =>
|
||||
setSel((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const row of rows ?? []) {
|
||||
if (allPicked) next.delete(row.id);
|
||||
else next.add(row.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const toggle = (id: string) =>
|
||||
setSel((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -327,7 +368,14 @@ export default function OrganizePage() {
|
||||
|
||||
const pickColumn: DataColumn<Rec> = {
|
||||
key: "pick",
|
||||
header: <Checkbox checked={allPicked} partial={selRows.length > 0 && !allPicked} label="全选" onToggle={pickAll} />,
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={allPicked}
|
||||
partial={!!rows?.some((r) => sel.has(r.id)) && !allPicked}
|
||||
label="全选本页"
|
||||
onToggle={pickAll}
|
||||
/>
|
||||
),
|
||||
className: "w-10",
|
||||
cell: (r) => <Checkbox checked={sel.has(r.id)} label={`选择 ${recordTitle(r)}`} onToggle={() => toggle(r.id)} />,
|
||||
};
|
||||
@@ -413,7 +461,7 @@ export default function OrganizePage() {
|
||||
onOpen={() => navigate(`/console/organize/${r.id}`)}
|
||||
onReorganize={() => reorganize([r])}
|
||||
onDelete={() => setToDelete([r])}
|
||||
onOnlyEntity={() => r.media && setParams({ mediaEntityId: r.media.entityId })}
|
||||
onOnlyEntity={() => r.media && setParams({ tab: "records", mediaEntityId: r.media.entityId })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -425,138 +473,163 @@ export default function OrganizePage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="整理记录"
|
||||
title="整理"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant={selecting ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
|
||||
>
|
||||
{selecting ? <X /> : <SquareCheck />}
|
||||
{selecting ? "退出批量" : "批量"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate("/console/files")}>
|
||||
{tab === "records" ? (
|
||||
<Button
|
||||
variant={selecting ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
|
||||
>
|
||||
{selecting ? <X /> : <SquareCheck />}
|
||||
{selecting ? "退出批量" : "批量"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" onClick={() => setChoosingSource(true)}>
|
||||
<FolderInput />
|
||||
手动整理
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<FilterBar
|
||||
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setQInput("");
|
||||
setParams(v.group ? { group: v.group } : {});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant={grouped ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-pressed={grouped}
|
||||
onClick={() => setFilter("group", grouped ? "" : "entity")}
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList aria-label="整理视图">
|
||||
<TabsTrigger value="queue">整理队列</TabsTrigger>
|
||||
<TabsTrigger value="records">整理记录</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="queue">
|
||||
<OrganizeQueue />
|
||||
</TabsContent>
|
||||
<TabsContent value="records">
|
||||
<FilterBar
|
||||
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setQInput("");
|
||||
setParams(v.group ? { tab: "records", group: v.group } : { tab: "records" });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant={grouped ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-pressed={grouped}
|
||||
onClick={() => setFilter("group", grouped ? "" : "entity")}
|
||||
>
|
||||
<ListTree />
|
||||
按作品分组
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListTree />
|
||||
按作品分组
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={v.status}
|
||||
onChange={(x) => setFilter("status", x)}
|
||||
options={Object.entries(RECORD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={v.status}
|
||||
onChange={(x) => setFilter("status", x)}
|
||||
options={Object.entries(RECORD_STATUS_LABEL)
|
||||
.filter(([value]) => value !== "queued" && value !== "running")
|
||||
.map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="类型"
|
||||
value={v.kind}
|
||||
onChange={(x) => setFilter("kind", x)}
|
||||
options={Object.entries(KIND_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="来源"
|
||||
value={v.trigger}
|
||||
onChange={(x) => setFilter("trigger", x)}
|
||||
options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="时间"
|
||||
value={v.time}
|
||||
allLabel="不限"
|
||||
onChange={(x) => setFilter("time", x)}
|
||||
options={Object.entries(TIME_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
{entityId ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setParams({ tab: "records" })}>
|
||||
只看该作品
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
{pinned ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setParams({ tab: "records" })}>
|
||||
本次整理的 {ids.length} 条
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
{pinned ? null : <StatsLine filters={base} status={v.status} onStatus={(s) => setFilter("status", s)} />}
|
||||
{selecting ? (
|
||||
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
|
||||
<span className="text-sm text-foreground tabular-nums">已选 {selRows.length}</span>
|
||||
<Button variant="ghost" size="sm" onClick={pickAll}>
|
||||
{allPicked ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
<span className="ml-auto flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={!selReorg} onClick={() => reorganize(selRows)}>
|
||||
<Wand2 />
|
||||
{selReorg && selReorg < selRows.length ? `重新整理 ${selReorg} 条` : "重新整理"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
disabled={!selDeletable.length}
|
||||
onClick={() => setToDelete(selDeletable)}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
pagination={paged.pagination}
|
||||
rowKey={(r) => r.id}
|
||||
loading={pinned ? byIds.isPending : records.isPending}
|
||||
error={pinned ? byIds.error : records.error}
|
||||
onRetry={() => void (pinned ? byIds.refetch() : records.refetch())}
|
||||
onRowClick={(r) => (selecting ? toggle(r.id) : navigate(`/console/organize/${r.id}`))}
|
||||
selectedKey={null}
|
||||
hasMore={!pinned && records.hasNextPage}
|
||||
onLoadMore={() => void records.fetchNextPage()}
|
||||
loadingMore={records.isFetchingNextPage}
|
||||
groupBy={
|
||||
grouped
|
||||
? {
|
||||
key: (r) => r.media?.entityId ?? "",
|
||||
label: (k, g) => {
|
||||
const m = g[0]?.media;
|
||||
return k && m ? `${m.title}${m.year ? ` (${m.year})` : ""}` : "未识别";
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
empty={{
|
||||
icon: FileStack,
|
||||
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
|
||||
description: filtered ? undefined : "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{choosingSource ? (
|
||||
<SourceSelection
|
||||
onClose={() => setChoosingSource(false)}
|
||||
onSelect={(source) => {
|
||||
setChoosingSource(false);
|
||||
setWizard(source);
|
||||
}}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="类型"
|
||||
value={v.kind}
|
||||
onChange={(x) => setFilter("kind", x)}
|
||||
options={Object.entries(KIND_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="来源"
|
||||
value={v.trigger}
|
||||
onChange={(x) => setFilter("trigger", x)}
|
||||
options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="时间"
|
||||
value={v.time}
|
||||
allLabel="不限"
|
||||
onChange={(x) => setFilter("time", x)}
|
||||
options={Object.entries(TIME_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
{entityId ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setParams({})}>
|
||||
只看该作品
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
{pinned ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setParams({})}>
|
||||
本次整理的 {ids.length} 条
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
{pinned ? null : <StatsLine filters={base} status={v.status} onStatus={(s) => setFilter("status", s)} />}
|
||||
{selecting ? (
|
||||
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
|
||||
<span className="text-sm text-foreground tabular-nums">已选 {selRows.length}</span>
|
||||
<Button variant="ghost" size="sm" onClick={pickAll}>
|
||||
{allPicked ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
<span className="ml-auto flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={!selReorg} onClick={() => reorganize(selRows)}>
|
||||
<Wand2 />
|
||||
{selReorg && selReorg < selRows.length ? `重新整理 ${selReorg} 条` : "重新整理"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
disabled={!selDeletable.length}
|
||||
onClick={() => setToDelete(selDeletable)}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={pinned ? byIds.isPending : records.isPending}
|
||||
error={pinned ? byIds.error : records.error}
|
||||
onRetry={() => void (pinned ? byIds.refetch() : records.refetch())}
|
||||
onRowClick={(r) => (selecting ? toggle(r.id) : navigate(`/console/organize/${r.id}`))}
|
||||
selectedKey={null}
|
||||
hasMore={!pinned && records.hasNextPage}
|
||||
onLoadMore={() => void records.fetchNextPage()}
|
||||
loadingMore={records.isFetchingNextPage}
|
||||
groupBy={
|
||||
grouped
|
||||
? {
|
||||
key: (r) => r.media?.entityId ?? "",
|
||||
label: (k, g) => {
|
||||
const m = g[0]?.media;
|
||||
return k && m ? `${m.title}${m.year ? ` (${m.year})` : ""}` : "未识别";
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
empty={{
|
||||
icon: FileStack,
|
||||
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
|
||||
description: filtered ? undefined : "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
|
||||
}}
|
||||
/>
|
||||
{wizard ? (
|
||||
<OrganizeWizard
|
||||
source={wizard}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ReleaseFilterState,
|
||||
ReleaseResults,
|
||||
} from "~/components/search/release-results.tsx";
|
||||
import { SearchHistory, useSearchHistory } from "~/components/search/search-history.tsx";
|
||||
import { ActionsSection, WorksSection } from "~/components/search/search-sections.tsx";
|
||||
import { SitePickerDialog } from "~/components/search/site-picker-dialog.tsx";
|
||||
import { releaseSearchPath } from "~/components/search/work-card.tsx";
|
||||
@@ -30,6 +31,11 @@ export default function SearchPage() {
|
||||
const siteIds = params.get("in") ?? "";
|
||||
const target = useMemo(() => (work ? parseWorkRef(work) : null), [work]);
|
||||
const [input, setInput] = useState(q);
|
||||
const history = useSearchHistory();
|
||||
const remember = history.add;
|
||||
useEffect(() => {
|
||||
if (q) remember(q);
|
||||
}, [q, remember]);
|
||||
const [picking, setPicking] = useState(false);
|
||||
useEffect(() => setInput(q), [q]);
|
||||
const f = useQueryState(RELEASE_FILTER_KEYS);
|
||||
@@ -61,6 +67,13 @@ export default function SearchPage() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<SearchHistory
|
||||
onSearch={(term) => {
|
||||
setInput(term);
|
||||
setParams(new URLSearchParams({ q: term }));
|
||||
}}
|
||||
/>
|
||||
|
||||
{!q ? (
|
||||
<EmptyState icon={Search} title="输入作品名开始搜索" description="也可以在任意页面按 Ctrl/⌘ K 打开搜索。" />
|
||||
) : target ? (
|
||||
|
||||
@@ -25,6 +25,7 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { QuickFilterBar } from "~/components/search/quick-filter-bar.tsx";
|
||||
import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/search/quick-filter-state.ts";
|
||||
import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx";
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
} from "~/components/sites/site-fields.ts";
|
||||
import { SiteOverview } from "~/components/sites/site-overview.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx";
|
||||
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
|
||||
@@ -166,6 +168,13 @@ export default function SiteWorkbenchPage() {
|
||||
const quickHidden = selectedCount(quick) > 0 && (pages[0]?.unfilteredTotal ?? 0) > 0 && quickTotal === 0;
|
||||
const now = Date.now();
|
||||
const rows = all.filter((r) => localMatch(r, local, now));
|
||||
const paged = useListPagination(rows, {
|
||||
hasMore: releases.hasNextPage,
|
||||
onLoadMore: () => releases.fetchNextPage(),
|
||||
loadingMore: releases.isFetchingNextPage,
|
||||
error: releases.error,
|
||||
resetKey: JSON.stringify({ siteId, filters, local, live }),
|
||||
});
|
||||
const localActive = Object.values(local).some(Boolean);
|
||||
const inventory = useInventory(all.flatMap((r) => (r.entity ? [r.entity.id] : [])));
|
||||
const categories = useMemo(() => {
|
||||
@@ -407,7 +416,7 @@ export default function SiteWorkbenchPage() {
|
||||
}
|
||||
description={
|
||||
all.length
|
||||
? "上传时间只筛选已加载的结果。加载更多或放宽条件。"
|
||||
? "上传时间只筛选已加载的结果。翻页加载后续资源或放宽条件。"
|
||||
: quickHidden
|
||||
? "减少快速筛选条件再看。"
|
||||
: coverage?.liveError
|
||||
@@ -437,7 +446,7 @@ export default function SiteWorkbenchPage() {
|
||||
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
|
||||
</div>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{rows.map((r) => (
|
||||
{paged.items?.map((r) => (
|
||||
<ReleaseRow
|
||||
key={r.id}
|
||||
r={r}
|
||||
@@ -451,18 +460,7 @@ export default function SiteWorkbenchPage() {
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{releases.hasNextPage ? (
|
||||
<div className="flex justify-center border-t border-border/60 px-4 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void releases.fetchNextPage()}
|
||||
disabled={releases.isFetchingNextPage}
|
||||
>
|
||||
{releases.isFetchingNextPage ? "加载中…" : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{all.length > 0 || releases.hasNextPage ? <Pagination {...paged.pagination} /> : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ import { useSites } from "~/api/sites.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { SearchDefaultDialog } from "~/components/sites/search-default-dialog.tsx";
|
||||
import { SiteAddDialog } from "~/components/sites/site-add-dialog.tsx";
|
||||
import { SiteCard, SiteTable } from "~/components/sites/site-card.tsx";
|
||||
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
|
||||
import { needsAttention, type SortKey, sortSites } from "~/components/sites/site-fields.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4);
|
||||
@@ -115,6 +117,8 @@ export default function SitesPage() {
|
||||
sort,
|
||||
);
|
||||
|
||||
const paged = useListPagination(shown, { resetKey: JSON.stringify({ filter, kindFilter, sort }) });
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -205,12 +209,16 @@ export default function SitesPage() {
|
||||
<SiteTable sites={shown} checks={checks} onDialog={setDialog} />
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{shown.map((s) => (
|
||||
{paged.items?.map((s) => (
|
||||
<SiteCard key={s.id} site={s} checks={checks} onDialog={setDialog} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view !== "list" && shown.length > 0 ? (
|
||||
<Pagination {...paged.pagination} className="mt-2 border-t-0 px-0" />
|
||||
) : null}
|
||||
|
||||
{adding ? <SiteAddDialog initialKind={adding === "any" ? null : adding} onClose={() => setAdding(null)} /> : null}
|
||||
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
|
||||
<SearchDefaultDialog open={searchDefaults} onOpenChange={setSearchDefaults} sites={all} />
|
||||
|
||||
@@ -22,6 +22,7 @@ import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { useQueryState } from "~/components/console/use-query-state.ts";
|
||||
import { opts, SUB_ORIGIN } from "~/components/library/labels.ts";
|
||||
import { DeleteSubscriptionsDialog, useSubscriptionOps } from "~/components/subscriptions/actions.tsx";
|
||||
@@ -30,6 +31,7 @@ import { GROUP_LABEL, GROUP_ORDER, SUB_KIND_LABEL } from "~/components/subscript
|
||||
import { SubscriptionCard, SubscriptionRow } from "~/components/subscriptions/overview-item.tsx";
|
||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { legacyRedirectLoader } from "./redirects.ts";
|
||||
|
||||
@@ -143,6 +145,14 @@ export default function SubscriptionsPage() {
|
||||
};
|
||||
const query = useSubscriptionOverview(filters);
|
||||
const items = useMemo(() => query.data?.pages.flatMap((p) => p.items) ?? [], [query.data]);
|
||||
const paged = useListPagination(items, {
|
||||
hasMore: query.hasNextPage,
|
||||
onLoadMore: () => query.fetchNextPage(),
|
||||
loadingMore: query.isFetchingNextPage,
|
||||
error: query.error,
|
||||
resetKey: JSON.stringify(filters),
|
||||
});
|
||||
const visible = paged.items ?? [];
|
||||
const first = query.data?.pages[0];
|
||||
const counts = first?.counts;
|
||||
const allCount = counts ? GROUP_ORDER.reduce((n, g) => n + (counts[g] ?? 0), 0) : undefined;
|
||||
@@ -344,9 +354,19 @@ export default function SubscriptionsPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSel(new Set(sel.size === items.length ? [] : items.map((x) => x.id)))}
|
||||
onClick={() =>
|
||||
setSel((previous) => {
|
||||
const next = new Set(previous);
|
||||
const selected = visible.every((x) => previous.has(x.id));
|
||||
for (const x of visible) {
|
||||
if (selected) next.delete(x.id);
|
||||
else next.add(x.id);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{sel.size === items.length && items.length ? "取消全选" : "全选"}
|
||||
{visible.length > 0 && visible.every((x) => sel.has(x.id)) ? "取消本页全选" : "全选本页"}
|
||||
</Button>
|
||||
<span className="ml-auto flex flex-wrap gap-2">
|
||||
<Button
|
||||
@@ -404,7 +424,7 @@ export default function SubscriptionsPage() {
|
||||
) : grouped ? (
|
||||
<div className="space-y-6">
|
||||
{GROUP_ORDER.map((g) => {
|
||||
const list = items.filter((x) => x.group === g);
|
||||
const list = visible.filter((x) => x.group === g);
|
||||
if (!list.length) return null;
|
||||
const closed = collapsed.has(g);
|
||||
return (
|
||||
@@ -437,15 +457,9 @@ export default function SubscriptionsPage() {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
renderItems(items)
|
||||
renderItems(visible)
|
||||
)}
|
||||
{query.hasNextPage ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="outline" onClick={() => query.fetchNextPage()} disabled={query.isFetchingNextPage}>
|
||||
{query.isFetchingNextPage ? "加载中…" : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{items.length > 0 || query.hasNextPage ? <Pagination {...paged.pagination} /> : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user