Files
UHD_Media_Tool/apps/web/app/components/settings/automation.tsx
T
rootandClaude Opus 5.5 a9147e647d feat(downloads): fold download plans into download panel (DL-3)
- Waiting plans (approval / disk space / automation off / queued) are
  active-segment rows on /download-tasks/board, merged by created_at,
  counted, with approve / cancel actions; ?plan= deep links and legacy
  /downloads/plans routes redirect to the panel
- Download detail shows "why this download" (source, purpose, replaced
  version, reasons); raw evaluation in tech info
- Failed / cancelled / expired plans appear in the entity download
  history; rejected / revoked / expired approvals close draft plans
- New GET /acquisition-plans/{id}/why, POST /acquisition-plans/{id}/cancel
- User-facing copy no longer says 下载计划; glossary updated

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 01:15:25 +08:00

352 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ChevronDown, Pause, Play, ShieldAlert } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useLocation } from "react-router";
import { toast } from "sonner";
import { useUpdateSettings } from "~/api/settings.ts";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { type FormValues, num } from "./resource-section.tsx";
import { anchorId, SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、后台任务并发(WK-1)、存储预算与保留(CACHE-2)
const GiB = 1024 ** 3;
const CACHE_CARD: CardSpec = {
anchor: "cache",
title: "下载盘",
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" },
],
fromSettings: (s) => ({
"cache.hardThresholdPercent": n(s.cache.hardThresholdPercent),
"cache.resumePercent": n(s.cache.resumePercent),
"cache.cleanupTargetPercent": n(s.cache.cleanupTargetPercent),
"cache.checkIntervalSec": n(s.cache.checkIntervalSec),
}),
toPatch: (v) => ({
cache: {
hardThresholdPercent: num(v["cache.hardThresholdPercent"]),
resumePercent: num(v["cache.resumePercent"]),
cleanupTargetPercent: num(v["cache.cleanupTargetPercent"]),
checkIntervalSec: num(v["cache.checkIntervalSec"]),
},
}),
};
const BUDGET_CARD: CardSpec = {
anchor: "budget",
title: "每日预算",
description: "任一项到上限后不再开始新的下载;前两项按自然日计。",
fields: [
{ name: "budget.maxNewTitlesPerDay", label: "每日新作品数", kind: "number" },
{ name: "budget.maxDownloadGiBPerDay", label: "每日下载量(GiB)", kind: "number" },
{ name: "budget.maxConcurrentDownloads", label: "最大并发下载", kind: "number", hint: "1–100" },
],
fromSettings: (s) => ({
"budget.maxNewTitlesPerDay": n(s.budget.maxNewTitlesPerDay),
"budget.maxDownloadGiBPerDay": n(Math.round((s.budget.maxDownloadBytesPerDay / GiB) * 100) / 100),
"budget.maxConcurrentDownloads": n(s.budget.maxConcurrentDownloads),
}),
toPatch: (v) => {
const gib = num(v["budget.maxDownloadGiBPerDay"]);
return {
budget: {
maxNewTitlesPerDay: num(v["budget.maxNewTitlesPerDay"]),
maxDownloadBytesPerDay: gib === undefined ? undefined : Math.round(gib * GiB),
maxConcurrentDownloads: num(v["budget.maxConcurrentDownloads"]),
},
};
},
};
const RETENTION_CARD: CardSpec = {
anchor: "retention",
title: "保留",
fields: [
{
name: "retention.seedRetentionDays",
label: "源文件保留天数",
kind: "number",
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
},
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number" },
],
fromSettings: (s) => ({
"retention.seedRetentionDays": n(s.retention.seedRetentionDays),
"retention.logRetentionDays": n(s.retention.logRetentionDays),
}),
toPatch: (v) => ({
retention: {
seedRetentionDays: num(v["retention.seedRetentionDays"]),
logRetentionDays: num(v["retention.logRetentionDays"]),
},
}),
};
// CACHE-2:存储预算、流水保留期、缓存有效期(settings.storage,docs/runbook/storage.md)
const gbField = (k: string, label: string, hint?: string) => ({
name: `storage.budgets.${k}`,
label,
kind: "number" as const,
hint,
});
const dayField = (group: "retentionDays" | "cacheTtl", k: string, label: string, hint?: string) => ({
name: `storage.${group}.${k}`,
label,
kind: "number" as const,
hint,
});
const pick = (v: FormValues, prefix: string, keys: readonly string[]) =>
Object.fromEntries(keys.map((k) => [k, num(v[`${prefix}.${k}`])]));
// WK-1:io worker 三条道的并发(settings.automation.workerLanes,docs/runbook/dev.md「IO Worker 分道」)
const LANE_KEYS = ["file", "network", "maintenance"] as const;
const WORKER_LANES_CARD: CardSpec = {
anchor: "worker-lanes",
title: "后台任务并发",
description:
"后台任务分三条道各自排队,互不占用。保存后几秒内生效,不用重启;服务器上设置了对应的环境变量时以环境变量为准。",
fields: [
{ name: "automation.workerLanes.file", label: "文件道", kind: "number", hint: "整理、硬链接、移动、校验;1–16" },
{
name: "automation.workerLanes.network",
label: "网络道",
kind: "number",
hint: "站点、RSS、TMDB、下载器、搜索;单站限流另计;1–16",
},
{
name: "automation.workerLanes.maintenance",
label: "维护道",
kind: "number",
hint: "清理、回填、对账;前两道有活时让路;1–16",
},
],
fromSettings: (s) =>
Object.fromEntries(LANE_KEYS.map((k) => [`automation.workerLanes.${k}`, n(s.automation.workerLanes[k])])),
toPatch: (v) => ({ automation: { workerLanes: pick(v, "automation.workerLanes", LANE_KEYS) } }),
};
const BUDGET_KEYS = ["pgDataGb", "pgDataWarnGb", "imagesGb", "backupsGb", "sessionsEvidenceGb"] as const;
const STORAGE_BUDGET_CARD: CardSpec = {
anchor: "storage",
title: "存储预算",
description: "超过预算时健康页告警;图片缓存超出容量按最久未访问删除。单位 GB。",
fields: [
gbField("pgDataGb", "数据库预算", "超过为严重"),
gbField("pgDataWarnGb", "数据库告警线", "不高于数据库预算"),
gbField("imagesGb", "图片缓存容量"),
gbField("backupsGb", "备份目录预算"),
gbField("sessionsEvidenceGb", "智能体会话与截图证据预算"),
],
fromSettings: (s) => Object.fromEntries(BUDGET_KEYS.map((k) => [`storage.budgets.${k}`, n(s.storage.budgets[k])])),
toPatch: (v) => ({ storage: { budgets: pick(v, "storage.budgets", BUDGET_KEYS) } }),
};
const RETENTION_KEYS = [
"observation",
"sseEvent",
"heartbeatHours",
"taskRun",
"agentEvent",
"audit",
"capabilityStats",
"identityPoolArchive",
] as const;
const STORAGE_RETENTION_CARD: CardSpec = {
anchor: "storage-retention",
title: "流水数据保留",
description: "到期由定时清理任务分批删除。单位天,心跳为小时。",
fields: [
dayField("retentionDays", "observation", "RSS 抓取记录"),
dayField("retentionDays", "sseEvent", "实时事件"),
dayField("retentionDays", "heartbeatHours", "进程心跳(小时)"),
dayField("retentionDays", "taskRun", "任务运行记录"),
dayField("retentionDays", "agentEvent", "智能体会话事件", "只删已结束的会话"),
dayField("retentionDays", "audit", "审计日志", "能力调用审计除外"),
dayField("retentionDays", "capabilityStats", "能力调用统计"),
dayField(
"retentionDays",
"identityPoolArchive",
"未识别资源归档",
"未命中的条目这么久没有新证据就归档,仍可手动指定",
),
],
fromSettings: (s) =>
Object.fromEntries(RETENTION_KEYS.map((k) => [`storage.retentionDays.${k}`, n(s.storage.retentionDays[k])])),
toPatch: (v) => ({ storage: { retentionDays: pick(v, "storage.retentionDays", RETENTION_KEYS) } }),
};
const TTL_KEYS = ["tmdbSearchDays", "tmdbDetailDays", "tmdbMissDays", "siteDetailDays", "webSearchDays"] as const;
const STORAGE_TTL_CARD: CardSpec = {
anchor: "storage-ttl",
title: "缓存有效期",
description: "单位天。追踪中的作品命中 TMDB 详情缓存时自动续期。",
fields: [
dayField("cacheTtl", "tmdbSearchDays", "TMDB 搜索与识别结果"),
dayField("cacheTtl", "tmdbDetailDays", "TMDB 详情"),
dayField("cacheTtl", "tmdbMissDays", "TMDB 空结果", "搜索无结果、未识别时的负缓存"),
dayField("cacheTtl", "siteDetailDays", "站点详情页内容"),
dayField("cacheTtl", "webSearchDays", "网页搜索结果"),
],
fromSettings: (s) => Object.fromEntries(TTL_KEYS.map((k) => [`storage.cacheTtl.${k}`, n(s.storage.cacheTtl[k])])),
toPatch: (v) => ({ storage: { cacheTtl: pick(v, "storage.cacheTtl", TTL_KEYS) } }),
};
function ToggleRow({
id,
icon,
title,
description,
checked,
onChange,
disabled,
tone,
}: {
id: string;
icon: ReactNode;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled: boolean;
tone: "success" | "warning";
}) {
return (
<div className="flex items-start gap-4 py-4 first:pt-0 last:pb-0">
<span
className={cn(
"grid h-9 w-9 shrink-0 place-items-center rounded-full",
tone === "success" ? "bg-success/10 text-success" : "bg-warning/10 text-warning",
)}
>
{icon}
</span>
<span className="min-w-0 flex-1">
<label htmlFor={id} className="block cursor-pointer text-sm font-medium text-foreground">
{title}
</label>
<span className="block text-caption text-muted-foreground">{description}</span>
</span>
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} aria-label={title} />
</div>
);
}
/** 高级:只读对账、后台任务并发、存储预算、流水保留、缓存有效期;默认收起,从 #automation/advanced 进来时展开 */
function Advanced({ children }: { children: ReactNode }) {
const location = useLocation();
const [open, setOpen] = useState(() => location.hash.endsWith("/advanced"));
useEffect(() => {
if (location.hash.endsWith("/advanced")) setOpen(true);
}, [location.hash]);
return (
<section id={anchorId("automation", "advanced")} className="scroll-mt-20">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1.5 text-lead font-semibold text-foreground"
>
高级
<ChevronDown
className={cn("size-4 text-muted-foreground transition-transform duration-base", open && "rotate-180")}
/>
</button>
<p className="mt-1 text-caption text-muted-foreground">
只读对账、后台任务并发、存储预算、数据保留和缓存有效期。一般不用改。
</p>
{open ? <div className="mt-6 grid grid-cols-1 gap-8">{children}</div> : null}
</section>
);
}
function ReadOnlySwitch() {
const update = useUpdateSettings();
return (
<SettingsGate>
{(s) => (
<SettingsBlock section="automation" anchor="readonly" title="只读对账">
<ToggleRow
id="tg-readonly"
icon={<ShieldAlert className="h-4 w-4" />}
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
checked={s.automation.readOnlyReconcile}
disabled={update.isPending}
onChange={(v) =>
update.mutateAsync({ automation: { readOnlyReconcile: v } }).then(
() => toast.success(v ? "已开启只读对账" : "已关闭只读对账"),
(err) => toast.error(errorMessage(err, "切换失败")),
)
}
/>
</SettingsBlock>
)}
</SettingsGate>
);
}
function Switches() {
const update = useUpdateSettings();
const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => {
try {
await update.mutateAsync({ automation: patch });
toast.success(msg);
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
}
};
return (
<SettingsGate>
{(s) => (
<SettingsBlock section="automation" anchor="switches" title="开关" description="切换后立即生效。">
<div className="divide-y divide-border/60">
<ToggleRow
id="tg-automation"
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="关闭后自动收录、下载、整理全部停止;查询与手动操作不受影响。"
checked={s.automation.enabled}
disabled={update.isPending}
onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")}
/>
</div>
</SettingsBlock>
)}
</SettingsGate>
);
}
export function AutomationSection() {
return (
<div className="grid grid-cols-1 gap-8">
<Switches />
<SettingsGate>
{(s) => (
<>
<SettingsCard section="automation" spec={CACHE_CARD} settings={s} />
<SettingsCard section="automation" spec={BUDGET_CARD} settings={s} />
<SettingsCard section="automation" spec={RETENTION_CARD} settings={s} />
<Advanced>
<ReadOnlySwitch />
<SettingsCard section="automation" spec={WORKER_LANES_CARD} settings={s} />
<SettingsCard section="automation" spec={STORAGE_BUDGET_CARD} settings={s} />
<SettingsCard section="automation" spec={STORAGE_RETENTION_CARD} settings={s} />
<SettingsCard section="automation" spec={STORAGE_TTL_CARD} settings={s} />
</Advanced>
</>
)}
</SettingsGate>
</div>
);
}