style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing, full-width parens around Chinese, single ellipsis, 「」 quotes, period rules) - scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX text (not comments, types, keys, match/compare strings); --fix, --strict, --by-file; wired into deno task ci as check:copy in warn mode - --fix applied to web, server and contracts; files other lanes were editing (subscriptions, sites, browser, agent, auth/account, inventory) left for them - tests updated for the changed messages Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9b017906cf
commit
52402864d6
143 files changed
+985
-606
No files matched your search
@@ -78,10 +78,10 @@ export function useFetchFeedNow() {
|
||||
export function feedGapHint(f: FeedSource, now = Date.now()): string | null {
|
||||
if (f.possibleGap) {
|
||||
const at = new Date(f.possibleGap.detectedAt).toLocaleString("zh-CN", { hour12: false });
|
||||
return `疑似缺口(${at} 检测,${f.possibleGap.backfillQueued ? "已排补漏" : "Feed 未关联站点,无法自动补漏"})`;
|
||||
return `疑似缺口(${at} 检测,${f.possibleGap.backfillQueued ? "已排补漏" : "Feed 未关联站点,无法自动补漏"})`;
|
||||
}
|
||||
if (!f.enabled || !f.lastSuccessAt) return null;
|
||||
const gapMs = now - new Date(f.lastSuccessAt).getTime();
|
||||
if (gapMs > f.pollIntervalSec * 1000 * 3) return "距上次成功已超过 3 个周期,可能漏项";
|
||||
if (gapMs > f.pollIntervalSec * 1000 * 3) return "距上次成功已超过 3 个周期,可能漏项";
|
||||
return null;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export async function apiParsed<S extends z.ZodType>(schema: S, path: string, ar
|
||||
const issue = parsed.error.issues[0];
|
||||
throw new ApiRequestError(
|
||||
"internal" satisfies ApiErrorCode,
|
||||
`响应格式不符合接口定义:${issue ? `${issue.path.join(".")} ${issue.message}` : "未知字段"}`,
|
||||
`响应格式不符合接口定义:${issue ? `${issue.path.join(".")} ${issue.message}` : "未知字段"}`,
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
|
||||
@@ -25,7 +25,7 @@ const DecisionPage = cursorPage(Decision);
|
||||
const RESOLUTION_LABEL = {
|
||||
unresolved: "待识别",
|
||||
resolved: "已识别",
|
||||
ambiguous: "有歧义,待确认",
|
||||
ambiguous: "有歧义,待确认",
|
||||
unidentifiable: "无法识别",
|
||||
rejected: "已排除",
|
||||
} as const;
|
||||
@@ -162,8 +162,8 @@ export function buildTimeline(input: {
|
||||
at: release.firstSeenAt,
|
||||
title:
|
||||
release.resolutionStatus === "resolved"
|
||||
? "识别结果:已关联本地作品"
|
||||
: `识别结果:${RESOLUTION_LABEL[release.resolutionStatus]}`,
|
||||
? "识别结果:已关联本地作品"
|
||||
: `识别结果:${RESOLUTION_LABEL[release.resolutionStatus]}`,
|
||||
tone: release.resolutionStatus === "resolved" ? "success" : "warning",
|
||||
});
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export function buildTimeline(input: {
|
||||
key: "plan-status",
|
||||
stage: "plan",
|
||||
at: plan.updatedAt,
|
||||
title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`,
|
||||
title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`,
|
||||
detail: plan.statusReason,
|
||||
tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral",
|
||||
});
|
||||
@@ -199,7 +199,7 @@ export function buildTimeline(input: {
|
||||
key: "dl-pause",
|
||||
stage: "download",
|
||||
at: task.updatedAt,
|
||||
title: task.pauseReason === "threshold" ? "缓存达到硬阈值,已暂停" : `已暂停(${task.pauseReason})`,
|
||||
title: task.pauseReason === "threshold" ? "缓存达到硬阈值,已暂停" : `已暂停(${task.pauseReason})`,
|
||||
tone: "warning",
|
||||
ongoing: task.status === "paused",
|
||||
});
|
||||
@@ -208,7 +208,7 @@ export function buildTimeline(input: {
|
||||
key: "dl-stall",
|
||||
stage: "download",
|
||||
at: task.noProgressSince,
|
||||
title: "无进展,不自动换源",
|
||||
title: "无进展,不自动换源",
|
||||
tone: "warning",
|
||||
ongoing: task.status === "stalled",
|
||||
});
|
||||
@@ -250,7 +250,7 @@ export function buildTimeline(input: {
|
||||
key: `dec-${d.id}`,
|
||||
stage: "agent",
|
||||
at: d.createdAt,
|
||||
title: `待处理:${d.title}`,
|
||||
title: `待处理:${d.title}`,
|
||||
detail: d.summary,
|
||||
tone: d.severity === "critical" ? "destructive" : "warning",
|
||||
href: "/console/inbox",
|
||||
|
||||
@@ -85,7 +85,9 @@ export function FileDiff({
|
||||
{l.type === "added" ? "+" : l.type === "removed" ? "−" : " "}
|
||||
</span>
|
||||
<span className="min-w-0 break-all text-foreground/85">
|
||||
<span className="sr-only">{l.type === "added" ? "新增:" : l.type === "removed" ? "移除:" : ""}</span>
|
||||
<span className="sr-only">
|
||||
{l.type === "added" ? "新增:" : l.type === "removed" ? "移除:" : ""}
|
||||
</span>
|
||||
{l.content}
|
||||
</span>
|
||||
{l.note ? <span className="shrink-0 font-sans text-muted-foreground">{l.note}</span> : null}
|
||||
|
||||
@@ -171,7 +171,7 @@ export function MessageScroller({
|
||||
>
|
||||
<ArrowDown aria-hidden className="size-3.5" />
|
||||
{jumpLabel}
|
||||
{busy ? <span className="sr-only">(仍在输出)</span> : null}
|
||||
{busy ? <span className="sr-only">(仍在输出)</span> : null}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@ export interface ToolApprovalProps {
|
||||
|
||||
export function ToolApproval({
|
||||
tool,
|
||||
title = "允许执行这个工具?",
|
||||
title = "允许执行这个工具?",
|
||||
description,
|
||||
parameters = [],
|
||||
status = "pending",
|
||||
@@ -134,7 +134,7 @@ export function ToolApproval({
|
||||
</div>
|
||||
{badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1">{badges}</div> : null}
|
||||
{rejected && reason ? (
|
||||
<p className="mt-2 text-caption font-medium break-words text-destructive">原因:{reason}</p>
|
||||
<p className="mt-2 text-caption font-medium break-words text-destructive">原因:{reason}</p>
|
||||
) : null}
|
||||
{description ? <p className="mt-1.5 text-caption leading-5 text-muted-foreground">{description}</p> : null}
|
||||
{rejected && explanation ? (
|
||||
|
||||
@@ -89,7 +89,7 @@ export function InboxBell() {
|
||||
{list.isPending ? (
|
||||
<p className="px-4 py-6 text-center text-caption text-muted-foreground">加载中…</p>
|
||||
) : list.isError ? (
|
||||
<p className="px-4 py-6 text-center text-caption text-destructive">加载失败,稍后再试</p>
|
||||
<p className="px-4 py-6 text-center text-caption text-destructive">加载失败,稍后再试</p>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 px-4 py-8 text-caption text-muted-foreground">
|
||||
<CheckCheck className="h-5 w-5" />
|
||||
|
||||
@@ -51,13 +51,13 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
to: "/console/tasks",
|
||||
label: "任务",
|
||||
icon: ListTodo,
|
||||
description: "每次运行都是一条任务:定时、事件、手动、Agent 触发都在这里,含定时任务计划",
|
||||
description: "每次运行都是一条任务:定时、事件、手动、Agent 触发都在这里,含定时任务计划",
|
||||
},
|
||||
{
|
||||
to: "/console/agent",
|
||||
label: "Agent 会话",
|
||||
icon: Bot,
|
||||
description: "交代目标,Agent 在授权内执行并汇报",
|
||||
description: "交代目标,Agent 在授权内执行并汇报",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -68,7 +68,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
to: "/console/sites",
|
||||
label: "站点",
|
||||
icon: Globe,
|
||||
description: "站点、凭证、账号数据与健康;资源、快照与 Feed",
|
||||
description: "站点、凭证、账号数据与健康;资源、快照与 Feed",
|
||||
},
|
||||
{ to: "/console/sources", label: "RSS 订阅", icon: Rss, description: "全部 Feed 与抓取状态" },
|
||||
],
|
||||
@@ -94,7 +94,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
to: "/console/files",
|
||||
label: "文件管理",
|
||||
icon: FolderTree,
|
||||
description: "浏览登记目录,改名、移动、删除与整理",
|
||||
description: "浏览登记目录,改名、移动、删除与整理",
|
||||
},
|
||||
{
|
||||
to: "/console/library",
|
||||
|
||||
@@ -103,7 +103,7 @@ export function useTaskAction() {
|
||||
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)),
|
||||
|
||||
@@ -37,7 +37,7 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[];
|
||||
})}
|
||||
</div>
|
||||
{ongoing.length ? (
|
||||
<p className="text-caption text-muted-foreground">当前:{ongoing.map((e) => e.title).join(";")}</p>
|
||||
<p className="text-caption text-muted-foreground">当前:{ongoing.map((e) => e.title).join(";")}</p>
|
||||
) : null}
|
||||
{loading && !events.length ? (
|
||||
<LoadingState rows={3} />
|
||||
|
||||
@@ -76,8 +76,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) => (
|
||||
@@ -112,8 +112,8 @@ export function CacheVolumes() {
|
||||
立即清理
|
||||
</Button>
|
||||
}
|
||||
title="按规则清理下载缓存?"
|
||||
description="只清理已入库、且满足保留期与 H&R 的纳管任务,其余不动。"
|
||||
title="按规则清理下载缓存?"
|
||||
description="只清理已入库、且满足保留期与 H&R 的纳管任务,其余不动。"
|
||||
confirmLabel="清理"
|
||||
onConfirm={() =>
|
||||
cleanup.mutateAsync(undefined).then(
|
||||
|
||||
@@ -69,7 +69,7 @@ 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();
|
||||
@@ -117,7 +117,7 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `下载:${task.name}`,
|
||||
label: `下载:${task.name}`,
|
||||
downloadTaskId: task.id,
|
||||
goal: task.status === "stalled" || task.status === "failed" ? "排查这个下载为什么没有进展" : "",
|
||||
})
|
||||
@@ -131,12 +131,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}
|
||||
|
||||
|
||||
@@ -184,14 +184,14 @@ export function MkdirDialog({
|
||||
// ─── 重命名 ─────────────────────────────────────────────────────
|
||||
|
||||
const SUGGEST_REASON: Record<string, string> = {
|
||||
identity_uncertain: "识别不可信,没有推荐名",
|
||||
identity_uncertain: "识别不可信,没有推荐名",
|
||||
};
|
||||
const TEMPLATE_SOURCE: Record<string, string> = { entity: "作品覆盖", root: "库根", global: "全局", override: "覆写" };
|
||||
|
||||
function suggestReasonText(r: string | null): string {
|
||||
if (!r) return "没有推荐名";
|
||||
if (SUGGEST_REASON[r]) return SUGGEST_REASON[r] as string;
|
||||
if (r.startsWith("naming_failed")) return `命名模板渲染失败:${r.replace(/^naming_failed:\s*/, "")}`;
|
||||
if (r.startsWith("naming_failed")) return `命名模板渲染失败:${r.replace(/^naming_failed:\s*/, "")}`;
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -291,13 +291,13 @@ export function RenameDialog({
|
||||
ev.preventDefault();
|
||||
if (problem) return setErr(problem);
|
||||
if (!changed) return onClose();
|
||||
if (taken) return setErr("同名已存在,不会覆盖");
|
||||
if (taken) return setErr("同名已存在,不会覆盖");
|
||||
exec.mutate(
|
||||
{ idempotencyKey: key, root: root.id, path: entry.path, newName: name.trim(), withSidecars, dryRun: false },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
toast.success(
|
||||
`已重命名${r.renames.length > 1 ? `(含 ${r.renames.length - 1} 个附属文件)` : ""}`,
|
||||
`已重命名${r.renames.length > 1 ? `(含 ${r.renames.length - 1} 个附属文件)` : ""}`,
|
||||
r.recordId
|
||||
? {
|
||||
action: {
|
||||
@@ -313,9 +313,9 @@ export function RenameDialog({
|
||||
const reason = filesErrorReason(e);
|
||||
setErr(
|
||||
reason === "exists"
|
||||
? "同名已存在,不会覆盖"
|
||||
? "同名已存在,不会覆盖"
|
||||
: reason === "download_active"
|
||||
? "做种中,改名会让下载器找不到文件"
|
||||
? "做种中,改名会让下载器找不到文件"
|
||||
: filesErrorText(e),
|
||||
);
|
||||
},
|
||||
@@ -338,7 +338,7 @@ export function RenameDialog({
|
||||
<FormField
|
||||
label="新名称"
|
||||
htmlFor="rename-name"
|
||||
error={err ?? (problem && changed ? problem : taken ? "同名已存在,不会覆盖" : null)}
|
||||
error={err ?? (problem && changed ? problem : taken ? "同名已存在,不会覆盖" : null)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -378,9 +378,9 @@ export function RenameDialog({
|
||||
? "当前名称与命名模板一致"
|
||||
: usingSuggestion
|
||||
? "已填入推荐名"
|
||||
: `推荐名:${suggested.name}`}
|
||||
: `推荐名:${suggested.name}`}
|
||||
{suggested.templateSource
|
||||
? `· 模板来源:${TEMPLATE_SOURCE[suggested.templateSource] ?? suggested.templateSource}`
|
||||
? `· 模板来源:${TEMPLATE_SOURCE[suggested.templateSource] ?? suggested.templateSource}`
|
||||
: ""}
|
||||
{usingSuggestion && name.trim() !== entry.name ? (
|
||||
<button
|
||||
@@ -434,10 +434,10 @@ export function RenameDialog({
|
||||
))}
|
||||
</ul>
|
||||
{pv.assetLocations > 0 ? (
|
||||
<Notice tone="neutral">同步 {pv.assetLocations} 条资产位置,写一条整理记录。</Notice>
|
||||
<Notice tone="neutral">同步 {pv.assetLocations} 条资产位置,写一条整理记录。</Notice>
|
||||
) : null}
|
||||
{pv.bindings.length > 0 ? (
|
||||
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后入库用新目录。</Notice>
|
||||
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后入库用新目录。</Notice>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
@@ -532,7 +532,7 @@ export function TransferDialog({
|
||||
onError: (e) => {
|
||||
const reason = filesErrorReason(e);
|
||||
if (reason === "plan_stale") {
|
||||
setErr("源文件在预览后有变化,已重新预览");
|
||||
setErr("源文件在预览后有变化,已重新预览");
|
||||
runPreview(target, mode);
|
||||
} else setErr(filesErrorText(e));
|
||||
},
|
||||
@@ -613,13 +613,15 @@ export function TransferDialog({
|
||||
{plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""}
|
||||
</StatusBadge>
|
||||
{conflicts.length ? (
|
||||
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
|
||||
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
|
||||
) : null}
|
||||
{plan.pairs.some((p) => p.registered) ? (
|
||||
<StatusBadge>含已登记资产,执行后同步位置</StatusBadge>
|
||||
) : null}
|
||||
{plan.pairs.some((p) => p.registered) ? <StatusBadge>含已登记资产,执行后同步位置</StatusBadge> : null}
|
||||
</div>
|
||||
{hnrBlocked ? (
|
||||
<Notice tone="destructive">
|
||||
{plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。
|
||||
{plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 underline"
|
||||
@@ -633,7 +635,9 @@ 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);
|
||||
@@ -761,7 +765,7 @@ export function DeleteDialog({
|
||||
reason === "permanent_disabled"
|
||||
? "设置里未开启永久删除(files.allowPermanentDelete)"
|
||||
: reason === "approval_required"
|
||||
? "用 API Key 删除,每次都要审批"
|
||||
? "用 API Key 删除,每次都要审批"
|
||||
: filesErrorText(e),
|
||||
);
|
||||
},
|
||||
@@ -772,7 +776,7 @@ export function DeleteDialog({
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
|
||||
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
|
||||
<DialogDescription>已检查做种任务、已登记资产和路径绑定。下载器里的任务不受影响。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 space-y-3 overflow-y-auto">
|
||||
@@ -820,15 +824,15 @@ export function DeleteDialog({
|
||||
</p>
|
||||
))}
|
||||
{c.assets.length ? (
|
||||
<p>已登记资产 {c.assets.length} 个(删除后退役,已满足的版本目标退回待处理)</p>
|
||||
<p>已登记资产 {c.assets.length} 个(删除后退役,已满足的版本目标退回待处理)</p>
|
||||
) : null}
|
||||
{c.bindings.map((bd) => (
|
||||
<p key={bd.bindingId}>
|
||||
路径绑定:{bd.title}
|
||||
路径绑定:{bd.title}
|
||||
{bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""}
|
||||
</p>
|
||||
))}
|
||||
{c.sharedInode ? <p>共享 inode(硬链接):只删这一处链接,其他位置不受影响</p> : null}
|
||||
{c.sharedInode ? <p>共享 inode(硬链接):只删这一处链接,其他位置不受影响</p> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -841,7 +845,7 @@ export function DeleteDialog({
|
||||
<span>
|
||||
同时退役资产
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
资产位置标为已移除,资产退役;已满足的版本目标退回待处理。
|
||||
资产位置标为已移除,资产退役;已满足的版本目标退回待处理。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -851,7 +855,9 @@ 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}
|
||||
@@ -931,8 +937,8 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
|
||||
const local = `${root.hostPath}${entry.path ? `/${entry.path}` : ""}`;
|
||||
const rows = [
|
||||
{ label: "本机路径", value: local },
|
||||
{ label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null },
|
||||
{ label: "登记目录内相对路径", value: entry.path || "(登记目录本身)" },
|
||||
{ label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null },
|
||||
{ label: "登记目录内相对路径", value: entry.path || "(登记目录本身)" },
|
||||
];
|
||||
const copy = async (v: string) => {
|
||||
try {
|
||||
@@ -969,7 +975,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>
|
||||
);
|
||||
@@ -994,7 +1000,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
|
||||
onSuccess: (r) => {
|
||||
setResult(r);
|
||||
(r.skipped.length ? toast.warning : toast.success)(
|
||||
`已提交 ${r.submitted.length} 个作品,锁定字段不变${r.skipped.length ? `;${r.skipped.length} 个未提交` : ""}`,
|
||||
`已提交 ${r.submitted.length} 个作品,锁定字段不变${r.skipped.length ? `;${r.skipped.length} 个未提交` : ""}`,
|
||||
);
|
||||
},
|
||||
onError: (e) => toast.error(filesErrorText(e, "提交失败")),
|
||||
@@ -1005,7 +1011,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>刷新元数据{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}</DialogTitle>
|
||||
<DialogDescription>按字段刷新,锁定的字段和路径不变。即 MP 的“刮削”。</DialogDescription>
|
||||
<DialogDescription>按字段刷新,锁定的字段和路径不变。即 MP 的「刮削」。</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!single && !result ? (
|
||||
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
|
||||
@@ -1028,7 +1034,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
|
||||
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -132,7 +132,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 },
|
||||
@@ -223,7 +223,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}
|
||||
|
||||
@@ -253,7 +253,7 @@ export function FileDetail({
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
const it = r.items[0];
|
||||
if (it?.status !== "ok") toast.warning(`探测不到媒体信息:${it?.reason ?? "未知原因"}`);
|
||||
if (it?.status !== "ok") toast.warning(`探测不到媒体信息:${it?.reason ?? "未知原因"}`);
|
||||
},
|
||||
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
|
||||
},
|
||||
@@ -286,7 +286,7 @@ export function FileDetail({
|
||||
/>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
未登记文件不自动探测。点“探测”读取 ffprobe 摘要,结果缓存 24 小时。
|
||||
未登记文件不自动探测。点「探测」读取 ffprobe 摘要,结果缓存 24 小时。
|
||||
</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
@@ -94,7 +94,7 @@ function Subline({ e }: { e: FileEntry }) {
|
||||
"block truncate text-meta",
|
||||
s.tone === "hint" ? "text-muted-foreground/70" : "text-muted-foreground",
|
||||
)}
|
||||
title={s.tone === "hint" ? `按文件名解析:${s.text}` : s.text}
|
||||
title={s.tone === "hint" ? `按文件名解析:${s.text}` : s.text}
|
||||
>
|
||||
{s.text}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
|
||||
|
||||
const KEYS: [string, string][] = [
|
||||
["↑ ↓ / Home End", "移动焦点(Shift 扩选)"],
|
||||
["↑ ↓ / Home End", "移动焦点(Shift 扩选)"],
|
||||
["Enter", "打开目录 / 看详情"],
|
||||
["Backspace / Alt+↑", "上一级"],
|
||||
["Space", "勾选当前行"],
|
||||
@@ -9,11 +9,11 @@ const KEYS: [string, string][] = [
|
||||
["Ctrl/⌘+A", "全选"],
|
||||
["Ctrl/⌘+Enter", "整理选中项"],
|
||||
["F2", "重命名"],
|
||||
["Del", "删除(先进回收站)"],
|
||||
["Del", "删除(先进回收站)"],
|
||||
["/", "搜索当前目录"],
|
||||
["Esc", "清除选择"],
|
||||
["Shift+F10 / 菜单键", "打开操作菜单"],
|
||||
["拖到目录", "移动到(按住 Alt 复制),先出预览"],
|
||||
["拖到目录", "移动到(按住 Alt 复制),先出预览"],
|
||||
["?", "显示这张表"],
|
||||
];
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export function IdentityDialog({
|
||||
setErrors({});
|
||||
set.mutate(r.data, {
|
||||
onSuccess: () => {
|
||||
toast.success(`已改为 TMDB ${r.data.entityType}:${r.data.externalId}${r.data.lock ? ",并加人工锁" : ""}`);
|
||||
toast.success(`已改为 TMDB ${r.data.entityType}:${r.data.externalId}${r.data.lock ? ",并加人工锁" : ""}`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, "修改失败")),
|
||||
@@ -102,7 +102,7 @@ export function IdentityDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改外部身份</DialogTitle>
|
||||
<DialogDescription>
|
||||
同类型的旧 TMDB 身份会退役,保留修订记录。加人工锁后,自动匹配和刷新不再改动。
|
||||
同类型的旧 TMDB 身份会退役,保留修订记录。加人工锁后,自动匹配和刷新不再改动。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-3">
|
||||
@@ -249,7 +249,7 @@ export function FieldLockDialog({
|
||||
<Dialog open={!!field} onOpenChange={(o) => !lock.isPending && onOpenChange(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>锁定字段:{field ? (ENTITY_FIELD_LABEL[field] ?? field) : ""}</DialogTitle>
|
||||
<DialogTitle>锁定字段:{field ? (ENTITY_FIELD_LABEL[field] ?? field) : ""}</DialogTitle>
|
||||
<DialogDescription>锁定后刷新元数据不改这个字段。已绑定的路径不变。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-3">
|
||||
@@ -383,7 +383,7 @@ export function MergeDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>合并作品</DialogTitle>
|
||||
<DialogDescription>
|
||||
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、资产和历史记录一并转过去。合并错了可以再修订。
|
||||
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、资产和历史记录一并转过去。合并错了可以再修订。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EntityPicker value={target} onChange={setTarget} excludeId={entity.id} />
|
||||
@@ -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="合并"
|
||||
|
||||
@@ -80,7 +80,7 @@ function AddAlias({ entity }: { entity: PickedEntity }) {
|
||||
value={f.alias}
|
||||
aria-invalid={!!errors.alias}
|
||||
onChange={(e) => setF((x) => ({ ...x, alias: e.target.value }))}
|
||||
placeholder="如:沙丘2"
|
||||
placeholder="如:沙丘 2"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="类型" htmlFor="alias-kind">
|
||||
@@ -170,8 +170,8 @@ export function AliasesPanel() {
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
title={`删除别名「${a.alias}」?`}
|
||||
description="删除后不再用这个别名匹配作品,已确认的外部身份不受影响。"
|
||||
title={`删除别名「${a.alias}」?`}
|
||||
description="删除后不再用这个别名匹配作品,已确认的外部身份不受影响。"
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export type PickedEntity = { id: string; title: string; year: number | null; kin
|
||||
export function EntityPicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "搜索本地作品(标题/原名)",
|
||||
placeholder = "搜索本地作品(标题/原名)",
|
||||
id,
|
||||
}: {
|
||||
value: PickedEntity | null;
|
||||
|
||||
@@ -127,7 +127,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
>
|
||||
{queued ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
已进待处理,在那里选择候选或忽略。
|
||||
已进待处理,在那里选择候选或忽略。
|
||||
</p>
|
||||
) : null}
|
||||
<KvList
|
||||
@@ -156,7 +156,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
<JsonView label="解析条件(原始)" value={item.query} defaultOpen={false} />
|
||||
<JsonView label="解析条件(原始)" value={item.query} defaultOpen={false} />
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function PoolTab() {
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-caption break-all text-foreground">{r.release.title}</p>
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground">
|
||||
{r.release.subtitle ? `${r.release.subtitle} · ` : ""}解析:{querySummary(r.query)}
|
||||
{r.release.subtitle ? `${r.release.subtitle} · ` : ""}解析:{querySummary(r.query)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
@@ -149,7 +149,7 @@ export function PoolTab() {
|
||||
empty={{
|
||||
icon: SearchX,
|
||||
title: filtered ? "没有符合条件的待识别资源" : "没有待识别资源",
|
||||
description: "证据不足或冲突的资源会出现在这里,有新证据时自动重试。",
|
||||
description: "证据不足或冲突的资源会出现在这里,有新证据时自动重试。",
|
||||
}}
|
||||
hasMore={pool.hasNextPage}
|
||||
onLoadMore={() => pool.fetchNextPage()}
|
||||
|
||||
@@ -128,7 +128,7 @@ export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
|
||||
reject: { label: "拒绝", tone: "destructive" },
|
||||
unknown_field: { label: "字段未知", tone: "warning" },
|
||||
skipped: { label: "跳过", tone: "neutral" },
|
||||
deferred: { label: "延后:已有计划在途", tone: "warning" },
|
||||
deferred: { label: "延后:已有计划在途", tone: "warning" },
|
||||
};
|
||||
|
||||
export const DECISION_KIND: Record<DecisionKind, string> = {
|
||||
@@ -174,7 +174,7 @@ export const POOL_STATUS: Record<string, ToneLabel> = {
|
||||
};
|
||||
|
||||
export const PREF_KIND: Record<PreferenceKind, { label: string; hint: string }> = {
|
||||
soft: { label: "软偏好", hint: "影响排序,不排除" },
|
||||
soft: { label: "软偏好", hint: "影响排序,不排除" },
|
||||
hard: { label: "硬规则", hint: "命中即排除" },
|
||||
version: { label: "版本偏好", hint: "同一作品内选哪种版本" },
|
||||
target: { label: "版本目标", hint: "为作品新建一个版本目标" },
|
||||
@@ -214,7 +214,7 @@ export const PRIORITY: Record<AcquisitionPriority, string> = {
|
||||
export const SUB_STATUS: Record<SubscriptionStatus, ToneLabel> = {
|
||||
active: { label: "进行中", tone: "success" },
|
||||
paused: { label: "已暂停", tone: "warning" },
|
||||
pending: { label: "待定(元数据未就绪)", tone: "warning" },
|
||||
pending: { label: "待定(元数据未就绪)", tone: "warning" },
|
||||
completed: { label: "已完成", tone: "neutral" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
};
|
||||
@@ -268,7 +268,7 @@ export const opts = <K extends string>(rec: Record<K, string | { label: string }
|
||||
/** 版本目标 / 候选的原因串(如 `blocked:no_main_video`、`deferred:slot_busy;inflight:<id>`)→ 中文;不认识的原样显示 */
|
||||
const SLOT_REASON: Record<string, string> = {
|
||||
"blocked:no_main_video": "没有可入库的主视频",
|
||||
"deferred:slot_busy": "已有计划在途,等它结束再评估",
|
||||
"deferred:slot_busy": "已有计划在途,等它结束再评估",
|
||||
verified: "已核验入库",
|
||||
verified_partial: "部分核验入库",
|
||||
hard_conditions_met: "硬条件全部满足",
|
||||
|
||||
@@ -31,7 +31,7 @@ const jsonObject = (name: string) =>
|
||||
} catch {
|
||||
// 落到下面的错误
|
||||
}
|
||||
ctx.addIssue({ code: "custom", message: `${name}必须是 JSON 对象,如 {"mediaKinds":["movie"]}` });
|
||||
ctx.addIssue({ code: "custom", message: `${name}必须是 JSON 对象,如 {"mediaKinds":["movie"]}` });
|
||||
return z.NEVER;
|
||||
});
|
||||
|
||||
@@ -108,8 +108,8 @@ export function PreferenceFormDialog({
|
||||
<DialogTitle>{tag ? "编辑偏好" : "添加偏好"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{tag
|
||||
? "修改作用范围或规则会留下旧语义的抑制记录,Agent 不会按旧证据加回。"
|
||||
: "手动添加等同对话确认。之前取消过的偏好,重新添加会解除抑制。"}
|
||||
? "修改作用范围或规则会留下旧语义的抑制记录,Agent 不会按旧证据加回。"
|
||||
: "手动添加等同对话确认。之前取消过的偏好,重新添加会解除抑制。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-2" noValidate>
|
||||
@@ -120,7 +120,7 @@ export function PreferenceFormDialog({
|
||||
value={draft.label}
|
||||
aria-invalid={!!errors.label}
|
||||
onChange={(e) => set("label", e.target.value)}
|
||||
placeholder="如:偏爱科幻"
|
||||
placeholder="如:偏爱科幻"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="类型" htmlFor="pref-kind" hint={PREF_KIND[draft.kind].hint}>
|
||||
@@ -157,7 +157,7 @@ export function PreferenceFormDialog({
|
||||
onChange={(e) => set("scope", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="规则(JSON)" htmlFor="pref-rule" error={errors.rule} hint="结构化条件,由代码执行">
|
||||
<FormField label="规则(JSON)" htmlFor="pref-rule" error={errors.rule} hint="结构化条件,由代码执行">
|
||||
<Textarea
|
||||
id="pref-rule"
|
||||
rows={4}
|
||||
|
||||
@@ -78,7 +78,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">
|
||||
|
||||
@@ -42,8 +42,8 @@ export const CUTS = [
|
||||
] as const;
|
||||
export const CONTENT_TYPES = ["main", "extra", "behind_the_scenes", "performance_only", "preview", "reaction"] as const;
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = { BluRay: "BluRay(压制)", "BluRay-Disc": "原盘" };
|
||||
const CUT_LABEL: Record<string, string> = { default: "默认(无剪辑标记)" };
|
||||
const SOURCE_LABEL: Record<string, string> = { BluRay: "BluRay(压制)", "BluRay-Disc": "原盘" };
|
||||
const CUT_LABEL: Record<string, string> = { default: "默认(无剪辑标记)" };
|
||||
const CT_LABEL: Record<string, string> = {
|
||||
main: "正片",
|
||||
extra: "花絮",
|
||||
@@ -52,7 +52,7 @@ const CT_LABEL: Record<string, string> = {
|
||||
preview: "预告",
|
||||
reaction: "reaction",
|
||||
};
|
||||
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
|
||||
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
const o = <T extends string>(list: readonly T[], labels: Record<string, string> = {}) =>
|
||||
@@ -235,7 +235,7 @@ export function summarizeHard(j: JsonObject | undefined, fallback?: JsonObject):
|
||||
if (!h) return ["规则无法解析"];
|
||||
if (h.resolutions?.length) out.push(`分辨率 ${h.resolutions.join(" / ")}`);
|
||||
if (h.sources?.length) out.push(`来源 ${h.sources.map((s) => SOURCE_LABEL[s] ?? s).join(" / ")}`);
|
||||
if (h.requireEncode) out.push("仅蓝光压制(不含原盘/Remux)");
|
||||
if (h.requireEncode) out.push("仅蓝光压制(不含原盘/Remux)");
|
||||
if (h.minSizeBytes !== undefined || h.maxSizeBytes !== undefined)
|
||||
out.push(
|
||||
`体积 ${h.minSizeBytes !== undefined ? formatBytes(h.minSizeBytes, 1) : "0"} – ${
|
||||
@@ -282,7 +282,7 @@ export function summarizeUpgrade(j: JsonObject | undefined): string[] {
|
||||
export function summarizeCompletion(j: JsonObject | undefined): string {
|
||||
const c = Completion.safeParse(j ?? {}).data;
|
||||
if (!c) return "未设置";
|
||||
return c.kind === "movie" ? "按内容版本核对(电影)" : "按内容单元覆盖核对(剧集/综艺)";
|
||||
return c.kind === "movie" ? "按内容版本核对(电影)" : "按内容单元覆盖核对(剧集/综艺)";
|
||||
}
|
||||
|
||||
// ── 编辑器 ──
|
||||
@@ -312,7 +312,7 @@ export function SlotRulesEditor({
|
||||
onChange={(v) => set("resolutions", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="交付来源" hint="BluRay 指蓝光压制;原盘与 Remux 单列">
|
||||
<FormField label="交付来源" hint="BluRay 指蓝光压制;原盘与 Remux 单列">
|
||||
<ChipToggleGroup
|
||||
label="交付来源"
|
||||
options={o(SOURCES, SOURCE_LABEL)}
|
||||
@@ -354,7 +354,7 @@ export function SlotRulesEditor({
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField label="必需字幕语言" htmlFor={`${id}-subs`} hint="逗号分隔;zh 可被 zh-Hans/zh-Hant 满足">
|
||||
<FormField label="必需字幕语言" htmlFor={`${id}-subs`} hint="逗号分隔;zh 可被 zh-Hans/zh-Hant 满足">
|
||||
<Input
|
||||
id={`${id}-subs`}
|
||||
value={value.requiredSubtitles}
|
||||
@@ -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
|
||||
@@ -437,14 +437,14 @@ export function SlotRulesEditor({
|
||||
onChange={(v) => set("softCodecs", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="偏好发布组" htmlFor={`${id}-grp`} hint="逗号分隔,越靠前越好">
|
||||
<FormField label="偏好发布组" htmlFor={`${id}-grp`} hint="逗号分隔,越靠前越好">
|
||||
<Input id={`${id}-grp`} value={value.softGroups} onChange={(e) => set("softGroups", e.target.value)} />
|
||||
</FormField>
|
||||
</fieldset>
|
||||
|
||||
<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>
|
||||
@@ -459,7 +459,7 @@ export function SlotRulesEditor({
|
||||
label="最小来源等级提升"
|
||||
htmlFor={`${id}-tier`}
|
||||
error={errors.minTierSteps}
|
||||
hint="默认 1;同等级不反复更换"
|
||||
hint="默认 1;同等级不反复更换"
|
||||
>
|
||||
<Input
|
||||
id={`${id}-tier`}
|
||||
@@ -481,7 +481,7 @@ export function SlotRulesEditor({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="合格压制组名单" htmlFor={`${id}-allow`} hint="设置后,名单外的压制不触发自动升级">
|
||||
<FormField label="合格压制组名单" htmlFor={`${id}-allow`} hint="设置后,名单外的压制不触发自动升级">
|
||||
<Input
|
||||
id={`${id}-allow`}
|
||||
value={value.encodeGroupAllowlist}
|
||||
@@ -511,8 +511,8 @@ export function SlotRulesEditor({
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="__none">未设置</SelectItem>
|
||||
<SelectItem value="movie">按内容版本核对(电影)</SelectItem>
|
||||
<SelectItem value="units">按内容单元覆盖核对(剧集/综艺)</SelectItem>
|
||||
<SelectItem value="movie">按内容版本核对(电影)</SelectItem>
|
||||
<SelectItem value="units">按内容单元覆盖核对(剧集/综艺)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.form ? <p className="text-meta text-destructive">{errors.form}</p> : null}
|
||||
|
||||
@@ -70,7 +70,7 @@ function EntityNamingDialog({ entityId, kind, onClose }: { entityId: string; kin
|
||||
} catch (err) {
|
||||
const split = splitNamingErrors(errorMessage(err));
|
||||
if (split) setErrors(split);
|
||||
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
|
||||
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,7 +89,7 @@ function EntityNamingDialog({ entityId, kind, onClose }: { entityId: string; kin
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={saved[kind] ? "success" : "neutral"}>
|
||||
当前:{saved[kind] ? "作品覆盖" : `沿用${inheritedSource}`}
|
||||
当前:{saved[kind] ? "作品覆盖" : `沿用${inheritedSource}`}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<NamingTemplateEditor
|
||||
|
||||
@@ -11,7 +11,7 @@ export function splitNamingErrors(message: string): Partial<Record<NamingKind, s
|
||||
for (const m of message.matchAll(/(movie|tv|variety):\s*([^;]+)/g)) {
|
||||
const [, k, msg] = m;
|
||||
const pos = /^(\d+):(\d+)\s*(.*)$/.exec((msg ?? "").trim());
|
||||
out[k as NamingKind] = pos ? `第 ${pos[1]} 行第 ${pos[2]} 列:${pos[3]}` : (msg ?? "").trim();
|
||||
out[k as NamingKind] = pos ? `第 ${pos[1]} 行第 ${pos[2]} 列:${pos[3]}` : (msg ?? "").trim();
|
||||
}
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const ROLE: Record<NamingPreviewResult["segments"][number]["role"], { label: str
|
||||
file: { label: "文件名", className: "text-foreground font-medium" },
|
||||
};
|
||||
|
||||
export const NAMING_NOTE = "已建立目录的作品不受模板影响,模板只决定新目录和新文件名。";
|
||||
export const NAMING_NOTE = "已建立目录的作品不受模板影响,模板只决定新目录和新文件名。";
|
||||
|
||||
function useDebounced<T>(v: T, ms = 300): T {
|
||||
const [d, setD] = useState(v);
|
||||
@@ -166,8 +166,8 @@ export function NamingTemplateEditor({
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-meta text-muted-foreground">
|
||||
<span>
|
||||
{override && !value.trim()
|
||||
? `留空:沿用${inherited?.[active]?.source ?? "上级"}模板`
|
||||
: "/ 分目录,最后一段是文件名,须以 {{fileExt}} 结尾"}
|
||||
? `留空:沿用${inherited?.[active]?.source ?? "上级"}模板`
|
||||
: "/ 分目录,最后一段是文件名,须以 {{fileExt}} 结尾"}
|
||||
</span>
|
||||
<span className="tabular-nums">{value.length} / 1024</span>
|
||||
</div>
|
||||
@@ -268,9 +268,9 @@ function NamingPreview({
|
||||
/>
|
||||
) : null}
|
||||
{!input ? (
|
||||
<p className="text-caption text-muted-foreground">模板为空,没有可预览的内容。</p>
|
||||
<p className="text-caption text-muted-foreground">模板为空,没有可预览的内容。</p>
|
||||
) : q.isError && !r ? (
|
||||
<p className="text-caption text-destructive">预览失败:{(q.error as Error).message}</p>
|
||||
<p className="text-caption text-destructive">预览失败:{(q.error as Error).message}</p>
|
||||
) : !r ? (
|
||||
<p className="text-caption text-muted-foreground">预览中…</p>
|
||||
) : (
|
||||
@@ -293,7 +293,7 @@ function PreviewBody({
|
||||
return (
|
||||
<>
|
||||
<p className="text-meta break-all text-muted-foreground">
|
||||
样本:<span className="font-mono">{r.sample.fileName}</span>
|
||||
样本:<span className="font-mono">{r.sample.fileName}</span>
|
||||
{r.sample.entityTitle ? ` · 作品 ${r.sample.entityTitle}` : ""}
|
||||
</p>
|
||||
{r.errors.length ? (
|
||||
@@ -317,10 +317,10 @@ function PreviewBody({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{r.usesBinding ? <Note>这部作品已有目录,目录沿用已建立的路径,模板只决定文件名。</Note> : null}
|
||||
{r.usesBinding ? <Note>这部作品已有目录,目录沿用已建立的路径,模板只决定文件名。</Note> : null}
|
||||
{r.missing.length ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
无值的变量(输出为空):<span className="font-mono">{r.missing.join("、")}</span>
|
||||
无值的变量(输出为空):<span className="font-mono">{r.missing.join("、")}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{r.warnings.map((w) => (
|
||||
@@ -422,7 +422,7 @@ function VariablePalette({
|
||||
{loading ? (
|
||||
<p className="text-meta text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="text-meta text-destructive">变量表加载失败:{(error as Error).message}</p>
|
||||
<p className="text-meta text-destructive">变量表加载失败:{(error as Error).message}</p>
|
||||
) : (
|
||||
<div className="max-h-96 space-y-3 overflow-y-auto pr-1">
|
||||
{GROUP_ORDER.map((g) => {
|
||||
@@ -478,7 +478,7 @@ function VariablePalette({
|
||||
{filtersOpen ? (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
<li className="text-meta text-muted-foreground">
|
||||
<span className="font-mono text-foreground">{"{% if 变量 %}…{% endif %}"}</span> 条件;
|
||||
<span className="font-mono text-foreground">{"{% if 变量 %}…{% endif %}"}</span> 条件;
|
||||
<span className="font-mono text-foreground">{"{{ a or b }}"}</span> 取第一个有值的
|
||||
</li>
|
||||
{data.filters.map((f) => (
|
||||
|
||||
@@ -259,7 +259,7 @@ export function EntityPicker({
|
||||
</ul>
|
||||
{!hits.local.length && !hits.tmdb.length && !search.isFetching ? (
|
||||
<p className="px-2 py-1.5 text-meta text-muted-foreground">
|
||||
没有匹配的作品{hits.tmdbConfigured ? "" : ";TMDB 未配置,只搜了本地"}
|
||||
没有匹配的作品{hits.tmdbConfigured ? "" : ";TMDB 未配置,只搜了本地"}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function rowProblem(r: WizardRow): string | null {
|
||||
if (r.excluded) return null;
|
||||
if (!r.entity) return "未选择作品";
|
||||
const eps = parseEpisodes(r.episodes);
|
||||
if (eps === null) return "集号格式不对,如 3、3-5、3,4";
|
||||
if (eps === null) return "集号格式不对,如 3、3-5、3,4";
|
||||
if (r.season.trim() && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字";
|
||||
if (!isMovieKind(r.entity.kind) && !r.season.trim()) return "剧集需要季号";
|
||||
return null;
|
||||
|
||||
@@ -98,7 +98,7 @@ export function WizardBulkBar({
|
||||
value={offset}
|
||||
onChange={(e) => setOffset(e.target.value)}
|
||||
placeholder="±N"
|
||||
aria-label="集号偏移量(可为负)"
|
||||
aria-label="集号偏移量(可为负)"
|
||||
className="h-8 w-16 text-center"
|
||||
/>
|
||||
<Button
|
||||
@@ -131,7 +131,7 @@ export function WizardBulkBar({
|
||||
)}
|
||||
{stats.excluded ? (
|
||||
<StatusBadge>
|
||||
{stats.excluded} 项已排除{stats.bySize ? `(${stats.bySize} 项低于最小体积)` : ""}
|
||||
{stats.excluded} 项已排除{stats.bySize ? `(${stats.bySize} 项低于最小体积)` : ""}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -270,7 +270,7 @@ export function StepFiles(p: StepFilesProps) {
|
||||
)}
|
||||
{p.truncated ? (
|
||||
<p className="rounded-lg border border-warning/35 bg-warning/10 px-3 py-1.5 text-meta text-warning">
|
||||
目录里视频超过 200 个,只识别了前 200 个。进入子目录分批整理。
|
||||
目录里视频超过 200 个,只识别了前 200 个。进入子目录分批整理。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ export function StepOptions({
|
||||
if (r.mediaKind === "mixed" || r.mediaKind === "other" || !r.mediaKind) return null;
|
||||
const hasMovie = [...kinds].some((k) => k === "movie");
|
||||
const hasShow = [...kinds].some((k) => k !== "movie");
|
||||
if (r.mediaKind === "movie" && hasShow) return "电影库,本次含剧集";
|
||||
if (r.mediaKind !== "movie" && hasMovie) return "剧集库,本次含电影";
|
||||
if (r.mediaKind === "movie" && hasShow) return "电影库,本次含剧集";
|
||||
if (r.mediaKind !== "movie" && hasMovie) return "剧集库,本次含电影";
|
||||
return null;
|
||||
};
|
||||
const t = options.target;
|
||||
@@ -100,14 +100,14 @@ export function StepOptions({
|
||||
checked={options.action === "copy"}
|
||||
onSelect={() => set({ action: "copy" })}
|
||||
title="复制"
|
||||
hint="保留源文件,不影响做种"
|
||||
hint="保留源文件,不影响做种"
|
||||
/>
|
||||
<Choice
|
||||
checked={options.action === "move"}
|
||||
onSelect={() => set({ action: "move" })}
|
||||
disabled={hnrCount ? `${hnrCount} 项做种中且 H&R 未满足,不能移动` : null}
|
||||
disabled={hnrCount ? `${hnrCount} 项做种中且 H&R 未满足,不能移动` : null}
|
||||
title="移动"
|
||||
hint="入库后删源;跨卷时按复制 → 核验 → 删源"
|
||||
hint="入库后删源;跨卷时按复制 → 核验 → 删源"
|
||||
/>
|
||||
<Choice checked={false} onSelect={() => {}} disabled="还不支持硬链接" title="硬链接" />
|
||||
</div>
|
||||
@@ -119,19 +119,19 @@ export function StepOptions({
|
||||
checked={options.onExisting === "conflict"}
|
||||
onSelect={() => set({ onExisting: "conflict" })}
|
||||
title="交给待处理"
|
||||
hint="该组不执行,冲突进待处理"
|
||||
hint="该组不执行,冲突进待处理"
|
||||
/>
|
||||
<Choice
|
||||
checked={options.onExisting === "skip"}
|
||||
onSelect={() => set({ onExisting: "skip" })}
|
||||
title="跳过"
|
||||
hint="只跳过已存在的文件,其余照常整理"
|
||||
hint="只跳过已存在的文件,其余照常整理"
|
||||
/>
|
||||
<Choice
|
||||
checked={options.onExisting === "replace_with_verify"}
|
||||
onSelect={() => set({ onExisting: "replace_with_verify" })}
|
||||
title="替换"
|
||||
hint="新文件核验通过后,旧文件移入回收站"
|
||||
hint="新文件核验通过后,旧文件移入回收站"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
@@ -144,7 +144,7 @@ export function StepOptions({
|
||||
checked={t.mode === "auto"}
|
||||
onSelect={() => set({ target: { mode: "auto" } })}
|
||||
title="自动"
|
||||
hint="已绑定的作品沿用原目录,否则按作品类型选库根"
|
||||
hint="已绑定的作品沿用原目录,否则按作品类型选库根"
|
||||
/>
|
||||
{libRoots.map((r) => (
|
||||
<Choice
|
||||
@@ -165,7 +165,7 @@ export function StepOptions({
|
||||
checked={t.mode === "dir"}
|
||||
onSelect={() => set({ target: { mode: "dir", ref: t.mode === "dir" ? t.ref : { root: "", path: "" } } })}
|
||||
title="指定目录"
|
||||
hint="文件按模板命名后直接放进这个目录,不建路径绑定"
|
||||
hint="文件按模板命名后直接放进这个目录,不建路径绑定"
|
||||
/>
|
||||
{t.mode === "dir" ? (
|
||||
<DirPicker
|
||||
@@ -186,7 +186,7 @@ export function StepOptions({
|
||||
title="按命名模板"
|
||||
hint={
|
||||
<>
|
||||
模板按 作品覆盖 → 库根 → 全局 取第一个,预览里显示实际来源。
|
||||
模板按 作品覆盖 → 库根 → 全局 取第一个,预览里显示实际来源。
|
||||
<Link to="/console/settings#library/naming" className="ml-1 underline underline-offset-2">
|
||||
去设置
|
||||
</Link>
|
||||
@@ -206,7 +206,7 @@ export function StepOptions({
|
||||
<div className="flex items-start justify-between gap-3 rounded-xl border border-border px-3 py-2">
|
||||
<span className="text-body">
|
||||
刷新元数据
|
||||
<span className="block text-meta text-muted-foreground">按字段刷新,锁定的字段不变</span>
|
||||
<span className="block text-meta text-muted-foreground">按字段刷新,锁定的字段不变</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={options.refreshMetadata}
|
||||
@@ -214,7 +214,7 @@ export function StepOptions({
|
||||
aria-label="入库后刷新元数据"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">同名字幕、NFO、图片随主文件一起处理,预览里列出。</p>
|
||||
<p className="text-meta text-muted-foreground">同名字幕、NFO、图片随主文件一起处理,预览里列出。</p>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,10 +27,10 @@ import { PathSegments } from "./path-segments.tsx";
|
||||
export type GroupTitle = (g: FilesOrganizeGroup) => string;
|
||||
|
||||
const ERROR_TEXT: Record<string, string> = {
|
||||
plan_blocked: "目标已存在,按“交给待处理”不执行",
|
||||
plan_blocked: "目标已存在,按「交给待处理」不执行",
|
||||
plan_stale: "源文件在预览后有变化",
|
||||
approval_required: "移动需要审批或持续授权",
|
||||
download_active: "做种中且 H&R 未满足,不能移动",
|
||||
download_active: "做种中且 H&R 未满足,不能移动",
|
||||
};
|
||||
|
||||
/** 服务端原因码(message 以 code: 开头)→ 文案 */
|
||||
@@ -187,7 +187,7 @@ export function PreviewGroupCard({
|
||||
{result && record ? `${record.counts.succeeded}/${record.counts.total} 成功 · ` : ""}
|
||||
{g.files.length} 个文件 · {formatBytes(size, 1)}
|
||||
{g.action ? ` · ${g.action === "move" ? "移动" : "复制"}` : ""}
|
||||
{g.naming ? ` · 命名:${TEMPLATE_SOURCE[g.naming.templateSource] ?? g.naming.templateSource}` : ""}
|
||||
{g.naming ? ` · 命名:${TEMPLATE_SOURCE[g.naming.templateSource] ?? g.naming.templateSource}` : ""}
|
||||
</span>
|
||||
{result && g.recordId ? (
|
||||
<Link className="ml-auto text-meta underline underline-offset-2" to={`/console/organize/${g.recordId}`}>
|
||||
@@ -210,7 +210,7 @@ export function PreviewGroupCard({
|
||||
</p>
|
||||
))}
|
||||
{!result ? <TargetLine g={g} roots={roots} /> : null}
|
||||
{g.truncated ? <p className="text-caption text-warning">文件过多,预览只列出前一部分。</p> : null}
|
||||
{g.truncated ? <p className="text-caption text-warning">文件过多,预览只列出前一部分。</p> : null}
|
||||
{!result && nested.length ? (
|
||||
<ul className="divide-y divide-border/50">
|
||||
{nested.map((n) => (
|
||||
@@ -227,7 +227,7 @@ export function PreviewGroupCard({
|
||||
) : null}
|
||||
{!result && g.excluded?.length ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
排除 {g.excluded.length} 个:
|
||||
排除 {g.excluded.length} 个:
|
||||
{g.excluded
|
||||
.slice(0, 6)
|
||||
.map((e) => `${baseName(e.source)}(${EXCLUDE_REASON[e.reason] ?? e.reason})`)
|
||||
@@ -258,7 +258,9 @@ export function PreviewList({
|
||||
const shown = onlyProblems ? groups.filter(groupHasProblem) : groups;
|
||||
const pg = pageOf(shown, page);
|
||||
if (!shown.length)
|
||||
return <p className="py-8 text-center text-caption text-muted-foreground">没有问题项。关闭“只看问题”查看全部。</p>;
|
||||
return (
|
||||
<p className="py-8 text-center text-caption text-muted-foreground">没有问题项。关闭「只看问题」查看全部。</p>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{pg.items.map((g, i) => (
|
||||
|
||||
@@ -100,7 +100,7 @@ export function RuleGroupEditor({
|
||||
ladderProblem(form.ladder),
|
||||
...form.excludes.map((e, i) => {
|
||||
const p = ladderProblem(e.state);
|
||||
return p ? `第 ${i + 1} 条硬排除:${p}` : null;
|
||||
return p ? `第 ${i + 1} 条硬排除:${p}` : null;
|
||||
}),
|
||||
].filter((p): p is string => !!p);
|
||||
const blocked = problems[0] ?? null;
|
||||
@@ -144,7 +144,7 @@ export function RuleGroupEditor({
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError && err.code === "conflict") {
|
||||
if (/revision/.test(err.message)) setConflict("这个规则组刚被别处改过,没有保存。载入最新版本后再改。");
|
||||
if (/revision/.test(err.message)) setConflict("这个规则组刚被别处改过,没有保存。载入最新版本后再改。");
|
||||
else setNameError(err.message);
|
||||
} else toast.error(errorMessage(err, "保存失败"));
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export function RuleGroupEditor({
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
"层从上到下依次匹配,先命中的层优先级高。"
|
||||
"层从上到下依次匹配,先命中的层优先级高。"
|
||||
)
|
||||
}
|
||||
footer={
|
||||
@@ -186,7 +186,7 @@ export function RuleGroupEditor({
|
||||
删除
|
||||
</Button>
|
||||
}
|
||||
title={`删除「${group.name}」?`}
|
||||
title={`删除「${group.name}」?`}
|
||||
description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`}
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
@@ -221,7 +221,7 @@ export function RuleGroupEditor({
|
||||
</div>
|
||||
) : null}
|
||||
{inDefault ? (
|
||||
<p className="text-meta text-muted-foreground">在默认链里的组不能删除,先在“默认值”里移出。</p>
|
||||
<p className="text-meta text-muted-foreground">在默认链里的组不能删除,先在「默认值」里移出。</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
@@ -263,7 +263,7 @@ export function RuleGroupEditor({
|
||||
label="作用范围"
|
||||
hint={
|
||||
group && (group.scope.entityIds?.length || group.scope.siteIds?.length || group.scope.subscriptionId)
|
||||
? `另有:${scopeText({ ...group.scope, mediaTypes: undefined, mediaKinds: undefined } as RuleGroupScope)}(此处不改)`
|
||||
? `另有:${scopeText({ ...group.scope, mediaTypes: undefined, mediaKinds: undefined } as RuleGroupScope)}(此处不改)`
|
||||
: "不选 = 全部类型"
|
||||
}
|
||||
className="sm:col-span-2"
|
||||
@@ -279,7 +279,7 @@ export function RuleGroupEditor({
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption font-medium text-foreground">一层都不中时拒绝</p>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{form.ladderRequired ? "资源至少要命中一层" : "只影响排序,不中的资源 pri 为 0"}
|
||||
{form.ladderRequired ? "资源至少要命中一层" : "只影响排序,不中的资源 pri 为 0"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -302,7 +302,7 @@ export function RuleGroupEditor({
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="硬排除">
|
||||
<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">
|
||||
{form.excludes.map((e, i) => (
|
||||
<li key={e.key} className="flex items-start gap-2">
|
||||
@@ -409,7 +409,7 @@ function Revisions({ id }: { id: string }) {
|
||||
</div>
|
||||
{r.changedFields.length ? (
|
||||
<p className="mt-1 text-meta text-muted-foreground">
|
||||
改动:{r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}
|
||||
改动:{r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
|
||||
@@ -22,7 +22,7 @@ function useToggle() {
|
||||
toggle: async (g: RuleGroup) => {
|
||||
try {
|
||||
await update.mutateAsync({ id: g.id, enabled: !g.enabled, expectedRevision: g.revision });
|
||||
toast.success(g.enabled ? "已停用,Agent 不会自动加回" : "已启用");
|
||||
toast.success(g.enabled ? "已停用,Agent 不会自动加回" : "已启用");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
@@ -38,7 +38,7 @@ function RefsCell({ g, refs }: { g: RuleGroup; refs: RuleRefs | undefined }) {
|
||||
if (subs.length) parts.push(`${subs.length} 个订阅`);
|
||||
if (slots.length) parts.push(`${slots.length} 个版本目标`);
|
||||
if (!parts.length) return <span className="text-meta text-muted-foreground">{refs ? "未引用" : "—"}</span>;
|
||||
const title = [...subs.map((s) => `订阅:${s.title}`), ...slots.map((s) => `版本目标:${s.name}`)].join("\n");
|
||||
const title = [...subs.map((s) => `订阅:${s.title}`), ...slots.map((s) => `版本目标:${s.name}`)].join("\n");
|
||||
return (
|
||||
<span className="text-meta text-foreground" title={title || undefined}>
|
||||
{parts.join(" · ")}
|
||||
@@ -140,7 +140,7 @@ export function RuleGroupTable({
|
||||
empty={{
|
||||
icon: ListChecks,
|
||||
title: filtered ? "没有符合条件的规则组" : "还没有规则组",
|
||||
description: filtered ? undefined : "新建一个规则组,或在 Agent 会话里说明长期偏好。",
|
||||
description: filtered ? undefined : "新建一个规则组,或在 Agent 会话里说明长期偏好。",
|
||||
action: filtered ? undefined : (
|
||||
<Button size="sm" onClick={onCreate}>
|
||||
新建规则组
|
||||
@@ -197,7 +197,7 @@ function RuleTag({ g, onOpen }: { g: RuleGroup; onOpen: () => void }) {
|
||||
停用
|
||||
</Button>
|
||||
}
|
||||
title={`停用「${g.name}」?`}
|
||||
title={`停用「${g.name}」?`}
|
||||
description={SUPPRESS_NOTE}
|
||||
confirmLabel="停用"
|
||||
onConfirm={() => toggle(g)}
|
||||
@@ -213,14 +213,14 @@ function RuleTag({ g, onOpen }: { g: RuleGroup; onOpen: () => void }) {
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
title={`删除「${g.name}」?`}
|
||||
title={`删除「${g.name}」?`}
|
||||
description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`}
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await del.mutateAsync(g.id);
|
||||
toast.success("已删除,Agent 不会自动加回");
|
||||
toast.success("已删除,Agent 不会自动加回");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "删除失败"));
|
||||
throw err;
|
||||
@@ -237,7 +237,7 @@ export function RuleTags({ rows, onOpen }: { rows: RuleGroup[]; onOpen: (g: Rule
|
||||
return (
|
||||
<Panel
|
||||
title="偏好"
|
||||
description="单条规则,多由对话或 Agent 提出。停用或删除后 Agent 不会自动加回。"
|
||||
description="单条规则,多由对话或 Agent 提出。停用或删除后 Agent 不会自动加回。"
|
||||
actions={<span className="text-meta text-muted-foreground tabular-nums">{rows.length} 条</span>}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
@@ -245,7 +245,7 @@ export function RuleTags({ rows, onOpen }: { rows: RuleGroup[]; onOpen: (g: Rule
|
||||
plain
|
||||
icon={Tags}
|
||||
title="还没有偏好"
|
||||
description="在 Agent 会话里说明长期偏好,会出现在这里。"
|
||||
description="在 Agent 会话里说明长期偏好,会出现在这里。"
|
||||
className="py-6"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,7 @@ export const RULE_KIND: Record<RuleGroupKind, { label: string; hint: string }> =
|
||||
search: { label: "搜索", hint: "手动搜索与 Agent 搜索资源时用" },
|
||||
};
|
||||
|
||||
export const SUPPRESS_NOTE = "停用或删除会留下抑制记录,Agent 不会自动加回;重新启用或手动新建同样的规则后解除。";
|
||||
export const SUPPRESS_NOTE = "停用或删除会留下抑制记录,Agent 不会自动加回;重新启用或手动新建同样的规则后解除。";
|
||||
|
||||
export const RULE_SOURCE: Record<RuleGroupSource, string> = {
|
||||
manual: "手动",
|
||||
@@ -59,5 +59,5 @@ export function ruleSummary(g: RuleGroup): string {
|
||||
parts.push(`${g.ladderRequired ? "要求" : "偏好"} ${g.layers.map((l) => exprToString(l.expr)).join(" > ")}`);
|
||||
for (const h of g.hardExcludes) parts.push(`排除 ${exprToString(h)}`);
|
||||
if (parts.length) return parts.join(";");
|
||||
return g.legacyKind ? "旧偏好标签,没有可评估的条件" : "没有条件";
|
||||
return g.legacyKind ? "旧偏好标签,没有可评估的条件" : "没有条件";
|
||||
}
|
||||
@@ -180,7 +180,7 @@ export function useRuleTextParser(
|
||||
if (single && r.layers.length > 1)
|
||||
errors.push({
|
||||
code: "unexpected_token",
|
||||
message: "硬排除只能写一层,不能用 >",
|
||||
message: "硬排除只能写一层,不能用 >",
|
||||
position: text.indexOf(">"),
|
||||
layer: 1,
|
||||
});
|
||||
@@ -253,7 +253,7 @@ export function RuleStringField({
|
||||
<li key={`${e.code}-${e.position}-${i}`} className="text-meta text-destructive">
|
||||
<ErrorCaret text={state.text} position={e.position} />
|
||||
<span>
|
||||
第 {e.position + 1} 个字符{single ? "" : `(第 ${e.layer + 1} 层)`}:{e.message}
|
||||
第 {e.position + 1} 个字符{single ? "" : `(第 ${e.layer + 1} 层)`}:{e.message}
|
||||
</span>
|
||||
{e.suggestions?.length ? (
|
||||
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
|
||||
@@ -396,7 +396,7 @@ function LayerCardView({
|
||||
<Input
|
||||
value={card.name ?? ""}
|
||||
onChange={(e) => onChange({ ...card, name: e.target.value || undefined })}
|
||||
placeholder="层名称(可选)"
|
||||
placeholder="层名称(可选)"
|
||||
aria-label={`第 ${index + 1} 层名称`}
|
||||
maxLength={40}
|
||||
className="h-7 w-36 text-meta"
|
||||
@@ -530,11 +530,11 @@ export function LadderEditor({ state, onChange }: { state: LadderState; onChange
|
||||
<div className="space-y-3">
|
||||
<RuleStringField state={state} onChange={onChange} ariaLabel="规则串" />
|
||||
{state.errors.length ? (
|
||||
<p className="text-meta text-muted-foreground">规则串改对后同步到下面的层;现在显示的是上次解析成功的结果。</p>
|
||||
<p className="text-meta text-muted-foreground">规则串改对后同步到下面的层;现在显示的是上次解析成功的结果。</p>
|
||||
) : null}
|
||||
{state.cards === null ? (
|
||||
<p className="rounded-lg border border-border/70 px-3 py-2 text-meta text-muted-foreground">
|
||||
嵌套超过两层,只能在规则串里改。
|
||||
嵌套超过两层,只能在规则串里改。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
@@ -559,7 +559,7 @@ export function LadderEditor({ state, onChange }: { state: LadderState; onChange
|
||||
))}
|
||||
</ol>
|
||||
) : state.errors.length ? null : (
|
||||
<p className="text-meta text-muted-foreground">没有层:只用硬排除过滤,不影响排序。</p>
|
||||
<p className="text-meta text-muted-foreground">没有层:只用硬排除过滤,不影响排序。</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -88,13 +88,13 @@ export function RulePreviewPanel({
|
||||
<Textarea
|
||||
value={titles}
|
||||
onChange={(e) => setTitles(e.target.value)}
|
||||
placeholder={"每行一个资源标题,最多 50 行\nDune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR"}
|
||||
placeholder={"每行一个资源标题,最多 50 行\nDune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR"}
|
||||
aria-label="资源标题"
|
||||
rows={4}
|
||||
className="font-mono text-meta"
|
||||
/>
|
||||
) : null}
|
||||
{blocked ? <p className="text-meta text-muted-foreground">{blocked},改好后再试算。</p> : null}
|
||||
{blocked ? <p className="text-meta text-muted-foreground">{blocked},改好后再试算。</p> : null}
|
||||
{result ? <PreviewResult result={result} /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -139,10 +139,10 @@ function AtomDialog({
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{atom ? `编辑条件 ${atom.key}` : "新建自定义条件"}</DialogTitle>
|
||||
<DialogDescription>字段同 MoviePilot 自定义规则。正则不区分大小写,多条之间是“或”。</DialogDescription>
|
||||
<DialogDescription>字段同 MoviePilot 自定义规则。正则不区分大小写,多条之间是「或」。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
<FormField label="关键字" htmlFor={`${uid}-key`} required hint="大写字母和数字;不能与内置关键字同名">
|
||||
<FormField label="关键字" htmlFor={`${uid}-key`} required hint="大写字母和数字;不能与内置关键字同名">
|
||||
<Input
|
||||
id={`${uid}-key`}
|
||||
value={f.key}
|
||||
@@ -158,7 +158,7 @@ function AtomDialog({
|
||||
<FormField label="说明" htmlFor={`${uid}-desc`} className="sm:col-span-2">
|
||||
<Input id={`${uid}-desc`} value={f.description} onChange={(e) => set("description", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="包含(正则,每行一条)" htmlFor={`${uid}-inc`}>
|
||||
<FormField label="包含(正则,每行一条)" htmlFor={`${uid}-inc`}>
|
||||
<Textarea
|
||||
id={`${uid}-inc`}
|
||||
rows={3}
|
||||
@@ -167,7 +167,7 @@ function AtomDialog({
|
||||
className="font-mono text-meta"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="排除(正则,每行一条)" htmlFor={`${uid}-exc`}>
|
||||
<FormField label="排除(正则,每行一条)" htmlFor={`${uid}-exc`}>
|
||||
<Textarea
|
||||
id={`${uid}-exc`}
|
||||
rows={3}
|
||||
@@ -184,7 +184,7 @@ function AtomDialog({
|
||||
onChange={(v) => set("matchFields", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="大小(MB,剧集按每集)">
|
||||
<FormField label="大小(MB,剧集按每集)">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
@@ -203,7 +203,7 @@ function AtomDialog({
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="发布后(分钟)">
|
||||
<FormField label="发布后(分钟)">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
@@ -322,7 +322,7 @@ export function AtomsTab() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-caption text-muted-foreground">
|
||||
内置关键字不够用时,用正则、大小、做种数等定义新条件,在规则串里按关键字引用。
|
||||
内置关键字不够用时,用正则、大小、做种数等定义新条件,在规则串里按关键字引用。
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setDialog({ open: true, atom: null })}>
|
||||
<Plus />
|
||||
@@ -339,7 +339,7 @@ export function AtomsTab() {
|
||||
onRowClick={(a) => setDialog({ open: true, atom: a })}
|
||||
empty={{ icon: FlaskConical, title: "还没有自定义条件", description: "内置关键字覆盖不到时再新建。" }}
|
||||
/>
|
||||
<Panel title="内置关键字" description="对齐 MoviePilot;判定依据按解析字段优先">
|
||||
<Panel title="内置关键字" description="对齐 MoviePilot;判定依据按解析字段优先">
|
||||
{keywords.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : keywords.isError ? (
|
||||
@@ -350,7 +350,7 @@ export function AtomsTab() {
|
||||
<li key={k.id} className="flex min-w-0 items-baseline gap-2 text-caption">
|
||||
<span className="w-16 shrink-0 font-mono font-medium text-foreground">{k.id}</span>
|
||||
<span className="min-w-0 text-muted-foreground">
|
||||
{k.supported ? k.description || k.name : "不支持,规则串里出现会报错"}
|
||||
{k.supported ? k.description || k.name : "不支持,规则串里出现会报错"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -385,7 +385,7 @@ export function DefaultsTab({ groups }: { groups: RuleGroup[] | undefined }) {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-caption text-muted-foreground">
|
||||
订阅和版本目标没指定规则组时用这里的链。链上的组按顺序串联,任一组拒绝即拒绝,优先级取最后一个带层的组。
|
||||
订阅和版本目标没指定规则组时用这里的链。链上的组按顺序串联,任一组拒绝即拒绝,优先级取最后一个带层的组。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{dirty ? <StatusBadge tone="warning">未保存</StatusBadge> : null}
|
||||
@@ -419,7 +419,7 @@ export function DefaultsTab({ groups }: { groups: RuleGroup[] | undefined }) {
|
||||
return (
|
||||
<Panel key={k} title={DEFAULT_CHAIN_LABEL[k]} description={RULE_KIND[k].hint}>
|
||||
{ids.length === 0 ? (
|
||||
<p className="text-meta text-muted-foreground">链为空:不按规则组过滤。</p>
|
||||
<p className="text-meta text-muted-foreground">链为空:不按规则组过滤。</p>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{ids.map((id, i) => {
|
||||
@@ -434,7 +434,7 @@ export function DefaultsTab({ groups }: { groups: RuleGroup[] | undefined }) {
|
||||
{id === lastLadder ? (
|
||||
<span className="text-micro text-muted-foreground">决定优先级</span>
|
||||
) : null}
|
||||
{g && !g.enabled ? <span className="text-micro text-warning">已停用,评估时跳过</span> : null}
|
||||
{g && !g.enabled ? <span className="text-micro text-warning">已停用,评估时跳过</span> : null}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -514,7 +514,8 @@ export function SuppressionsTab() {
|
||||
return (
|
||||
<Panel title="抑制记录" description={sups.data ? `${active} 条生效中` : undefined}>
|
||||
<p className="mb-3 text-caption text-muted-foreground">
|
||||
停用、删除,或改掉对话 / Agent 提出的规则,都会留下抑制记录。Agent 再提出同样的规则会被直接拒绝,解除后才能再提。
|
||||
停用、删除,或改掉对话 / Agent 提出的规则,都会留下抑制记录。Agent
|
||||
再提出同样的规则会被直接拒绝,解除后才能再提。
|
||||
</p>
|
||||
{sups.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
@@ -549,8 +550,8 @@ export function SuppressionsTab() {
|
||||
解除抑制
|
||||
</Button>
|
||||
}
|
||||
title="解除这条抑制?"
|
||||
description="解除后 Agent 可以再次提出这条规则,仍需审批。规则组本身不会恢复。"
|
||||
title="解除这条抑制?"
|
||||
description="解除后 Agent 可以再次提出这条规则,仍需审批。规则组本身不会恢复。"
|
||||
confirmLabel="解除"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
|
||||
@@ -142,7 +142,7 @@ export function AgentCheckDialog({
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{job ? "编辑巡检" : "新建巡检"}</DialogTitle>
|
||||
<DialogDescription>到点按目标发起一次 Agent 会话,只在白名单和预算内执行。</DialogDescription>
|
||||
<DialogDescription>到点按目标发起一次 Agent 会话,只在白名单和预算内执行。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-1"
|
||||
@@ -161,13 +161,13 @@ export function AgentCheckDialog({
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="目标" htmlFor="ac-goal" required error={errors.goal} hint={`可用占位符:${PLACEHOLDERS}`}>
|
||||
<FormField label="目标" htmlFor="ac-goal" required error={errors.goal} hint={`可用占位符:${PLACEHOLDERS}`}>
|
||||
<Textarea
|
||||
id="ac-goal"
|
||||
rows={4}
|
||||
value={d.goal}
|
||||
aria-invalid={!!errors.goal}
|
||||
placeholder="检查 {{openDecisions}} 条待处理事项,给出处理建议"
|
||||
placeholder="检查 {{openDecisions}} 条待处理事项,给出处理建议"
|
||||
onChange={(e) => set("goal", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -207,7 +207,7 @@ export function AgentCheckDialog({
|
||||
onChange={(e) => set("maxToolCalls", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="最长时长(秒)" htmlFor="ac-secs" error={errors.maxDurationSec}>
|
||||
<FormField label="最长时长(秒)" htmlFor="ac-secs" error={errors.maxDurationSec}>
|
||||
<Input
|
||||
id="ac-secs"
|
||||
type="number"
|
||||
@@ -217,7 +217,7 @@ export function AgentCheckDialog({
|
||||
onChange={(e) => set("maxDurationSec", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="触发条件" htmlFor="ac-trigger" hint="条件不满足时跳过,不唤醒模型">
|
||||
<FormField label="触发条件" htmlFor="ac-trigger" hint="条件不满足时跳过,不唤醒模型">
|
||||
<Select value={d.trigger} onValueChange={(v) => set("trigger", v as AgentCheckTrigger)}>
|
||||
<SelectTrigger id="ac-trigger" className="w-full">
|
||||
<SelectValue />
|
||||
@@ -270,7 +270,7 @@ export function AgentCheckDialog({
|
||||
<label htmlFor="ac-recover" className="flex items-center justify-between gap-3 text-caption">
|
||||
<span>
|
||||
允许授权内恢复
|
||||
<span className="ml-2 text-meta text-muted-foreground">需有效持续授权,否则只读</span>
|
||||
<span className="ml-2 text-meta text-muted-foreground">需有效持续授权,否则只读</span>
|
||||
</span>
|
||||
<Switch id="ac-recover" checked={d.allowRecover} onCheckedChange={(v) => set("allowRecover", v)} />
|
||||
</label>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function IntervalEditor({
|
||||
const save = () => {
|
||||
if (schedule.type === "cron") {
|
||||
const c = cron.trim();
|
||||
if (c.split(/\s+/).length !== 5) return setError("cron 需要 5 段:分 时 日 月 周");
|
||||
if (c.split(/\s+/).length !== 5) return setError("cron 需要 5 段:分 时 日 月 周");
|
||||
onSave({ type: "cron", cron: c });
|
||||
} else {
|
||||
const n = Number(value);
|
||||
|
||||
@@ -19,7 +19,7 @@ export function RunsSheet({ job, onClose }: { job: ScheduledJob | null; onClose:
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={job ? `${job.name} · 运行记录` : "运行记录"}
|
||||
description={
|
||||
job ? `${describeSchedule(job.schedule)} · 累计 ${job.runCount} 次,失败 ${job.failCount} 次` : undefined
|
||||
job ? `${describeSchedule(job.schedule)} · 累计 ${job.runCount} 次,失败 ${job.failCount} 次` : undefined
|
||||
}
|
||||
>
|
||||
{runs.isPending ? (
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ScheduleBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
className="mb-4 flex items-center gap-2 rounded-tight border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
调度器未在线,任务不会按时运行。
|
||||
调度器未在线,任务不会按时运行。
|
||||
</p>
|
||||
) : (
|
||||
<div className="mb-4" />
|
||||
|
||||
@@ -38,12 +38,12 @@ function AgentCheckSummary({ config }: { config: Partial<AgentCheckConfig> }) {
|
||||
{config.goalTemplate ?? "—"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
<span>条件:{CHECK_TRIGGER_LABEL[config.triggerCondition ?? "always"]}</span>
|
||||
<span>条件:{CHECK_TRIGGER_LABEL[config.triggerCondition ?? "always"]}</span>
|
||||
<span>
|
||||
预算:{budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : "默认"}
|
||||
预算:{budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : "默认"}
|
||||
{budget.maxDurationSec ? ` · ${budget.maxDurationSec} 秒` : ""}
|
||||
</span>
|
||||
<span>能力:{allow.length ? `${allow.length} 项` : "全部只读能力"}</span>
|
||||
<span>能力:{allow.length ? `${allow.length} 项` : "全部只读能力"}</span>
|
||||
{config.allowRecover ? <span>允许授权内恢复</span> : null}
|
||||
{config.reportAsDecision ? <span>写入待处理</span> : null}
|
||||
</div>
|
||||
@@ -200,8 +200,8 @@ export function ScheduleCard({
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
title={`删除「${job.name}」?`}
|
||||
description="运行记录一并删除,已产生的 Agent 会话保留。"
|
||||
title={`删除「${job.name}」?`}
|
||||
description="运行记录一并删除,已产生的 Agent 会话保留。"
|
||||
confirmLabel="删除巡检"
|
||||
destructive
|
||||
onConfirm={() =>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function CommandPalette() {
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">搜索</DialogTitle>
|
||||
<DialogDescription className="sr-only">搜索作品、TMDB 和页面;回车在资源中搜索</DialogDescription>
|
||||
<DialogDescription className="sr-only">搜索作品、TMDB 和页面;回车在资源中搜索</DialogDescription>
|
||||
{open ? <PaletteBody initial={initial} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -114,7 +114,7 @@ function PaletteBody({ initial }: { initial: string }) {
|
||||
在资源中搜索 <span className="font-medium">“{term}”</span>
|
||||
</span>
|
||||
),
|
||||
meta: "查询全部已启用站点,最慢约 15 秒",
|
||||
meta: "查询全部已启用站点,最慢约 15 秒",
|
||||
hint: "打开",
|
||||
run: () => go(searchPagePath(term)),
|
||||
});
|
||||
@@ -236,14 +236,14 @@ function PaletteBody({ initial }: { initial: string }) {
|
||||
})}
|
||||
{!stale && media.data && tmdb && !tmdb.configured ? (
|
||||
<p className="px-3 py-2 text-meta text-muted-foreground">
|
||||
TMDB 未配置,只搜本地作品。
|
||||
TMDB 未配置,只搜本地作品。
|
||||
<Link to={settingsPath("metadata")} onClick={close} className="ml-1 underline underline-offset-4">
|
||||
去配置
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
{!stale && tmdb?.error ? (
|
||||
<p className="px-3 py-2 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
|
||||
<p className="px-3 py-2 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
|
||||
) : null}
|
||||
{media.isError || actions.isError ? (
|
||||
<p className="px-3 py-2 text-meta text-destructive">
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ReleaseResults({
|
||||
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
|
||||
{res.isPending ? (
|
||||
<div className="p-4">
|
||||
<p className="mb-3 text-meta text-muted-foreground">正在查询各站点,最慢约 15 秒…</p>
|
||||
<p className="mb-3 text-meta text-muted-foreground">正在查询各站点,最慢约 15 秒…</p>
|
||||
<LoadingState rows={4} />
|
||||
</div>
|
||||
) : res.isError && !rows.length ? (
|
||||
@@ -144,7 +144,7 @@ export function ReleaseResults({
|
||||
plain
|
||||
icon={PackageSearch}
|
||||
title={filtered ? "没有符合筛选条件的资源" : "各站点都没有找到资源"}
|
||||
description={filtered ? "放宽筛选条件再看。" : "换个关键词,或到站点工作台检查站点状态。"}
|
||||
description={filtered ? "放宽筛选条件再看。" : "换个关键词,或到站点工作台检查站点状态。"}
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
@@ -180,7 +180,7 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<div className="min-w-0">
|
||||
<p>
|
||||
{page.failedSites.length} 个站点没有返回结果,下面只有 {ok} 个站点的资源。
|
||||
{page.failedSites.length} 个站点没有返回结果,下面只有 {ok} 个站点的资源。
|
||||
</p>
|
||||
<ul className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-meta text-muted-foreground">
|
||||
{page.failedSites.map((s) => (
|
||||
@@ -323,7 +323,7 @@ function Filters({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={desc ? "降序,点击改为升序" : "升序,点击改为降序"}
|
||||
aria-label={desc ? "降序,点击改为升序" : "升序,点击改为降序"}
|
||||
title={desc ? "降序" : "升序"}
|
||||
onClick={() => setFilter("order", desc ? "asc" : "")}
|
||||
>
|
||||
|
||||
@@ -23,8 +23,8 @@ import { formatBytes } from "~/lib/utils.ts";
|
||||
// 与站点工作台同一套下载说明;请求走 POST /search/releases/{siteId}/{siteReleaseId}/plan(同 /acquisition-plans 流程)
|
||||
|
||||
const STEPS = [
|
||||
"生成下载计划,预留版本目标和预算;每个版本目标同时只有一个在途计划",
|
||||
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
|
||||
"生成下载计划,预留版本目标和预算;每个版本目标同时只有一个在途计划",
|
||||
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
|
||||
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
|
||||
"提交前复核免费、做种等易变字段",
|
||||
];
|
||||
@@ -32,13 +32,13 @@ const STEPS = [
|
||||
/** 委托 Agent:和工作台一样带上资源、作品、站点 */
|
||||
export function delegateToAgent(r: SearchRelease) {
|
||||
openAgentDrawer({
|
||||
label: `资源:${r.title}`,
|
||||
label: `资源:${r.title}`,
|
||||
releaseId: r.sourceReleaseId ?? undefined,
|
||||
entityId: r.entity?.id,
|
||||
siteId: r.siteId,
|
||||
goal: r.sourceReleaseId
|
||||
? "判断这个资源是否值得下载;合适就按正常流程提交"
|
||||
: `判断站点 ${r.siteName} 的资源「${r.title}」是否值得下载;合适就按正常流程提交`,
|
||||
? "判断这个资源是否值得下载;合适就按正常流程提交"
|
||||
: `判断站点 ${r.siteName} 的资源「${r.title}」是否值得下载;合适就按正常流程提交`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function SearchDownloadDialog({
|
||||
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("没有生成下载计划");
|
||||
@@ -107,7 +107,7 @@ export function SearchDownloadDialog({
|
||||
))}
|
||||
</ol>
|
||||
{!entityId ? (
|
||||
<p className="text-meta text-muted-foreground">资源未关联本地作品。先在“身份与元数据”指定。</p>
|
||||
<p className="text-meta text-muted-foreground">资源未关联本地作品。先在「身份与元数据」指定。</p>
|
||||
) : slots.isPending ? (
|
||||
<p className="text-meta text-muted-foreground">正在读取版本目标…</p>
|
||||
) : slotList.length ? (
|
||||
@@ -127,11 +127,11 @@ export function SearchDownloadDialog({
|
||||
<p className="text-meta text-muted-foreground">这个作品没有版本目标。先在订阅里新建。</p>
|
||||
)}
|
||||
{release.entityMatch === "alias" ? (
|
||||
<p className="text-meta text-warning">作品关联来自标题别名,尚未确认。</p>
|
||||
<p className="text-meta text-warning">作品关联来自标题别名,尚未确认。</p>
|
||||
) : null}
|
||||
{rejected ? (
|
||||
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
|
||||
<p className="mb-1">没有生成下载计划:</p>
|
||||
<p className="mb-1">没有生成下载计划:</p>
|
||||
<ul className="list-disc space-y-0.5 pl-5">
|
||||
{rejected.map((r, i) => (
|
||||
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
|
||||
|
||||
@@ -130,14 +130,14 @@ export function MediaSection({ q }: { q: string }) {
|
||||
)}
|
||||
{tmdb && !tmdb.configured ? (
|
||||
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-muted-foreground">
|
||||
TMDB 未配置,只搜了本地作品。
|
||||
TMDB 未配置,只搜了本地作品。
|
||||
<Link to={settingsPath("metadata")} className="ml-1 underline underline-offset-4">
|
||||
去配置
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
{tmdb?.error ? (
|
||||
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
|
||||
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -26,9 +26,9 @@ import { SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
const SCOPES: { value: ApiScope; label: string; hint: string }[] = [
|
||||
{ value: "read", label: "read", hint: "只读查询全部数据" },
|
||||
{ value: "organize.read", label: "organize.read", hint: "只读整理记录,供外部增量同步" },
|
||||
{ value: "organize.read", label: "organize.read", hint: "只读整理记录,供外部增量同步" },
|
||||
{ value: "organize.execute", label: "organize.execute", hint: "发起整理" },
|
||||
{ value: "admin", label: "admin", hint: "全部管理权限,含改设置" },
|
||||
{ value: "admin", label: "admin", hint: "全部管理权限,含改设置" },
|
||||
];
|
||||
|
||||
function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; onClose: () => void }) {
|
||||
@@ -38,7 +38,7 @@ function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; on
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key 已创建</DialogTitle>
|
||||
<DialogDescription>明文只显示这一次,关闭后无法再查看。</DialogDescription>
|
||||
<DialogDescription>明文只显示这一次,关闭后无法再查看。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3">
|
||||
<code className="min-w-0 flex-1 font-mono text-caption break-all text-foreground">{plaintext}</code>
|
||||
@@ -52,7 +52,7 @@ function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; on
|
||||
setCopied(true);
|
||||
toast.success("已复制");
|
||||
} catch {
|
||||
toast.error("复制失败,手动选中复制");
|
||||
toast.error("复制失败,手动选中复制");
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -83,7 +83,7 @@ export function ApiKeysSection() {
|
||||
const parsed = ApiKeyCreate.safeParse({ name: name.trim(), scopes });
|
||||
if (!parsed.success) {
|
||||
const fe = zodFieldErrors(parsed.error.issues);
|
||||
if (fe.name) fe.name = "名称必填,80 字以内";
|
||||
if (fe.name) fe.name = "名称必填,80 字以内";
|
||||
if (fe.scopes) fe.scopes = "至少选一个权限范围";
|
||||
setErrors(fe);
|
||||
return;
|
||||
@@ -105,7 +105,7 @@ export function ApiKeysSection() {
|
||||
section="api-keys"
|
||||
anchor="keys"
|
||||
title="已签发的 Key"
|
||||
description="给媒体服务器插件、脚本等外部程序用,按权限范围授权。本机 Token 在「账户与安全」。"
|
||||
description="给媒体服务器插件、脚本等外部程序用,按权限范围授权。本机 Token 在「账户与安全」。"
|
||||
plain
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
@@ -226,7 +226,7 @@ export function ApiKeysSection() {
|
||||
open={!!revoking}
|
||||
onOpenChange={(o) => !o && setRevoking(null)}
|
||||
title={`撤销「${revoking?.name ?? ""}」?`}
|
||||
description="使用这个 Key 的程序立即失去访问权限,无法恢复。"
|
||||
description="使用这个 Key 的程序立即失去访问权限,无法恢复。"
|
||||
confirmLabel="撤销 Key"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
|
||||
@@ -16,12 +16,12 @@ const GiB = 1024 ** 3;
|
||||
const CACHE_CARD: CardSpec = {
|
||||
anchor: "cache",
|
||||
title: "缓存水位",
|
||||
description: "缓存卷使用率到硬阈值时停止新增下载并暂停纳管任务;降到恢复水位以下只恢复因阈值暂停的任务。",
|
||||
description: "缓存卷使用率到硬阈值时停止新增下载并暂停纳管任务;降到恢复水位以下只恢复因阈值暂停的任务。",
|
||||
fields: [
|
||||
{ name: "cache.hardThresholdPercent", label: "硬阈值(%)", kind: "number" },
|
||||
{ name: "cache.resumePercent", label: "恢复水位(%)", kind: "number", hint: "低于硬阈值" },
|
||||
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复水位" },
|
||||
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
|
||||
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"cache.hardThresholdPercent": n(s.cache.hardThresholdPercent),
|
||||
@@ -42,7 +42,7 @@ const CACHE_CARD: CardSpec = {
|
||||
const BUDGET_CARD: CardSpec = {
|
||||
anchor: "budget",
|
||||
title: "每日预算",
|
||||
description: "任一项到上限后不再建新的下载计划;前两项按自然日计。",
|
||||
description: "任一项到上限后不再建新的下载计划;前两项按自然日计。",
|
||||
fields: [
|
||||
{ name: "budget.maxNewTitlesPerDay", label: "每日新作品数", kind: "number" },
|
||||
{ name: "budget.maxDownloadGiBPerDay", label: "每日下载量(GiB)", kind: "number" },
|
||||
@@ -73,7 +73,7 @@ const RETENTION_CARD: CardSpec = {
|
||||
name: "retention.seedRetentionDays",
|
||||
label: "源文件保留天数",
|
||||
kind: "number",
|
||||
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
|
||||
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
|
||||
},
|
||||
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" },
|
||||
],
|
||||
@@ -149,7 +149,7 @@ function Switches() {
|
||||
icon={s.automation.enabled ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
|
||||
tone={s.automation.enabled ? "success" : "warning"}
|
||||
title={`自动化总开关 · ${s.automation.enabled ? "运行中" : "已暂停"}`}
|
||||
description="关闭后自动收录、下载、整理全部停止;查询与手动操作不受影响。"
|
||||
description="关闭后自动收录、下载、整理全部停止;查询与手动操作不受影响。"
|
||||
checked={s.automation.enabled}
|
||||
disabled={update.isPending}
|
||||
onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")}
|
||||
@@ -159,7 +159,7 @@ function Switches() {
|
||||
icon={<ShieldAlert className="h-4 w-4" />}
|
||||
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
|
||||
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
|
||||
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
|
||||
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
|
||||
checked={s.automation.readOnlyReconcile}
|
||||
disabled={update.isPending}
|
||||
onChange={(v) => set({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账")}
|
||||
|
||||
@@ -87,7 +87,7 @@ function DownloadersTable() {
|
||||
config={{
|
||||
noun: "下载器实例",
|
||||
description:
|
||||
"只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。",
|
||||
"只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。",
|
||||
emptyHint: "添加 qBittorrent 后才能提交下载。",
|
||||
drawerExtra: (r) => <PathMappingsPanel viewKind="downloader" instanceId={r.id} />,
|
||||
columns: [
|
||||
@@ -113,7 +113,7 @@ function DownloadersTable() {
|
||||
],
|
||||
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
|
||||
fields: [
|
||||
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:家里的 qB" },
|
||||
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:家里的 qB" },
|
||||
{
|
||||
name: "baseUrl",
|
||||
label: "WebUI 地址",
|
||||
@@ -181,7 +181,7 @@ function MediaServersTable() {
|
||||
hooks={{ list, create, update, remove } as never}
|
||||
config={{
|
||||
noun: "媒体服务器",
|
||||
description: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和路径映射。",
|
||||
description: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和路径映射。",
|
||||
drawerExtra: (r) => <PathMappingsPanel viewKind="media_server" instanceId={r.id} />,
|
||||
columns: [
|
||||
{
|
||||
@@ -211,7 +211,7 @@ function MediaServersTable() {
|
||||
],
|
||||
},
|
||||
{ name: "baseUrl", label: "地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8096" },
|
||||
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选,按该用户的可见范围查询" },
|
||||
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选,按该用户的可见范围查询" },
|
||||
{ name: "credential", label: "API Key", kind: "password", ...credentialHint },
|
||||
{ name: "enabled", label: "启用", kind: "switch" },
|
||||
],
|
||||
@@ -258,8 +258,9 @@ export function LibraryRootsTable() {
|
||||
hooks={{ list, create, update, remove } as never}
|
||||
config={{
|
||||
noun: "库根",
|
||||
description: "登记或改路径时会在目录里写哨兵文件 .uhd-root,据此判断库根是否在线;目录不存在或不可写时无法保存。",
|
||||
deleteHint: "只删除登记,磁盘上的文件不动。",
|
||||
description:
|
||||
"登记或改路径时会在目录里写哨兵文件 .uhd-root,据此判断库根是否在线;目录不存在或不可写时无法保存。",
|
||||
deleteHint: "只删除登记,磁盘上的文件不动。",
|
||||
columns: [
|
||||
{
|
||||
key: "name",
|
||||
@@ -287,7 +288,7 @@ export function LibraryRootsTable() {
|
||||
],
|
||||
rowActions: (r) => <TestButton label="检查哨兵" pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
|
||||
fields: [
|
||||
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" },
|
||||
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" },
|
||||
{ name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND },
|
||||
{
|
||||
name: "localPath",
|
||||
@@ -304,7 +305,7 @@ export function LibraryRootsTable() {
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "register_only", label: "只登记" },
|
||||
{ value: "managed", label: "自动维护(补齐、升级)" },
|
||||
{ value: "managed", label: "自动维护(补齐、升级)" },
|
||||
],
|
||||
},
|
||||
{ name: "enabled", label: "启用", kind: "switch" },
|
||||
|
||||
@@ -9,15 +9,15 @@ import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
const ORGANIZE_CARD: CardSpec = {
|
||||
anchor: "organize",
|
||||
title: "整理方式",
|
||||
description: "复制入库时源文件留在缓存继续做种,保留天数在自动化分区设置。",
|
||||
description: "复制入库时源文件留在缓存继续做种,保留天数在自动化分区设置。",
|
||||
fields: [
|
||||
{
|
||||
name: "organize.defaultMode",
|
||||
label: "默认整理方式",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "copy", label: "复制(源文件继续做种)" },
|
||||
{ value: "move", label: "移动(移走源文件)" },
|
||||
{ value: "copy", label: "复制(源文件继续做种)" },
|
||||
{ value: "move", label: "移动(移走源文件)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ const TMDB_CARD: CardSpec = {
|
||||
name: "metadata.tmdbApiKey",
|
||||
label: "API Key",
|
||||
kind: "password",
|
||||
hint: "v3 API Key 或 v4 Read Access Token;留空不修改",
|
||||
hint: "v3 API Key 或 v4 Read Access Token;留空不修改",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
@@ -25,7 +25,7 @@ const TMDB_CARD: CardSpec = {
|
||||
label: "Base URL",
|
||||
kind: "url",
|
||||
placeholder: "https://api.themoviedb.org",
|
||||
hint: "自建反代时填写;留空用官方地址",
|
||||
hint: "自建反代时填写;留空用官方地址",
|
||||
wide: true,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -56,7 +56,7 @@ function GlobalNaming() {
|
||||
} catch (err) {
|
||||
const split = splitNamingErrors(errorMessage(err));
|
||||
if (split) setErrors(split);
|
||||
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
|
||||
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,7 +100,7 @@ function GlobalNaming() {
|
||||
<span className="min-w-0">
|
||||
<span className="block text-body text-foreground">已有目录的作品固定文件名</span>
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
开启后,这些作品的新文件按建立目录时的模板版本命名
|
||||
开启后,这些作品的新文件按建立目录时的模板版本命名
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
@@ -127,7 +127,7 @@ function RootNaming() {
|
||||
section="library"
|
||||
anchor="naming-roots"
|
||||
title="库根命名覆盖"
|
||||
description="按类型覆盖全局模板;留空的类型沿用全局。"
|
||||
description="按类型覆盖全局模板;留空的类型沿用全局。"
|
||||
>
|
||||
{roots.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
@@ -146,7 +146,7 @@ function RootNaming() {
|
||||
{list.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
{r.naming && Object.keys(r.naming).length ? "(已覆盖)" : ""}
|
||||
{r.naming && Object.keys(r.naming).length ? "(已覆盖)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -187,7 +187,7 @@ function RootNamingEditor({ root }: { root: LibraryRoot }) {
|
||||
} catch (err) {
|
||||
const split = splitNamingErrors(errorMessage(err));
|
||||
if (split) setErrors(split);
|
||||
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
|
||||
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -88,12 +88,12 @@ export function PathMappingsPanel({
|
||||
>
|
||||
<p className="mb-2 text-meta text-muted-foreground">
|
||||
把{viewKind === "downloader" ? "下载器" : "媒体服务器"}
|
||||
看到的路径换成本服务能访问的路径。前缀越长越优先,同长比优先级。
|
||||
看到的路径换成本服务能访问的路径。前缀越长越优先,同长比优先级。
|
||||
</p>
|
||||
{list.isPending ? (
|
||||
<p className="text-caption text-muted-foreground">加载中…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-caption text-muted-foreground">还没有映射,路径两边一致时不用加。</p>
|
||||
<p className="text-caption text-muted-foreground">还没有映射,路径两边一致时不用加。</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60 rounded-lg border border-border/60">
|
||||
{rows.map((m) => {
|
||||
@@ -104,7 +104,7 @@ export function PathMappingsPanel({
|
||||
<p className="truncate">{m.remotePrefix}</p>
|
||||
<p className="truncate text-muted-foreground">→ {m.localPrefix}</p>
|
||||
{rootName(m.libraryRootId) ? (
|
||||
<p className="font-sans text-muted-foreground">库根:{rootName(m.libraryRootId)}</p>
|
||||
<p className="font-sans text-muted-foreground">库根:{rootName(m.libraryRootId)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
|
||||
@@ -209,8 +209,8 @@ export function PathMappingsPanel({
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="删除路径映射?"
|
||||
description="依赖这条映射的路径换算会失效,磁盘文件不动。"
|
||||
title="删除路径映射?"
|
||||
description="依赖这条映射的路径换算会失效,磁盘文件不动。"
|
||||
confirmLabel="删除映射"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
|
||||
@@ -94,7 +94,7 @@ export function ModelInput({
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder ?? (models.length ? `从 ${models.length} 个模型中选,或手填 id` : "模型 id")}
|
||||
placeholder={placeholder ?? (models.length ? `从 ${models.length} 个模型中选,或手填 id` : "模型 id")}
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{models.map((m) => (
|
||||
|
||||
@@ -22,9 +22,9 @@ const SOURCE_TEXT: Record<string, string> = { "stored credential": "已存凭证
|
||||
|
||||
/** 上游返回的多是英文原文:先给一句怎么办,原文留作排查 */
|
||||
const ERROR_HINT: Record<string, string> = {
|
||||
network: "连不上上游,检查 Base URL、端口和网络",
|
||||
auth: "凭证被拒,检查 API Key 或重新订阅登录",
|
||||
model: "模型不可用,换一个模型 id",
|
||||
network: "连不上上游,检查 Base URL、端口和网络",
|
||||
auth: "凭证被拒,检查 API Key 或重新订阅登录",
|
||||
model: "模型不可用,换一个模型 id",
|
||||
};
|
||||
|
||||
export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?: string | null }) {
|
||||
@@ -60,7 +60,7 @@ export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?:
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
「测试」只检查凭证和模型,不产生费用。最小补全会调用一次模型(最多 16 个 token)。
|
||||
「测试」只检查凭证和模型,不产生费用。最小补全会调用一次模型(最多 16 个 token)。
|
||||
</p>
|
||||
{result ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-caption">
|
||||
@@ -73,7 +73,7 @@ export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?:
|
||||
</span>
|
||||
{!result.ok && result.message ? (
|
||||
<span className="w-full break-words text-destructive">
|
||||
{ERROR_HINT[result.errorKind ?? ""] ? `${ERROR_HINT[result.errorKind ?? ""]}。原始错误:` : ""}
|
||||
{ERROR_HINT[result.errorKind ?? ""] ? `${ERROR_HINT[result.errorKind ?? ""]}。原始错误:` : ""}
|
||||
{result.message}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -230,7 +230,7 @@ function UpstreamDrawer({
|
||||
{upstream.oauthExpiresAt ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">令牌到期</dt>
|
||||
<dd>{formatDateTime(upstream.oauthExpiresAt)}(到期前自动刷新)</dd>
|
||||
<dd>{formatDateTime(upstream.oauthExpiresAt)}(到期前自动刷新)</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt className="text-muted-foreground">最近验证</dt>
|
||||
@@ -268,7 +268,7 @@ function UpstreamDrawer({
|
||||
}
|
||||
>
|
||||
{upstream.hasCredential && upstream.status === "ok" ? (
|
||||
<p className="text-caption text-muted-foreground">已登录。退出只删除本地令牌,不会撤销提供方侧的授权。</p>
|
||||
<p className="text-caption text-muted-foreground">已登录。退出只删除本地令牌,不会撤销提供方侧的授权。</p>
|
||||
) : (
|
||||
<PiOAuthLogin upstream={upstream} provider={provider} />
|
||||
)}
|
||||
@@ -285,7 +285,7 @@ function UpstreamDrawer({
|
||||
disabled={pending}
|
||||
/>
|
||||
<FieldControl
|
||||
spec={{ name: "enabled", label: "启用", kind: "switch", hint: "停用后,使用它的运行会失败" }}
|
||||
spec={{ name: "enabled", label: "启用", kind: "switch", hint: "停用后,使用它的运行会失败" }}
|
||||
value={v.enabled}
|
||||
onChange={(x) => setV((s) => ({ ...s, enabled: x === true }))}
|
||||
disabled={pending}
|
||||
@@ -294,7 +294,7 @@ function UpstreamDrawer({
|
||||
label="默认模型"
|
||||
htmlFor="pu-model"
|
||||
error={errors.defaultModel}
|
||||
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
|
||||
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<ModelInput
|
||||
@@ -312,7 +312,7 @@ function UpstreamDrawer({
|
||||
label: "思考档位",
|
||||
kind: "select",
|
||||
options: THINKING_OPTIONS,
|
||||
hint: `当前 ${THINKING_LABEL[upstream.thinkingLevel]};运行时按模型支持的档位取近`,
|
||||
hint: `当前 ${THINKING_LABEL[upstream.thinkingLevel]};运行时按模型支持的档位取近`,
|
||||
}}
|
||||
value={v.thinking}
|
||||
onChange={(x) => setV((s) => ({ ...s, thinking: x as PiThinkingLevel }))}
|
||||
@@ -369,9 +369,9 @@ function UpstreamDrawer({
|
||||
<ConfirmDialog
|
||||
open={deleting}
|
||||
onOpenChange={setDeleting}
|
||||
title={`删除上游「${upstream.name}」?`}
|
||||
title={`删除上游「${upstream.name}」?`}
|
||||
description={
|
||||
upstream.isDefault ? "它是默认上游,删除后没有默认上游;凭证一并删除,无法恢复。" : "凭证一并删除,无法恢复。"
|
||||
upstream.isDefault ? "它是默认上游,删除后没有默认上游;凭证一并删除,无法恢复。" : "凭证一并删除,无法恢复。"
|
||||
}
|
||||
confirmLabel="删除上游"
|
||||
destructive
|
||||
|
||||
@@ -49,12 +49,12 @@ export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: ()
|
||||
: (created?.name ?? "");
|
||||
const description =
|
||||
step === "provider"
|
||||
? "第 1 步,共 3 步:选 provider"
|
||||
? "第 1 步,共 3 步:选 provider"
|
||||
: step === "config"
|
||||
? "第 2 步,共 3 步:凭证与模型"
|
||||
? "第 2 步,共 3 步:凭证与模型"
|
||||
: created?.authKind === "oauth"
|
||||
? "第 3 步,共 3 步:登录并测试。上游已创建。"
|
||||
: "第 3 步,共 3 步:测试。上游已创建。";
|
||||
? "第 3 步,共 3 步:登录并测试。上游已创建。"
|
||||
: "第 3 步,共 3 步:测试。上游已创建。";
|
||||
|
||||
return (
|
||||
<DetailSheet open={open} onOpenChange={(o) => !o && close()} title={title} description={description} width="lg">
|
||||
@@ -95,7 +95,7 @@ function ProviderPicker({ onPick }: { onPick: (p: PiProviderInfo) => void }) {
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="筛选 provider"
|
||||
placeholder="筛选,例如 openai"
|
||||
placeholder="筛选,例如 openai"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="pl-9"
|
||||
@@ -180,7 +180,7 @@ function ConfigStep({
|
||||
const fe: Record<string, string> = parsed.success ? {} : zodFieldErrors(parsed.error.issues);
|
||||
for (const f of provider.envFields) if (f.required && !env[f.name]?.trim()) fe[`env.${f.name}`] = `${f.label}必填`;
|
||||
if (authKind === "api_key" && !apiKey.trim() && !custom) fe.apiKey = "填写 API Key";
|
||||
if (fe.name) fe.name = "名称必填,64 字以内";
|
||||
if (fe.name) fe.name = "名称必填,64 字以内";
|
||||
if (fe["extra.contextWindow"]) fe.contextWindow = "填 1024 以上的整数";
|
||||
if (!parsed.success || Object.keys(fe).length) {
|
||||
setErrors(fe);
|
||||
@@ -236,7 +236,7 @@ function ConfigStep({
|
||||
kind: "password",
|
||||
required: !custom,
|
||||
wide: true,
|
||||
hint: custom ? "端点不要求鉴权时可留空" : "只写入,保存后只显示前后 4 位",
|
||||
hint: custom ? "端点不要求鉴权时可留空" : "只写入,保存后只显示前后 4 位",
|
||||
}}
|
||||
value={apiKey}
|
||||
error={errors.apiKey}
|
||||
@@ -246,7 +246,7 @@ function ConfigStep({
|
||||
) : null}
|
||||
{authKind === "none" && provider.group === "cloud" ? (
|
||||
<p className="mb-3 text-caption text-muted-foreground sm:col-span-2">
|
||||
不存密钥,运行时读服务端进程的环境凭证(AWS 凭证链 / Google ADC)。
|
||||
不存密钥,运行时读服务端进程的环境凭证(AWS 凭证链 / Google ADC)。
|
||||
</p>
|
||||
) : null}
|
||||
{showBaseUrl ? (
|
||||
@@ -375,7 +375,7 @@ function VerifyStep({
|
||||
<FormField
|
||||
label="默认模型"
|
||||
htmlFor="pv-model"
|
||||
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
|
||||
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
|
||||
>
|
||||
<ModelInput id="pv-model" value={model} onChange={setModel} models={models.data?.items ?? []} />
|
||||
</FormField>
|
||||
|
||||
@@ -214,7 +214,7 @@ function ResourceFormDrawer<Row extends { id: string }>({
|
||||
open={!!row}
|
||||
onOpenChange={(o) => !o && !pending && onClose()}
|
||||
title={`${mode === "create" ? "添加" : "编辑"}${config.noun}`}
|
||||
description="凭证只写入,保存后不回显。"
|
||||
description="凭证只写入,保存后不回显。"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={pending}>
|
||||
|
||||
@@ -98,7 +98,7 @@ function stepItems(steps: EngineStepAttempt[], onOpenRun?: (id: string) => void)
|
||||
function businessTimeline(r: EngineRunDetail): TimelineItem[] {
|
||||
const src = sourceOf(r.business?.triggeredBy);
|
||||
const items: TimelineItem[] = [
|
||||
{ id: "created", at: r.createdAt, title: `创建${src ? `(${SOURCE_LABEL[src]}触发)` : ""}`, tone: "neutral" },
|
||||
{ id: "created", at: r.createdAt, title: `创建${src ? `(${SOURCE_LABEL[src]}触发)` : ""}`, tone: "neutral" },
|
||||
];
|
||||
if (r.startedAt)
|
||||
items.push({
|
||||
@@ -109,12 +109,17 @@ function businessTimeline(r: EngineRunDetail): TimelineItem[] {
|
||||
});
|
||||
for (const st of r.stepAttempts) {
|
||||
if (st.waitSignal && !st.finishedAt)
|
||||
items.push({ id: `w-${st.id}`, at: st.startedAt ?? st.createdAt, title: `等待:${st.stepName}`, tone: "warning" });
|
||||
items.push({
|
||||
id: `w-${st.id}`,
|
||||
at: st.startedAt ?? st.createdAt,
|
||||
title: `等待:${st.stepName}`,
|
||||
tone: "warning",
|
||||
});
|
||||
else if (st.status === "failed")
|
||||
items.push({
|
||||
id: `f-${st.id}`,
|
||||
at: st.finishedAt ?? st.startedAt ?? st.createdAt,
|
||||
title: `步骤失败:${st.stepName}`,
|
||||
title: `步骤失败:${st.stepName}`,
|
||||
description: errorText(st.error),
|
||||
tone: "destructive",
|
||||
});
|
||||
@@ -161,7 +166,7 @@ export function RunActions({
|
||||
</Button>
|
||||
}
|
||||
title="取消这个任务?"
|
||||
description="正在执行的步骤会在下一个检查点退出,已完成的步骤不回滚。"
|
||||
description="正在执行的步骤会在下一个检查点退出,已完成的步骤不回滚。"
|
||||
confirmLabel="取消任务"
|
||||
cancelLabel="不取消"
|
||||
destructive
|
||||
@@ -186,13 +191,13 @@ export function RunActions({
|
||||
</Button>
|
||||
}
|
||||
title="重跑这个任务?"
|
||||
description="用原始输入新建一条任务,原记录不变。已完成的文件操作不会重复执行。"
|
||||
description="用原始输入新建一条任务,原记录不变。已完成的文件操作不会重复执行。"
|
||||
confirmLabel="重跑"
|
||||
cancelLabel="不重跑"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
const res = await retry.mutateAsync({ id: r.id });
|
||||
toast.success(`已重新排队(第 ${res.attempt} 次重跑)`);
|
||||
toast.success(`已重新排队(第 ${res.attempt} 次重跑)`);
|
||||
onOpenRun?.(res.runId);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "重跑失败"));
|
||||
|
||||
@@ -224,7 +224,8 @@ export function TaskStream({
|
||||
: tab === "failed"
|
||||
? "没有失败的任务"
|
||||
: "还没有任务",
|
||||
description: filters.source && list.hasNextPage ? "按来源筛选时一页可能较少,可加载更多继续查找。" : undefined,
|
||||
description:
|
||||
filters.source && list.hasNextPage ? "按来源筛选时一页可能较少,可加载更多继续查找。" : undefined,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
|
||||
@@ -19,8 +19,8 @@ import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
const STEPS = [
|
||||
"生成下载计划,预留版本目标和预算;每个版本目标同时只有一个在途计划",
|
||||
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
|
||||
"生成下载计划,预留版本目标和预算;每个版本目标同时只有一个在途计划",
|
||||
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
|
||||
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
|
||||
"提交前复核免费、做种等易变字段",
|
||||
];
|
||||
@@ -56,7 +56,7 @@ 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("没有生成下载计划");
|
||||
@@ -102,12 +102,14 @@ export function DownloadDialog({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{release.entity ? "这个作品没有版本目标。先在订阅里新建。" : "资源未关联本地作品。先在“身份与元数据”指定。"}
|
||||
{release.entity
|
||||
? "这个作品没有版本目标。先在订阅里新建。"
|
||||
: "资源未关联本地作品。先在「身份与元数据」指定。"}
|
||||
</p>
|
||||
)}
|
||||
{rejected ? (
|
||||
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
|
||||
<p className="mb-1">没有生成下载计划:</p>
|
||||
<p className="mb-1">没有生成下载计划:</p>
|
||||
<ul className="list-disc space-y-0.5 pl-5">
|
||||
{rejected.map((r, i) => (
|
||||
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
|
||||
@@ -132,11 +134,11 @@ export function DownloadDialog({
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
openAgentDrawer({
|
||||
label: `资源:${release.title}`,
|
||||
label: `资源:${release.title}`,
|
||||
releaseId: release.id,
|
||||
entityId: release.entity?.id,
|
||||
siteId: release.siteId,
|
||||
goal: "判断这个资源是否值得下载;合适就按正常流程提交",
|
||||
goal: "判断这个资源是否值得下载;合适就按正常流程提交",
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 加密保存,之后只显示脱敏地址。</DialogDescription>
|
||||
<DialogDescription>URL 加密保存,之后只显示脱敏地址。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-1"
|
||||
@@ -152,7 +152,7 @@ export function FeedFormDialog({
|
||||
htmlFor="feed-url"
|
||||
required={!feed}
|
||||
error={errors.url}
|
||||
hint={feed ? `当前:${feed.urlDisplay};留空表示不变` : "RSS / Atom 地址"}
|
||||
hint={feed ? `当前:${feed.urlDisplay};留空表示不变` : "RSS / Atom 地址"}
|
||||
>
|
||||
<Input
|
||||
id="feed-url"
|
||||
@@ -179,7 +179,7 @@ export function FeedFormDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="轮询间隔(分钟)" htmlFor="feed-poll" error={errors.pollMin}>
|
||||
<FormField label="轮询间隔(分钟)" htmlFor="feed-poll" error={errors.pollMin}>
|
||||
<Input
|
||||
id="feed-poll"
|
||||
type="number"
|
||||
@@ -233,7 +233,7 @@ export function FeedFormDialog({
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="过滤条件(JSON,可选)"
|
||||
label="过滤条件(JSON,可选)"
|
||||
htmlFor="feed-filter"
|
||||
error={errors.filter}
|
||||
hint='如 {"include":["2160p"],"exclude":["CAM"]}'
|
||||
@@ -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">
|
||||
|
||||
@@ -59,7 +59,7 @@ function FeedActions({ f }: { f: FeedSource }) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="立即抓取(受站点限流)"
|
||||
title="立即抓取(受站点限流)"
|
||||
aria-label="立即抓取"
|
||||
disabled={!f.enabled || fetchNow.isPending}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
@@ -207,7 +207,7 @@ export function FeedTable({
|
||||
empty={{
|
||||
icon: Rss,
|
||||
title: filtered ? "没有符合筛选的 Feed" : "还没有 Feed",
|
||||
description: filtered ? undefined : "新建 Feed 后按轮询间隔抓取,新资源进入识别和收录评估。",
|
||||
description: filtered ? undefined : "新建 Feed 后按轮询间隔抓取,新资源进入识别和收录评估。",
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@ import { categoryLabel, hrOf, ORIGIN_LABEL, relTime } from "./release-fields.ts"
|
||||
import { InventoryOverlay, ReleaseBadges } from "./release-list.tsx";
|
||||
|
||||
const DECISION_LABEL = {
|
||||
accept: { label: "符合,可收录", tone: "success" },
|
||||
accept: { label: "符合,可收录", tone: "success" },
|
||||
upgrade: { label: "可升级", tone: "success" },
|
||||
reject: { label: "不收录", tone: "destructive" },
|
||||
unknown_field: { label: "缺字段,待复核", tone: "warning" },
|
||||
unknown_field: { label: "缺字段,待复核", tone: "warning" },
|
||||
skipped: { label: "被跳过", tone: "neutral" },
|
||||
deferred: { label: "延后:已有计划在途", tone: "warning" },
|
||||
deferred: { label: "延后:已有计划在途", tone: "warning" },
|
||||
} as const;
|
||||
|
||||
/** 本地库存 + 候选理由(来自作品详情:Slot / CandidateEvaluation),不从标题猜 */
|
||||
@@ -62,7 +62,7 @@ function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry:
|
||||
{detail.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : detail.error ? (
|
||||
<p className="text-meta text-destructive">作品详情读取失败:{errorMessage(detail.error)}</p>
|
||||
<p className="text-meta text-destructive">作品详情读取失败:{errorMessage(detail.error)}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
@@ -140,7 +140,7 @@ export function ReleaseDetailSheet({
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `资源:${r.title}`,
|
||||
label: `资源:${r.title}`,
|
||||
releaseId: r.id,
|
||||
entityId: r.entity?.id,
|
||||
siteId,
|
||||
@@ -177,7 +177,7 @@ export function ReleaseDetailSheet({
|
||||
{
|
||||
onSuccess: (d) =>
|
||||
d.refreshError
|
||||
? toast.warning(`复核失败,显示上次数据:${d.refreshError}`)
|
||||
? 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
|
||||
@@ -206,7 +206,7 @@ export function ReleaseDetailSheet({
|
||||
{ label: "促销截止", value: formatDateTime(detail.data.volatile.promotionExpiresAt) },
|
||||
{
|
||||
label: "做种 / 下载",
|
||||
value: `${r.seeders ?? "?"} / ${r.leechers ?? "?"}(${relTime(detail.data.volatile.peersObservedAt)}观察)`,
|
||||
value: `${r.seeders ?? "?"} / ${r.leechers ?? "?"}(${relTime(detail.data.volatile.peersObservedAt)}观察)`,
|
||||
},
|
||||
{ label: "详情抓取于", value: formatDateTime(detail.data.volatile.detailFetchedAt) },
|
||||
]}
|
||||
@@ -225,7 +225,7 @@ export function ReleaseDetailSheet({
|
||||
items={[
|
||||
{ label: "分类", value: categoryLabel(r) },
|
||||
{ label: "大小", value: r.sizeBytes === null ? "未知" : formatBytes(r.sizeBytes, 2) },
|
||||
{ label: "H&R", value: { yes: "有", no: "无", unknown: "未知(按有义务处理)" }[hrOf(r)] },
|
||||
{ label: "H&R", value: { yes: "有", no: "无", unknown: "未知(按有义务处理)" }[hrOf(r)] },
|
||||
{ label: "站点上传", value: formatDateTime(r.sourcePublishedAt) },
|
||||
{ label: "首次发现", value: formatDateTime(r.firstSeenAt) },
|
||||
{ label: "最近确认", value: `${formatDateTime(r.dataUpdatedAt)}(${ORIGIN_LABEL[r.origin]})` },
|
||||
|
||||
@@ -91,6 +91,6 @@ export function accountSummary(accounts: SiteAccount[]): { label: string; tone:
|
||||
|
||||
export function circuitLabel(site: Pick<Site, "circuitState" | "circuitOpenUntil">): string | null {
|
||||
if (site.circuitState === "closed") return null;
|
||||
if (site.circuitState === "half_open") return "熔断半开(试探中)";
|
||||
if (site.circuitState === "half_open") return "熔断半开(试探中)";
|
||||
return `熔断中${site.circuitOpenUntil ? `,${relTime(site.circuitOpenUntil)}恢复` : ""}`;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
|
||||
{promo ? (
|
||||
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "neutral"}>
|
||||
{promo.label}
|
||||
{promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""}
|
||||
{promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
{hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
|
||||
@@ -114,7 +114,7 @@ export function ReleaseRow({
|
||||
{r.leechers ?? "?"}
|
||||
</span>
|
||||
<span title="站点上传时间">上传 {relTime(r.sourcePublishedAt)}</span>
|
||||
<span className="inline-flex items-center gap-1" title={`数据最近确认:${r.dataUpdatedAt}`}>
|
||||
<span className="inline-flex items-center gap-1" title={`数据最近确认:${r.dataUpdatedAt}`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
{ORIGIN_LABEL[r.origin]} · {relTime(r.dataUpdatedAt)}
|
||||
</span>
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
)
|
||||
@@ -90,7 +90,7 @@ export function SnapshotPanel({ site }: { site: Site }) {
|
||||
{s.query ? <span className="min-w-0 truncate text-muted-foreground">“{s.query}”</span> : null}
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{s.pages.length ? `第 ${s.pages.join(",")} 页 · ` : ""}
|
||||
{s.itemCount} 条(新 {s.newCount})
|
||||
{s.itemCount} 条(新 {s.newCount})
|
||||
</span>
|
||||
<span className="ml-auto text-meta text-muted-foreground" title={formatDateTime(s.startedAt)}>
|
||||
{relTime(s.startedAt)}
|
||||
|
||||
@@ -19,5 +19,5 @@ export function revealFirstError(root?: HTMLElement | null) {
|
||||
export function formErrorSummary(errors: Record<string, string>): string {
|
||||
const msgs = Object.values(errors).filter(Boolean);
|
||||
if (msgs.length === 0) return "表单有错误";
|
||||
return msgs.length === 1 ? `需要修改:${msgs[0]}` : `有 ${msgs.length} 处需要修改:${msgs[0]}`;
|
||||
return msgs.length === 1 ? `需要修改:${msgs[0]}` : `有 ${msgs.length} 处需要修改:${msgs[0]}`;
|
||||
}
|
||||
@@ -86,8 +86,8 @@ export const agentRunLabel = (v: string | null | undefined) => pick(AGENT_RUN, v
|
||||
const ENGINE_RUN: Record<string, Labeled> = {
|
||||
pending: { label: "待执行", tone: "neutral" },
|
||||
running: { label: "运行中", tone: "neutral" },
|
||||
sleeping: { label: "休眠(旧)", tone: "neutral" },
|
||||
succeeded: { label: "完成(旧)", tone: "success" },
|
||||
sleeping: { label: "休眠(旧)", tone: "neutral" },
|
||||
succeeded: { label: "完成(旧)", tone: "success" },
|
||||
completed: { label: "完成", tone: "success" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
canceled: { label: "已取消", tone: "warning" },
|
||||
|
||||
@@ -82,7 +82,7 @@ const ACTION_OPTIONS = [
|
||||
"downloader_instance",
|
||||
"media_server_instance",
|
||||
"path_mapping",
|
||||
].map((p) => ({ value: `${p}.*`, label: `${p}.*(全部)` })),
|
||||
].map((p) => ({ value: `${p}.*`, label: `${p}.*(全部)` })),
|
||||
...AuditAction.options.map((a) => ({ value: a, label: `${actionLabel(a)} · ${a}` })),
|
||||
];
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function AuditPage() {
|
||||
<>
|
||||
<PageHeader title="审计" description="登录、设置、密钥与实例变更,新的在前。不记录凭证内容。" />
|
||||
<FilterBar
|
||||
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
|
||||
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function DownloadPlansPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="下载面板" description="候选被选中后生成计划,同时预留版本目标、空间与预算。" />
|
||||
<PageHeader title="下载面板" description="候选被选中后生成计划,同时预留版本目标、空间与预算。" />
|
||||
<DownloadsTabs />
|
||||
<FilterBar onReset={f.active ? f.reset : undefined}>
|
||||
<FilterSelect
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function DownloadsPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="下载面板"
|
||||
description="阈值暂停不能手动绕过;无进展只记录,不自动换源。"
|
||||
description="阈值暂停不能手动绕过;无进展只记录,不自动换源。"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => void qc.invalidateQueries({ queryKey: ["downloads"] })}>
|
||||
<RefreshCw className={downloads.isFetching ? "animate-spin" : undefined} />
|
||||
|
||||
@@ -97,7 +97,7 @@ import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
const HEALTH_REASON: Record<string, string> = {
|
||||
root_missing: "挂载点不存在",
|
||||
sentinel_missing: "目录里没有哨兵文件,挂载可能没生效",
|
||||
sentinel_missing: "目录里没有哨兵文件,挂载可能没生效",
|
||||
timeout: "探测超时",
|
||||
permission_denied: "没有读取权限",
|
||||
};
|
||||
@@ -365,7 +365,7 @@ export default function FilesPage() {
|
||||
setQInput("");
|
||||
};
|
||||
const open = (e: FileEntry) => {
|
||||
if (e.escape) return toast.error("链接指向登记目录之外,不能打开");
|
||||
if (e.escape) return toast.error("链接指向登记目录之外,不能打开");
|
||||
if (e.type === "dir") go({ root: rootId, path: e.path });
|
||||
else patch({ sel: e.path, idf: null });
|
||||
};
|
||||
@@ -397,7 +397,7 @@ export default function FilesPage() {
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
const ok = r.items.filter((i) => i.status === "ok").length;
|
||||
toast.success(`探测完成:${ok}/${r.items.length} 个读到媒体信息`, {
|
||||
toast.success(`探测完成:${ok}/${r.items.length} 个读到媒体信息`, {
|
||||
description: r.items.some((i) => i.savedToAsset) ? "媒体信息已写入资产" : "未登记文件的结果缓存 24 小时",
|
||||
});
|
||||
},
|
||||
@@ -598,7 +598,7 @@ export default function FilesPage() {
|
||||
<p className="mt-1.5 max-w-md text-caption text-muted-foreground">
|
||||
{offline ? filesErrorText(list.error) : healthReasonText(root.healthReason)}。离线期间资产状态不变。
|
||||
</p>
|
||||
<p className="mt-1 text-meta text-muted-foreground">上次检查:{relTime(root.checkedAt)}</p>
|
||||
<p className="mt-1 text-meta text-muted-foreground">上次检查:{relTime(root.checkedAt)}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -629,7 +629,7 @@ export default function FilesPage() {
|
||||
plain
|
||||
icon={SearchX}
|
||||
title="没有符合筛选的项"
|
||||
description={q ? `没有匹配“${q}”的项。支持子串和 glob,如 *.mkv` : undefined}
|
||||
description={q ? `没有匹配「${q}」的项。支持子串和 glob,如 *.mkv` : undefined}
|
||||
action={
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
<X />
|
||||
@@ -682,7 +682,7 @@ export default function FilesPage() {
|
||||
const blockedTip = (op: Parameters<typeof opDisabledReason>[0]) => {
|
||||
if (!root) return null;
|
||||
const bad = selected.map((e) => opDisabledReason(op, e, root)).filter(Boolean) as string[];
|
||||
return bad.length ? `${bad[0]}(${bad.length} 项)` : null;
|
||||
return bad.length ? `${bad[0]}(${bad.length} 项)` : null;
|
||||
};
|
||||
const moreItems = (): MenuItem[] => {
|
||||
const items: MenuItem[] = [];
|
||||
@@ -706,7 +706,7 @@ export default function FilesPage() {
|
||||
.filter((e) => opApplies("identify", e))
|
||||
.map((e) => (root ? opDisabledReason("organize", e, root) : null))
|
||||
.filter(Boolean) as string[];
|
||||
return bad.length ? `${bad[0]}(${bad.length} 项)` : null;
|
||||
return bad.length ? `${bad[0]}(${bad.length} 项)` : null;
|
||||
})();
|
||||
const moveTip = blockedTip("move");
|
||||
const deleteTip = blockedTip("delete");
|
||||
@@ -796,7 +796,7 @@ export default function FilesPage() {
|
||||
}
|
||||
if (e.key === "ArrowDown") gridRef.current?.focus();
|
||||
}}
|
||||
placeholder="名称或 glob,如 *.mkv"
|
||||
placeholder="名称或 glob,如 *.mkv"
|
||||
aria-label="搜索当前目录"
|
||||
className="h-8 pl-9"
|
||||
/>
|
||||
@@ -819,7 +819,7 @@ export default function FilesPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={order === "asc" ? "升序,点此改为降序" : "降序,点此改为升序"}
|
||||
aria-label={order === "asc" ? "升序,点此改为降序" : "降序,点此改为升序"}
|
||||
title={order === "asc" ? "升序" : "降序"}
|
||||
onClick={() => patch({ order: order === "asc" ? "desc" : null })}
|
||||
>
|
||||
@@ -861,7 +861,7 @@ export default function FilesPage() {
|
||||
key: "mkdir",
|
||||
label: "新建目录",
|
||||
icon: FolderPlus,
|
||||
disabled: root?.writable ? null : root?.kind === "download" ? "下载目录里不能新建目录" : "只读目录,不能新建",
|
||||
disabled: root?.writable ? null : root?.kind === "download" ? "下载目录里不能新建目录" : "只读目录,不能新建",
|
||||
onSelect: () => setDialog({ kind: "mkdir" }),
|
||||
},
|
||||
{
|
||||
@@ -1046,7 +1046,7 @@ export default function FilesPage() {
|
||||
? undefined
|
||||
: root?.kind === "download"
|
||||
? "下载目录里不能新建目录"
|
||||
: "只读目录,不能新建"
|
||||
: "只读目录,不能新建"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
@@ -1070,7 +1070,7 @@ export default function FilesPage() {
|
||||
|
||||
{first?.truncated ? (
|
||||
<div role="status" className="border-b border-warning/30 bg-warning/10 px-3 py-1.5 text-meta text-warning">
|
||||
目录过大,只读了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录。
|
||||
目录过大,只读了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -163,10 +163,10 @@ function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
|
||||
</div>
|
||||
<p className="mt-0.5 text-caption text-muted-foreground">
|
||||
{paused
|
||||
? "自动收录、下载、整理已停止,手动操作仍可用。"
|
||||
? "自动收录、下载、整理已停止,手动操作仍可用。"
|
||||
: readOnly
|
||||
? "只核对,不写入。确认无误后关闭。"
|
||||
: `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
|
||||
? "只核对,不写入。确认无误后关闭。"
|
||||
: `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,7 +201,7 @@ function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
|
||||
</Button>
|
||||
}
|
||||
title="暂停全部自动化?"
|
||||
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
|
||||
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
|
||||
confirmLabel="暂停自动化"
|
||||
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
|
||||
/>
|
||||
|
||||
@@ -141,7 +141,7 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
|
||||
description={
|
||||
<>
|
||||
{option.description ? `${option.description}。` : ""}
|
||||
{option.capabilityId ? `由能力 ${option.capabilityId} 执行,仍做授权校验。` : null}
|
||||
{option.capabilityId ? `由能力 ${option.capabilityId} 执行,仍做授权校验。` : null}
|
||||
</>
|
||||
}
|
||||
confirmLabel={option.label}
|
||||
@@ -155,7 +155,7 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
|
||||
...(identity ? { identity } : {}),
|
||||
})
|
||||
.then(
|
||||
() => toast.success(`已处理:${option.label}`),
|
||||
() => toast.success(`已处理:${option.label}`),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "处理失败"));
|
||||
throw err;
|
||||
@@ -176,7 +176,7 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} required hint="纯数字,如 872585">
|
||||
<FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} required hint="纯数字,如 872585">
|
||||
<Input
|
||||
id={`${uid}-tmdb`}
|
||||
inputMode="numeric"
|
||||
@@ -191,7 +191,7 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
|
||||
<EntityPicker value={entity} onChange={setEntity} />
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField label="备注" htmlFor={`${uid}-note`} hint="可选,记在处理结果里">
|
||||
<FormField label="备注" htmlFor={`${uid}-note`} hint="可选,记在处理结果里">
|
||||
<Textarea id={`${uid}-note`} value={note} onChange={(e) => setNote(e.target.value)} rows={2} />
|
||||
</FormField>
|
||||
</ConfirmDialog>
|
||||
@@ -208,8 +208,8 @@ function DismissButton({ d }: { d: Decision }) {
|
||||
忽略
|
||||
</Button>
|
||||
}
|
||||
title="忽略这条事项?"
|
||||
description="只关闭提醒,问题本身不变;同一根因出现新证据时会重新提出。"
|
||||
title="忽略这条事项?"
|
||||
description="只关闭提醒,问题本身不变;同一根因出现新证据时会重新提出。"
|
||||
confirmLabel="忽略"
|
||||
onConfirm={() =>
|
||||
resolve.mutateAsync({ id: d.id, dismiss: true }).then(
|
||||
@@ -343,7 +343,7 @@ export default function InboxPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="待处理"
|
||||
description="Agent 调查和重试后仍未解决的事项,按作品和问题类型合并"
|
||||
description="Agent 调查和重试后仍未解决的事项,按作品和问题类型合并"
|
||||
actions={
|
||||
<>
|
||||
<span className="hidden items-center gap-1.5 text-meta text-muted-foreground sm:inline-flex">
|
||||
@@ -379,7 +379,7 @@ export default function InboxPage() {
|
||||
key={k}
|
||||
label={DECISION_KIND[k]}
|
||||
value={v.count}
|
||||
hint={`最高级别:${SEVERITY[v.sev].label}`}
|
||||
hint={`最高级别:${SEVERITY[v.sev].label}`}
|
||||
tone={SEVERITY[v.sev].tone}
|
||||
active={kind === k}
|
||||
onClick={() => set("kind", kind === k ? "" : k)}
|
||||
|
||||
@@ -101,7 +101,7 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
plain
|
||||
icon={FolderTree}
|
||||
title="还没有入库文件"
|
||||
description="版本目标满足后,文件位置显示在这里。"
|
||||
description="版本目标满足后,文件位置显示在这里。"
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
@@ -173,7 +173,7 @@ function IdentityPanel({ detail }: { detail: LibraryDetail }) {
|
||||
return (
|
||||
<Panel
|
||||
title="外部身份"
|
||||
description="以本地 ID 为准,外部身份可修改"
|
||||
description="以本地 ID 为准,外部身份可修改"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<PencilLine />
|
||||
@@ -287,7 +287,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
) : null}
|
||||
{cands.length ? (
|
||||
<p className="mt-0.5 text-meta break-words text-muted-foreground">
|
||||
候选:
|
||||
候选:
|
||||
{cands.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${String(c.value)}」`).join(" / ")}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -305,8 +305,8 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
<LockOpen />
|
||||
</Button>
|
||||
}
|
||||
title={`解锁「${ENTITY_FIELD_LABEL[field]}」?`}
|
||||
description="取消人工修改,下次刷新按来源优先级取值。"
|
||||
title={`解锁「${ENTITY_FIELD_LABEL[field]}」?`}
|
||||
description="取消人工修改,下次刷新按来源优先级取值。"
|
||||
confirmLabel="解锁"
|
||||
onConfirm={() =>
|
||||
unlock.mutateAsync(field).then(
|
||||
@@ -335,7 +335,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
</ul>
|
||||
{entity?.aliases.length ? (
|
||||
<div className="mt-3 border-t border-border/60 pt-3">
|
||||
<p className="mb-1.5 text-meta text-muted-foreground">别名(用于模糊召回)</p>
|
||||
<p className="mb-1.5 text-meta text-muted-foreground">别名(用于模糊召回)</p>
|
||||
<ChipList
|
||||
items={entity.aliases.map((a) => (
|
||||
<span key={a.id}>
|
||||
@@ -360,7 +360,7 @@ function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUn
|
||||
plain
|
||||
icon={CalendarClock}
|
||||
title="还没有版本目标"
|
||||
description="新建订阅或版本目标后,按目标补齐和升级。"
|
||||
description="新建订阅或版本目标后,按目标补齐和升级。"
|
||||
action={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={consolePath("subscriptions")}>去订阅</Link>
|
||||
@@ -456,7 +456,7 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
|
||||
a.segment.localeCompare(b.segment),
|
||||
);
|
||||
return (
|
||||
<Panel title="内容单元" description="临时单元按解析结果创建,确认或改编号后锁定" bodyClassName="p-0 sm:p-0">
|
||||
<Panel title="内容单元" description="临时单元按解析结果创建,确认或改编号后锁定" bodyClassName="p-0 sm:p-0">
|
||||
<DataTable
|
||||
plain
|
||||
columns={columns}
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function LibraryPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="媒体概览"
|
||||
description="本地账本:作品、版本目标、资产与外部身份。自动匹配不覆盖人工锁定的外部身份。"
|
||||
description="本地账本:作品、版本目标、资产与外部身份。自动匹配不覆盖人工锁定的外部身份。"
|
||||
actions={
|
||||
view === "unidentified" ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
@@ -208,7 +208,7 @@ function WorksView() {
|
||||
? { title: "没有符合条件的作品", description: "换个关键字或清除筛选。", icon: LibraryIcon }
|
||||
: {
|
||||
title: "还没有作品",
|
||||
description: "扫描库根或新建订阅后,作品会出现在这里。",
|
||||
description: "扫描库根或新建订阅后,作品会出现在这里。",
|
||||
icon: LibraryIcon,
|
||||
action: (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
|
||||
@@ -107,7 +107,7 @@ function FileRow({ f }: { f: PublicOrganizationFile }) {
|
||||
</div>
|
||||
{reason ? (
|
||||
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
|
||||
{f.failure ? "失败" : "跳过"}原因:{reasonText(reason)}
|
||||
{f.failure ? "失败" : "跳过"}原因:{reasonText(reason)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-meta text-muted-foreground">
|
||||
@@ -153,10 +153,10 @@ export default function OrganizeRecordPage() {
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `整理记录:${r.media?.title ?? r.id.slice(0, 8)}`,
|
||||
label: `整理记录:${r.media?.title ?? r.id.slice(0, 8)}`,
|
||||
recordId: r.id,
|
||||
entityId: r.media?.entityId,
|
||||
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
|
||||
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -180,7 +180,7 @@ export default function OrganizeRecordPage() {
|
||||
<StatusBadge>{ACTION_LABEL[r.action]}</StatusBadge>
|
||||
<StatusBadge>
|
||||
{TRIGGER_LABEL[r.trigger]}
|
||||
{r.trigger === "agent" ? "(计为自动)" : ""}
|
||||
{r.trigger === "agent" ? "(计为自动)" : ""}
|
||||
</StatusBadge>
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
{r.counts.succeeded}/{r.counts.total} 成功 · 失败 {r.counts.failed} · 跳过 {r.counts.skipped}
|
||||
@@ -243,7 +243,7 @@ export default function OrganizeRecordPage() {
|
||||
</ul>
|
||||
) : (
|
||||
<p className="px-4 pb-4 text-caption text-muted-foreground">
|
||||
{r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"}
|
||||
{r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function OrganizePage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="整理记录"
|
||||
description="每次整理的前后路径与逐文件结果,自动和手动整理都记在这里。"
|
||||
description="每次整理的前后路径与逐文件结果,自动和手动整理都记在这里。"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => navigate("/console/files")}>
|
||||
<FolderInput />
|
||||
@@ -180,7 +180,7 @@ export default function OrganizePage() {
|
||||
empty={{
|
||||
icon: FileStack,
|
||||
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
|
||||
description: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
|
||||
description: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -76,7 +76,7 @@ function problems(d: SystemHealth): string[] {
|
||||
const out: string[] = [];
|
||||
const stale = d.processes.filter((p) => p.stale);
|
||||
if (stale.length) out.push(`${stale.length} 个进程超过 45 秒没有心跳`);
|
||||
if (!d.processes.some((p) => !p.stale && p.processKind !== "api")) out.push("没有在线的 worker,任务不会被执行");
|
||||
if (!d.processes.some((p) => !p.stale && p.processKind !== "api")) out.push("没有在线的 worker,任务不会被执行");
|
||||
const lag = d.processes.filter((p) => !p.stale && p.eventLoopLagMaxMs >= 1000);
|
||||
if (lag.length) out.push(`${lag.length} 个进程事件循环延迟超过 1 秒`);
|
||||
if (d.db.level === "warn" || d.db.level === "critical") out.push("数据库延迟或连接数偏高");
|
||||
@@ -116,7 +116,7 @@ function Banner({ d }: { d: SystemHealth }) {
|
||||
<p className="font-medium text-foreground">
|
||||
{ok ? "全部正常" : lv.label}
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
检查于 {formatRelative(d.checkedAt)} · API {d.api.version},已运行 {formatDuration(d.api.uptimeSec)}
|
||||
检查于 {formatRelative(d.checkedAt)} · API {d.api.version},已运行 {formatDuration(d.api.uptimeSec)}
|
||||
</span>
|
||||
</p>
|
||||
{list.length ? (
|
||||
@@ -262,7 +262,7 @@ function History() {
|
||||
<ErrorState plain error={h.error} onRetry={() => h.refetch()} />
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<DetailSection title="事件循环延迟峰值(毫秒)">
|
||||
<DetailSection title="事件循环延迟峰值(毫秒)">
|
||||
<HistoryChart data={h.data} metric="lagMaxMs" unit="毫秒" hours={hours} />
|
||||
</DetailSection>
|
||||
<DetailSection title="SSE 连接数">
|
||||
@@ -280,7 +280,7 @@ function Disks({ d }: { d: SystemHealth }) {
|
||||
return (
|
||||
<Card title="磁盘" icon={HardDrive} level={worst(d.disks.map((x) => x.level))}>
|
||||
<p className="mb-3 text-meta text-muted-foreground">
|
||||
阈值:{t.warnPercent}% 需要关注,{t.criticalPercent}% 需要处理(取自缓存设置)
|
||||
阈值:{t.warnPercent}% 需要关注,{t.criticalPercent}% 需要处理(取自缓存设置)
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{d.disks.map((x) => {
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function TasksPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="任务"
|
||||
description="每次运行都是一条任务,不论定时、事件、手动还是 Agent 触发。"
|
||||
description="每次运行都是一条任务,不论定时、事件、手动还是 Agent 触发。"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function NotFound() {
|
||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<EmptyState
|
||||
title="页面不存在"
|
||||
description="检查地址,或回到首页。"
|
||||
description="检查地址,或回到首页。"
|
||||
action={
|
||||
<Link to="/console" className={buttonVariants({ variant: "outline" })}>
|
||||
回到首页
|
||||
|
||||
Reference in new issue
Block a user