docs(plan): v0.2 adds Pi subagents and scheduled-jobs board
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
b839ed4056
commit
16ab2a6c84
97 files changed
+592
-648
No files matched your search
@@ -147,14 +147,14 @@ function TotpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: b
|
||||
try {
|
||||
if (enabled) {
|
||||
if (!password) {
|
||||
setError("请输入当前密码");
|
||||
setError("输入当前密码");
|
||||
return;
|
||||
}
|
||||
await action.mutateAsync({ action: "disable", password, code });
|
||||
toast.success("已关闭二次验证");
|
||||
} else {
|
||||
await action.mutateAsync({ action: "enable", code });
|
||||
toast.success("已开启二次验证,下次登录需要输入验证码");
|
||||
toast.success("已开启二次验证");
|
||||
}
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
@@ -174,8 +174,8 @@ function TotpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: b
|
||||
{status.isLoading
|
||||
? "读取状态…"
|
||||
: enabled
|
||||
? "已开启:登录时需要验证器里的 6 位验证码。关闭需要当前密码和验证码。"
|
||||
: "未开启:开启后登录除密码外还需要验证器(如 1Password、Google Authenticator)里的 6 位验证码。"}
|
||||
? "已开启。关闭需要当前密码和验证码。"
|
||||
: "未开启。开启后登录还要输入验证器里的 6 位验证码。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!enabled && !setup ? (
|
||||
@@ -190,8 +190,7 @@ function TotpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: b
|
||||
{setup ? (
|
||||
<div className="mb-2 grid gap-1.5">
|
||||
<p className="text-meta text-muted-foreground">
|
||||
在验证器里选“手动输入密钥”,填入下面的密钥(基于时间,6 位);或把 otpauth
|
||||
链接粘贴到支持的验证器。密钥只显示这一次。
|
||||
在验证器里手动输入下面的密钥,或粘贴 otpauth 链接。密钥只显示这一次。
|
||||
</p>
|
||||
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm break-all select-all">
|
||||
{grouped}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function ApiKeyDialog() {
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={hasKey ? "已设置,输入新值以替换" : "粘贴 API Key"}
|
||||
placeholder={hasKey ? "输入新值替换" : "粘贴 API Key"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -11,11 +11,7 @@ export function WorkspacePlaceholder({ milestone = "W3" }: { milestone?: string
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={item?.label ?? "未命名页面"} description={item?.description} />
|
||||
<EmptyState
|
||||
icon={item?.icon ?? Construction}
|
||||
title="这个工作区还在建设中"
|
||||
description={`计划在 ${milestone} 接入真实数据。`}
|
||||
/>
|
||||
<EmptyState icon={item?.icon ?? Construction} title="尚未接入" description={`计划在 ${milestone} 接入。`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const TASK_KIND_LABEL: Record<TaskKind, string> = {
|
||||
refreshMetadata: "刷新元数据",
|
||||
reconcileLibrary: "资产对账",
|
||||
cleanupDownloadCache: "清理缓存",
|
||||
resolveDecision: "处理待办",
|
||||
resolveDecision: "处理待处理事项",
|
||||
agentRun: "Agent 运行",
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@ export function BusinessTasks() {
|
||||
body: op === "cancel" ? { reason: "控制台取消" } : {},
|
||||
}).then((d) => Task.parse(d)),
|
||||
onSuccess: (_t, v) => {
|
||||
toast.success(v.op === "cancel" ? "已请求取消(只停后续步骤)" : "已重新排队");
|
||||
toast.success(v.op === "cancel" ? "已请求取消,后续步骤不再执行" : "已重新排队");
|
||||
void qc.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
@@ -218,7 +218,7 @@ export function BusinessTasks() {
|
||||
empty={{
|
||||
icon: ListTodo,
|
||||
title: kind || status ? "没有符合筛选的任务" : "还没有业务任务",
|
||||
description: "提交下载、核验入库、缓存清理、对账等后台工作都会记在这里。",
|
||||
description: "提交下载、核验入库、清理缓存、对账等后台任务会出现在这里。",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[];
|
||||
/>
|
||||
)}
|
||||
{loading && events.length ? (
|
||||
<p className="text-meta text-muted-foreground">正在补全关联的整理记录与引擎 run…</p>
|
||||
<p className="text-meta text-muted-foreground">正在加载关联的整理记录与引擎运行…</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -63,8 +63,8 @@ function VolumeBar({ v }: { v: Volume }) {
|
||||
<span>保留锁定 {formatBytes(v.retentionLockedBytes, 1)}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{pct === null ? <StatusBadge tone="warning">读不到磁盘状态:已保守阻止新提交</StatusBadge> : null}
|
||||
{tripped ? <StatusBadge tone="destructive">已达硬阈值:停止新提交</StatusBadge> : null}
|
||||
{pct === null ? <StatusBadge tone="warning">读不到磁盘状态,已暂停新提交</StatusBadge> : null}
|
||||
{tripped ? <StatusBadge tone="destructive">已达硬阈值,停止新提交</StatusBadge> : null}
|
||||
{v.pausedByThreshold ? <StatusBadge tone="warning">阈值暂停 {v.pausedByThreshold} 个任务</StatusBadge> : null}
|
||||
</div>
|
||||
{v.notes.map((n) => (
|
||||
@@ -93,12 +93,12 @@ export function CacheVolumes() {
|
||||
</Button>
|
||||
}
|
||||
title="按规则清理下载缓存?"
|
||||
description="只清理已核验入库、且满足保留期与 H&R 的纳管任务;未纳管任务与未满足保留的不会动。"
|
||||
description="只清理已入库、且满足保留期与 H&R 的纳管任务,其余不动。"
|
||||
confirmLabel="清理"
|
||||
onConfirm={() =>
|
||||
cleanup.mutateAsync(undefined).then(
|
||||
(r) => {
|
||||
toast.success(`已排队清理任务 ${r.taskId.slice(0, 8)}`, { description: "后台执行,结果见任务详情" });
|
||||
toast.success(`已排队清理任务 ${r.taskId.slice(0, 8)}`, { description: "结果见任务详情" });
|
||||
navigate(`/console/engine/${r.runId}`);
|
||||
},
|
||||
(e) => toast.error(errorMessage(e)),
|
||||
@@ -111,7 +111,7 @@ export function CacheVolumes() {
|
||||
) : vols.error ? (
|
||||
<ErrorState plain error={vols.error} onRetry={() => void vols.refetch()} />
|
||||
) : !vols.data.length ? (
|
||||
<p className="text-caption text-muted-foreground">还没有下载器缓存卷的统计(未配置下载器或尚未巡检)。</p>
|
||||
<p className="text-caption text-muted-foreground">还没有缓存卷数据。配置下载器并完成巡检后显示。</p>
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{vols.data.map((v) => (
|
||||
|
||||
@@ -66,14 +66,14 @@ export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" |
|
||||
variant="outline"
|
||||
size={size}
|
||||
disabled={act.isPending}
|
||||
title={threshold ? "阈值暂停:缓存回落到恢复水位后由系统自动恢复" : undefined}
|
||||
title={threshold ? "阈值暂停,缓存回落到恢复水位后自动恢复" : undefined}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
act.mutate(
|
||||
{ id: t.id, action: canResume ? "resume" : "pause" },
|
||||
{
|
||||
onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停(记为用户暂停,阈值恢复不会自动恢复它)"),
|
||||
onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停。阈值回落时不会自动恢复"),
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
},
|
||||
);
|
||||
@@ -128,13 +128,12 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
{task.pauseReason === "threshold" ? (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
缓存卷达到硬阈值,系统暂停了这个纳管任务。水位回落到恢复线后自动恢复;手动恢复、优先级或 Agent
|
||||
推荐都不能绕过阈值。
|
||||
缓存卷达到硬阈值,任务已暂停,水位回落到恢复线后自动恢复。手动恢复和 Agent 都不能绕过阈值。
|
||||
</p>
|
||||
) : null}
|
||||
{task.noProgressSince ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
自 {formatDateTime(task.noProgressSince)} 起无进展。系统只记录证据并汇入待处理,不会自动换源。
|
||||
自 {formatDateTime(task.noProgressSince)} 起无进展,已记入待处理,不会自动换源。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -44,9 +44,7 @@ function PlanPreview({ planId }: { planId: string }) {
|
||||
return (
|
||||
<p className="rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground">
|
||||
计划已生成(planId {planId.slice(0, 8)}…),
|
||||
{isMissingEndpoint(art.error)
|
||||
? "计划内容不存在或已过期,请重新预览。"
|
||||
: `读取计划失败:${errorMessage(art.error)}`}
|
||||
{isMissingEndpoint(art.error) ? "计划已过期,重新预览。" : `读取计划失败:${errorMessage(art.error)}`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +63,7 @@ function PlanPreview({ planId }: { planId: string }) {
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusBadge>{org.operation}</StatusBadge>
|
||||
{org.crossDevice ? <StatusBadge tone="warning">跨卷:Copy → 核验 → 删源</StatusBadge> : null}
|
||||
{org.crossDevice ? <StatusBadge tone="warning">跨卷:复制 → 核验 → 删源</StatusBadge> : null}
|
||||
{org.requiresApproval ? <StatusBadge tone="warning">需要审批</StatusBadge> : null}
|
||||
{blocking.length ? <StatusBadge tone="destructive">阻塞冲突 {blocking.length}</StatusBadge> : null}
|
||||
</div>
|
||||
@@ -155,7 +153,7 @@ export function OrganizeDialog({
|
||||
const pathErr = Object.entries(e).find(([k]) => k.startsWith("sourcePaths"));
|
||||
setErrors({
|
||||
...e,
|
||||
paths: pathErr ? "每行一个以 / 开头的绝对路径" : e[""] ? "填写源路径(每行一个绝对路径)" : "",
|
||||
paths: pathErr ? "每行一个以 / 开头的绝对路径" : e[""] ? "填写源路径" : "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -165,8 +163,8 @@ export function OrganizeDialog({
|
||||
if (dryRun) {
|
||||
setPlanId(r.planId);
|
||||
if (r.truncated)
|
||||
toast.warning("源目录文件数超过上限,预览只含前一部分文件", {
|
||||
description: "正式执行会被拒绝,请把源路径缩小到具体的作品目录",
|
||||
toast.warning("源目录文件过多,预览只含前一部分", {
|
||||
description: "执行会被拒绝。把源路径缩小到具体的作品目录",
|
||||
});
|
||||
} else {
|
||||
toast.success("已发起整理");
|
||||
@@ -183,9 +181,7 @@ export function OrganizeDialog({
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>手动整理</DialogTitle>
|
||||
<DialogDescription>
|
||||
先预览计划(不动文件),确认后再执行。Move 跨卷时按 Copy → 核验 → 删源执行。
|
||||
</DialogDescription>
|
||||
<DialogDescription>先预览计划(不动文件),确认后执行。跨卷移动按复制 → 核验 → 删源执行。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-1">
|
||||
{downloadTaskId ? (
|
||||
@@ -266,7 +262,7 @@ export function OrganizeDialog({
|
||||
<FormField
|
||||
label="动作"
|
||||
htmlFor="org-action"
|
||||
hint={action === "move" ? "Move 需要对应授权" : "保留源文件(做种不受影响)"}
|
||||
hint={action === "move" ? "移动需要对应授权" : "保留源文件,不影响做种"}
|
||||
>
|
||||
<Select
|
||||
value={action}
|
||||
@@ -279,8 +275,8 @@ export function OrganizeDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="copy">Copy</SelectItem>
|
||||
<SelectItem value="move">Move</SelectItem>
|
||||
<SelectItem value="copy">复制</SelectItem>
|
||||
<SelectItem value="move">移动</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
@@ -220,7 +220,7 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry:
|
||||
onSuccess: (r) => {
|
||||
const it = r.items[0];
|
||||
const c = it?.candidates[0];
|
||||
if (!it || !c) return toast.info("没有可信的识别结果,无法给出建议名");
|
||||
if (!it || !c) return toast.info("识别结果不可信,没有建议名");
|
||||
setName(suggestName(c, it.parsed, entry.name, isDir));
|
||||
setErr(null);
|
||||
},
|
||||
@@ -255,7 +255,7 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry:
|
||||
reason === "exists"
|
||||
? "目标名已存在,不会覆盖"
|
||||
: reason === "download_active"
|
||||
? "做种中,改名会让下载器找不到文件"
|
||||
? "正在做种,改名会让下载器找不到文件"
|
||||
: filesErrorText(e),
|
||||
);
|
||||
},
|
||||
@@ -325,14 +325,10 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry:
|
||||
))}
|
||||
</ul>
|
||||
{pv.assetLocations > 0 ? (
|
||||
<Notice tone="neutral">
|
||||
将同步 {pv.assetLocations} 条资产位置,并写一条整理记录(action=rename)。
|
||||
</Notice>
|
||||
<Notice tone="neutral">将同步 {pv.assetLocations} 条资产位置,并写一条整理记录。</Notice>
|
||||
) : null}
|
||||
{pv.bindings.length > 0 ? (
|
||||
<Notice tone="warning">
|
||||
这是作品/季的绑定目录:将新建绑定并把旧绑定标记为已替代,之后新集入库进入新目录。
|
||||
</Notice>
|
||||
<Notice tone="warning">这是作品/季的绑定目录。改名后旧绑定标为已替代,新集入库到新目录。</Notice>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
@@ -422,7 +418,7 @@ export function TransferDialog({
|
||||
onError: (e) => {
|
||||
const reason = filesErrorReason(e);
|
||||
if (reason === "plan_stale") {
|
||||
setErr("源文件在预览后发生了变化,已重新生成预览,请再确认一次。");
|
||||
setErr("源文件在预览后有变化,已重新预览。确认后再执行。");
|
||||
runPreview(target, mode);
|
||||
} else setErr(filesErrorText(e));
|
||||
},
|
||||
@@ -437,7 +433,7 @@ export function TransferDialog({
|
||||
<DialogTitle>{mode === "move" ? "移动到…" : "复制到…"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} ·
|
||||
先预览前后路径、冲突与空间,确认后才执行; 统一走可恢复的整理计划,跨卷为 Copy → 核验 → 删源。
|
||||
先预览路径、冲突与空间,确认后执行。跨卷按复制 → 核验 → 删源执行。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid min-h-0 gap-4 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)]">
|
||||
@@ -475,7 +471,7 @@ export function TransferDialog({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">只有可写的媒体库根能作为目标。</p>
|
||||
<p className="text-meta text-muted-foreground">只有可写的库根能作为目标。</p>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
{target && targetRoot ? (
|
||||
@@ -512,7 +508,7 @@ export function TransferDialog({
|
||||
</div>
|
||||
{hnrBlocked ? (
|
||||
<Notice tone="destructive">
|
||||
{plan.hnrBlocks.length} 项仍在做种且 H&R 未满足,不能 Move。可改用“复制”,或等 H&R 满足后再移动。
|
||||
{plan.hnrBlocks.length} 项仍在做种且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 underline"
|
||||
@@ -526,7 +522,7 @@ export function TransferDialog({
|
||||
</button>
|
||||
</Notice>
|
||||
) : null}
|
||||
{plan.truncated ? <Notice tone="warning">源文件过多,预览只列出前一部分;执行覆盖全部。</Notice> : null}
|
||||
{plan.truncated ? <Notice tone="warning">源文件过多,预览只列出前一部分,执行时处理全部。</Notice> : null}
|
||||
<ul className="space-y-1.5">
|
||||
{plan.pairs.map((p) => {
|
||||
const sr = rootById.get(p.source.root);
|
||||
@@ -654,7 +650,7 @@ export function DeleteDialog({
|
||||
reason === "permanent_disabled"
|
||||
? "系统设置未开启永久删除(files.allowPermanentDelete)"
|
||||
: reason === "approval_required"
|
||||
? "需要审批:API Key 删除每次都要一次性审批"
|
||||
? "用 API Key 删除,每次都需要审批"
|
||||
: filesErrorText(e),
|
||||
);
|
||||
},
|
||||
@@ -666,9 +662,7 @@ export function DeleteDialog({
|
||||
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
删除前已做引用检查:做种任务、已登记资产、路径绑定。删除不会动下载器里的任务。
|
||||
</DialogDescription>
|
||||
<DialogDescription>已检查做种任务、已登记资产和路径绑定。下载器里的任务不受影响。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 space-y-3 overflow-y-auto">
|
||||
{check.isPending ? (
|
||||
@@ -715,7 +709,7 @@ export function DeleteDialog({
|
||||
</p>
|
||||
))}
|
||||
{c.assets.length ? (
|
||||
<p>已登记资产 {c.assets.length} 个(删除后退役,满足的 Slot 退回待处理)</p>
|
||||
<p>已登记资产 {c.assets.length} 个(删除后退役,已满足的版本目标退回待处理)</p>
|
||||
) : null}
|
||||
{c.bindings.map((bd) => (
|
||||
<p key={bd.bindingId}>
|
||||
@@ -723,7 +717,7 @@ export function DeleteDialog({
|
||||
{bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""}
|
||||
</p>
|
||||
))}
|
||||
{c.sharedInode ? <p>共享 inode(硬链接):只删除这一处链接,其他位置的文件不受影响</p> : null}
|
||||
{c.sharedInode ? <p>共享 inode(硬链接):只删这一处链接,其他位置不受影响</p> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -736,7 +730,7 @@ export function DeleteDialog({
|
||||
<span>
|
||||
同时退役资产
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
资产位置标为已移除、资产退役;满足的 Slot 退回待处理并建待处理事项。
|
||||
资产位置标为已移除,资产退役;已满足的版本目标退回待处理。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -746,9 +740,7 @@ export function DeleteDialog({
|
||||
<input type="checkbox" className="mt-1" checked={unbind} onChange={(e) => setUnbind(e.target.checked)} />
|
||||
<span>
|
||||
解除路径绑定
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
绑定标为已替代;之后该作品入库会重新选择目录。
|
||||
</span>
|
||||
<span className="block text-meta text-muted-foreground">绑定标为已替代,该作品下次入库重新选目录。</span>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
@@ -764,7 +756,7 @@ export function DeleteDialog({
|
||||
/>
|
||||
<span>
|
||||
移入回收站
|
||||
<span className="block text-meta text-muted-foreground">保留 7 天,期间可在回收站还原到原位置。</span>
|
||||
<span className="block text-meta text-muted-foreground">到期前可在回收站还原。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
@@ -782,7 +774,7 @@ export function DeleteDialog({
|
||||
<span>
|
||||
永久删除
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
{isAdmin ? "不可恢复;需在系统设置开启后才可用。" : "仅管理员会话可用。"}
|
||||
{isAdmin ? "不可恢复。需要在系统设置里开启。" : "仅管理员会话可用。"}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -866,9 +858,7 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
下载器与媒体服务器视角的路径按系统设置里的路径映射换算,见“系统设置 → 路径映射”。
|
||||
</p>
|
||||
<p className="text-meta text-muted-foreground">下载器与媒体服务器看到的路径按“系统设置 → 路径映射”换算。</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -890,9 +880,9 @@ export function ScrapeDialog({ entry, onClose }: { entry: FileEntry; onClose: ()
|
||||
onSuccess: (r) => {
|
||||
const extra =
|
||||
r.nfo === "not_implemented" || r.images === "not_implemented"
|
||||
? "NFO/图片投影尚未实现,本次只刷新了字段。"
|
||||
? "还不支持写 NFO 和图片,只刷新了字段。"
|
||||
: r.nfo === "skipped"
|
||||
? "目标 NFO 不是本系统生成且字段已锁定,未覆盖。"
|
||||
? "目标 NFO 不是本工具生成且字段已锁定,未覆盖。"
|
||||
: "";
|
||||
toast.success(`已提交元数据刷新${r.lockedFields.length ? `,${r.lockedFields.length} 个锁定字段不动` : ""}`, {
|
||||
description: extra || undefined,
|
||||
@@ -908,9 +898,7 @@ export function ScrapeDialog({ entry, onClose }: { entry: FileEntry; onClose: ()
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>刮削 {asset.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
字段级刷新元数据:用户锁定的字段不动,不改路径。非本系统生成的 NFO 不会被覆盖。
|
||||
</DialogDescription>
|
||||
<DialogDescription>按字段刷新元数据,锁定的字段和路径不变。不覆盖非本工具生成的 NFO。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3 text-caption">
|
||||
写 NFO 到绑定目录
|
||||
|
||||
@@ -71,7 +71,7 @@ export function FileDetail({
|
||||
label: "链接数",
|
||||
value:
|
||||
stat.data?.nlink != null
|
||||
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,禁止原地改写)" : ""}`
|
||||
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,不能原地改写)" : ""}`
|
||||
: null,
|
||||
},
|
||||
{ label: "本机路径", value: `${root.hostPath}${e.path ? `/${e.path}` : ""}`, mono: true, wide: true },
|
||||
@@ -110,7 +110,7 @@ export function FileDetail({
|
||||
{e.asset.slotStatus ?? "查看"}
|
||||
</Link>
|
||||
) : (
|
||||
"未满足任何 Slot"
|
||||
"未满足任何版本目标"
|
||||
),
|
||||
},
|
||||
{ label: "主资产", value: e.asset.isPrimary ? "是" : "否" },
|
||||
@@ -162,7 +162,7 @@ export function FileDetail({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1 text-meta text-muted-foreground">改名/移动此目录会同步修订绑定;元数据刷新不会改路径。</p>
|
||||
<p className="mt-1 text-meta text-muted-foreground">改名或移动此目录会同步更新绑定;刷新元数据不改路径。</p>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
@@ -225,7 +225,7 @@ export function FileDetail({
|
||||
/>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
未登记文件不自动探测;点“探测”读取 ffprobe 摘要(缓存 24 小时)。
|
||||
未登记文件不自动探测。点“探测”读取 ffprobe 摘要,缓存 24 小时。
|
||||
</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
@@ -684,7 +684,7 @@ function doMove(body: {
|
||||
executed: null,
|
||||
};
|
||||
if (body.dryRun) return res;
|
||||
if (hnrBlocks.length) throw new ApiRequestError("conflict", "download_active: 含 H&R 未满足的做种文件,不能 Move");
|
||||
if (hnrBlocks.length) throw new ApiRequestError("conflict", "download_active: 含 H&R 未满足的做种文件,不能移动");
|
||||
const succeeded: string[] = [];
|
||||
const failed: { path: string; code: string; message: string }[] = [];
|
||||
for (const p of pairs) {
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("状态与禁用原因", () => {
|
||||
expect(opDisabledReason("rename", base, { kind: "download", writable: false })).toBeNull();
|
||||
expect(opDisabledReason("scrape", base, lib)).toMatch(/未登记/);
|
||||
expect(opDisabledReason("copyPath", { ...base, escape: true }, lib)).toBeNull();
|
||||
expect(opDisabledReason("rename", { ...base, escape: true }, lib)).toMatch(/根之外/);
|
||||
expect(opDisabledReason("rename", { ...base, escape: true }, lib)).toMatch(/登记目录之外/);
|
||||
});
|
||||
|
||||
it("删除阻断分软硬", () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ export type StatusView = { label: string; tone: StatusTone; hint?: string };
|
||||
|
||||
/** 行主状态 → 徽标(文字必带,不只靠颜色) */
|
||||
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;
|
||||
switch (s) {
|
||||
case "temp":
|
||||
@@ -22,7 +22,7 @@ export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "esca
|
||||
return { label: "已登记", tone: "success" };
|
||||
case "seeding":
|
||||
return e.download?.hnr === "unsatisfied"
|
||||
? { label: "做种中·H&R 未满足", tone: "warning", hint: "H&R 未满足前不能改名、删除或 Move" }
|
||||
? { label: "做种中·H&R 未满足", tone: "warning", hint: "H&R 满足前不能改名、删除或移动" }
|
||||
: { label: "做种中", tone: "neutral" };
|
||||
case "unidentified":
|
||||
return { label: "待识别", tone: "warning" };
|
||||
@@ -65,7 +65,7 @@ export type FileOp =
|
||||
|
||||
/** 返回 null = 可用;否则为禁用原因 */
|
||||
export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot, "kind" | "writable">): string | null {
|
||||
if (e.escape && op !== "copyPath") return "链接指向登记根之外,已禁用";
|
||||
if (e.escape && op !== "copyPath") return "链接指向登记目录之外,已禁用";
|
||||
// 下载根不能作为写入目标,但其中的文件可改名/删除/移出(服务端仍按引用检查拒绝做种项)
|
||||
const rootWritable = root.writable || root.kind === "download";
|
||||
const seeding = e.status === "seeding" || e.status === "downloading";
|
||||
@@ -77,8 +77,8 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
|
||||
if (e.status === "temp") return "临时文件不能改名";
|
||||
return null;
|
||||
case "move":
|
||||
if (!rootWritable) return "只读登记根,不能移出(可用“复制到…”)";
|
||||
if (hnrBlocked) return "做种中且 H&R 未满足,不能 Move";
|
||||
if (!rootWritable) return "只读根,不能移出。可用“复制到…”";
|
||||
if (hnrBlocked) return "做种中且 H&R 未满足,不能移动";
|
||||
return null;
|
||||
case "delete":
|
||||
if (!rootWritable) return "只读根,不能删除";
|
||||
@@ -90,7 +90,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
|
||||
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录可识别/整理";
|
||||
return null;
|
||||
case "scrape":
|
||||
return e.asset ? null : "未登记,请先识别并整理入库";
|
||||
return e.asset ? null : "未登记,先识别并整理入库";
|
||||
case "probe":
|
||||
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
|
||||
case "openMedia":
|
||||
@@ -299,7 +299,7 @@ export function deleteBlockText(b: string | null): { text: string; soft: boolean
|
||||
case "needs_retire":
|
||||
return { text: "含已登记资产,需勾选“同时退役资产”", soft: true };
|
||||
case "needs_unbind":
|
||||
return { text: "是作品/季的绑定目录,需勾选“解除绑定”", soft: true };
|
||||
return { text: "是作品/季的绑定目录,需勾选“解除路径绑定”", soft: true };
|
||||
case "root_unavailable":
|
||||
return { text: "根离线,不能确认文件状态", soft: false };
|
||||
case "not_found":
|
||||
|
||||
@@ -249,8 +249,7 @@ export function OrganizeWizard({
|
||||
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
|
||||
<SheetTitle className="text-lead">整理向导</SheetTitle>
|
||||
<SheetDescription>
|
||||
{entries.length === 1 ? entries[0]?.name : `${entries.length} 项`} ·
|
||||
确认后才落库;与自动整理产出同格式的整理记录。
|
||||
{entries.length === 1 ? entries[0]?.name : `${entries.length} 项`} · 确认后才执行,结果写入整理记录。
|
||||
</SheetDescription>
|
||||
<ol className="mt-3 flex flex-wrap items-center gap-1.5" aria-label="步骤">
|
||||
{STEPS.map((s, i) => (
|
||||
@@ -279,7 +278,7 @@ export function OrganizeWizard({
|
||||
{step === 0 ? (
|
||||
identify.isPending ? (
|
||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" /> 识别中:解析文件名并匹配本地作品与 TMDB…
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" /> 识别中:匹配本地作品与 TMDB…
|
||||
</p>
|
||||
) : identify.error ? (
|
||||
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
|
||||
@@ -288,7 +287,7 @@ export function OrganizeWizard({
|
||||
<div className="flex flex-wrap items-center gap-2 text-caption">
|
||||
<StatusBadge>{rows.length} 个视频</StatusBadge>
|
||||
{problems.length ? (
|
||||
<StatusBadge tone="warning">{problems.length} 项未决:选作品或排除</StatusBadge>
|
||||
<StatusBadge tone="warning">{problems.length} 项待定:选作品或排除</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="success">全部已确认</StatusBadge>
|
||||
)}
|
||||
@@ -352,13 +351,13 @@ export function OrganizeWizard({
|
||||
action === a ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
|
||||
)}
|
||||
>
|
||||
<span className="block text-body font-medium">{a === "copy" ? "Copy" : "Move"}</span>
|
||||
<span className="block text-body font-medium">{a === "copy" ? "复制" : "移动"}</span>
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
{a === "copy"
|
||||
? "保留源文件,做种不受影响"
|
||||
: disabled
|
||||
? "含做种中且 H&R 未满足的文件,不能 Move"
|
||||
: "入库后删源;跨卷为 Copy → 核验 → 删源"}
|
||||
? "含做种中且 H&R 未满足的文件,不能移动"
|
||||
: "入库后删源;跨卷按复制 → 核验 → 删源"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -386,8 +385,8 @@ export function OrganizeWizard({
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-1 rounded-xl border border-border px-3 py-2.5 text-caption text-muted-foreground">
|
||||
<p>命名:按系统设置里的命名模板重命名;已绑定的作品/季沿用原目录。</p>
|
||||
<p>目标已存在:不覆盖,列为冲突进待处理。</p>
|
||||
<p>命名:按系统设置的命名模板;已绑定的作品/季沿用原目录。</p>
|
||||
<p>目标已存在:不覆盖,作为冲突进入待处理。</p>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -446,9 +445,7 @@ export function OrganizeWizard({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-caption text-muted-foreground">
|
||||
整理在后台执行(逐文件可恢复),完成后列表会自动刷新状态。
|
||||
</p>
|
||||
<p className="text-caption text-muted-foreground">整理在后台执行,完成后列表自动刷新。</p>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
@@ -568,7 +565,7 @@ function GroupCard({ g, title }: { g: FilesOrganizeGroup; title?: Entity }) {
|
||||
{g.error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-destructive">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{g.error.message}(返回第 1 步修改后重新预览)
|
||||
{g.error.message}(回到第 1 步修改后重新预览)
|
||||
</p>
|
||||
) : null}
|
||||
{g.warnings.map((w) => (
|
||||
@@ -714,7 +711,7 @@ function SlotSelect({
|
||||
disabled={!entityId}
|
||||
className="h-9 w-full min-w-0 rounded-lg border border-input bg-transparent px-2 text-caption disabled:opacity-50"
|
||||
>
|
||||
<option value="">Slot:自动</option>
|
||||
<option value="">版本目标:自动</option>
|
||||
{items.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}({s.status})
|
||||
@@ -803,8 +800,7 @@ function EntityPicker({
|
||||
{remoteOnly.length ? (
|
||||
<p className="px-2 pt-2 text-meta text-muted-foreground">
|
||||
TMDB 另有 {remoteOnly.length} 个未入库候选(
|
||||
{remoteOnly.map((c) => `${c.title}${c.year ? ` ${c.year}` : ""}`).join("、")}),
|
||||
需先在“身份与元数据”建立作品。
|
||||
{remoteOnly.map((c) => `${c.title}${c.year ? ` ${c.year}` : ""}`).join("、")}), 先在“身份与元数据”建立作品。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="relative mt-2">
|
||||
|
||||
@@ -37,7 +37,7 @@ import { Tone } from "./parts.tsx";
|
||||
// ── 改外部映射 ──
|
||||
|
||||
const IdentityForm = EntityIdentitySet.extend({
|
||||
externalId: z.string().trim().regex(/^\d+$/, "TMDB id 是纯数字"),
|
||||
externalId: z.string().trim().regex(/^\d+$/, "TMDB ID 只能是数字"),
|
||||
});
|
||||
|
||||
export function IdentityDialog({
|
||||
@@ -90,7 +90,7 @@ export function IdentityDialog({
|
||||
toast.success(`已改为 TMDB ${r.data.entityType}:${r.data.externalId}${r.data.lock ? ",并加人工锁" : ""}`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, "改映射失败")),
|
||||
onError: (err) => toast.error(errorMessage(err, "修改失败")),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -100,15 +100,15 @@ export function IdentityDialog({
|
||||
<Dialog open={open} onOpenChange={(o) => !set.isPending && onOpenChange(o)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>改外部映射</DialogTitle>
|
||||
<DialogTitle>修改外部身份</DialogTitle>
|
||||
<DialogDescription>
|
||||
旧的同类型 TMDB 映射会退役并留下修订记录;新映射直接确认。加人工锁后自动匹配与刷新不再改动它。
|
||||
同类型的旧 TMDB 身份会退役,保留修订记录。加人工锁后,自动匹配和刷新不再改动。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-3">
|
||||
{usable.length ? (
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-caption font-medium text-foreground">从候选中选</p>
|
||||
<p className="text-caption font-medium text-foreground">候选</p>
|
||||
<ul className="grid gap-1.5">
|
||||
{usable.map((c) => (
|
||||
<li key={c.id}>
|
||||
@@ -164,7 +164,7 @@ export function IdentityDialog({
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption font-medium text-foreground">加人工锁</p>
|
||||
<p className="text-meta text-muted-foreground">自动匹配不得覆盖人工锁定的关系</p>
|
||||
<p className="text-meta text-muted-foreground">自动匹配不会覆盖</p>
|
||||
</div>
|
||||
<Switch checked={lock} onCheckedChange={setLock} aria-label="加人工锁" />
|
||||
</div>
|
||||
@@ -174,7 +174,7 @@ export function IdentityDialog({
|
||||
</Button>
|
||||
<Button type="submit" disabled={set.isPending}>
|
||||
{set.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存映射
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -186,7 +186,7 @@ export function IdentityDialog({
|
||||
// ── 字段覆盖并锁定 ──
|
||||
|
||||
const FieldForm = z.object({
|
||||
value: z.string().trim().min(1, "请填写值"),
|
||||
value: z.string().trim().min(1, "填写值"),
|
||||
reason: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
@@ -227,7 +227,7 @@ export function FieldLockDialog({
|
||||
if (field === "year") {
|
||||
const n = Number(r.data.value);
|
||||
if (!Number.isInteger(n) || n < 1870 || n > 2200) {
|
||||
setErrors({ value: "年份需为 1870–2200 的整数" });
|
||||
setErrors({ value: "年份须是 1870–2200 的整数" });
|
||||
return;
|
||||
}
|
||||
v = n;
|
||||
@@ -250,7 +250,7 @@ export function FieldLockDialog({
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>锁定字段:{field ? (ENTITY_FIELD_LABEL[field] ?? field) : ""}</DialogTitle>
|
||||
<DialogDescription>保存为人工值并加锁,之后刷新元数据不会改写这个字段;不影响已绑定的路径。</DialogDescription>
|
||||
<DialogDescription>锁定后刷新元数据不改这个字段。已绑定的路径不变。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-3">
|
||||
<FormField label="值" htmlFor={`${id}-v`} error={errors.value} required>
|
||||
@@ -381,13 +381,13 @@ export function MergeDialog({
|
||||
<Dialog open={open && !confirm} onOpenChange={(o) => !o && onOpenChange(false)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>合并到另一作品</DialogTitle>
|
||||
<DialogTitle>合并作品</DialogTitle>
|
||||
<DialogDescription>
|
||||
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品;订阅、资产与历史引用一并转到目标。错误合并可以再修订。
|
||||
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、资产和历史记录一并转过去。合并错了可以再修订。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EntityPicker value={target} onChange={setTarget} excludeId={entity.id} />
|
||||
<FormField label="原因" htmlFor="merge-reason" hint="写入审计与跳转记录">
|
||||
<FormField label="原因" htmlFor="merge-reason" hint="写入审计日志">
|
||||
<Input
|
||||
id="merge-reason"
|
||||
value={reason}
|
||||
@@ -411,10 +411,10 @@ export function MergeDialog({
|
||||
setConfirm(o);
|
||||
if (!o && !merge.isSuccess) return;
|
||||
}}
|
||||
title="确认合并?"
|
||||
title="合并作品?"
|
||||
description={
|
||||
target
|
||||
? `把「${entity.title}」合并到「${target.title}${target.year ? ` (${target.year})` : ""}」。合并后本页将跳转到目标作品。`
|
||||
? `把「${entity.title}」合并到「${target.title}${target.year ? ` (${target.year})` : ""}」,完成后跳转到目标作品。`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="合并"
|
||||
|
||||
@@ -31,7 +31,7 @@ const ALIAS_KIND: Record<z.infer<typeof AliasKind>, string> = {
|
||||
};
|
||||
|
||||
const AliasForm = z.object({
|
||||
alias: z.string().trim().min(1, "请填写别名").max(200, "最多 200 字"),
|
||||
alias: z.string().trim().min(1, "填写别名").max(200, "最多 200 字"),
|
||||
kind: AliasKind,
|
||||
language: z
|
||||
.string()
|
||||
@@ -171,7 +171,7 @@ export function AliasesPanel() {
|
||||
</Button>
|
||||
}
|
||||
title={`删除别名「${a.alias}」?`}
|
||||
description="删除后该别名不再参与模糊召回;已确认的身份映射不受影响。"
|
||||
description="删除后不再用这个别名匹配作品,已确认的外部身份不受影响。"
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
@@ -190,7 +190,7 @@ export function AliasesPanel() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Panel title="按作品管理" description="选中作品后可新增别名;不选则按关键字检索全部别名" bodyClassName="space-y-3">
|
||||
<Panel title="按作品管理" description="选中作品后可添加别名" bodyClassName="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<EntityPicker value={entity} onChange={setEntity} />
|
||||
<FilterBar search={{ value: q, onChange: setQ, placeholder: "按别名关键字过滤" }} className="mb-0" />
|
||||
@@ -206,7 +206,7 @@ export function AliasesPanel() {
|
||||
onRetry={() => list.refetch()}
|
||||
empty={{
|
||||
title: entity ? `「${entity.title}」还没有别名` : "没有匹配的别名",
|
||||
description: "别名用于从原始标题、译名、简称找回作品。",
|
||||
description: "别名用于按原始标题、译名、简称匹配作品。",
|
||||
}}
|
||||
hasMore={list.hasNextPage}
|
||||
onLoadMore={() => list.fetchNextPage()}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ExternalIdentitySheet({ item, onClose }: { item: ExternalIdentit
|
||||
locked: !item.locked,
|
||||
reason: item.locked ? "控制台解除人工锁" : "控制台加人工锁",
|
||||
});
|
||||
toast.success(item.locked ? "已解除人工锁,自动匹配可再次修订" : "已加人工锁,自动匹配不会改动");
|
||||
toast.success(item.locked ? "已解除人工锁" : "已加人工锁");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
@@ -82,7 +82,6 @@ export function ExternalIdentitySheet({ item, onClose }: { item: ExternalIdentit
|
||||
width="lg"
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={identityRef(item)}
|
||||
description="外部身份是辅助映射;本地 UUID 才是主身份"
|
||||
footer={
|
||||
<Button variant="outline" onClick={toggleLock} disabled={update.isPending || item.status === "retired"}>
|
||||
{item.locked ? <LockOpen /> : <Lock />}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Chip, Tone } from "./parts.tsx";
|
||||
|
||||
const TmdbForm = z.object({
|
||||
entityType: z.enum(["movie", "tv"]),
|
||||
externalId: z.string().trim().regex(/^\d+$/, "TMDB id 是纯数字"),
|
||||
externalId: z.string().trim().regex(/^\d+$/, "TMDB ID 只能是数字"),
|
||||
});
|
||||
|
||||
/** query 摘要:标题 / 年份 / 季集 */
|
||||
@@ -78,7 +78,7 @@ function CandidateRow({
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-meta text-muted-foreground">本地未建档,指定后按外部详情建档</span>
|
||||
<span className="text-meta text-muted-foreground">本地未建档,指定后按 TMDB 信息建档</span>
|
||||
)}
|
||||
</div>
|
||||
{c.evidence.length ? (
|
||||
@@ -147,7 +147,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
</Button>
|
||||
}
|
||||
title="忽略这条待识别资源?"
|
||||
description="忽略后不再自动复查;资源仍可在待识别入口按原始标题找回。"
|
||||
description="忽略后不再自动复查,仍可按原始标题搜到。"
|
||||
confirmLabel="忽略"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
@@ -160,12 +160,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="备注(可选):为什么忽略"
|
||||
rows={2}
|
||||
/>
|
||||
<Textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="忽略原因(可选)" rows={2} />
|
||||
</ConfirmDialog>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -180,7 +175,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
}}
|
||||
>
|
||||
{retry.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCw />}
|
||||
立即重试
|
||||
重新识别
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -215,7 +210,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">没有候选。可以手动指定 TMDB id 或本地作品。</p>
|
||||
<p className="text-caption text-muted-foreground">没有候选。手动指定 TMDB ID 或本地作品。</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ export const DECISION_KIND: Record<DecisionKind, string> = {
|
||||
download_failed: "下载失败",
|
||||
path_conflict: "路径冲突",
|
||||
space_insufficient: "空间不足",
|
||||
approval_required: "需要授权",
|
||||
approval_required: "需要审批",
|
||||
metadata_conflict: "元数据冲突",
|
||||
source_unreachable: "来源不可达",
|
||||
other: "其他",
|
||||
@@ -176,7 +176,7 @@ export const PREF_KIND: Record<PreferenceKind, { label: string; hint: string }>
|
||||
soft: { label: "软偏好", hint: "影响排序,不排除" },
|
||||
hard: { label: "硬规则", hint: "命中即排除" },
|
||||
version: { label: "版本偏好", hint: "同一作品内选哪种版本" },
|
||||
target: { label: "独立目标", hint: "为作品建立独立的收录目标" },
|
||||
target: { label: "版本目标", hint: "为作品新建一个版本目标" },
|
||||
};
|
||||
|
||||
export const PREF_ORIGIN: Record<PreferenceOrigin, string> = {
|
||||
|
||||
@@ -36,7 +36,7 @@ const jsonObject = (name: string) =>
|
||||
});
|
||||
|
||||
const FormSchema = z.object({
|
||||
label: z.string().trim().min(1, "请填写标签名").max(80, "最多 80 字"),
|
||||
label: z.string().trim().min(1, "填写标签名").max(80, "最多 80 字"),
|
||||
kind: PreferenceKindSchema,
|
||||
description: z.string().trim().max(2000, "最多 2000 字"),
|
||||
scope: jsonObject("作用范围"),
|
||||
@@ -108,8 +108,8 @@ export function PreferenceFormDialog({
|
||||
<DialogTitle>{tag ? "编辑偏好" : "添加偏好"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{tag
|
||||
? "修改作用范围或规则会改变语义:旧语义会留下抑制记录,Agent 不会据旧证据加回。"
|
||||
: "手动添加的偏好与对话确认等价;若之前被取消过,重新添加即解除对应抑制。"}
|
||||
? "修改作用范围或规则会留下旧语义的抑制记录,Agent 不会按旧证据加回。"
|
||||
: "手动添加等同对话确认。之前取消过的偏好,重新添加会解除抑制。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-2" noValidate>
|
||||
@@ -144,7 +144,7 @@ export function PreferenceFormDialog({
|
||||
rows={2}
|
||||
value={draft.description}
|
||||
onChange={(e) => set("description", e.target.value)}
|
||||
placeholder="为什么有这个偏好、适用边界"
|
||||
placeholder="原因和适用范围"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="作用范围(JSON)" htmlFor="pref-scope" error={errors.scope} hint="{} 表示全局">
|
||||
|
||||
@@ -76,7 +76,7 @@ export function SlotCard({
|
||||
</dl>
|
||||
{latest.length ? (
|
||||
<div className="mt-3 border-t border-border/60 pt-3">
|
||||
<p className="mb-2 text-meta text-muted-foreground">最近候选 · 为什么选 / 不选(每个资源显示最近一次评估)</p>
|
||||
<p className="mb-2 text-meta text-muted-foreground">最近候选及选用理由(每个资源最近一次评估)</p>
|
||||
<ul className="space-y-2">
|
||||
{latest.map((c) => (
|
||||
<li key={c.sourceReleaseId} className="min-w-0">
|
||||
|
||||
@@ -168,7 +168,7 @@ export function draftToJson(
|
||||
if (s.trim() === "") return undefined;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || (opts.int && !Number.isInteger(n))) {
|
||||
errors[key] = opts.int ? "请输入整数" : "请输入数字";
|
||||
errors[key] = opts.int ? "须是整数" : "须是数字";
|
||||
return undefined;
|
||||
}
|
||||
if (opts.min !== undefined && n < opts.min) errors[key] = `不能小于 ${opts.min}`;
|
||||
@@ -177,7 +177,7 @@ export function draftToJson(
|
||||
};
|
||||
const minGb = num("minSizeGb", d.minSizeGb, { min: 0 });
|
||||
const maxGb = num("maxSizeGb", d.maxSizeGb, { min: 0.01 });
|
||||
if (minGb !== undefined && maxGb !== undefined && minGb > maxGb) errors.maxSizeGb = "上限需大于下限";
|
||||
if (minGb !== undefined && maxGb !== undefined && minGb > maxGb) errors.maxSizeGb = "上限须大于下限";
|
||||
const minTier = num("minTierSteps", d.minTierSteps, { int: true, min: 0, max: 5 });
|
||||
const ceiling = num("ceilingTier", d.ceilingTier, { int: true, min: 0, max: 5 });
|
||||
if (Object.keys(errors).length) return { ok: false, errors };
|
||||
@@ -323,7 +323,7 @@ export function SlotRulesEditor({
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption font-medium text-foreground">只接受蓝光二次压制</p>
|
||||
<p className="text-meta text-muted-foreground">不含原盘与 Remux;压制不等于"越大越好"</p>
|
||||
<p className="text-meta text-muted-foreground">不含原盘和 Remux</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.requireEncode}
|
||||
@@ -370,7 +370,7 @@ export function SlotRulesEditor({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="内容版本(剪辑)" hint="与分辨率等交付特征分开;不选即不限">
|
||||
<FormField label="内容版本(剪辑)" hint="不选即不限">
|
||||
<ChipToggleGroup
|
||||
label="内容版本"
|
||||
options={o(CUTS, CUT_LABEL)}
|
||||
@@ -386,7 +386,7 @@ export function SlotRulesEditor({
|
||||
onChange={(v) => set("contentTypes", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="字段未知时" hint="资源缺少判断所需字段(如字幕)时的处理">
|
||||
<FormField label="字段未知时" hint="资源缺少判断字段(如字幕)时">
|
||||
<Select
|
||||
value={value.unknownPolicy || "__none"}
|
||||
onValueChange={(v) => set("unknownPolicy", v === "__none" ? "" : (v as SlotRulesDraft["unknownPolicy"]))}
|
||||
@@ -408,7 +408,7 @@ export function SlotRulesEditor({
|
||||
|
||||
<fieldset className="space-y-3 border-t border-border/60 pt-4">
|
||||
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
软偏好 · 硬条件通过后排序,按点选顺序越靠前越好
|
||||
软偏好 · 通过硬条件后排序,越靠前越优先
|
||||
</legend>
|
||||
<FormField label="HDR">
|
||||
<ChipToggleGroup
|
||||
@@ -444,7 +444,7 @@ export function SlotRulesEditor({
|
||||
|
||||
<fieldset className="space-y-3 border-t border-border/60 pt-4">
|
||||
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
升级阈值 · 创建替换计划,不直接覆盖旧文件
|
||||
升级 · 生成替换计划,不直接覆盖旧文件
|
||||
</legend>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-caption font-medium text-foreground">允许自动升级</p>
|
||||
@@ -470,12 +470,7 @@ export function SlotRulesEditor({
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="封顶等级"
|
||||
htmlFor={`${id}-ceil`}
|
||||
error={errors.ceilingTier}
|
||||
hint="当前资产达到后不再升级(0–5)"
|
||||
>
|
||||
<FormField label="封顶等级" htmlFor={`${id}-ceil`} error={errors.ceilingTier} hint="达到后不再升级(0–5)">
|
||||
<Input
|
||||
id={`${id}-ceil`}
|
||||
inputMode="numeric"
|
||||
@@ -486,11 +481,7 @@ export function SlotRulesEditor({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="合格压制组名单"
|
||||
htmlFor={`${id}-allow`}
|
||||
hint="设置后名单外的压制不自动升级,低劣压制不会只因 BluRay 标签替换"
|
||||
>
|
||||
<FormField label="合格压制组名单" htmlFor={`${id}-allow`} hint="设置后,名单外的压制不触发自动升级">
|
||||
<Input
|
||||
id={`${id}-allow`}
|
||||
value={value.encodeGroupAllowlist}
|
||||
|
||||
@@ -51,7 +51,7 @@ const KIND_OPTS: { value: string; label: string }[] = SubscriptionMediaKind.opti
|
||||
}));
|
||||
|
||||
export const PUBLISH_NOTICE =
|
||||
"发布即授权:scope 范围内的正常下载、追更与整理将按此策略持续自动执行,不再逐次审批;升级替换、媒体清理与下载缓存清理另行授权。旧的已发布版本会被替代,其授权随之吊销。";
|
||||
"发布后,作用范围内的下载、追更和整理按此策略自动执行,不再逐次审批。升级替换、媒体清理和下载缓存清理仍需单独审批。旧的已发布版本被替代,授权随之失效。";
|
||||
|
||||
type TemplateDraft = { key: string; name: string; rules: SlotRulesDraft; open: boolean };
|
||||
type PolicyDraft = {
|
||||
@@ -98,10 +98,10 @@ const Meta = z.object({
|
||||
policyKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写策略键")
|
||||
.min(1, "填写策略键")
|
||||
.max(64, "最多 64 个字符")
|
||||
.regex(/^[a-z0-9][a-z0-9_.-]*$/, "只用小写字母、数字和 - _ ."),
|
||||
title: z.string().trim().min(1, "请填写标题").max(120, "最多 120 个字符"),
|
||||
title: z.string().trim().min(1, "填写标题").max(120, "最多 120 个字符"),
|
||||
summary: z.string().trim().max(500, "最多 500 个字符"),
|
||||
});
|
||||
|
||||
@@ -119,7 +119,7 @@ function buildPolicy(d: PolicyDraft):
|
||||
if (!meta.success) for (const i of meta.error.issues) errors[String(i.path[0])] ??= i.message;
|
||||
const templates = (list: TemplateDraft[]) =>
|
||||
list.map((t) => {
|
||||
if (!t.name.trim()) errors[`${t.key}.name`] = "请填写模板名称";
|
||||
if (!t.name.trim()) errors[`${t.key}.name`] = "填写模板名称";
|
||||
const r = draftToJson(t.rules);
|
||||
if (!r.ok) {
|
||||
for (const [k, v] of Object.entries(r.errors)) errors[`${t.key}.${k}`] = v;
|
||||
@@ -141,7 +141,7 @@ function buildPolicy(d: PolicyDraft):
|
||||
windowDays = Number(d.windowDays);
|
||||
if (!Number.isInteger(windowDays) || windowDays < 1 || windowDays > 3650) errors.windowDays = "1–3650 的整数";
|
||||
}
|
||||
if (d.newTitleEnabled && newTitleSlots.length === 0) errors.newTitleSlots = "开启新作品收录时至少要有一个目标模板";
|
||||
if (d.newTitleEnabled && newTitleSlots.length === 0) errors.newTitleSlots = "开启新作品收录时至少需要一个模板";
|
||||
const rules = {
|
||||
...(defaultSlots.length ? { defaultSlots } : {}),
|
||||
...(d.newTitleEnabled || newTitleSlots.length
|
||||
@@ -208,9 +208,7 @@ export function PolicyWorkspace({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-caption text-muted-foreground">
|
||||
策略只保存结构化条件。草案可以反复预览,发布才生效;回滚会以旧版本内容发布一个新版本,历史只增不改。
|
||||
</p>
|
||||
<p className="text-caption text-muted-foreground">草案发布后才生效。回滚会用旧版本内容发布一个新版本。</p>
|
||||
{q.isPending ? (
|
||||
<div className="pg-slab rounded-xl p-4">
|
||||
<LoadingState rows={4} />
|
||||
@@ -221,7 +219,7 @@ export function PolicyWorkspace({
|
||||
<EmptyState
|
||||
icon={ScrollText}
|
||||
title="还没有收录策略"
|
||||
description="新建一份草案,设置缺省目标模板与新作品收录条件,预览后再发布。"
|
||||
description="新建草案,预览后发布。"
|
||||
action={
|
||||
<Button onClick={() => onCreatingChange(true)}>
|
||||
<FilePlus2 />
|
||||
@@ -297,7 +295,7 @@ function PolicyGroup({
|
||||
description={
|
||||
published
|
||||
? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${published.publishedBy ?? "—"} 发布`
|
||||
: "尚未发布,不产生任何自动操作"
|
||||
: "未发布,不会自动执行"
|
||||
}
|
||||
actions={
|
||||
draft ? (
|
||||
@@ -348,13 +346,13 @@ function PolicyGroup({
|
||||
}
|
||||
|
||||
function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefined; valid: boolean }) {
|
||||
if (!valid || !rules) return <p className="text-caption text-destructive">规则结构无法解析(不会被执行)</p>;
|
||||
if (!valid || !rules) return <p className="text-caption text-destructive">规则无法解析,不会执行</p>;
|
||||
const tplLine = (t: SlotTemplate) => `${t.name}:${summarizeHard(t.hardConditions, t.fallback).join(",") || "不限"}`;
|
||||
return (
|
||||
<div className="space-y-2 text-caption">
|
||||
<div>
|
||||
<span className="text-muted-foreground">缺省目标 </span>
|
||||
<ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有目标时不自动建)" />
|
||||
<span className="text-muted-foreground">默认版本目标 </span>
|
||||
<ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有版本目标时不自动创建)" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">新作品收录 </span>
|
||||
@@ -375,9 +373,9 @@ function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefi
|
||||
<div className="text-muted-foreground">
|
||||
整理方式{" "}
|
||||
<span className="text-foreground">
|
||||
{rules.organizeMode === "move" ? "Move" : rules.organizeMode === "copy" ? "Copy" : "按系统设置"}
|
||||
{rules.organizeMode === "move" ? "移动" : rules.organizeMode === "copy" ? "复制" : "按系统设置"}
|
||||
</span>
|
||||
{rules.allowCopyWhenHnrPending ? " · H&R 未满足时改用 Copy" : ""}
|
||||
{rules.allowCopyWhenHnrPending ? " · H&R 未满足时改用复制" : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -436,11 +434,11 @@ function PolicyDetailSheet({
|
||||
}
|
||||
title={`发布 ${rev.policyKey} r${rev.revision}?`}
|
||||
description={PUBLISH_NOTICE}
|
||||
confirmLabel="授权并发布"
|
||||
confirmLabel="发布"
|
||||
onConfirm={() =>
|
||||
publish
|
||||
.mutateAsync({ id: rev.id, expectedRevision: rev.revision })
|
||||
.then(() => toast.success(`r${rev.revision} 已发布并生效`))
|
||||
.then(() => toast.success(`r${rev.revision} 已发布`))
|
||||
.catch((e) => {
|
||||
toast.error(errorMessage(e));
|
||||
throw e;
|
||||
@@ -449,7 +447,7 @@ function PolicyDetailSheet({
|
||||
>
|
||||
{!pv?.success ? (
|
||||
<p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
这个草案还没有预览结果,建议先预览确认影响范围。
|
||||
这个草案还没有预览。先预览可以看到影响范围。
|
||||
</p>
|
||||
) : null}
|
||||
</ConfirmDialog>
|
||||
@@ -469,12 +467,12 @@ function PolicyDetailSheet({
|
||||
</Button>
|
||||
}
|
||||
title={`回滚到 r${rev.revision}?`}
|
||||
description={`将以 r${rev.revision} 的内容发布一个新版本,当前已发布版本标记为已回滚。${PUBLISH_NOTICE}`}
|
||||
confirmLabel="回滚并发布"
|
||||
description={`用 r${rev.revision} 的内容发布一个新版本,当前版本标记为已回滚。${PUBLISH_NOTICE}`}
|
||||
confirmLabel="回滚"
|
||||
onConfirm={() =>
|
||||
rollback
|
||||
.mutateAsync(rev.id)
|
||||
.then((n) => toast.success(`已回滚,新版本 r${n.revision} 生效`))
|
||||
.then((n) => toast.success(`已回滚,r${n.revision} 生效`))
|
||||
.then(onClose)
|
||||
.catch((e) => {
|
||||
toast.error(errorMessage(e));
|
||||
@@ -536,7 +534,7 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
|
||||
if (!value)
|
||||
return (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
还没有预览。预览用最近已识别的发布评估模板,不产生任何副作用。
|
||||
还没有预览。预览用最近已识别的资源评估模板,不会执行任何操作。
|
||||
</p>
|
||||
);
|
||||
const p = PolicyPreview.safeParse(value);
|
||||
@@ -546,12 +544,12 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
|
||||
<div className="space-y-3">
|
||||
{!v.valid ? (
|
||||
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-caption text-destructive">
|
||||
规则结构未通过校验,预览按空模板计算;发布会被拒绝。
|
||||
规则未通过校验,预览按空模板计算,无法发布。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[
|
||||
["样本发布", v.sampleSize],
|
||||
["样本资源", v.sampleSize],
|
||||
["模板数", v.templates],
|
||||
["会接受", v.matched],
|
||||
["会排除", v.excluded],
|
||||
@@ -563,7 +561,7 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
|
||||
))}
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
计算于 {formatDateTime(v.evaluatedAt)};接受/排除按"发布 × 模板"计数。
|
||||
计算于 {formatDateTime(v.evaluatedAt)};接受/排除按"资源 × 模板"计数。
|
||||
</p>
|
||||
{v.samples.length ? (
|
||||
<ul className="divide-y divide-border/60 rounded-lg border border-border/70">
|
||||
@@ -584,7 +582,7 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">没有可用于回放的已识别发布。</p>
|
||||
<p className="text-caption text-muted-foreground">没有可用于回放的已识别资源。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -632,12 +630,12 @@ function PolicyEditorSheet({
|
||||
open: t.open || Object.keys(r.errors).some((k) => k.startsWith(`${t.key}.`)),
|
||||
})),
|
||||
}));
|
||||
toast.error("请先修正表单中的错误");
|
||||
toast.error("表单有错误");
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
const done = (rev: PolicyRevision) => {
|
||||
toast.success(`草案 r${rev.revision} 已保存,预览确认后再发布`);
|
||||
toast.success(`草案 r${rev.revision} 已保存`);
|
||||
onSaved(rev);
|
||||
};
|
||||
if (editId) {
|
||||
@@ -657,7 +655,7 @@ function PolicyEditorSheet({
|
||||
onOpenChange={(o) => !o && !pending && onClose()}
|
||||
width="lg"
|
||||
title={editId ? `编辑草案 r${base?.revision}` : base ? `基于 r${base.revision} 新建草案` : "新建收录策略草案"}
|
||||
description="保存只生成草案,不会生效;发布时才授权自动执行。"
|
||||
description="保存为草案,发布后才生效。"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={pending}>
|
||||
@@ -676,7 +674,7 @@ function PolicyEditorSheet({
|
||||
htmlFor={`${id}-key`}
|
||||
required
|
||||
error={errors.policyKey}
|
||||
hint="同一键下版本递增,只有一个生效"
|
||||
hint="同一键下只有一个版本生效"
|
||||
>
|
||||
<Input
|
||||
id={`${id}-key`}
|
||||
@@ -696,12 +694,7 @@ function PolicyEditorSheet({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="本版说明"
|
||||
htmlFor={`${id}-sum`}
|
||||
error={errors.summary}
|
||||
hint="写清这一版改了什么,出现在版本历史里"
|
||||
>
|
||||
<FormField label="本版说明" htmlFor={`${id}-sum`} error={errors.summary} hint="这一版改了什么,显示在版本历史里">
|
||||
<Textarea id={`${id}-sum`} rows={2} value={d.summary} onChange={(e) => set("summary", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="作用范围" hint="不选即全部作品类型">
|
||||
@@ -715,7 +708,7 @@ function PolicyEditorSheet({
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection
|
||||
title="缺省目标模板"
|
||||
title="默认版本目标模板"
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -727,7 +720,7 @@ function PolicyEditorSheet({
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-meta text-muted-foreground">订阅对应的作品还没有目标时,按这些模板建 Slot。</p>
|
||||
<p className="mb-2 text-meta text-muted-foreground">订阅的作品没有版本目标时,按这些模板创建。</p>
|
||||
<TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} />
|
||||
</DetailSection>
|
||||
|
||||
@@ -736,7 +729,7 @@ function PolicyEditorSheet({
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption font-medium text-foreground">开启新作品收录</p>
|
||||
<p className="text-meta text-muted-foreground">按发行/首播日期判断,站点今天上传的旧片不算新作品</p>
|
||||
<p className="text-meta text-muted-foreground">按发行/首播日期判断,新上传的旧片不算</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={d.newTitleEnabled}
|
||||
@@ -765,7 +758,7 @@ function PolicyEditorSheet({
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-meta text-muted-foreground">新作品的目标模板</p>
|
||||
<p className="text-meta text-muted-foreground">新作品的版本目标模板</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@@ -792,17 +785,17 @@ function PolicyEditorSheet({
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="__default">按系统设置</SelectItem>
|
||||
<SelectItem value="copy">Copy(保留源文件做种)</SelectItem>
|
||||
<SelectItem value="move">Move</SelectItem>
|
||||
<SelectItem value="copy">复制(保留源文件做种)</SelectItem>
|
||||
<SelectItem value="move">移动</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<p className="min-w-0 text-caption font-medium text-foreground">H&R 未满足时经授权改用 Copy</p>
|
||||
<p className="min-w-0 text-caption font-medium text-foreground">H&R 未满足时经审批改用复制</p>
|
||||
<Switch
|
||||
checked={d.allowCopyWhenHnrPending}
|
||||
onCheckedChange={(v) => set("allowCopyWhenHnrPending", v)}
|
||||
aria-label="H&R 未满足时改用 Copy"
|
||||
aria-label="H&R 未满足时改用复制"
|
||||
/>
|
||||
</div>
|
||||
{errors.rules ? <p className="text-meta text-destructive">{errors.rules}</p> : null}
|
||||
|
||||
@@ -21,7 +21,7 @@ const SlotMeta = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写目标名称")
|
||||
.min(1, "填写名称")
|
||||
.max(64, "最多 64 个字符")
|
||||
.regex(/^[\w\-.+ ]+$/u, "只用字母、数字、空格和 - _ . +"),
|
||||
distinctGroup: z.string().trim().max(64, "最多 64 个字符"),
|
||||
@@ -30,7 +30,7 @@ const SlotMeta = z.object({
|
||||
/** 409:别人(或状态机)已改过这个 Slot */
|
||||
export function conflictToast(err: unknown, onRefresh: () => void) {
|
||||
if (err instanceof ApiRequestError && (err.code === "conflict" || err.code === "precondition_failed")) {
|
||||
toast.error("规则已被修改(版本不一致),已刷新,请在最新版本上重新编辑", { duration: 4000 });
|
||||
toast.error("规则已被修改,已刷新。在最新版本上重新编辑。", { duration: 4000 });
|
||||
onRefresh();
|
||||
return;
|
||||
}
|
||||
@@ -51,10 +51,10 @@ export function SlotsPanel({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-meta text-muted-foreground">每个目标独立判定与升级;状态只由状态机推进,这里只改规则。</p>
|
||||
<p className="text-meta text-muted-foreground">每个版本目标单独判定和升级。这里只改规则。</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing("new")}>
|
||||
<Plus />
|
||||
新建目标
|
||||
新建版本目标
|
||||
</Button>
|
||||
</div>
|
||||
{q.isPending ? (
|
||||
@@ -65,8 +65,8 @@ export function SlotsPanel({
|
||||
<EmptyState
|
||||
plain
|
||||
icon={Target}
|
||||
title="还没有收录目标"
|
||||
description="没有目标时按已发布收录策略的缺省模板建;也可以在这里手动建。"
|
||||
title="还没有版本目标"
|
||||
description="没有时按已发布收录策略的默认模板创建,也可以手动新建。"
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
@@ -140,7 +140,7 @@ function SlotEditorSheet({
|
||||
const errs: Record<string, string> = {};
|
||||
if (!meta.success) for (const i of meta.error.issues) errs[String(i.path[0])] ??= i.message;
|
||||
if (!rules.ok) Object.assign(errs, rules.errors);
|
||||
if (requireDistinct && !distinctGroup.trim()) errs.distinctGroup = "需要不同物理资产时请填写分组名";
|
||||
if (requireDistinct && !distinctGroup.trim()) errs.distinctGroup = "要求不同物理资产时须填写分组";
|
||||
setErrors(errs);
|
||||
if (Object.keys(errs).length || !meta.success || !rules.ok) return;
|
||||
const common = {
|
||||
@@ -153,7 +153,7 @@ function SlotEditorSheet({
|
||||
{ id: slot.id, expectedRevision: slot.revision, ...common },
|
||||
{
|
||||
onSuccess: (s) => {
|
||||
toast.success(`已保存目标 ${s.name}(r${s.revision})`);
|
||||
toast.success(`已保存 ${s.name}(r${s.revision})`);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => conflictToast(e, onConflict),
|
||||
@@ -164,7 +164,7 @@ function SlotEditorSheet({
|
||||
{ mediaEntityId, subscriptionId, name: meta.data.name, ...common },
|
||||
{
|
||||
onSuccess: (s) => {
|
||||
toast.success(`已新建目标 ${s.name}`);
|
||||
toast.success(`已新建 ${s.name}`);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
@@ -178,11 +178,9 @@ function SlotEditorSheet({
|
||||
open
|
||||
onOpenChange={(o) => !o && !pending && onClose()}
|
||||
width="lg"
|
||||
title={slot ? `编辑目标 ${slot.name}` : "新建收录目标"}
|
||||
title={slot ? `编辑版本目标 ${slot.name}` : "新建版本目标"}
|
||||
description={
|
||||
slot
|
||||
? `当前版本 r${slot.revision};保存时校验版本,期间被修改会提示刷新。`
|
||||
: "硬条件决定能不能收,软偏好决定先收哪个。"
|
||||
slot ? `当前 r${slot.revision}。保存时若已被修改,会提示刷新。` : "不满足硬条件的资源不收;软偏好决定先收哪个。"
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
@@ -228,7 +226,7 @@ function SlotEditorSheet({
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption font-medium text-foreground">要求不同物理资产</p>
|
||||
<p className="text-meta text-muted-foreground">真正的双版本:一个文件不能同时算两个目标</p>
|
||||
<p className="text-meta text-muted-foreground">同一个文件不能同时满足两个目标</p>
|
||||
</div>
|
||||
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求不同物理资产" />
|
||||
</div>
|
||||
|
||||
@@ -19,10 +19,10 @@ import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
const STEPS = [
|
||||
"生成下载计划并预留 Slot 与预算(同一 Slot 同时只有一个在途计划)",
|
||||
"去重:已有相同资源或同 hash 任务时走接管,不重复提交",
|
||||
"缓存卷硬阈值检查:超过阈值或读不到磁盘状态时阻止新提交,不能手动绕过",
|
||||
"选择下载器实例并打管理标签提交;下单前复核免费/做种等易变字段",
|
||||
"生成下载计划,预留版本目标和预算;每个版本目标同时只有一个在途计划",
|
||||
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
|
||||
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
|
||||
"提交前复核免费、做种等易变字段",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -56,10 +56,10 @@ export function DownloadDialog({
|
||||
if (r.submitted && r.plan) {
|
||||
setRejected(null);
|
||||
setDone(r.plan.id);
|
||||
toast.success(`已提交(计划${slotName(r.slotId) ? ` · ${slotName(r.slotId)}` : ""}),进度见“下载与任务”`);
|
||||
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见“下载与任务”`);
|
||||
} else {
|
||||
setRejected(r.rejected);
|
||||
toast.warning("没有生成下载计划,原因见对话框");
|
||||
toast.warning("没有生成下载计划");
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
@@ -102,9 +102,7 @@ export function DownloadDialog({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{release.entity
|
||||
? "该作品还没有版本目标(Slot),需要先在订阅里建目标才能提交。"
|
||||
: "资源尚未识别到本地作品,请先在“身份与元数据”指定作品。"}
|
||||
{release.entity ? "这个作品没有版本目标。先在订阅里新建。" : "资源未关联本地作品。先在“身份与元数据”指定。"}
|
||||
</p>
|
||||
)}
|
||||
{rejected ? (
|
||||
@@ -124,7 +122,7 @@ export function DownloadDialog({
|
||||
<p className="text-caption text-success">
|
||||
计划已生成。
|
||||
<Link to="/console/tasks" className="underline underline-offset-4">
|
||||
去“下载与任务”查看
|
||||
查看
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function FeedFormDialog({
|
||||
if (!parsed.success) {
|
||||
const e = zodFieldErrors(parsed.error.issues);
|
||||
if (e.pollIntervalSec) e.pollMin = "轮询间隔至少 1 分钟";
|
||||
if (e.url) e.url = feed ? "URL 格式不对" : "需要完整的 Feed URL";
|
||||
if (e.url) e.url = feed ? "URL 格式不对" : "填写完整的 Feed URL";
|
||||
if (e.name) e.name = "名称不能为空";
|
||||
setErrors(e);
|
||||
return;
|
||||
@@ -116,7 +116,7 @@ export function FeedFormDialog({
|
||||
setErrors({});
|
||||
const done = {
|
||||
onSuccess: () => {
|
||||
toast.success(feed ? "已保存" : "已新建 Feed,会按轮询策略抓取");
|
||||
toast.success(feed ? "已保存" : "Feed 已创建");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (e: unknown) => toast.error(errorMessage(e)),
|
||||
@@ -130,7 +130,7 @@ export function FeedFormDialog({
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{feed ? "编辑 Feed" : "新建 Feed"}</DialogTitle>
|
||||
<DialogDescription>URL 可能含 passkey,只写入加密凭证,之后只显示脱敏地址。</DialogDescription>
|
||||
<DialogDescription>URL 加密保存,之后只显示脱敏地址。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-1"
|
||||
@@ -248,7 +248,7 @@ export function FeedFormDialog({
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between gap-3 py-1 text-caption text-foreground">
|
||||
<label htmlFor="feed-enabled">启用(关闭后停止轮询,历史观察保留)</label>
|
||||
<label htmlFor="feed-enabled">启用(关闭后停止轮询)</label>
|
||||
<Switch id="feed-enabled" checked={d.enabled} onCheckedChange={(v) => set("enabled", v)} />
|
||||
</div>
|
||||
<DialogFooter className="mt-2 gap-2">
|
||||
|
||||
@@ -33,9 +33,9 @@ function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry:
|
||||
if (!release.entity) {
|
||||
return (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
尚未识别到本地作品({release.resolutionStatus}),不显示库存推断。
|
||||
未关联本地作品({release.resolutionStatus})。
|
||||
<Link to="/console/identity" className="ml-1 underline underline-offset-4">
|
||||
去身份与元数据处理
|
||||
去指定作品
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
@@ -100,7 +100,7 @@ function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry:
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-meta text-muted-foreground">规则引擎还没评估过这条资源。</p>
|
||||
<p className="text-meta text-muted-foreground">这条资源还没有评估。</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -177,8 +177,8 @@ export function ReleaseDetailSheet({
|
||||
{
|
||||
onSuccess: (d) =>
|
||||
d.refreshError
|
||||
? toast.warning(`复核失败,保留既有数据:${d.refreshError}`)
|
||||
: toast.success("已从站点复核详情"),
|
||||
? toast.warning(`复核失败,显示上次数据:${d.refreshError}`)
|
||||
: toast.success("已复核"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
@@ -197,7 +197,7 @@ export function ReleaseDetailSheet({
|
||||
<>
|
||||
{detail.data.refreshError ? (
|
||||
<p className="mb-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
最近一次复核失败,以下为既有数据(已过期):{detail.data.refreshError}
|
||||
最近一次复核失败,以下是过期数据:{detail.data.refreshError}
|
||||
</p>
|
||||
) : null}
|
||||
<KvList
|
||||
@@ -211,9 +211,7 @@ export function ReleaseDetailSheet({
|
||||
{ label: "详情抓取于", value: formatDateTime(detail.data.volatile.detailFetchedAt) },
|
||||
]}
|
||||
/>
|
||||
<p className="mt-2 text-meta text-muted-foreground">
|
||||
缓存命中不代替下单前复核:提交下载时会再次核对免费与做种状态。
|
||||
</p>
|
||||
<p className="mt-2 text-meta text-muted-foreground">提交下载时会再次核对免费和做种状态。</p>
|
||||
</>
|
||||
) : null}
|
||||
</DetailSection>
|
||||
@@ -232,7 +230,7 @@ export function ReleaseDetailSheet({
|
||||
{ label: "首次发现", value: formatDateTime(r.firstSeenAt) },
|
||||
{ label: "最近确认", value: `${formatDateTime(r.dataUpdatedAt)}(${ORIGIN_LABEL[r.origin]})` },
|
||||
{ label: "Feed 观察次数", value: r.observedByFeeds },
|
||||
{ label: "站点资源 ID", value: r.siteReleaseId, mono: true },
|
||||
{ label: "站点 ID", value: r.siteReleaseId, mono: true },
|
||||
{ label: "infohash", value: r.infohashV1 ?? r.infohashV2, mono: true, wide: true },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function SnapshotPanel({ site }: { site: Site }) {
|
||||
snap.mutate(
|
||||
{ id: site.id, maxPages: 2 },
|
||||
{
|
||||
onSuccess: () => toast.success(`${site.name}:已排队手动快照`),
|
||||
onSuccess: () => toast.success(`${site.name}:快照已排队`),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
@@ -80,7 +80,7 @@ export function SnapshotPanel({ site }: { site: Site }) {
|
||||
) : snaps.error ? (
|
||||
<ErrorState plain error={snaps.error} onRetry={() => void snaps.refetch()} />
|
||||
) : !snaps.data.items.length ? (
|
||||
<p className="text-caption text-muted-foreground">还没有快照记录。</p>
|
||||
<p className="text-caption text-muted-foreground">还没有快照。</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60 text-caption">
|
||||
{snaps.data.items.map((s) => (
|
||||
|
||||
Reference in new issue
Block a user