文案(文件管理):按 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:
1 parent
542f107566
commit
32bf49a148
11 files changed
+87
-86
No files matched your search
@@ -145,7 +145,7 @@ export function UploadButton({ root, path }: { root: FileRoot; path: string }) {
|
|||||||
setBusy(null);
|
setBusy(null);
|
||||||
if (input.current) input.current.value = "";
|
if (input.current) input.current.value = "";
|
||||||
if (ok) toast.success(`已上传 ${ok} 个文件到 ${displayPath(root, path)}`);
|
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 (
|
return (
|
||||||
<span title={root.writable ? undefined : "只读目录,不能上传"}>
|
<span title={root.writable ? undefined : "只读目录,不能上传"}>
|
||||||
@@ -190,7 +190,7 @@ export function MkdirDialog({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onCreated?.(r.path);
|
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> = {
|
const SUGGEST_REASON: Record<string, string> = {
|
||||||
identity_uncertain: "识别不可信,没有推荐名",
|
identity_uncertain: "认不准作品,没有推荐名",
|
||||||
};
|
};
|
||||||
const TEMPLATE_SOURCE: Record<string, string> = {
|
const TEMPLATE_SOURCE: Record<string, string> = {
|
||||||
entity: "作品覆盖",
|
entity: "作品覆盖",
|
||||||
root: "媒体库目录",
|
root: "媒体库目录",
|
||||||
global: "全局",
|
global: "全局",
|
||||||
override: "覆写",
|
override: "保留原文件名",
|
||||||
};
|
};
|
||||||
|
|
||||||
function suggestReasonText(r: string | null): string {
|
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">
|
<div className="-mt-2 space-y-1 text-meta text-muted-foreground">
|
||||||
{suggest.isPending && !suggested ? (
|
{suggest.isPending && !suggested ? (
|
||||||
<p className="flex items-center gap-1.5">
|
<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>
|
</p>
|
||||||
) : suggest.error ? (
|
) : suggest.error ? (
|
||||||
<p className="text-destructive">{filesErrorText(suggest.error, "取推荐名失败")}</p>
|
<p className="text-destructive">{filesErrorText(suggest.error, "生成推荐名失败")}</p>
|
||||||
) : suggested && !suggested.name ? (
|
) : suggested && !suggested.name ? (
|
||||||
<p>{suggestReasonText(suggested.reason)}</p>
|
<p>{suggestReasonText(suggested.reason)}</p>
|
||||||
) : suggested?.name ? (
|
) : suggested?.name ? (
|
||||||
<>
|
<>
|
||||||
<p className="flex flex-wrap items-center gap-x-2">
|
<p className="flex flex-wrap items-center gap-x-2">
|
||||||
{suggested.name === entry.name
|
{suggested.name === entry.name
|
||||||
? "当前名称与命名模板一致"
|
? "已符合命名模板"
|
||||||
: usingSuggestion
|
: usingSuggestion
|
||||||
? "已填入推荐名"
|
? "已填入推荐名"
|
||||||
: `推荐名:${suggested.name}`}
|
: `推荐名:${suggested.name}`}
|
||||||
@@ -460,17 +460,17 @@ export function RenameDialog({
|
|||||||
{!isDir ? (
|
{!isDir ? (
|
||||||
<div className="flex items-center justify-between gap-3 text-caption">
|
<div className="flex items-center justify-between gap-3 text-caption">
|
||||||
<span>
|
<span>
|
||||||
同名附属文件一起改
|
附属文件一起改名
|
||||||
<span className="block text-meta text-muted-foreground">主干相同的字幕、NFO、图片</span>
|
<span className="block text-meta text-muted-foreground">主文件名相同的字幕、NFO 和图片</span>
|
||||||
</span>
|
</span>
|
||||||
<Switch checked={withSidecars} onCheckedChange={setWithSidecars} aria-label="同名附属文件一起改" />
|
<Switch checked={withSidecars} onCheckedChange={setWithSidecars} aria-label="附属文件一起改名" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{changed && !problem && !taken ? (
|
{changed && !problem && !taken ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{preview.isPending ? (
|
{preview.isPending ? (
|
||||||
<p className="flex items-center gap-2 text-meta text-muted-foreground">
|
<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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{pvErr ? <Notice tone="destructive">{filesErrorText(pvErr)}</Notice> : null}
|
{pvErr ? <Notice tone="destructive">{filesErrorText(pvErr)}</Notice> : null}
|
||||||
@@ -487,10 +487,10 @@ export function RenameDialog({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{pv.assetLocations > 0 ? (
|
{pv.assetLocations > 0 ? (
|
||||||
<Notice tone="neutral">同步 {pv.assetLocations} 条文件位置,写一条整理记录。</Notice>
|
<Notice tone="neutral">会同步 {pv.assetLocations} 条文件位置,并写一条整理记录。</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
{pv.bindings.length > 0 ? (
|
{pv.bindings.length > 0 ? (
|
||||||
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后整理用新目录。</Notice>
|
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定作废,之后整理用新目录。</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -570,11 +570,11 @@ export function TransferDialog({
|
|||||||
const ok = r.executed?.succeeded.length ?? 0;
|
const ok = r.executed?.succeeded.length ?? 0;
|
||||||
const failed = r.executed?.failed ?? [];
|
const failed = r.executed?.failed ?? [];
|
||||||
const recordId = r.executed?.recordId;
|
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, {
|
(failed.length ? toast.warning : toast.success)(msg, {
|
||||||
description: failed
|
description: failed
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((f) => `${baseName(f.path)}:${f.message}`)
|
.map((f) => `${baseName(f.path)}:${f.message}`)
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
action: recordId
|
action: recordId
|
||||||
? { label: "整理记录", onClick: () => window.location.assign(`/console/organize/${recordId}`) }
|
? { label: "整理记录", onClick: () => window.location.assign(`/console/organize/${recordId}`) }
|
||||||
@@ -599,8 +599,7 @@ export function TransferDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{mode === "move" ? "移动到…" : "复制到…"}</DialogTitle>
|
<DialogTitle>{mode === "move" ? "移动到…" : "复制到…"}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · 先预览再执行。跨卷移动按复制
|
{items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · 跨卷移动会先复制、核验,再删除源文件。
|
||||||
→ 核验 → 删源。
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid min-h-0 gap-4 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)]">
|
<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"
|
className="max-h-72 md:max-h-96"
|
||||||
/>
|
/>
|
||||||
<p className="text-meta text-muted-foreground">
|
<p className="text-meta text-muted-foreground">
|
||||||
可选可写的媒体库目录或 Alist 存储;本机和 Alist 之间会上传或下载。
|
目标可以是可写的媒体库目录或 Alist 存储。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 space-y-3">
|
<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>
|
<p className="font-mono text-caption break-all">{displayPath(targetRoot, target.path)}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Notice tone="neutral">在左侧选择目标目录。</Notice>
|
<Notice tone="neutral">选择目标目录。</Notice>
|
||||||
)}
|
)}
|
||||||
{dry.isPending ? (
|
{dry.isPending ? (
|
||||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
<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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{err ? <Notice tone="destructive">{err}</Notice> : null}
|
{err ? <Notice tone="destructive">{err}</Notice> : null}
|
||||||
@@ -666,15 +665,15 @@ export function TransferDialog({
|
|||||||
{plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""}
|
{plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
{conflicts.length ? (
|
{conflicts.length ? (
|
||||||
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
|
<StatusBadge tone="warning">{conflicts.length} 项目标已存在 · 跳过</StatusBadge>
|
||||||
) : null}
|
) : null}
|
||||||
{plan.pairs.some((p) => p.registered) ? (
|
{plan.pairs.some((p) => p.registered) ? (
|
||||||
<StatusBadge>含已整理文件,执行后同步位置</StatusBadge>
|
<StatusBadge>含已整理文件,会同步文件位置</StatusBadge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{hnrBlocked ? (
|
{hnrBlocked ? (
|
||||||
<Notice tone="destructive">
|
<Notice tone="destructive">
|
||||||
{plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。
|
{plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。可以改用复制。
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ml-1 underline"
|
className="ml-1 underline"
|
||||||
@@ -689,7 +688,7 @@ export function TransferDialog({
|
|||||||
</Notice>
|
</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
{plan.truncated ? (
|
{plan.truncated ? (
|
||||||
<Notice tone="warning">源文件过多,预览只列出前一部分,执行时处理全部。</Notice>
|
<Notice tone="warning">文件太多,只列出前一部分。执行时全部处理。</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{plan.pairs.map((p) => {
|
{plan.pairs.map((p) => {
|
||||||
@@ -762,7 +761,7 @@ export function DeleteDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkMutate(
|
checkMutate(
|
||||||
{ idempotencyKey: `${key}-check`, items, mode: "trash", dryRun: true, retireAssets: false, unbind: false },
|
{ 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]);
|
}, [checkMutate, items, key]);
|
||||||
|
|
||||||
@@ -803,7 +802,7 @@ export function DeleteDialog({
|
|||||||
(r.failed.length ? toast.warning : toast.success)(
|
(r.failed.length ? toast.warning : toast.success)(
|
||||||
mode === "trash" ? `已移入回收站 ${n} 项` : `已永久删除 ${n} 项`,
|
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:
|
action:
|
||||||
mode === "trash"
|
mode === "trash"
|
||||||
? { label: "回收站", onClick: () => window.location.assign("/console/files/trash") }
|
? { label: "回收站", onClick: () => window.location.assign("/console/files/trash") }
|
||||||
@@ -816,7 +815,7 @@ export function DeleteDialog({
|
|||||||
const reason = filesErrorReason(e);
|
const reason = filesErrorReason(e);
|
||||||
setErr(
|
setErr(
|
||||||
reason === "permanent_disabled"
|
reason === "permanent_disabled"
|
||||||
? "设置里未开启永久删除(files.allowPermanentDelete)"
|
? "永久删除没有开启。改用回收站"
|
||||||
: reason === "approval_required"
|
: reason === "approval_required"
|
||||||
? "用 API Key 删除,每次都要审批"
|
? "用 API Key 删除,每次都要审批"
|
||||||
: filesErrorText(e),
|
: filesErrorText(e),
|
||||||
@@ -829,13 +828,13 @@ export function DeleteDialog({
|
|||||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
|
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
|
<DialogTitle>{single ? `删除「${single}」?` : `删除 ${items.length} 项?`}</DialogTitle>
|
||||||
<DialogDescription>已检查做种任务、已整理文件和路径绑定。下载器里的任务不受影响。</DialogDescription>
|
<DialogDescription>已检查做种任务、已整理文件和路径绑定。下载器里的任务不受影响。</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-0 space-y-3 overflow-y-auto">
|
<div className="min-h-0 space-y-3 overflow-y-auto">
|
||||||
{check.isPending ? (
|
{check.isPending ? (
|
||||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
<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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{err ? <Notice tone="destructive">{err}</Notice> : null}
|
{err ? <Notice tone="destructive">{err}</Notice> : null}
|
||||||
@@ -877,7 +876,7 @@ export function DeleteDialog({
|
|||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
{c.assets.length ? (
|
{c.assets.length ? (
|
||||||
<p>已整理文件 {c.assets.length} 个(删除后从媒体库移除,已满足的收录版本退回待处理)</p>
|
<p>含 {c.assets.length} 个已整理文件</p>
|
||||||
) : null}
|
) : null}
|
||||||
{c.bindings.map((bd) => (
|
{c.bindings.map((bd) => (
|
||||||
<p key={bd.bindingId}>
|
<p key={bd.bindingId}>
|
||||||
@@ -885,7 +884,7 @@ export function DeleteDialog({
|
|||||||
{bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""}
|
{bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
{c.sharedInode ? <p>共享 inode(硬链接):只删这一处链接,其他位置不受影响</p> : null}
|
{c.sharedInode ? <p>硬链接:只删这一处,其他位置的文件不受影响</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -898,7 +897,7 @@ export function DeleteDialog({
|
|||||||
<span>
|
<span>
|
||||||
同时从媒体库移除
|
同时从媒体库移除
|
||||||
<span className="block text-meta text-muted-foreground">
|
<span className="block text-meta text-muted-foreground">
|
||||||
这些文件从媒体库记录中移除;已满足的收录版本退回待处理。
|
已整理文件从媒体库移除,用到它们的收录版本改为需处理。
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -909,7 +908,7 @@ export function DeleteDialog({
|
|||||||
<span>
|
<span>
|
||||||
解除路径绑定
|
解除路径绑定
|
||||||
<span className="block text-meta text-muted-foreground">
|
<span className="block text-meta text-muted-foreground">
|
||||||
绑定标为已替代,该作品下次整理重新选目录。
|
绑定作废,这个作品下次整理时重新选目录。
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -931,7 +930,7 @@ export function DeleteDialog({
|
|||||||
</label>
|
</label>
|
||||||
<label
|
<label
|
||||||
className={cn("flex items-start gap-2.5 text-caption", !isAdmin && "opacity-50")}
|
className={cn("flex items-start gap-2.5 text-caption", !isAdmin && "opacity-50")}
|
||||||
title={isAdmin ? undefined : "仅管理员会话可永久删除"}
|
title={isAdmin ? undefined : "只有管理员会话可以永久删除"}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -944,7 +943,7 @@ export function DeleteDialog({
|
|||||||
<span>
|
<span>
|
||||||
永久删除
|
永久删除
|
||||||
<span className="block text-meta text-muted-foreground">
|
<span className="block text-meta text-muted-foreground">
|
||||||
{isAdmin ? "不可恢复。需要在设置里开启。" : "仅管理员会话可用。"}
|
{isAdmin ? "无法恢复。部署时设置 FILES_ALLOW_PERMANENT_DELETE=true 才能用。" : "只有管理员会话可用。"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -952,7 +951,7 @@ export function DeleteDialog({
|
|||||||
<FormField
|
<FormField
|
||||||
label={
|
label={
|
||||||
<>
|
<>
|
||||||
输入 <span className="font-mono">{confirmPhrase}</span> 以确认
|
输入 <span className="font-mono">{confirmPhrase}</span> 确认
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
htmlFor="del-confirm"
|
htmlFor="del-confirm"
|
||||||
@@ -998,7 +997,7 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
|
|||||||
await navigator.clipboard.writeText(v);
|
await navigator.clipboard.writeText(v);
|
||||||
toast.success("已复制");
|
toast.success("已复制");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("浏览器不允许写剪贴板");
|
toast.error("复制失败,浏览器不允许访问剪贴板");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@@ -1051,9 +1050,9 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
|
|||||||
{
|
{
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
setResult(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.skipped.length ? toast.warning : toast.success)(
|
||||||
`已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`,
|
`已提交 ${r.submitted.length} 个作品${skipped}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(filesErrorText(e, "提交失败")),
|
onError: (e) => toast.error(filesErrorText(e, "提交失败")),
|
||||||
@@ -1067,7 +1066,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
|
|||||||
刷新元数据
|
刷新元数据
|
||||||
{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}
|
{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>按字段从 TMDB 刷新,锁定的字段和路径不变。不写目录里的文件。</DialogDescription>
|
<DialogDescription>从 TMDB 刷新,锁定的字段和路径不变,不写目录里的文件。</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!single && !result ? (
|
{!single && !result ? (
|
||||||
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
|
<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) ─────────────────────────────────────────
|
// ─── 刮削(SCR-2) ─────────────────────────────────────────
|
||||||
|
|
||||||
const SKIP_TEXT: Record<string, string> = {
|
const SKIP_TEXT: Record<string, string> = {
|
||||||
scrape_root_read_only: "只登记模式的目录,不写文件",
|
scrape_root_read_only: "只读目录,不写文件",
|
||||||
scrape_not_video: "不是视频文件或目录",
|
scrape_not_video: "不是视频文件或目录",
|
||||||
scrape_no_videos: "目录里没有视频",
|
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 }))]);
|
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("、")}`);
|
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]);
|
const ready = choices.filter((c) => picked[c.path]);
|
||||||
@@ -1179,7 +1178,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`}</DialogTitle>
|
<DialogTitle>刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
按路径识别作品,在所在目录写 NFO 和图片,Emby、Jellyfin、Kodi 直接读取。没整理的也能刮削。
|
按路径识别作品,在文件所在目录写 NFO 和图片。没整理过的文件也能刮削。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!started ? (
|
{!started ? (
|
||||||
@@ -1193,8 +1192,8 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
<FormField label="已有文件">
|
<FormField label="覆盖方式">
|
||||||
<fieldset aria-label="已有文件" className="m-0 flex flex-wrap gap-1.5 border-0 p-0">
|
<fieldset aria-label="覆盖方式" className="m-0 flex flex-wrap gap-1.5 border-0 p-0">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["update", "更新"],
|
["update", "更新"],
|
||||||
@@ -1220,7 +1219,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</FormField>
|
</FormField>
|
||||||
{curMode === "overwrite" ? (
|
{curMode === "overwrite" ? (
|
||||||
<p className="text-meta text-warning">目录里已有的 NFO 和图片会被替换,包括其他工具写的。</p>
|
<p className="text-meta text-warning">目录里已有的 NFO 和图片都会被替换,包括其他工具写的。</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1230,7 +1229,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
|
|||||||
<p className="text-sm font-medium">认不准作品,选一个再刮削</p>
|
<p className="text-sm font-medium">认不准作品,选一个再刮削</p>
|
||||||
{choices.map((c) => (
|
{choices.map((c) => (
|
||||||
<div key={c.path} className="grid gap-1.5 rounded-lg border border-border/60 px-3 py-2">
|
<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
|
<EntityPicker
|
||||||
candidates={c.candidates}
|
candidates={c.candidates}
|
||||||
value={picked[c.path] ?? null}
|
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}
|
{recordIds.length && !recs.length ? <p className="text-caption text-muted-foreground">加载中…</p> : null}
|
||||||
{skipped.map((x) => (
|
{skipped.map((x) => (
|
||||||
<p key={x.path} className="text-meta text-warning break-all">
|
<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>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ function Children({
|
|||||||
className="flex items-center gap-2 py-1 text-meta text-muted-foreground"
|
className="flex items-center gap-2 py-1 text-meta text-muted-foreground"
|
||||||
style={{ paddingLeft: depth * 14 + 28 }}
|
style={{ paddingLeft: depth * 14 + 28 }}
|
||||||
>
|
>
|
||||||
<LoaderCircle className="h-3 w-3 animate-spin" /> 加载中
|
<LoaderCircle className="h-3 w-3 animate-spin" /> 加载中…
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (q.error)
|
if (q.error)
|
||||||
@@ -242,7 +242,7 @@ function Children({
|
|||||||
if (!dirs.length)
|
if (!dirs.length)
|
||||||
return (
|
return (
|
||||||
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
|
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
|
||||||
无子目录
|
没有子目录
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -293,7 +293,7 @@ function Children({
|
|||||||
{paginate && dirs.length > 0 ? <Pagination {...paged.pagination} /> : null}
|
{paginate && dirs.length > 0 ? <Pagination {...paged.pagination} /> : null}
|
||||||
{q.data?.truncated ? (
|
{q.data?.truncated ? (
|
||||||
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
|
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
|
||||||
子目录过多,只列出一部分
|
子目录太多,只列出一部分
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ export function DirectoryDialog({
|
|||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="sm:max-w-3xl">
|
<DialogContent className="sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{mode === "source" ? "选择来源目录" : "选择媒体库目标目录"}</DialogTitle>
|
<DialogTitle>{mode === "source" ? "选择来源目录" : "选择目标目录"}</DialogTitle>
|
||||||
<DialogDescription>展开登记目录,选择一个目录后确认。</DialogDescription>
|
<DialogDescription>
|
||||||
|
{mode === "source" ? "可以选下载目录或媒体库目录。" : "只能选可写的媒体库目录。"}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="max-h-80 overflow-auto rounded-xl border p-2">
|
<div className="max-h-80 overflow-auto rounded-xl border p-2">
|
||||||
@@ -56,7 +58,7 @@ export function DirectoryDialog({
|
|||||||
paginate
|
paginate
|
||||||
/>
|
/>
|
||||||
{roots.isPending ? (
|
{roots.isPending ? (
|
||||||
<p>读取目录中…</p>
|
<p className="text-caption text-muted-foreground">加载中…</p>
|
||||||
) : !available.length ? (
|
) : !available.length ? (
|
||||||
<p className="text-caption text-muted-foreground">没有可用的登记目录。</p>
|
<p className="text-caption text-muted-foreground">没有可用的登记目录。</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -75,12 +77,12 @@ export function DirectoryDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{!selected ? (
|
{!selected ? (
|
||||||
<p className="text-caption text-muted-foreground">在左侧选择登记目录,查看内容。</p>
|
<p className="text-caption text-muted-foreground">选择一个登记目录。</p>
|
||||||
) : listing.isPending ? (
|
) : listing.isPending ? (
|
||||||
<p className="text-caption">读取目录中…</p>
|
<p className="text-caption">加载中…</p>
|
||||||
) : listing.error ? (
|
) : listing.error ? (
|
||||||
<p role="alert" className="text-caption text-destructive">
|
<p role="alert" className="text-caption text-destructive">
|
||||||
{filesErrorText(listing.error, "目录不可读取")}
|
{filesErrorText(listing.error, "读取目录失败")}
|
||||||
</p>
|
</p>
|
||||||
) : !entries.length ? (
|
) : !entries.length ? (
|
||||||
<p className="text-caption text-muted-foreground">目录为空。</p>
|
<p className="text-caption text-muted-foreground">目录为空。</p>
|
||||||
@@ -110,15 +112,15 @@ export function DirectoryDialog({
|
|||||||
</div>
|
</div>
|
||||||
{roots.error ? (
|
{roots.error ? (
|
||||||
<p role="alert" className="text-destructive">
|
<p role="alert" className="text-destructive">
|
||||||
读取目录失败,请关闭后重试。
|
读取登记目录失败。关闭后重试
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="break-all font-mono text-meta">
|
<p className="break-all font-mono text-meta">
|
||||||
{root ? `${root.name} / ${selected?.path || "(根目录)"}` : "尚未选择"}
|
{root ? `${root.name} / ${selected?.path || "(登记目录本身)"}` : "未选择目录"}
|
||||||
</p>
|
</p>
|
||||||
{!allowRoot && selected && !selected.path ? (
|
{!allowRoot && selected && !selected.path ? (
|
||||||
<p role="status" className="text-caption text-warning">
|
<p role="status" className="text-caption text-warning">
|
||||||
请选择子目录,或先新增来源配置,再整理根目录存量。
|
不能直接整理登记目录本身。选一个子目录,或在设置里新增整理来源后用「整理存量」。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@@ -129,7 +131,7 @@ export function DirectoryDialog({
|
|||||||
disabled={!root || !directorySelectionAllowed(selected, allowRoot) || listing.isPending || !!listing.error}
|
disabled={!root || !directorySelectionAllowed(selected, allowRoot) || listing.isPending || !!listing.error}
|
||||||
onClick={() => selected && directorySelectionAllowed(selected, allowRoot) && onSelect(selected)}
|
onClick={() => selected && directorySelectionAllowed(selected, allowRoot) && onSelect(selected)}
|
||||||
>
|
>
|
||||||
使用此目录
|
使用这个目录
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function FileDetail({
|
|||||||
label: "链接数",
|
label: "链接数",
|
||||||
value:
|
value:
|
||||||
stat.data?.nlink != null
|
stat.data?.nlink != null
|
||||||
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,不能原地改写)" : ""}`
|
? `${stat.data.nlink}${stat.data.sharedInode ? "(硬链接,不能原地改写)" : ""}`
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -163,7 +163,7 @@ export function FileDetail({
|
|||||||
{e.asset.slotStatus ? (SLOT_STATUS[e.asset.slotStatus]?.label ?? e.asset.slotStatus) : "查看"}
|
{e.asset.slotStatus ? (SLOT_STATUS[e.asset.slotStatus]?.label ?? e.asset.slotStatus) : "查看"}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
"未满足任何收录版本"
|
"未关联收录版本"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ label: "主文件", value: e.asset.isPrimary ? "是" : "否" },
|
{ label: "主文件", value: e.asset.isPrimary ? "是" : "否" },
|
||||||
@@ -215,7 +215,7 @@ export function FileDetail({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mt-1 text-meta text-muted-foreground">改名或移动此目录会同步更新绑定;刷新元数据不改路径。</p>
|
<p className="mt-1 text-meta text-muted-foreground">改名或移动这个目录会同步更新绑定。刷新元数据不改路径。</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ export function FileDetail({
|
|||||||
{
|
{
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
const it = r.items[0];
|
const it = r.items[0];
|
||||||
if (it?.status !== "ok") toast.warning(`探测不到媒体信息:${it?.reason ?? "未知原因"}`);
|
if (it?.status !== "ok") toast.warning(`没有探测到媒体信息${it?.reason ? `:${it.reason}` : ""}`);
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
|
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
|
||||||
},
|
},
|
||||||
@@ -278,7 +278,7 @@ export function FileDetail({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-caption text-muted-foreground">
|
<p className="text-caption text-muted-foreground">
|
||||||
未登记文件不自动探测。点「探测」读取 ffprobe 摘要,结果缓存 24 小时。
|
未整理的文件不自动探测。点「探测」用 ffprobe 读取,结果缓存 24 小时。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|||||||
@@ -562,7 +562,7 @@ export function FileList({
|
|||||||
</div>
|
</div>
|
||||||
{loadingMore ? (
|
{loadingMore ? (
|
||||||
<div className="flex items-center justify-center gap-2 py-3 text-meta text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 py-3 text-meta text-muted-foreground">
|
||||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 加载下一页
|
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 加载中…
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{footer}
|
{footer}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ function ImageView({
|
|||||||
className="max-h-[28rem] rounded-xl border border-border object-contain"
|
className="max-h-[28rem] rounded-xl border border-border object-contain"
|
||||||
/>
|
/>
|
||||||
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
|
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
|
||||||
<ExternalLink className="h-3 w-3" /> 新窗口看原图
|
<ExternalLink className="h-3 w-3" /> 在新窗口打开原图
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -52,12 +52,12 @@ export function IdentifyPanel({
|
|||||||
>
|
>
|
||||||
{identify.isPending ? (
|
{identify.isPending ? (
|
||||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 匹配本地作品与 TMDB…
|
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 匹配本地作品和 TMDB…
|
||||||
</p>
|
</p>
|
||||||
) : identify.error ? (
|
) : identify.error ? (
|
||||||
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
|
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
|
||||||
) : !identify.data ? (
|
) : !identify.data ? (
|
||||||
<p className="text-caption text-muted-foreground">按文件名解析,匹配本地作品和 TMDB。只读,不改文件。</p>
|
<p className="text-caption text-muted-foreground">按文件名匹配本地作品和 TMDB,不改文件。</p>
|
||||||
) : !it ? (
|
) : !it ? (
|
||||||
<p className="text-caption text-muted-foreground">{isDir ? "目录里没有视频文件。" : "没有识别结果。"}</p>
|
<p className="text-caption text-muted-foreground">{isDir ? "目录里没有视频文件。" : "没有识别结果。"}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -98,7 +98,7 @@ export function IdentifyPanel({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{it.skipped === "below_min_size" ? (
|
{it.skipped === "below_min_size" ? (
|
||||||
<p className="text-caption text-muted-foreground">低于最小体积,没有查候选。</p>
|
<p className="text-caption text-muted-foreground">文件小于最小体积,没有查找候选。</p>
|
||||||
) : null}
|
) : null}
|
||||||
{it.warnings.map((w) => (
|
{it.warnings.map((w) => (
|
||||||
<p key={w} className="text-caption text-warning">
|
<p key={w} className="text-caption text-warning">
|
||||||
@@ -132,7 +132,7 @@ export function IdentifyPanel({
|
|||||||
{c.year ? ` (${c.year})` : ""}
|
{c.year ? ` (${c.year})` : ""}
|
||||||
</span>
|
</span>
|
||||||
<StatusBadge tone={LEVEL[c.level]?.tone}>置信 {LEVEL[c.level]?.label ?? c.level}</StatusBadge>
|
<StatusBadge tone={LEVEL[c.level]?.tone}>置信 {LEVEL[c.level]?.label ?? c.level}</StatusBadge>
|
||||||
{c.mediaEntityId ? null : <StatusBadge tone="warning">仅 TMDB</StatusBadge>}
|
{c.mediaEntityId ? null : <StatusBadge tone="warning">不在媒体库</StatusBadge>}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-meta text-muted-foreground">
|
<p className="text-meta text-muted-foreground">
|
||||||
{[
|
{[
|
||||||
|
|||||||
@@ -951,7 +951,7 @@ function organize(body: {
|
|||||||
recordId: body.dryRun || conflict ? null : `rec-${++seq}`,
|
recordId: body.dryRun || conflict ? null : `rec-${++seq}`,
|
||||||
taskId: body.dryRun || conflict ? null : `task-${seq}`,
|
taskId: body.dryRun || conflict ? null : `task-${seq}`,
|
||||||
warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [],
|
warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [],
|
||||||
error: conflict ? { code: "plan_blocked", message: "目标已存在同名文件,按「交给待处理」整组不执行" } : null,
|
error: conflict ? { code: "plan_blocked", message: "目标已存在同名文件,整组不执行" } : null,
|
||||||
action: body.action,
|
action: body.action,
|
||||||
entity: items.some((x) => x.external)
|
entity: items.some((x) => x.external)
|
||||||
? { provider: "tmdb", externalId: "95396", created: true, title: "人生切割术" }
|
? { provider: "tmdb", externalId: "95396", created: true, title: "人生切割术" }
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ describe("transferTargetProblem(U13)", () => {
|
|||||||
];
|
];
|
||||||
it("目标就是源所在目录 / 在源目录之内时拦下", () => {
|
it("目标就是源所在目录 / 在源目录之内时拦下", () => {
|
||||||
expect(transferTargetProblem(items, { root: "dl", path: "a" })).toMatch(/已在这个目录里/);
|
expect(transferTargetProblem(items, { root: "dl", path: "a" })).toMatch(/已在这个目录里/);
|
||||||
expect(transferTargetProblem(items, { root: "dl", path: "pack/sub" })).toMatch(/目标在源目录之内/);
|
expect(transferTargetProblem(items, { root: "dl", path: "pack/sub" })).toMatch(/目标不能是 pack 或它的子目录/);
|
||||||
expect(transferTargetProblem([{ root: "dl", path: "x.mkv" }], { root: "dl", path: "" })).toMatch(/已在/);
|
expect(transferTargetProblem([{ root: "dl", path: "x.mkv" }], { root: "dl", path: "" })).toMatch(/已在/);
|
||||||
});
|
});
|
||||||
it("其他目录、其他根放行", () => {
|
it("其他目录、其他根放行", () => {
|
||||||
@@ -370,7 +370,7 @@ describe("识别远端回填(S-7)", () => {
|
|||||||
{ ...other, taskId: "run-1", status: "failed", error: "超时" } as never,
|
{ ...other, taskId: "run-1", status: "failed", error: "超时" } as never,
|
||||||
);
|
);
|
||||||
expect(f.remoteTaskId).toBeNull();
|
expect(f.remoteTaskId).toBeNull();
|
||||||
expect(f.items[0]?.warnings).toEqual(["远端查询失败,只有本地结果:超时"]);
|
expect(f.items[0]?.warnings).toEqual(["TMDB 查询失败,只显示本地结果:超时"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ export type StatusView = { label: string; tone: StatusTone; hint?: string };
|
|||||||
|
|
||||||
/** 行主状态 → 徽标(文字必带,不只靠颜色) */
|
/** 行主状态 → 徽标(文字必带,不只靠颜色) */
|
||||||
export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null {
|
export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null {
|
||||||
if (e.escape) return { label: "链接越界", tone: "destructive", hint: "符号链接指向登记目录之外,所有操作已禁用" };
|
if (e.escape) return { label: "链接越界", tone: "destructive", hint: "符号链接指向登记目录之外,不能操作" };
|
||||||
const s: FileStatus = e.status;
|
const s: FileStatus = e.status;
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case "temp":
|
case "temp":
|
||||||
return { label: "未完成", tone: "neutral", hint: "下载器或整理过程中的未完成文件" };
|
return { label: "未完成", tone: "neutral", hint: "下载或整理还没完成的临时文件" };
|
||||||
case "downloading":
|
case "downloading":
|
||||||
return {
|
return {
|
||||||
label: e.download?.progress != null ? `下载中 ${Math.floor(e.download.progress * 100)}%` : "下载中",
|
label: e.download?.progress != null ? `下载中 ${Math.floor(e.download.progress * 100)}%` : "下载中",
|
||||||
@@ -44,7 +44,7 @@ export const FILTER_CHIPS = [
|
|||||||
{ value: "unidentified", label: "未识别" },
|
{ value: "unidentified", label: "未识别" },
|
||||||
{ value: "registered", label: "已整理" },
|
{ value: "registered", label: "已整理" },
|
||||||
{ value: "downloading", label: "下载中" },
|
{ value: "downloading", label: "下载中" },
|
||||||
{ value: "temp", label: "未完成下载" },
|
{ value: "temp", label: "未完成" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const SORT_OPTIONS = [
|
export const SORT_OPTIONS = [
|
||||||
@@ -100,7 +100,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
|
|||||||
case "scrape":
|
case "scrape":
|
||||||
return e.asset ? null : "整理后才能刷新元数据";
|
return e.asset ? null : "整理后才能刷新元数据";
|
||||||
case "writeNfo":
|
case "writeNfo":
|
||||||
if (root.kind === "library" && !root.writable) return "只读目录,不写 NFO";
|
if (root.kind === "library" && !root.writable) return "只读目录,不能刮削";
|
||||||
if (e.status === "temp") return "未完成的文件不能刮削";
|
if (e.status === "temp") return "未完成的文件不能刮削";
|
||||||
return null;
|
return null;
|
||||||
case "probe":
|
case "probe":
|
||||||
@@ -201,7 +201,7 @@ export function transferTargetProblem(
|
|||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
if (it.root !== target.root) continue;
|
if (it.root !== target.root) continue;
|
||||||
if (target.path === it.path || target.path.startsWith(`${it.path}/`))
|
if (target.path === it.path || target.path.startsWith(`${it.path}/`))
|
||||||
return `目标在源目录之内(${baseName(it.path)}),换一个目录`;
|
return `目标不能是 ${baseName(it.path)} 或它的子目录,换一个目录`;
|
||||||
if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`;
|
if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -226,11 +226,11 @@ export function collapseCrumbs<T>(items: T[], max: number): (T | null)[] {
|
|||||||
export function nameProblem(name: string): string | null {
|
export function nameProblem(name: string): string | null {
|
||||||
const n = name.trim();
|
const n = name.trim();
|
||||||
if (!n) return "名称不能为空";
|
if (!n) return "名称不能为空";
|
||||||
if (n === "." || n === "..") return "不能用 . 或 ..";
|
if (n === "." || n === "..") return "名称不能是 . 或 ..";
|
||||||
if (/[/\\]/.test(n)) return "名称不能包含 / 或 \\";
|
if (/[/\\]/.test(n)) return "名称不能包含 / 或 \\";
|
||||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: 过滤控制字符
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: 过滤控制字符
|
||||||
if (/[\u0000-\u001f]/.test(n)) return "名称不能包含控制字符";
|
if (/[\u0000-\u001f]/.test(n)) return "名称不能包含控制字符";
|
||||||
if (n.length > 255) return "名称过长";
|
if (n.length > 255) return "名称不能超过 255 个字符";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +318,7 @@ export function deleteBlockText(b: string | null): { text: string; soft: boolean
|
|||||||
case "needs_unbind":
|
case "needs_unbind":
|
||||||
return { text: "是作品或季的绑定目录,勾选「解除路径绑定」后才能删除", soft: true };
|
return { text: "是作品或季的绑定目录,勾选「解除路径绑定」后才能删除", soft: true };
|
||||||
case "root_unavailable":
|
case "root_unavailable":
|
||||||
return { text: "目录离线,不能确认文件状态", soft: false };
|
return { text: "目录离线,无法确认文件状态", soft: false };
|
||||||
case "not_found":
|
case "not_found":
|
||||||
return { text: "文件已不存在", soft: false };
|
return { text: "文件已不存在", soft: false };
|
||||||
case "escape":
|
case "escape":
|
||||||
@@ -557,7 +557,7 @@ const REMOTE_PENDING_WARNING = /^远端候选在后台查询/;
|
|||||||
export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyCompletedPayload): FilesIdentifyResult {
|
export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyCompletedPayload): FilesIdentifyResult {
|
||||||
if (!r.remoteTaskId || p.taskId !== r.remoteTaskId) return r;
|
if (!r.remoteTaskId || p.taskId !== r.remoteTaskId) return r;
|
||||||
if (p.status === "failed") {
|
if (p.status === "failed") {
|
||||||
const note = `远端查询失败,只有本地结果${p.error ? `:${p.error.slice(0, 200)}` : ""}`;
|
const note = `TMDB 查询失败,只显示本地结果${p.error ? `:${p.error.slice(0, 200)}` : ""}`;
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
remoteTaskId: null,
|
remoteTaskId: null,
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ const KEYS: [string, string][] = [
|
|||||||
["Ctrl/⌘+A", "全选"],
|
["Ctrl/⌘+A", "全选"],
|
||||||
["Ctrl/⌘+Enter", "整理选中项"],
|
["Ctrl/⌘+Enter", "整理选中项"],
|
||||||
["F2", "重命名"],
|
["F2", "重命名"],
|
||||||
["Del", "删除(先进回收站)"],
|
["Del", "删除(移入回收站)"],
|
||||||
["/", "搜索当前目录"],
|
["/", "搜索当前目录"],
|
||||||
["Esc", "清除选择"],
|
["Esc", "清除选择"],
|
||||||
["Shift+F10 / 菜单键", "打开操作菜单"],
|
["Shift+F10 / 菜单键", "打开操作菜单"],
|
||||||
["拖到目录", "移动到(按住 Alt 复制),先出预览"],
|
["拖到目录", "移动(按住 Alt 复制),先预览"],
|
||||||
["?", "显示这张表"],
|
["?", "显示这张表"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user