feat(web): 设置页按新分类重排,加搜索、缓存分区与旧锚点跳转
- 左栏三组分区 + 当前分区的节;窄屏为搜索框、分组下拉和节的横排胶囊 - 搜索框按分区、节、节别名和字段名定位,选中字段时滚到该字段并聚焦 - 旧 hash(#automation/cache、#api-keys、#library/scrape 等)replace 到新位置 - 下载器分区放下载盘水位与保种缺省值,名字与实例抽屉统一(暂停线 / 恢复线 / 保种天数) - 新增获取、整理与刮削、系统、缓存分区;API Key 并入账户与安全,快捷键移到界面 - 补上缺的入口:最小视频体积、子会话并发与层数、搜索站点;日志保留天数(已废弃)不再显示 - 缓存分区按区列出条数、占用、最近写入,单区清空二次确认;TMDB 与图片缓存并入 - 保存提示统一为「已保存「节名」」;按入库口径把相关文案改成「整理」 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
93a6c8a910
commit
8071ad33f9
23 files changed
+1499
-642
No files matched your search
@@ -0,0 +1,228 @@
|
||||
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 { type FormValues, num } from "./resource-section.tsx";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
|
||||
// 系统分区:自动化总开关、只读对账、后台任务并发(WK-1)、存储预算与流水数据保留(CACHE-2)。
|
||||
// 下载盘水位与保种在「下载器」,每日下载预算在「获取」,缓存有效期在「缓存」。
|
||||
|
||||
// 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", 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;
|
||||
export 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;
|
||||
export 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;
|
||||
export 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) } }),
|
||||
};
|
||||
|
||||
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 ReadOnlySwitch() {
|
||||
const update = useUpdateSettings();
|
||||
return (
|
||||
<SettingsGate>
|
||||
{(s) => (
|
||||
<SettingsBlock
|
||||
section="system"
|
||||
anchor="readonly"
|
||||
title="只读对账"
|
||||
description="从备份恢复后先开启,核对完再关闭。切换后立即生效。"
|
||||
>
|
||||
<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="system" 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 SystemSection() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<Switches />
|
||||
<ReadOnlySwitch />
|
||||
<SettingsGate>
|
||||
{(s) => (
|
||||
<>
|
||||
<SettingsCard section="system" spec={WORKER_LANES_CARD} settings={s} />
|
||||
<SettingsCard section="system" spec={STORAGE_BUDGET_CARD} settings={s} />
|
||||
<SettingsCard section="system" spec={STORAGE_RETENTION_CARD} settings={s} />
|
||||
</>
|
||||
)}
|
||||
</SettingsGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user