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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user