文案(文件管理):按 human-copy 重写对话框、状态、菜单与提示

- 永久删除的说明改成实际开关 FILES_ALLOW_PERMANENT_DELETE,去掉「在设置里开启」的错误说法
- 去掉「待处理」「仅 TMDB」「目录根」等旧称,收录版本退回状态写「需处理」
- 删除、移动/复制、重命名、刮削、刷新元数据对话框去掉复述界面的说明
- 加载与进行中文字统一带省略号;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:48:20 +08:00
1 parent 542f107566
commit 32bf49a148
11 files changed
+87 -86

No files matched your search

+47 -48
View File
@@ -145,7 +145,7 @@ export function UploadButton({ root, path }: { root: FileRoot; path: string }) {
setBusy(null);
if (input.current) input.current.value = "";
if (ok) toast.success(`已上传 ${ok} 个文件到 ${displayPath(root, path)}`);
if (failed.length) toast.error(`${failed.length} 个文件没有上传`, { description: failed.join("\n") });
if (failed.length) toast.error(`${failed.length} 个文件上传失败`, { description: failed.join("\n") });
};
return (
<span title={root.writable ? undefined : "只读目录,不能上传"}>
@@ -190,7 +190,7 @@ export function MkdirDialog({
onOpenChange(false);
onCreated?.(r.path);
},
onError: (e) => setErr(filesErrorReason(e) === "exists" ? "同名项已存在" : filesErrorText(e)),
onError: (e) => setErr(filesErrorReason(e) === "exists" ? "同名已存在" : filesErrorText(e)),
},
);
};
@@ -232,13 +232,13 @@ export function MkdirDialog({
// ─── 重命名 ─────────────────────────────────────────────────────
const SUGGEST_REASON: Record<string, string> = {
identity_uncertain: "识别不可信,没有推荐名",
identity_uncertain: "认不准作品,没有推荐名",
};
const TEMPLATE_SOURCE: Record<string, string> = {
entity: "作品覆盖",
root: "媒体库目录",
global: "全局",
override: "覆写",
override: "保留原文件名",
};
function suggestReasonText(r: string | null): string {
@@ -418,17 +418,17 @@ export function RenameDialog({
<div className="-mt-2 space-y-1 text-meta text-muted-foreground">
{suggest.isPending && !suggested ? (
<p className="flex items-center gap-1.5">
<LoaderCircle className="h-3 w-3 animate-spin" /> 取推荐名…
<LoaderCircle className="h-3 w-3 animate-spin" /> 生成推荐名…
</p>
) : suggest.error ? (
<p className="text-destructive">{filesErrorText(suggest.error, "取推荐名失败")}</p>
<p className="text-destructive">{filesErrorText(suggest.error, "生成推荐名失败")}</p>
) : suggested && !suggested.name ? (
<p>{suggestReasonText(suggested.reason)}</p>
) : suggested?.name ? (
<>
<p className="flex flex-wrap items-center gap-x-2">
{suggested.name === entry.name
? "当前名称与命名模板一致"
? "已符合命名模板"
: usingSuggestion
? "已填入推荐名"
: `推荐名:${suggested.name}`}
@@ -460,17 +460,17 @@ export function RenameDialog({
{!isDir ? (
<div className="flex items-center justify-between gap-3 text-caption">
<span>
同名附属文件一起改
<span className="block text-meta text-muted-foreground">主干相同的字幕、NFO、图片</span>
附属文件一起改名
<span className="block text-meta text-muted-foreground">主文件名相同的字幕、NFO 和图片</span>
</span>
<Switch checked={withSidecars} onCheckedChange={setWithSidecars} aria-label="同名附属文件一起改" />
<Switch checked={withSidecars} onCheckedChange={setWithSidecars} aria-label="附属文件一起改名" />
</div>
) : null}
{changed && !problem && !taken ? (
<div className="space-y-2">
{preview.isPending ? (
<p className="flex items-center gap-2 text-meta text-muted-foreground">
<LoaderCircle className="h-3 w-3 animate-spin" /> 预览中
<LoaderCircle className="h-3 w-3 animate-spin" /> 预览中…
</p>
) : null}
{pvErr ? <Notice tone="destructive">{filesErrorText(pvErr)}</Notice> : null}
@@ -487,10 +487,10 @@ export function RenameDialog({
))}
</ul>
{pv.assetLocations > 0 ? (
<Notice tone="neutral">同步 {pv.assetLocations} 条文件位置,写一条整理记录。</Notice>
<Notice tone="neutral">会同步 {pv.assetLocations} 条文件位置,并写一条整理记录。</Notice>
) : null}
{pv.bindings.length > 0 ? (
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后整理用新目录。</Notice>
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定作废,之后整理用新目录。</Notice>
) : null}
</>
) : null}
@@ -570,11 +570,11 @@ export function TransferDialog({
const ok = r.executed?.succeeded.length ?? 0;
const failed = r.executed?.failed ?? [];
const recordId = r.executed?.recordId;
const msg = `${mode === "move" ? "已移动" : "已复制"} ${ok} 项${failed.length ? `,${failed.length} 项失败` : ""}`;
const msg = `${mode === "move" ? "已移动" : "已复制"} ${ok} 项${failed.length ? `,${failed.length} 项失败` : ""}`;
(failed.length ? toast.warning : toast.success)(msg, {
description: failed
.slice(0, 3)
.map((f) => `${baseName(f.path)}:${f.message}`)
.map((f) => `${baseName(f.path)}:${f.message}`)
.join("\n"),
action: recordId
? { label: "整理记录", onClick: () => window.location.assign(`/console/organize/${recordId}`) }
@@ -599,8 +599,7 @@ export function TransferDialog({
<DialogHeader>
<DialogTitle>{mode === "move" ? "移动到…" : "复制到…"}</DialogTitle>
<DialogDescription>
{items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · 先预览再执行。跨卷移动按复制
→ 核验 → 删源。
{items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · 跨卷移动会先复制、核验,再删除源文件。
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 gap-4 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)]">
@@ -637,7 +636,7 @@ export function TransferDialog({
className="max-h-72 md:max-h-96"
/>
<p className="text-meta text-muted-foreground">
可选可写的媒体库目录或 Alist 存储;本机和 Alist 之间会上传或下载。
目标可以是可写的媒体库目录或 Alist 存储。
</p>
</div>
<div className="min-w-0 space-y-3">
@@ -647,11 +646,11 @@ export function TransferDialog({
<p className="font-mono text-caption break-all">{displayPath(targetRoot, target.path)}</p>
</div>
) : (
<Notice tone="neutral">在左侧选择目标目录。</Notice>
<Notice tone="neutral">选择目标目录。</Notice>
)}
{dry.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 生成预览
<LoaderCircle className="h-4 w-4 animate-spin" /> 生成预览…
</p>
) : null}
{err ? <Notice tone="destructive">{err}</Notice> : null}
@@ -666,15 +665,15 @@ export function TransferDialog({
{plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""}
</StatusBadge>
{conflicts.length ? (
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
<StatusBadge tone="warning">{conflicts.length} 项目标已存在 · 跳过</StatusBadge>
) : null}
{plan.pairs.some((p) => p.registered) ? (
<StatusBadge>含已整理文件,执行后同步位置</StatusBadge>
<StatusBadge>含已整理文件,会同步文件位置</StatusBadge>
) : null}
</div>
{hnrBlocked ? (
<Notice tone="destructive">
{plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。
{plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。可以改用复制。
<button
type="button"
className="ml-1 underline"
@@ -689,7 +688,7 @@ export function TransferDialog({
</Notice>
) : null}
{plan.truncated ? (
<Notice tone="warning">源文件过多,预览只列出前一部分,执行时处理全部。</Notice>
<Notice tone="warning">文件太多,只列出前一部分。执行时全部处理。</Notice>
) : null}
<ul className="space-y-1.5">
{plan.pairs.map((p) => {
@@ -762,7 +761,7 @@ export function DeleteDialog({
useEffect(() => {
checkMutate(
{ idempotencyKey: `${key}-check`, items, mode: "trash", dryRun: true, retireAssets: false, unbind: false },
{ onError: (e) => setErr(filesErrorText(e, "引用检查失败")) },
{ onError: (e) => setErr(filesErrorText(e, "删除前检查失败")) },
);
}, [checkMutate, items, key]);
@@ -803,7 +802,7 @@ export function DeleteDialog({
(r.failed.length ? toast.warning : toast.success)(
mode === "trash" ? `已移入回收站 ${n} 项` : `已永久删除 ${n} 项`,
{
description: r.failed.map((f) => `${baseName(f.path)}:${f.message}`).join("\n") || undefined,
description: r.failed.map((f) => `${baseName(f.path)}:${f.message}`).join("\n") || undefined,
action:
mode === "trash"
? { label: "回收站", onClick: () => window.location.assign("/console/files/trash") }
@@ -816,7 +815,7 @@ export function DeleteDialog({
const reason = filesErrorReason(e);
setErr(
reason === "permanent_disabled"
? "设置里未开启永久删除(files.allowPermanentDelete)"
? "永久删除没有开启。改用回收站"
: reason === "approval_required"
? "用 API Key 删除,每次都要审批"
: filesErrorText(e),
@@ -829,13 +828,13 @@ export function DeleteDialog({
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
<DialogTitle>{single ? `删除「${single}」?` : `删除 ${items.length} 项?`}</DialogTitle>
<DialogDescription>已检查做种任务、已整理文件和路径绑定。下载器里的任务不受影响。</DialogDescription>
</DialogHeader>
<div className="min-h-0 space-y-3 overflow-y-auto">
{check.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 引用检查中…
<LoaderCircle className="h-4 w-4 animate-spin" /> 检查中…
</p>
) : null}
{err ? <Notice tone="destructive">{err}</Notice> : null}
@@ -877,7 +876,7 @@ export function DeleteDialog({
</p>
))}
{c.assets.length ? (
<p>已整理文件 {c.assets.length} 个(删除后从媒体库移除,已满足的收录版本退回待处理)</p>
<p>含 {c.assets.length} 个已整理文件</p>
) : null}
{c.bindings.map((bd) => (
<p key={bd.bindingId}>
@@ -885,7 +884,7 @@ export function DeleteDialog({
{bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""}
</p>
))}
{c.sharedInode ? <p>共享 inode(硬链接):只删这一处链接,其他位置不受影响</p> : null}
{c.sharedInode ? <p>硬链接:只删这一处,其他位置的文件不受影响</p> : null}
</div>
</li>
);
@@ -898,7 +897,7 @@ export function DeleteDialog({
<span>
同时从媒体库移除
<span className="block text-meta text-muted-foreground">
这些文件从媒体库记录中移除;已满足的收录版本退回待处理。
已整理文件从媒体库移除,用到它们的收录版本改为需处理。
</span>
</span>
</label>
@@ -909,7 +908,7 @@ export function DeleteDialog({
<span>
解除路径绑定
<span className="block text-meta text-muted-foreground">
绑定标为已替代,该作品下次整理重新选目录。
绑定作废,这个作品下次整理时重新选目录。
</span>
</span>
</label>
@@ -931,7 +930,7 @@ export function DeleteDialog({
</label>
<label
className={cn("flex items-start gap-2.5 text-caption", !isAdmin && "opacity-50")}
title={isAdmin ? undefined : "仅管理员会话可永久删除"}
title={isAdmin ? undefined : "只有管理员会话可以永久删除"}
>
<input
type="radio"
@@ -944,7 +943,7 @@ export function DeleteDialog({
<span>
永久删除
<span className="block text-meta text-muted-foreground">
{isAdmin ? "不可恢复。需要在设置里开启。" : "仅管理员会话可用。"}
{isAdmin ? "无法恢复。部署时设置 FILES_ALLOW_PERMANENT_DELETE=true 才能用。" : "只有管理员会话可用。"}
</span>
</span>
</label>
@@ -952,7 +951,7 @@ export function DeleteDialog({
<FormField
label={
<>
输入 <span className="font-mono">{confirmPhrase}</span> 以确认
输入 <span className="font-mono">{confirmPhrase}</span> 确认
</>
}
htmlFor="del-confirm"
@@ -998,7 +997,7 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
await navigator.clipboard.writeText(v);
toast.success("已复制");
} catch {
toast.error("浏览器不允许写剪贴板");
toast.error("复制失败,浏览器不允许访问剪贴板");
}
};
return (
@@ -1051,9 +1050,9 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
{
onSuccess: (r) => {
setResult(r);
const skipped = r.skipped.length ? `;${r.skipped.length} 个未提交` : "";
const skipped = r.skipped.length ? `,${r.skipped.length} 个跳过` : "";
(r.skipped.length ? toast.warning : toast.success)(
`已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`,
`已提交 ${r.submitted.length} 个作品${skipped}`,
);
},
onError: (e) => toast.error(filesErrorText(e, "提交失败")),
@@ -1067,7 +1066,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
刷新元数据
{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}
</DialogTitle>
<DialogDescription>按字段从 TMDB 刷新,锁定的字段和路径不变。不写目录里的文件。</DialogDescription>
<DialogDescription>从 TMDB 刷新,锁定的字段和路径不变,不写目录里的文件。</DialogDescription>
</DialogHeader>
{!single && !result ? (
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
@@ -1121,7 +1120,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
// ─── 刮削(SCR-2) ─────────────────────────────────────────
const SKIP_TEXT: Record<string, string> = {
scrape_root_read_only: "只登记模式的目录,不写文件",
scrape_root_read_only: "只读目录,不写文件",
scrape_not_video: "不是视频文件或目录",
scrape_no_videos: "目录里没有视频",
};
@@ -1166,7 +1165,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
setSkipped((cur) => [...cur, ...r.skipped.map((x) => ({ path: x.path, reason: x.reason }))]);
if (r.entities.length) toast.success(`已加入媒体库:${r.entities.map((e) => e.title).join("、")}`);
},
onError: (e) => toast.error(filesErrorText(e, "刮削没有提交")),
onError: (e) => toast.error(filesErrorText(e, "刮削提交失败")),
},
);
const ready = choices.filter((c) => picked[c.path]);
@@ -1179,7 +1178,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
<DialogHeader>
<DialogTitle>刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`}</DialogTitle>
<DialogDescription>
按路径识别作品,在所在目录写 NFO 和图片,Emby、Jellyfin、Kodi 直接读取。没整理的也能刮削。
按路径识别作品,在文件所在目录写 NFO 和图片。没整理过的文件也能刮削。
</DialogDescription>
</DialogHeader>
{!started ? (
@@ -1193,8 +1192,8 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
))}
</ul>
) : null}
<FormField label="已有文件">
<fieldset aria-label="已有文件" className="m-0 flex flex-wrap gap-1.5 border-0 p-0">
<FormField label="覆盖方式">
<fieldset aria-label="覆盖方式" className="m-0 flex flex-wrap gap-1.5 border-0 p-0">
{(
[
["update", "更新"],
@@ -1220,7 +1219,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
</fieldset>
</FormField>
{curMode === "overwrite" ? (
<p className="text-meta text-warning">目录里已有的 NFO 和图片会被替换,包括其他工具写的。</p>
<p className="text-meta text-warning">目录里已有的 NFO 和图片都会被替换,包括其他工具写的。</p>
) : null}
</div>
) : (
@@ -1230,7 +1229,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
<p className="text-sm font-medium">认不准作品,选一个再刮削</p>
{choices.map((c) => (
<div key={c.path} className="grid gap-1.5 rounded-lg border border-border/60 px-3 py-2">
<p className="text-caption break-all">{c.path || "目录根"}</p>
<p className="text-caption break-all">{c.path || "(登记目录本身)"}</p>
<EntityPicker
candidates={c.candidates}
value={picked[c.path] ?? null}
@@ -1263,7 +1262,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
{recordIds.length && !recs.length ? <p className="text-caption text-muted-foreground">加载中…</p> : null}
{skipped.map((x) => (
<p key={x.path} className="text-meta text-warning break-all">
{x.path || "目录根"}:{SKIP_TEXT[x.reason] ?? x.reason}
{x.path || "(登记目录本身)"}:{SKIP_TEXT[x.reason] ?? x.reason}
</p>
))}
</div>