文案(整理记录与队列):按 human-copy 重写队列面板、删除确认、整理来源选择

- 队列面板与按钮统一叫「队列」,阶段文字去掉「登记媒体库」「NFO / 图片」等重复说明
- 「选择整理策略」改「选择整理来源」(策略是收录策略的词),「来源配置」统一为「整理来源」
- 扫描存量结果只列非零项;失败 Toast 写清是哪个动作失败

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-07 01:49:25 +08:00
1 parent 32bf49a148
commit a15d81360b
8 files changed
+74 -64

No files matched your search

@@ -86,7 +86,7 @@ export function DeleteRecordsDialog({
<input type="checkbox" className="mt-1" checked={dst} onChange={(e) => setDst(e.target.checked)} />
<span>
同时删除媒体库里的文件
<span className="block text-meta text-muted-foreground">整理后放进媒体库的文件,会从媒体库移除。</span>
<span className="block text-meta text-muted-foreground">整理到媒体库目录里的文件。</span>
</span>
</label>
</div>
@@ -127,8 +127,8 @@ function Summary({
src: boolean;
withFiles: boolean;
}) {
if (error) return <p className="text-destructive">{errorMessage(error, "核对失败")}</p>;
if (!plan) return <p className="text-muted-foreground">{pending ? "正在核对要删的文件…" : null}</p>;
if (error) return <p className="text-destructive">{errorMessage(error, "检查失败")}</p>;
if (!plan) return <p className="text-muted-foreground">{pending ? "检查要删的文件…" : null}</p>;
const files = [
plan.sourceFiles ? `${plan.sourceFiles} 个源文件` : null,
plan.targetFiles ? `${plan.targetFiles} 个媒体库文件` : null,
@@ -137,15 +137,15 @@ function Summary({
<div className="space-y-1 text-muted-foreground">
{files.length ? (
<p>
{files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以在回收站还原。
{files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以还原。
{plan.assets ? `其中 ${plan.assets} 个是已整理文件,会从媒体库移除。` : ""}
</p>
) : (
<p>{withFiles ? "没有找到还在的文件,只删记录。" : "只删记录,文件不动。"}</p>
)}
{src && plan.sourceFiles ? <p>下载器里的任务不会删除。</p> : null}
{src && plan.sourceFiles ? <p>下载器里的任务不受影响。</p> : null}
{plan.autoRetry ? (
<p className="text-foreground">{plan.autoRetry} 个下载任务没有别的整理记录了,几分钟后会重新自动整理。</p>
<p className="text-foreground">{plan.autoRetry} 个下载任务没有别的整理记录了,几分钟后会自动重新整理。</p>
) : null}
</div>
);
@@ -24,7 +24,7 @@ export function FileSourceSelection({
const profile = matches[0];
onSelect(
profile
? { ...source, sourceConfigId: profile.id, defaultAction: profile.action, label: `来源 · ${profile.name}` }
? { ...source, sourceConfigId: profile.id, defaultAction: profile.action, label: `整理来源 · ${profile.name}` }
: source,
);
}, [profiles.isSuccess, matches, source, onSelect]);
@@ -32,14 +32,14 @@ export function FileSourceSelection({
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{profiles.isPending ? "读取整理来源…" : "选择整理策略"}</DialogTitle>
<DialogTitle>{profiles.isPending ? "读取整理来源…" : "选择整理来源"}</DialogTitle>
<DialogDescription>
匹配来源配置后,使用其整理方式、目标目录与分类规则。媒体类型将在整理预览时核对。
整理时沿用所选整理来源的整理方式、目标目录和分类。
</DialogDescription>
</DialogHeader>
{profiles.isPending ? (
<p role="status" className="text-caption text-muted-foreground">
正在检查所选文件对应的来源配置…
匹配整理来源…
</p>
) : profiles.error ? (
<>
@@ -62,19 +62,19 @@ export function FileSourceSelection({
...source,
sourceConfigId: profile.id,
defaultAction: profile.action,
label: `来源 · ${profile.name}`,
label: `整理来源 · ${profile.name}`,
})
}
>
{profile.name} · {profile.action === "copy" ? "复制" : "移动"} · {profile.category ?? "自动分类"}
{!profile.enabled ? " · 自动监控已停用" : ""}
{!profile.enabled ? " · 已停用" : ""}
</Button>
))}
</div>
) : null}
{!profiles.isPending && (profiles.error || matches.length > 1) ? (
<Button variant="outline" onClick={() => onSelect(source)}>
不使用来源配置,手动指定选项
不用整理来源,手动设置
</Button>
) : null}
</DialogContent>
@@ -30,15 +30,15 @@ function ScrapeQueueItem({ run }: { run: EngineRun }) {
return (
<li className="pg-slab space-y-2 rounded-xl p-4">
<div className="flex flex-wrap items-center gap-2">
<strong className="min-w-0 flex-1 break-words text-body">{record?.match?.title ?? "媒体库刮削"}</strong>
<strong className="min-w-0 flex-1 break-words text-body">{record?.match?.title ?? "刮削"}</strong>
<StatusBadge>刮削</StatusBadge>
<StatusBadge tone={run.status === "sleeping" ? "warning" : "neutral"}>{status}</StatusBadge>
<span className="text-meta text-muted-foreground">{relTime(run.createdAt)}</span>
</div>
<div className="min-w-0 text-caption">
<span className="text-muted-foreground">目标目录 / 文件</span>
<span className="text-muted-foreground">位置</span>
<p className="break-all font-mono text-meta">
{record ? `${record.root} / ${record.path || "(根目录)"}` : "读取刮削目标中…"}
{record ? `${record.root} / ${record.path || "(登记目录本身)"}` : "加载中…"}
</p>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
@@ -59,12 +59,12 @@ function ScrapeQueueItem({ run }: { run: EngineRun }) {
className="hover:text-foreground hover:underline"
to={`/console/organize/${record.organizationRecordId}`}
>
关联整理记录
整理记录
</Link>
) : null}
</div>
{detail.error || scrape.error ? (
<p className="text-meta text-warning">刮削详细信息暂时无法读取,任务状态仍会自动刷新。</p>
<p className="text-meta text-warning">刮削详情读取失败,状态仍会自动刷新。</p>
) : null}
</li>
);
@@ -83,7 +83,7 @@ function QueueItem({ run }: { run: EngineRun }) {
const download = useDownloadTask(downloadId);
const record = recordQuery.data ?? matchingRecord;
const progress = record ? recordFileProgress(record) : null;
const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "核验整理任务");
const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "整理任务");
const source = record?.source.path ?? download.data?.contentPath ?? download.data?.savePath;
const status = run.status === "pending" ? "排队" : run.status === "sleeping" ? "等待" : "执行中";
return (
@@ -99,13 +99,13 @@ function QueueItem({ run }: { run: EngineRun }) {
<p className="break-all font-mono text-meta">
{source ??
(detail.isFetching || download.isFetching || recordQuery.isFetching
? "读取来源信息中…"
: "来源信息暂不可用")}
? "加载中…"
: "读取不到来源")}
</p>
</div>
<div className="min-w-0">
<span className="text-muted-foreground">目标</span>
<p className="break-all font-mono text-meta">{record?.target.path ?? "核验与生成整理计划后确定"}</p>
<p className="break-all font-mono text-meta">{record?.target.path ?? "生成整理计划后确定"}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
@@ -131,7 +131,7 @@ function QueueItem({ run }: { run: EngineRun }) {
) : null}
</div>
{detail.error || recordQuery.error || related.error || download.error ? (
<p className="text-meta text-warning">部分详细信息暂时无法读取,任务状态仍会自动刷新。</p>
<p className="text-meta text-warning">部分详情读取失败,状态仍会自动刷新。</p>
) : null}
</li>
);
@@ -159,7 +159,7 @@ export function OrganizeQueue() {
resetKey: "organize-queue",
});
return (
<section aria-label="排队与执行中的整理任务" className="space-y-3">
<section aria-label="排队和执行中的任务" className="space-y-3">
<div className="flex justify-end">
<Button variant="outline" size="sm" disabled={runs.isFetching} onClick={() => void runs.refetch()}>
<RefreshCw />
@@ -171,7 +171,7 @@ export function OrganizeQueue() {
) : runs.error && !rows ? (
<ErrorState error={runs.error} onRetry={() => runs.refetch()} />
) : !rows?.length ? (
<EmptyState icon={FileStack} title="没有排队或执行中的整理任务" />
<EmptyState icon={FileStack} title="没有排队或执行中的任务" />
) : (
<ul className="space-y-3">
{paged.items?.map((run) =>
@@ -207,7 +207,7 @@ export function OrganizeQueueButton({ open, onOpenChange }: { open: boolean; onO
size="sm"
aria-haspopup="dialog"
aria-expanded={open}
aria-label={count ? `整理队列:${count} 个任务` : "整理队列"}
aria-label={count ? `队列:${count} 个任务` : "队列"}
onClick={() => onOpenChange(true)}
>
{running ? <Loader2 className="animate-spin" /> : <ListOrdered />}
@@ -217,9 +217,9 @@ export function OrganizeQueueButton({ open, onOpenChange }: { open: boolean; onO
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 sm:max-w-xl">
<SheetHeader className="pr-12">
<SheetTitle>整理队列</SheetTitle>
<SheetTitle>队列</SheetTitle>
<SheetDescription className="text-meta">
下载核验、手动与自动整理、整理后的 NFO / 图片刮削在这里排队。整理完成后进入整理记录,刮削显示到任务结束。
排队和执行中的整理、刮削任务。整理完成后移到整理记录。
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{open ? <OrganizeQueue /> : null}</div>
@@ -32,7 +32,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
) : query.error ? (
<ErrorState error={query.error} onRetry={() => query.refetch()} />
) : query.data?.records.length ? (
<PaginatedList rows={query.data.records} label="关联刮削记录" resetKey={recordId} as="ul" className="space-y-3">
<PaginatedList rows={query.data.records} label="刮削记录" resetKey={recordId} as="ul" className="space-y-3">
{(record) => (
<li key={record.id} className="space-y-1.5 rounded-xl border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
@@ -44,7 +44,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
{record.match?.title ?? record.path} · {record.path || "/"}
</p>
{record.status === "skipped" ? null : (
<p className="text-meta text-muted-foreground">{scrapeCounts(record) || "等待写入 NFO 与图片"}</p>
<p className="text-meta text-muted-foreground">{scrapeCounts(record) || "等待写入"}</p>
)}
{record.reason ? (
<p
@@ -57,7 +57,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
) : null}
{unfinished(record.files).length ? (
<details className="text-meta">
<summary className="cursor-pointer text-muted-foreground">失败或没写的文件</summary>
<summary className="cursor-pointer text-muted-foreground">失败或跳过的文件</summary>
<PaginatedList
rows={unfinished(record.files)}
label="刮削失败或跳过的文件"
@@ -90,7 +90,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
)}
</PaginatedList>
) : (
<p className="text-caption text-muted-foreground">没有关联的刮削记录。</p>
<p className="text-caption text-muted-foreground">没有刮削记录。</p>
)}
</section>
);
@@ -8,24 +8,22 @@ describe("organize queue", () => {
recordId: undefined,
});
expect(organizeQueueStage({ status: "pending" })).toBe("等待执行");
expect(organizeQueueStage({ status: "running" })).toBe("核验文件与生成整理计划");
expect(organizeQueueStage({ status: "running" })).toBe("核验文件,生成整理计划");
});
it("distinguishes file transfer, plan waiting, and workflow waiting", () => {
expect(organizeRunInput({ recordId: "record" }).recordId).toBe("record");
expect(organizeQueueStage({ status: "running" }, { status: "queued" })).toContain("等待搬运");
expect(organizeQueueStage({ status: "running" }, { status: "running" })).toContain("搬运文件");
expect(organizeQueueStage({ status: "sleeping" }, { status: "running" })).toBe("等待继续执行");
expect(organizeQueueStage({ status: "sleeping" }, { status: "running" })).toBe("等待继续");
});
it("keeps the independent scrape stage distinct after importing finishes", () => {
expect(organizeRunInput({ input: { scrapeRecordId: "scrape" } }).scrapeRecordId).toBe("scrape");
expect(scrapeQueueStage({ status: "running" })).toBe("刮削 NFO / 图片");
expect(scrapeQueueStage({ status: "running" })).toBe("刮削中");
expect(scrapeQueueStage({ status: "pending" })).toContain("等待刮削");
});
it("原地入库显示核验与登记,不显示搬运", () => {
expect(organizeQueueStage({ status: "running" }, { status: "running", action: "in_place" })).toBe(
"核验原文件与登记媒体库",
);
expect(organizeQueueStage({ status: "running" }, { status: "queued", action: "in_place" })).toBe("等待原地登记");
it("原地整理不显示搬运", () => {
expect(organizeQueueStage({ status: "running" }, { status: "running", action: "in_place" })).toBe("原地整理中");
expect(organizeQueueStage({ status: "running" }, { status: "queued", action: "in_place" })).toBe("等待原地整理");
});
it("does not describe a finished business record as transferring", () => {
expect(organizeQueueStage({ status: "running" }, { status: "succeeded" })).toContain("收尾");
@@ -20,18 +20,18 @@ export function organizeQueueStage(
run: Pick<EngineRun, "status">,
record?: Pick<PublicOrganizationRecord, "status"> & Partial<Pick<PublicOrganizationRecord, "action">>,
) {
if (run.status === "sleeping") return "等待继续执行";
if (run.status === "sleeping") return "等待继续";
if (run.status === "pending") return "等待执行";
if (!record) return "核验文件与生成整理计划";
if (!record) return "核验文件,生成整理计划";
if (record.action === "in_place" && (record.status === "queued" || record.status === "running"))
return record.status === "queued" ? "等待原地登记" : "核验原文件与登记媒体库";
if (record.status === "queued") return "整理计划已生成,等待搬运";
if (record.status === "running") return "搬运文件与登记媒体库";
return "整理已结束,完成任务收尾";
return record.status === "queued" ? "等待原地整理" : "原地整理中";
if (record.status === "queued") return "等待搬运文件";
if (record.status === "running") return "搬运文件中";
return "收尾中";
}
export function scrapeQueueStage(run: Pick<EngineRun, "status">) {
if (run.status === "pending") return "等待刮削 NFO / 图片";
if (run.status === "sleeping") return "等待继续刮削 NFO / 图片";
return "刮削 NFO / 图片";
if (run.status === "pending") return "等待刮削";
if (run.status === "sleeping") return "等待继续刮削";
return "刮削中";
}
@@ -27,7 +27,7 @@ type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attentio
export function attentionLine(r: Rec): string | null {
const a = r.attention;
if (!a) return null;
if (a.decision?.status === "investigating") return "智能体正在识别作品,认出来会自动重新整理";
if (a.decision?.status === "investigating") return "智能体正在识别作品,识别出来后自动重新整理";
if (a.topic === "identity") return "没识别出作品。指定作品,或让智能体识别";
return a.decision?.summary ? humanizeReason(a.decision.summary) : null;
}
@@ -96,8 +96,8 @@ export function RecordAttentionActions({
disabled={identify.isPending}
onClick={() =>
identify.mutateAsync(r.id).then(
(x) => toast.success(x.queued ? "已让智能体识别,认出来会自动重新整理" : "智能体已经在识别了"),
(err) => toast.error(errorMessage(err, "操作失败")),
(x) => toast.success(x.queued ? "已交给智能体识别" : "智能体正在识别"),
(err) => toast.error(errorMessage(err, "交给智能体识别失败")),
)
}
>
@@ -112,14 +112,14 @@ export function RecordAttentionActions({
忽略
</Button>
}
title="忽略这条记录的问题?"
description="不再算需要处理,文件和记录都不动;之后可以随时重新整理。"
title="忽略这个问题?"
description="这条记录不再算需要处理,文件和记录不变。之后仍可重新整理。"
confirmLabel="忽略"
onConfirm={() =>
dismiss.mutateAsync(r.id).then(
() => toast.success("已忽略"),
(err) => {
toast.error(errorMessage(err, "操作失败"));
toast.error(errorMessage(err, "忽略失败"));
throw err;
},
)
@@ -17,9 +17,21 @@ export function stockWizardSource(profile: OrganizeSourceView, stock: OrganizeSt
sourceConfigId: profile.id,
defaultAction: profile.action,
label: `整理存量 · ${profile.name}`,
scanSummary: `找到 ${stock.items.length} 个文件,跳过 ${stock.skipped} 个,不可读取 ${stock.unavailable} 个${stock.truncated ? ";扫描已达到上限,仅显示本批结果" : ""}。本次扫描不改变自动整理基线,请先预览再执行。`,
scanSummary: `找到 ${stock.items.length} 个文件${stockNotes(stock)}。`,
};
}
/** 扫描结果的补充说明:只写非零项 */
function stockNotes(r: Pick<OrganizeStockResult, "skipped" | "unavailable" | "truncated">): string {
return [
r.skipped ? `跳过 ${r.skipped} 个` : "",
r.unavailable ? `${r.unavailable} 个读取失败` : "",
r.truncated ? "达到扫描上限,只列出这一批" : "",
]
.filter(Boolean)
.map((x) => `,${x}`)
.join("");
}
export function SourceSelection({
onClose,
onSelect,
@@ -36,7 +48,7 @@ export function SourceSelection({
mode="source"
allowRoot={false}
onClose={() => setManual(false)}
onSelect={(ref) => onSelect({ kind: "files", items: [ref], label: "手动选择来源目录" })}
onSelect={(ref) => onSelect({ kind: "files", items: [ref], label: "手动选择的目录" })}
/>
);
return (
@@ -44,19 +56,19 @@ export function SourceSelection({
<DialogContent>
<DialogHeader>
<DialogTitle>选择整理来源</DialogTitle>
<DialogDescription>扫描来源存量后,进入文件识别、预览与执行。扫描不会改变自动整理基线。</DialogDescription>
<DialogDescription>扫描来源目录里已有的文件,预览后再整理。扫描不改变自动整理的基线。</DialogDescription>
</DialogHeader>
<div className="max-h-72 space-y-2 overflow-auto">
{sources.isPending ? <p>读取来源中…</p> : null}
{sources.isPending ? <p className="text-caption text-muted-foreground">加载中…</p> : null}
{sources.error ? (
<p role="alert" className="text-destructive">
来源配置读取失败,仍可手动选择目录。
读取整理来源失败。可以手动选择目录
</p>
) : null}
{sources.data?.items.length ? (
<PaginatedList
rows={sources.data.items}
label="整理来源配置"
label="整理来源"
resetKey="organize-source-selection"
className="space-y-2"
>
@@ -74,7 +86,7 @@ export function SourceSelection({
r.items.length
? onSelect(stockWizardSource(p, r))
: toast.info(
`没有可整理文件;跳过 ${r.skipped},不可读取 ${r.unavailable}${r.truncated ? ",扫描已达到上限" : ""}`,
`没有可整理的文件${stockNotes(r)}`,
),
onError: (e) => toast.error(filesErrorText(e, "扫描存量失败")),
},
@@ -89,7 +101,7 @@ export function SourceSelection({
) : null}
</div>
<Button variant="outline" disabled={stock.isPending} onClick={() => setManual(true)}>
手动选择其他来源目录
手动选择目录
</Button>
{sources.isSuccess && !sources.data.items.length ? (
<Link
@@ -97,7 +109,7 @@ export function SourceSelection({
onClick={onClose}
className="text-caption text-muted-foreground underline"
>
去设置新增整理来源
新增整理来源
</Link>
) : null}
</DialogContent>