feat(web): 记录抽屉与两层导航标准

控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。

- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 14:03:47 +08:00
1 parent dd0f6e2c09
commit 21c55dfb3a
60 files changed
+2768 -2461

No files matched your search

@@ -8,6 +8,7 @@ import { useScrapeRecords } from "~/api/files.ts";
import { ACTION_LABEL, useRecord } from "~/api/records.ts";
import { useRecordsForDownload } from "~/api/tasks.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { RecordLink, taskPath } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { relTime } from "~/components/library/parts.tsx";
@@ -48,19 +49,18 @@ function ScrapeQueueItem({ run }: { run: EngineRun }) {
已写入 {record.written} · 跳过 {record.skipped} · 失败 {record.failed}
</span>
) : null}
<Link
className="ml-auto hover:text-foreground hover:underline"
to={`/console/tasks/${encodeURIComponent(run.id)}`}
>
<Link className="ml-auto hover:text-foreground hover:underline" to={taskPath(run.id)}>
任务详情
</Link>
{record?.organizationRecordId ? (
<Link
<RecordLink
className="hover:text-foreground hover:underline"
to={`/console/organize/${record.organizationRecordId}`}
page="organize"
id={record.organizationRecordId}
extra={{ queue: null }}
>
整理记录
</Link>
</RecordLink>
) : null}
</div>
{detail.error || scrape.error ? (
@@ -116,16 +116,18 @@ function QueueItem({ run }: { run: EngineRun }) {
</span>
) : null}
{run.attempts > 1 ? <span>第 {run.attempts} 次尝试</span> : null}
<Link
className="ml-auto hover:text-foreground hover:underline"
to={`/console/tasks/${encodeURIComponent(run.id)}`}
>
<Link className="ml-auto hover:text-foreground hover:underline" to={taskPath(run.id)}>
任务详情
</Link>
{record ? (
<Link className="hover:text-foreground hover:underline" to={`/console/organize/${record.id}`}>
<RecordLink
className="hover:text-foreground hover:underline"
page="organize"
id={record.id}
extra={{ queue: null }}
>
整理详情
</Link>
</RecordLink>
) : null}
</div>
{detail.error || recordQuery.error || related.error || download.error ? (
@@ -0,0 +1,311 @@
import type { PublicOrganizationFile, PublicPathRef } from "@umt/contracts";
import { ArrowRight, Bot, ChevronRight, Trash2, Wand2 } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { ACTION_LABEL, RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecord } from "~/api/records.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PaginatedList } from "~/components/console/paginated-list.tsx";
import { useStickyId } from "~/components/console/record-sheet.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
import { OrganizeScrapeHistory } from "~/components/organize/organize-scrape-history.tsx";
import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.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";
import { reasonCode, reasonLine } from "~/lib/reasons.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
const FILE_STATUS: Record<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
queued: { label: "排队中", tone: "neutral" },
running: { label: "整理中", tone: "neutral" },
succeeded: { label: "成功", tone: "success" },
failed: { label: "失败", tone: "destructive" },
skipped: { label: "已跳过", tone: "neutral" },
cancelled: { label: "已取消", tone: "neutral" },
};
const ROLE_LABEL = {
main: "主视频",
subtitle: "字幕",
audio: "音轨",
bluray_dir: "原盘目录",
nfo: "NFO",
image: "图片",
other: "其他",
} as const;
/** 路径:失败且没有目标路径时写"未生成目标路径",其余没有路径的写"未整理" */
function PathBlock({ label, p, failed = false }: { label: string; p: PublicPathRef; failed?: boolean }) {
const noTarget = failed && p.path === null;
return (
<div className="min-w-0 flex-1 rounded-lg border border-border/70 px-3 py-2">
<p className="text-meta text-muted-foreground">{label}</p>
<p className="mt-0.5 font-mono text-caption break-all text-foreground">
{p.path ?? (noTarget ? "未生成目标路径" : "未整理")}
</p>
</div>
);
}
function duration(a: string | null, b: string | null) {
if (!a || !b) return null;
const s = (new Date(b).getTime() - new Date(a).getTime()) / 1000;
return s < 60 ? `${s.toFixed(1)} 秒` : `${Math.round(s / 60)} 分钟`;
}
function FileRow({ f }: { f: PublicOrganizationFile }) {
const [open, setOpen] = useState(false);
const st = FILE_STATUS[f.status];
const reason = f.failure ?? f.skipReason;
return (
<li className="min-w-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full min-w-0 cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-base",
open && "rotate-90",
)}
/>
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
<span className="shrink-0 text-meta text-muted-foreground">{ROLE_LABEL[f.role]}</span>
<span className="min-w-0 flex-1 truncate font-mono text-meta text-foreground">
{(f.target.path ?? f.source.path ?? "").split("/").pop()}
</span>
{f.sizeBytes !== null ? (
<span className="hidden shrink-0 text-meta text-muted-foreground tabular-nums sm:inline">
{formatBytes(f.sizeBytes, 1)}
</span>
) : null}
</button>
{open ? (
<div className="space-y-2 px-3 pb-3 pl-9">
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<PathBlock label="整理前" p={f.source} />
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
<PathBlock label="整理后" p={f.target} failed={f.status === "failed"} />
</div>
{reason ? (
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
{f.failure ? "失败" : "跳过"}原因:{reasonLine(reason)}
</p>
) : null}
<p className="text-meta text-muted-foreground">
{ACTION_LABEL[f.mode]} · {formatDateTime(f.startedAt)}
</p>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(reason) },
{ label: "用时", value: duration(f.startedAt, f.finishedAt), mono: false },
{ label: "文件结果 ID", value: f.id },
]}
/>
</div>
) : null}
</li>
);
}
/**
* 整理记录抽屉(console-ia.md §9):整理记录页上 ?open=<记录 id> 打开,不再有独立页。
* 头部:状态与主操作;正文:概况 → 文件明细 → 整理后刮削。
*/
export function OrganizeRecordSheet({ id, onClose }: { id: string | null; onClose: () => void }) {
const shown = useStickyId(id);
const rec = useRecord(shown ?? undefined);
const r = rec.data?.id === shown ? rec.data : undefined;
const [reorganize, setReorganize] = useState(false);
const [removing, setRemoving] = useState(false);
return (
<>
<DetailSheet
open={!!id}
onOpenChange={(o) => !o && onClose()}
width="lg"
title={r?.media ? `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}` : r ? "未关联作品" : "整理记录"}
description={
r ? (
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
<span className="tabular-nums">
{[
`${ACTION_LABEL[r.action]} · ${TRIGGER_LABEL[r.trigger]}`,
r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null,
r.counts.total ? `${r.counts.succeeded}/${r.counts.total} 个文件成功` : null,
r.counts.failed ? `${r.counts.failed} 个失败` : null,
r.counts.skipped ? `${r.counts.skipped} 个跳过` : null,
]
.filter(Boolean)
.join(" · ")}
</span>
</span>
) : undefined
}
actions={
r ? (
<>
{canReorganize(r) ? (
<Button variant="outline" size="sm" onClick={() => setReorganize(true)}>
<Wand2 />
重新整理
</Button>
) : null}
{r.status === "failed" || r.status === "partial" ? (
<Button
variant="outline"
size="sm"
onClick={() =>
openAgentDrawer({
label: `整理记录:${r.media?.title ?? "未关联作品"}`,
recordId: r.id,
entityId: r.media?.entityId,
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
})
}
>
<Bot />
让智能体排查
</Button>
) : null}
{r.media ? (
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("library", r.media.entityId)}>查看作品</Link>
</Button>
) : null}
{!isRunning(r) ? (
<Button variant="outline" size="sm" className="text-destructive" onClick={() => setRemoving(true)}>
<Trash2 />
删除
</Button>
) : null}
</>
) : null
}
>
{rec.isPending ? (
<LoadingState rows={5} />
) : rec.error || !r ? (
<ErrorState plain error={rec.error} onRetry={() => void rec.refetch()} />
) : (
<RecordBody r={r} onReorganize={() => setReorganize(true)} />
)}
</DetailSheet>
{r ? (
<DeleteRecordsDialog
records={[r]}
open={removing}
onOpenChange={setRemoving}
onDone={(removed) => {
if (removed.includes(r.id)) onClose();
}}
/>
) : null}
{reorganize && r ? (
<OrganizeWizard source={{ kind: "record", recordId: r.id }} onClose={() => setReorganize(false)} />
) : null}
</>
);
}
function RecordBody({
r,
onReorganize,
}: {
r: NonNullable<ReturnType<typeof useRecord>["data"]>;
onReorganize: () => void;
}) {
const progress = recordFileProgress(r);
return (
<>
<section className="space-y-4">
{progress ? (
<p className="break-all text-caption text-muted-foreground">
已完成 {progress.completed}/{progress.total} 个文件
{progress.current ? ` · 当前文件:${progress.current}` : ""}
</p>
) : null}
{r.reason ? (
<p
className={cn(
"rounded-lg border px-3 py-2 text-caption break-words",
r.status === "failed"
? "border-destructive/40 bg-destructive/10 text-destructive"
: "border-warning/40 bg-warning/10 text-warning",
)}
>
{reasonLine(r.reason)}
</p>
) : null}
{r.attention ? (
<div className="space-y-2 rounded-lg border border-warning/40 px-3 py-2">
<p className="text-caption text-foreground">{attentionLine(r) ?? "这条记录需要处理"}</p>
<RecordAttentionActions r={r} onReorganize={onReorganize} />
</div>
) : null}
<div className="flex flex-col gap-2">
<PathBlock label="整理前" p={r.source} />
<PathBlock label="整理后" p={r.target} failed={r.status === "failed"} />
</div>
<KvList
items={[
{ label: "开始", value: formatDateTime(r.startedAt) },
{ label: "完成", value: formatDateTime(r.finishedAt) },
{ label: "操作者", value: actorTypeLabel(r.actor.type) },
...(r.download
? [
{
label: "下载任务",
value: `${r.download.name}${r.download.downloaderName ? ` · ${r.download.downloaderName}` : ""}`,
},
]
: []),
]}
/>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(r.reason) },
{ label: "用时", value: duration(r.startedAt, r.finishedAt), mono: false },
{
label: "外部编号",
value: r.media?.externalIds.map((e) => `${e.provider}:${e.entityType}:${e.externalId}`).join(", "),
},
{ label: "操作者 ID", value: r.actor.type !== "system" ? r.actor.id : null },
{ label: "变更序号", value: r.changeSeq },
{ label: "记录 ID", value: r.id, wide: true },
{ label: "任务 ID", value: r.taskId, wide: true },
{ label: "种子 hash", value: r.download?.hash, wide: true },
]}
/>
</section>
<DetailSection title={`文件明细(${r.files?.length ?? 0})`}>
{r.files?.length ? (
<PaginatedList
rows={r.files}
label="整理文件明细"
resetKey={r.id}
as="ul"
className="divide-y divide-border/60 rounded-lg border border-border/60"
>
{(f) => <FileRow key={f.id} f={f} />}
</PaginatedList>
) : (
<p className="text-caption text-muted-foreground">
{r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"}
</p>
)}
</DetailSection>
<OrganizeScrapeHistory recordId={r.id} />
</>
);
}
@@ -2,8 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import { FilesScrapeRecordsResult, type ScrapeFileResult } from "@umt/contracts";
import { Link } from "react-router";
import { apiParsed } from "~/api/parse.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { PaginatedList } from "~/components/console/paginated-list.tsx";
import { taskPath } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { EXPECTED_SCRAPE_SKIPS, SCRAPE_STATUS, scrapeCounts } from "~/components/library/scrape-panel.tsx";
import { reasonLine } from "~/lib/reasons.ts";
@@ -25,8 +27,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
refetchInterval: 5_000,
});
return (
<section className="pg-slab space-y-3 rounded-xl p-4" aria-label="整理后刮削">
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">整理后刮削</h2>
<DetailSection title="整理后刮削">
{query.isPending ? (
<LoadingState rows={1} />
) : query.error ? (
@@ -79,10 +80,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
</details>
) : null}
{record.taskId ? (
<Link
to={`/console/tasks/${encodeURIComponent(record.taskId)}`}
className="inline-block text-meta underline"
>
<Link to={taskPath(record.taskId)} className="inline-block text-meta underline">
查看刮削任务
</Link>
) : null}
@@ -92,6 +90,6 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
) : (
<p className="text-caption text-muted-foreground">没有刮削记录。</p>
)}
</section>
</DetailSection>
);
}