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:
rootandClaude Opus 5.5 committed 2026-10-02 15:47:17 +08:00
1 parent 8013196e3f
commit ceb66f8c77
265 files changed
+17751 -2483

No files matched your search

+1
View File
@@ -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}
+14 -2
View File
@@ -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 ? (
+9 -10
View File
@@ -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>
)}
</>
);
+13 -14
View File
@@ -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}
</>
);
}
+87 -62
View File
@@ -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>
+1
View File
@@ -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>
);
}
+20 -7
View File
@@ -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 ? (
+201 -128
View File
@@ -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}
+13
View File
@@ -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 ? (
+12 -14
View File
@@ -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>
+9 -1
View File
@@ -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} />
+25 -11
View File
@@ -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}
</>
)}