- Trim descriptions, empty states, confirm buttons and toasts to facts - Unify terms per glossary: 待处理/待识别/整理记录/运行记录/凭证/子会话/Agent 上游 - Subscription-login termsNote states facts only (borrowed client identity, ToS/ban risk) - Shorter, concrete goal templates for built-in Agent checks - Human-readable nouns instead of table names in instance CRUD errors - Audit labels for pi_upstream / schedule / automation_grant actions - Glossary: add 上游、订阅登录、凭证、定时任务、巡检、运行记录、子会话、停止、持续授权 Co-Authored-By: Claude Opus 5.5 <[email protected]>
190 lines
6.9 KiB
TypeScript
190 lines
6.9 KiB
TypeScript
import { Pause, Play, ShieldAlert } from "lucide-react";
|
||
import type { ReactNode } from "react";
|
||
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 { num } from "./resource-section.tsx";
|
||
import { SettingsBlock } from "./settings-block.tsx";
|
||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||
|
||
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数
|
||
|
||
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", hint: "观察与日志类数据" },
|
||
],
|
||
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"]),
|
||
},
|
||
}),
|
||
};
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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 ? "自动化已恢复" : "自动化已暂停")}
|
||
/>
|
||
<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) => set({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账")}
|
||
/>
|
||
</div>
|
||
</SettingsBlock>
|
||
)}
|
||
</SettingsGate>
|
||
);
|
||
}
|
||
|
||
export function AutomationSection() {
|
||
return (
|
||
<div className="grid 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} />
|
||
</>
|
||
)}
|
||
</SettingsGate>
|
||
</div>
|
||
);
|
||
}
|