merge: ui2/w-settings

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:14:44 +08:00
commit 83bb4c939a
38 files changed
+2088 -817

No files matched your search

+25 -4
View File
@@ -1,10 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CacheClearResult, ImageCacheStatus, TmdbCacheList } from "@umt/contracts";
import type { CacheClearResult, CacheRegionList, ImageCacheStatus, TmdbCacheList } from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
// C2-A 缓存管理:TMDB 缓存(条数 / 上限 / TTL / 清空)、图片代理缓存(用量 / 清空)
// C2-A 缓存管理:TMDB 缓存(条数 / 上限 / TTL / 清空)、图片代理缓存(用量 / 清空)、通用缓存按区统计与清空
const keys = { tmdb: ["cache", "tmdb"] as const, images: ["cache", "images"] as const };
const keys = {
tmdb: ["cache", "tmdb"] as const,
images: ["cache", "images"] as const,
regions: ["cache", "regions"] as const,
};
export function useTmdbCache() {
return useQuery({
@@ -17,7 +21,7 @@ export function useClearTmdbCache() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiData<CacheClearResult>("/cache/tmdb/clear", { method: "POST" }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.tmdb }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["cache"] }),
});
}
@@ -35,3 +39,20 @@ export function useClearImageCache() {
onSuccess: () => qc.invalidateQueries({ queryKey: keys.images }),
});
}
export function useCacheRegions() {
return useQuery({
queryKey: keys.regions,
queryFn: ({ signal }) => apiData<CacheRegionList>("/cache/regions", { signal }),
});
}
/** 清空一个区;TMDB 两个区也在这里清,顺带刷新 TMDB 缓存计数 */
export function useClearCacheRegion() {
const qc = useQueryClient();
return useMutation({
mutationFn: (region: string) =>
apiData<CacheClearResult>(`/cache/regions/${encodeURIComponent(region)}/clear`, { method: "POST" }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["cache"] }),
});
}
+11 -8
View File
@@ -4,7 +4,6 @@ import { useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { useChangePassword, useLogout, useSession } from "~/api/auth.ts";
import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { SecretInput } from "~/components/console/secret-input.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -13,9 +12,11 @@ import { clearAuth, errorMessage, getToken, LOGIN_PATH, setToken } from "~/lib/a
import { formatDateTime } from "~/lib/utils.ts";
import { PasskeyBlock } from "./account-passkeys.tsx";
import { TotpBlock } from "./account-totp.tsx";
import { anchorId, SettingsBlock } from "./settings-block.tsx";
import { ApiKeysSection } from "./api-keys.tsx";
import { SettingsBlock } from "./settings-block.tsx";
// 账户与安全:改密、二次验证(TOTP,account-totp.tsx)、Passkey(account-passkeys.tsx)、当前会话与本机 Token、快捷键
// 账户与安全:改密、二次验证(TOTP,account-totp.tsx)、Passkey(account-passkeys.tsx)、当前会话与本机 Token、
// 签发给外部程序的 API Key(api-keys.tsx)。快捷键在「界面」。
const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, {
message: "两次输入的新密码不一致",
@@ -122,7 +123,12 @@ function SessionBlock() {
};
return (
<SettingsBlock section="account" anchor="session" title="会话">
<SettingsBlock
section="account"
anchor="session"
title="会话"
description="当前登录状态和这个浏览器保存的本机 Token。"
>
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-caption">
<dt className="text-muted-foreground">身份</dt>
<dd>
@@ -194,10 +200,7 @@ export function AccountSection() {
<TotpBlock disabled={!cookie} />
<PasskeyBlock disabled={!cookie} />
<SessionBlock />
{/* 快捷键说明自带标题,只包卡片与锚点 */}
<section id={anchorId("account", "shortcuts")} className="pg-slab min-w-0 scroll-mt-24 rounded-xl p-5">
<AgentShortcutsHelp />
</section>
<ApiKeysSection />
</div>
);
}
@@ -0,0 +1,213 @@
import { ArrowRight } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { useEnrichStats } from "~/api/identity.ts";
import { useSettings } from "~/api/settings.ts";
import { useSites } from "~/api/sites.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { ENRICH_SOURCE_KIND } from "~/components/library/labels.ts";
import { SearchDefaultDialog } from "~/components/sites/search-default-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { num } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 获取分区:每日下载预算(budget)、搜索站点(search,编辑沿用站点页的对话框)、资源识别(identity)。
// 订阅、规则、站点本身在各自页面;这里只放它们共用的缺省值。
const GiB = 1024 ** 3;
export 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 SEARCH_MODE_LABEL = { default: "默认站点", all: "全部站点", custom: "指定站点" } as const;
/** 搜索站点(settings.search):显示当前范围,修改用站点页同一个对话框 */
function SearchSitesBlock() {
const settings = useSettings();
const sites = useSites();
const [open, setOpen] = useState(false);
const cfg = settings.data?.search;
const mode = cfg?.defaultSiteMode ?? "default";
return (
<SettingsBlock
section="acquire"
anchor="search"
title="搜索站点"
description="搜资源时默认查哪些站点,API 不指定站点时也按这个范围。每次搜索前仍可改选。"
actions={
<Button size="sm" variant="outline" disabled={!sites.data} onClick={() => setOpen(true)}>
修改
</Button>
}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-foreground" id="f-search.defaultSiteMode">
{SEARCH_MODE_LABEL[mode]}
{mode === "custom" ? (
<span className="ml-2 text-caption text-muted-foreground">{cfg?.siteIds.length ?? 0} 个站点</span>
) : null}
</p>
<Button asChild size="sm" variant="ghost">
<Link to={consolePath("sites")}>
站点页
<ArrowRight />
</Link>
</Button>
</div>
{sites.data ? <SearchDefaultDialog open={open} onOpenChange={setOpen} sites={sites.data.items} /> : null}
</SettingsBlock>
);
}
// 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)
export const IDENTITY_CARD: CardSpec = {
anchor: "identity",
title: "资源识别",
description: "默认只在资源命中订阅、作品别名或新作品规则,或你手动识别和搜索时查 TMDB;其余放进未识别,不进待处理。",
fields: [
{
name: "identity.eagerTmdbLookup",
label: "每条新资源都查 TMDB",
kind: "switch",
hint: "每条新资源都查一次 TMDB。RSS 量大时每天可能上千次请求,站点独有的短剧和综艺多半查不到",
wide: true,
},
{
name: "identity.detailIdsPerSiteHour",
label: "识别时每站每小时最多抓详情页",
kind: "number",
hint: "标题认不准时,抓站点详情页取 IMDb、TMDB 或豆瓣 ID 再识别;只在搜索、订阅相关资源、补全和手动重新识别时抓,RSS 收进来的资源不抓。0 = 不抓",
},
{
name: "identity.autoEnrich",
label: "TMDB 查不到时自动补全元数据",
kind: "switch",
hint: "资源命中订阅或新作品规则、TMDB 却查不到时,搜索网页出一份补全建议(每条资源一次,不建作品)。每次最多读 3 个网页,调用一次模型服务",
wide: true,
},
{
name: "identity.enrichDailyBudget",
label: "元数据补全每日上限",
kind: "number",
hint: "手动和自动合计;7 天内同一标题直接用上次结果,不计次。0 = 停用",
},
{
name: "identity.enrichDomains",
label: "补全可读取的网站",
kind: "textarea",
hint: "每行一个域名,含子域名;只放平台和百科类网站。豆瓣只用搜索摘要,不读页面;robots.txt 不允许的页面不读。已登记站点的域名一律不读",
wide: true,
},
{
name: "identity.enrichSnippetAllSites",
label: "也用其它网站的搜索摘要",
kind: "switch",
hint: "短剧多半只有聚合站收录。打开后,白名单以外网站的搜索摘要也用来抽年份、集数、主演、简介(只看搜索引擎给的摘要,不打开这些网页)",
wide: true,
},
],
fromSettings: (s) => ({
"identity.eagerTmdbLookup": s.identity.eagerTmdbLookup,
"identity.autoEnrich": s.identity.autoEnrich,
"identity.enrichDailyBudget": n(s.identity.enrichDailyBudget),
"identity.enrichDomains": s.identity.enrichDomains.join("\n"),
"identity.enrichSnippetAllSites": s.identity.enrichSnippetAllSites ?? true,
"identity.detailIdsPerSiteHour": n(s.identity.detailIdsPerSiteHour ?? 10),
}),
toPatch: (v) => ({
identity: {
eagerTmdbLookup: v["identity.eagerTmdbLookup"] === true,
autoEnrich: v["identity.autoEnrich"] === true,
enrichDailyBudget: Number(v["identity.enrichDailyBudget"]),
enrichDomains: String(v["identity.enrichDomains"] ?? "")
.split(/[\s,,]+/)
.map((d) => d.trim().toLowerCase())
.filter(Boolean),
enrichSnippetAllSites: v["identity.enrichSnippetAllSites"] === true,
detailIdsPerSiteHour: Number(v["identity.detailIdsPerSiteHour"]),
},
}),
extra: () => <EnrichStatsTable />,
};
type StatRow = { kind: string; withResults: number; hits: number; rate: number | null };
/** 最近 30 天补全命中率(按来源类别,META-2) */
function EnrichStatsTable() {
const q = useEnrichStats(30);
const d = q.data;
return (
<div className="mb-3 space-y-2">
<p className="text-meta text-muted-foreground tabular-nums">
{d
? `最近 ${d.days} 天元数据补全 ${d.total} 次,出了建议 ${d.suggested} 次${d.tracked < d.total ? `;有来源明细的 ${d.tracked} 次` : ""}`
: "读取补全命中率…"}
</p>
{d && d.tracked > 0 ? (
<DataTable<StatRow>
rows={d.sources}
rowKey={(r) => r.kind}
empty={{ title: "还没有补全记录" }}
columns={[
{ key: "kind", header: "来源", cell: (r) => ENRICH_SOURCE_KIND[r.kind] ?? r.kind },
{
key: "withResults",
header: "有结果",
align: "right",
cell: (r) => <span className="tabular-nums">{r.withResults}</span>,
},
{
key: "hits",
header: "命中",
align: "right",
cell: (r) => <span className="tabular-nums">{r.hits}</span>,
},
{
key: "rate",
header: "命中率",
align: "right",
cell: (r) => (
<span className="tabular-nums">{r.rate === null ? "—" : `${Math.round(r.rate * 100)}%`}</span>
),
},
]}
/>
) : null}
</div>
);
}
export function AcquireSection() {
return (
<div className="grid grid-cols-1 gap-8">
<SettingsGate>{(s) => <SettingsCard section="acquire" spec={BUDGET_CARD} settings={s} />}</SettingsGate>
<SearchSitesBlock />
<SettingsGate>{(s) => <SettingsCard section="acquire" spec={IDENTITY_CARD} settings={s} />}</SettingsGate>
</div>
);
}
+39 -12
View File
@@ -7,9 +7,10 @@ import type { FieldSpec } from "./resource-section.tsx";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// Agent 分区:上游(pi_upstream)+ 上下文与子代理(pi)+ 用量与限制(单次防跑飞上限 + 花费统计)+ 自动调查 + 网页搜索 + 高级
// 智能体分区:模型服务(pi_upstream)→ 用量与限制(不设预算,只有单次防跑飞上限 + 花费统计)→ 自动处理(识别失败自动派智能体、
// 自动调查)→ 上下文与子会话 → 网页搜索 → MCP 服务端与能力清单(agent-tools.tsx)→ 高级
const RUNTIME_CARD: CardSpec = {
export const RUNTIME_CARD: CardSpec = {
anchor: "runtime",
title: "上下文与子会话",
fields: [
@@ -20,7 +21,26 @@ const RUNTIME_CARD: CardSpec = {
tokens: { min: 4000, max: 2_000_000, required: true },
hint: "4K–2000K,超过模型窗口时按窗口算",
},
{ name: "pi.subagentsEnabled", label: "允许子会话", kind: "switch" },
{
name: "pi.subagentsEnabled",
label: "允许子会话",
kind: "switch",
hint: "智能体可以把独立的小目标交给子会话并行做",
},
{
name: "pi.subagents.maxConcurrent",
label: "同时运行的子会话",
kind: "number",
hint: "一个会话最多同时跑几个子会话;1–8",
visible: (v) => v["pi.subagentsEnabled"] === true,
},
{
name: "pi.subagents.maxDepth",
label: "派生层数",
kind: "number",
hint: "1 表示子会话不能再派生;1–3",
visible: (v) => v["pi.subagentsEnabled"] === true,
},
{ name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" },
{
name: "pi.compaction.thresholdRatio",
@@ -32,7 +52,9 @@ const RUNTIME_CARD: CardSpec = {
],
fromSettings: (s) => ({
"pi.contextBudget": tokensToK(s.pi.contextBudget),
"pi.subagentsEnabled": s.pi.subagentsEnabled,
"pi.subagentsEnabled": s.pi.subagents?.enabled ?? s.pi.subagentsEnabled,
"pi.subagents.maxConcurrent": n(s.pi.subagents?.maxConcurrent),
"pi.subagents.maxDepth": n(s.pi.subagents?.maxDepth),
"pi.compaction.enabled": s.pi.compaction.enabled,
"pi.compaction.thresholdRatio": n(s.pi.compaction.thresholdRatio),
}),
@@ -40,6 +62,11 @@ const RUNTIME_CARD: CardSpec = {
pi: {
contextBudget: kToTokens(str(v["pi.contextBudget"])),
subagentsEnabled: v["pi.subagentsEnabled"] === true,
subagents: {
enabled: v["pi.subagentsEnabled"] === true,
maxConcurrent: num(v["pi.subagents.maxConcurrent"]),
maxDepth: num(v["pi.subagents.maxDepth"]),
},
compaction: {
enabled: v["pi.compaction.enabled"] === true,
thresholdRatio: num(v["pi.compaction.thresholdRatio"]),
@@ -50,7 +77,7 @@ const RUNTIME_CARD: CardSpec = {
// 2026-10-06:不设每日次数 / 每日花费 / 单次花费 / 单次 token 上限(旧值兼容读取、不生效),
// 只保留单次工具调用次数与时长两个防跑飞限制;花费在这里与运行状态里可见
const BUDGET_CARD: CardSpec = {
export const BUDGET_CARD: CardSpec = {
anchor: "budget",
title: "用量与限制",
description: "不限次数和花费。单次运行只限制工具调用次数和时长,防止跑飞。",
@@ -75,10 +102,10 @@ const BUDGET_CARD: CardSpec = {
),
};
const INVESTIGATE_CARD: CardSpec = {
export const INVESTIGATE_CARD: CardSpec = {
anchor: "investigate",
title: "自动调查",
description: "下载完识别不到作品时叫智能体识别;下载失败、集数对不上等问题出现时,自动发起一次有界调查。",
title: "自动处理",
description: "整理时识别不到作品就叫智能体识别;下载失败、集数对不上等问题出现时,自动发起一次有界调查。",
fields: [
{
name: "agent.autoIdentifyOrganize",
@@ -144,13 +171,13 @@ const INVESTIGATE_CARD: CardSpec = {
const DEV_ONLY_FIELDS = new Set(["agent.sessionDir", "agent.fakeScript"]);
const ADVANCED_CARD: CardSpec = {
export const ADVANCED_CARD: CardSpec = {
anchor: "advanced",
title: "高级",
// 会话目录与 fake 脚本是开发调试项,生产构建不显示(DEAI-2)
fields: (
[
{ name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" },
{ name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、整理结束时的检查间隔" },
{
name: "agent.goalWaitHours",
label: "目标最长等待(小时)",
@@ -223,15 +250,15 @@ export function AgentSection() {
<SettingsGate>
{(s) => (
<>
<SettingsCard section="agent" spec={RUNTIME_CARD} settings={s} />
<SettingsCard section="agent" spec={BUDGET_CARD} settings={s} />
<SettingsCard section="agent" spec={INVESTIGATE_CARD} settings={s} />
<SettingsCard section="agent" spec={RUNTIME_CARD} settings={s} />
<SettingsCard section="agent" spec={WEBSEARCH_CARD} settings={s} />
<SettingsCard section="agent" spec={ADVANCED_CARD} settings={s} />
</>
)}
</SettingsGate>
<AgentToolsBlock />
<SettingsGate>{(s) => <SettingsCard section="agent" spec={ADVANCED_CARD} settings={s} />}</SettingsGate>
</div>
);
}
@@ -102,10 +102,10 @@ export function ApiKeysSection() {
return (
<SettingsBlock
section="api-keys"
anchor="keys"
title="已签发的 Key"
description="给媒体服务器插件、脚本等外部程序用,按权限范围授权。本机 Token 在「账户与安全」。"
section="account"
anchor="api-keys"
title="API Key"
description="签发给媒体服务器插件、脚本等外部程序,按权限范围授权。浏览器自用的本机 Token 在上面的「会话」里。"
plain
actions={
<Button size="sm" onClick={() => setOpen(true)}>
@@ -174,7 +174,7 @@ export function BrowserProvidersBlock() {
},
{
name: "accessKey",
label: "访问凭据",
label: "访问凭证",
kind: "secret",
secret: { set: (v) => v["accessKey:set"] === true, clearable: true },
hint: "camofox 的 CAMOFOX_ACCESS_KEY;没设就留空",
+266
View File
@@ -0,0 +1,266 @@
import { cacheRegionLabel } from "@umt/contracts";
import { useState } from "react";
import { toast } from "sonner";
import {
useCacheRegions,
useClearCacheRegion,
useClearImageCache,
useClearTmdbCache,
useImageCache,
useTmdbCache,
} from "~/api/cache.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts";
import { formatBytes } 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";
// 缓存分区:通用缓存(kv_cache)按区统计与清空 + 图片缓存(磁盘)同表展示;TMDB 缓存条数上限、图片缓存开关、各类有效期。
// 清空只删缓存,下次用到时重新请求;不会删作品、资源或文件。
/** 图片缓存在磁盘上,不在 kv_cache 里;表里用这个保留名占一行 */
const IMAGES_ROW = "images";
export type CacheRow = {
region: string;
label: string;
entries: number;
expired: number;
bytes: number;
lastWriteAt: string | null;
};
/** 区名 → 行:有中文名的用中文名,没有的原样显示 region */
export function cacheRow(r: Omit<CacheRow, "label">): CacheRow {
return { ...r, label: cacheRegionLabel(r.region) ?? r.region };
}
function CacheRegionsBlock() {
const regions = useCacheRegions();
const images = useImageCache();
const clearRegion = useClearCacheRegion();
const clearImages = useClearImageCache();
const [target, setTarget] = useState<CacheRow | null>(null);
const rows: CacheRow[] | undefined = regions.data
? [
...regions.data.items.map(cacheRow),
...(images.data
? [
cacheRow({
region: IMAGES_ROW,
entries: images.data.files,
expired: 0,
bytes: images.data.usedBytes,
lastWriteAt: null,
}),
]
: []),
]
: undefined;
const columns: DataColumn<CacheRow>[] = [
{
key: "label",
header: "缓存",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{r.label}</p>
{r.label !== r.region ? <p className="font-mono text-meta text-muted-foreground">{r.region}</p> : null}
</div>
),
},
{
key: "entries",
header: "条数",
align: "right",
cell: (r) => (
<div className="tabular-nums">
{r.entries}
{r.expired > 0 ? <p className="text-meta text-muted-foreground">另有 {r.expired} 条已过期</p> : null}
</div>
),
},
{
key: "bytes",
header: "占用",
align: "right",
cell: (r) => <span className="tabular-nums">{formatBytes(r.bytes, 1)}</span>,
},
{
key: "last",
header: "最近写入",
hideBelow: "md",
cell: (r) => <span className="text-muted-foreground">{formatRelative(r.lastWriteAt)}</span>,
},
{
key: "actions",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (r) => (
<Button
type="button"
variant="ghost"
size="sm"
className="hover:text-destructive"
disabled={r.entries + r.expired === 0}
onClick={() => setTarget(r)}
>
清空
</Button>
),
},
];
return (
<SettingsBlock
section="cache"
anchor="regions"
title="缓存区"
description="清空后下次用到时重新请求,不影响作品、资源和文件。已过期的条目由定时任务清理。"
plain
>
<DataTable<CacheRow>
rows={rows}
rowKey={(r) => r.region}
loading={regions.isPending}
error={regions.error}
onRetry={() => regions.refetch()}
empty={{ title: "还没有缓存", description: "用到 TMDB、豆瓣、站点详情等数据后会出现在这里。" }}
columns={columns}
/>
<ConfirmDialog
open={!!target}
onOpenChange={(o) => !o && setTarget(null)}
title={`清空「${target?.label ?? ""}」?`}
description={`删除 ${target ? target.entries + target.expired : 0} 条缓存,之后用到时重新请求。`}
confirmLabel="清空"
destructive
onConfirm={async () => {
if (!target) return;
try {
const r =
target.region === IMAGES_ROW
? await clearImages.mutateAsync()
: await clearRegion.mutateAsync(target.region);
toast.success(`已清空「${target.label}」,删除 ${r.deleted} 条`);
setTarget(null);
} catch (err) {
toast.error(errorMessage(err, "清空失败"));
throw err;
}
}}
/>
</SettingsBlock>
);
}
// TMDB 缓存(对照 MP:CONF.tmdb 条数;有效期在下面「缓存有效期」)
export const TMDB_CACHE_CARD: CardSpec = {
anchor: "tmdb-cache",
title: "TMDB 缓存",
description: "请求响应与识别结果存在数据库里,三个进程共用。超过条数上限时删最久未用的条目。",
fields: [{ name: "cache.tmdbMaxSize", label: "条数上限", kind: "number", hint: "请求缓存和识别缓存各自计数" }],
fromSettings: (s) => ({ "cache.tmdbMaxSize": n(s.cache.tmdbMaxSize) }),
toPatch: (v) => ({ cache: { tmdbMaxSize: num(v["cache.tmdbMaxSize"]) } }),
extra: () => <TmdbCacheStatus />,
};
function TmdbCacheStatus() {
const q = useTmdbCache();
const clear = useClearTmdbCache();
const c = q.data?.counts;
return (
<div className="mb-2 flex flex-wrap items-center gap-2 text-meta text-muted-foreground">
<span className="tabular-nums">
{c ? `识别 ${c.recognize} 条 · 请求 ${c.request} 条 · 上限各 ${q.data?.maxSize} 条` : "读取中…"}
</span>
<ConfirmDialog
trigger={
<Button type="button" variant="ghost" size="xs" className="hover:text-destructive">
清空
</Button>
}
title="清空 TMDB 缓存?"
description="请求响应和识别结果一起删除,之后用到时重新请求 TMDB。"
confirmLabel="清空"
destructive
onConfirm={async () => {
try {
const r = await clear.mutateAsync();
toast.success(`已清空 ${r.deleted} 条 TMDB 缓存`);
} catch (err) {
toast.error(errorMessage(err, "清空失败"));
throw err;
}
}}
/>
</div>
);
}
// 图片代理与缓存(对照 MP GLOBAL_IMAGE_CACHE / GLOBAL_IMAGE_CACHE_DAYS);用量与清空在上面的缓存区表里
export const IMAGES_CARD: CardSpec = {
anchor: "images",
title: "图片缓存",
description: "海报经服务端从 TMDB 取回。开启缓存后保存到数据目录,下次直接读取;容量上限在「系统 → 存储预算」。",
fields: [
{ name: "cache.globalImageCache", label: "缓存图片", kind: "switch" },
{ name: "cache.globalImageCacheDays", label: "保留天数", kind: "number", hint: "超过天数的图片每天清理一次" },
],
fromSettings: (s) => ({
"cache.globalImageCache": s.cache.globalImageCache,
"cache.globalImageCacheDays": n(s.cache.globalImageCacheDays),
}),
toPatch: (v) => ({
cache: {
globalImageCache: v["cache.globalImageCache"] === true,
globalImageCacheDays: num(v["cache.globalImageCacheDays"]),
},
}),
};
const TTL_KEYS = ["tmdbSearchDays", "tmdbDetailDays", "tmdbMissDays", "siteDetailDays", "webSearchDays"] as const;
const ttlField = (k: string, label: string, hint?: string) => ({
name: `storage.cacheTtl.${k}`,
label,
kind: "number" as const,
hint,
});
export const TTL_CARD: CardSpec = {
anchor: "ttl",
title: "缓存有效期",
description: "单位天。追踪中的作品命中 TMDB 详情缓存时自动续期。",
fields: [
ttlField("tmdbSearchDays", "TMDB 搜索与识别结果"),
ttlField("tmdbDetailDays", "TMDB 详情"),
ttlField("tmdbMissDays", "TMDB 空结果", "搜索无结果、未识别时的负缓存"),
ttlField("siteDetailDays", "站点详情页内容"),
ttlField("webSearchDays", "网页搜索结果"),
],
fromSettings: (s) => Object.fromEntries(TTL_KEYS.map((k) => [`storage.cacheTtl.${k}`, n(s.storage.cacheTtl[k])])),
toPatch: (v) => ({
storage: { cacheTtl: Object.fromEntries(TTL_KEYS.map((k) => [k, num(v[`storage.cacheTtl.${k}`])])) },
}),
};
export function CacheSection() {
return (
<div className="grid grid-cols-1 gap-8">
<CacheRegionsBlock />
<SettingsGate>
{(s) => (
<>
<SettingsCard section="cache" spec={TMDB_CACHE_CARD} settings={s} />
<SettingsCard section="cache" spec={IMAGES_CARD} settings={s} />
<SettingsCard section="cache" spec={TTL_CARD} settings={s} />
</>
)}
</SettingsGate>
</div>
);
}
@@ -0,0 +1,68 @@
import { DownloadersTable } from "./instances.tsx";
import { num } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 下载器分区:qB 实例(抽屉里有路径映射和本实例的水位 / 保种 / H&R)+ 下载盘水位与保种天数的缺省值。
// 实例上留空的项用这里的缺省值,所以两处用同一组名字:暂停线、恢复线、清理目标、保种天数。
export 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"]),
},
}),
};
export const SEEDING_CARD: CardSpec = {
anchor: "seeding",
title: "保种",
description: "复制整理后,下载源留在下载盘继续做种。站点的 H&R 要求另外遵守;实例里可以单独设置。",
fields: [
{
name: "retention.seedRetentionDays",
label: "保种天数",
kind: "number",
hint: "到期后才允许清理;0–365",
},
],
fromSettings: (s) => ({ "retention.seedRetentionDays": n(s.retention.seedRetentionDays) }),
toPatch: (v) => ({ retention: { seedRetentionDays: num(v["retention.seedRetentionDays"]) } }),
};
export function DownloadersSection() {
return (
<div className="grid grid-cols-1 gap-8">
<SettingsBlock section="downloaders" anchor="instances" title="qBittorrent 实例" plain>
<DownloadersTable />
</SettingsBlock>
<SettingsGate>
{(s) => (
<>
<SettingsCard section="downloaders" spec={CACHE_CARD} settings={s} />
<SettingsCard section="downloaders" spec={SEEDING_CARD} settings={s} />
</>
)}
</SettingsGate>
</div>
);
}
+9 -17
View File
@@ -82,15 +82,7 @@ function authKindOf(r: DownloaderInstance): string {
return r.username ? "password" : r.secretRef ? "api_key" : "none";
}
export function DownloadersSection() {
return (
<SettingsBlock section="downloaders" anchor="instances" title="qBittorrent 实例" plain>
<DownloadersTable />
</SettingsBlock>
);
}
/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用全局 */
/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用「下载器」分区的缺省值 */
const POLICY_FIELDS = [
["hardPct", "hardThresholdPercent"],
["resumePct", "resumePercent"],
@@ -114,7 +106,7 @@ function policyBody(v: FormValues): Record<string, number | boolean> {
return out;
}
function DownloadersTable() {
export function DownloadersTable() {
const list = downloaders.useList();
const create = downloaders.useCreate();
const update = downloaders.useUpdate();
@@ -192,19 +184,19 @@ function DownloadersTable() {
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
{
name: "hardPct",
label: "硬阈值(%)",
label: "暂停线(%)",
kind: "number",
placeholder: "留空用全局",
hint: "这个下载器的缓存盘用到多少就停止新增下载;多个下载器共用一块盘时取最严格的",
placeholder: "留空用缺省值",
hint: "这个下载器的下载盘用到多少就停止新增下载;多个下载器共用一块盘时取最严格的",
},
{ name: "resumePct", label: "恢复水位(%)", kind: "number", placeholder: "留空用全局" },
{ name: "targetPct", label: "清理目标(%)", kind: "number", placeholder: "留空用全局" },
{ name: "resumePct", label: "恢复线(%)", kind: "number", placeholder: "留空用缺省值" },
{ name: "targetPct", label: "清理目标(%)", kind: "number", placeholder: "留空用缺省值" },
{
name: "seedDays",
label: "保种天数",
kind: "number",
placeholder: "留空用全局",
hint: "入库后至少保留几天再允许清理;公网 BT 可填 0(站点 H&R 要求仍然遵守)",
placeholder: "留空用缺省值",
hint: "整理后至少保留几天再允许清理;公网 BT 可填 0(站点 H&R 要求仍然遵守)",
},
{
name: "hnrUnknownCleanable",
+16 -2
View File
@@ -1,10 +1,23 @@
import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { setUiPref, useUiPrefs } from "~/lib/ui-prefs.ts";
import { SettingsBlock } from "./settings-block.tsx";
import { anchorId, SettingsBlock } from "./settings-block.tsx";
// 界面:本机显示偏好(存浏览器,不进 settings 表)
// 界面:本机显示偏好(存浏览器,不进 settings 表)与快捷键说明
export function InterfaceSection() {
return (
<div className="grid grid-cols-1 gap-8">
<DisplayBlock />
{/* 快捷键说明自带标题,只包卡片与锚点 */}
<section id={anchorId("interface", "shortcuts")} className="pg-slab min-w-0 scroll-mt-20 rounded-xl p-5 sm:p-6">
<AgentShortcutsHelp />
</section>
</div>
);
}
function DisplayBlock() {
const prefs = useUiPrefs();
return (
<SettingsBlock section="interface" anchor="display" title="显示">
@@ -18,6 +31,7 @@ export function InterfaceSection() {
</p>
</div>
<Switch
id="f-ui.techInfoExpanded"
aria-labelledby="ui-techinfo-label"
checked={prefs.techInfoExpanded}
onCheckedChange={(v) => setUiPref("techInfoExpanded", v)}
+10 -95
View File
@@ -1,110 +1,25 @@
import { LibraryRootsTable } from "./instances.tsx";
import { NamingSection } from "./naming.tsx";
import { ClassificationSection, OrganizeSourcesSection } from "./organize-sources.tsx";
import { str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式 + 刮削 + 命名模板
const ORGANIZE_CARD: CardSpec = {
anchor: "organize",
title: "整理方式",
description: "复制入库时源文件留在缓存继续做种,保留天数在自动化分区设置。",
fields: [
{
name: "organize.defaultMode",
label: "默认整理方式",
kind: "select",
options: [
{ value: "copy", label: "复制(源文件继续做种)" },
{ value: "move", label: "移动(移走源文件)" },
],
},
{
name: "organize.allowCrossVolumeMove",
label: "允许跨卷移动",
kind: "switch",
hint: "跨卷时按复制 → 核验 → 删源执行",
},
],
fromSettings: (s) => ({
"organize.defaultMode": s.organize.defaultMode,
"organize.allowCrossVolumeMove": s.organize.allowCrossVolumeMove,
}),
toPatch: (v) => ({
organize: {
defaultMode: str(v["organize.defaultMode"]) as "copy" | "move",
allowCrossVolumeMove: v["organize.allowCrossVolumeMove"] === true,
},
}),
};
// SCR-1 刮削:入库后写 NFO 与图片到媒体库目录(Emby / Jellyfin / Kodi 读取)
const SCRAPE_CARD: CardSpec = {
anchor: "scrape",
title: "刮削",
description: "把作品信息写成 NFO,连同海报、背景图放进媒体库目录,Emby、Jellyfin、Kodi 直接读取。",
fields: [
{
name: "organize.scrapeAfterImport",
label: "入库后自动刮削",
kind: "switch",
hint: "关闭时可在作品详情或文件管理里手动刮削",
},
{
name: "organize.scrapeMode",
label: "已有文件",
kind: "select",
options: [
{ value: "update", label: "更新" },
{ value: "missing", label: "只补缺失" },
{ value: "overwrite", label: "全部覆盖" },
],
hint: "更新只替换本工具写过、之后没被改过的文件;全部覆盖会替换目录里已有的 NFO 和图片,包括其他工具写的",
},
{
name: "organize.scrapeImages",
label: "下载图片",
kind: "switch",
hint: "海报、背景图、季海报",
},
{
name: "organize.scrapeEpisodeThumbs",
label: "下载剧照",
kind: "switch",
hint: "每集一张,存为「视频名-thumb.jpg」;关闭下载图片时不生效",
},
],
fromSettings: (s) => ({
"organize.scrapeAfterImport": s.organize.scrapeAfterImport,
"organize.scrapeMode": s.organize.scrapeMode,
"organize.scrapeImages": s.organize.scrapeImages,
"organize.scrapeEpisodeThumbs": s.organize.scrapeEpisodeThumbs,
}),
toPatch: (v) => ({
organize: {
scrapeAfterImport: v["organize.scrapeAfterImport"] === true,
scrapeMode: scrapeModeOf(str(v["organize.scrapeMode"])),
scrapeImages: v["organize.scrapeImages"] === true,
scrapeEpisodeThumbs: v["organize.scrapeEpisodeThumbs"] === true,
},
}),
};
const scrapeModeOf = (v: string | null | undefined): "missing" | "overwrite" | "update" =>
v === "overwrite" || v === "missing" ? v : "update";
// 媒体库与目录:媒体库目录(含标记文件检查)、整理来源、分类规则、命名模板(全局 + 目录覆盖)。
// 整理方式与刮削在「整理与刮削」。
export function LibrarySection() {
return (
<div className="grid grid-cols-1 gap-8">
<SettingsBlock section="library" anchor="roots" title="媒体库目录" plain>
<SettingsBlock
section="library"
anchor="roots"
title="媒体库目录"
description="整理把作品放进这些目录;媒体服务器扫的也是它们。"
plain
>
<LibraryRootsTable />
</SettingsBlock>
<SettingsGate>{(s) => <SettingsCard section="library" spec={ORGANIZE_CARD} settings={s} />}</SettingsGate>
<SettingsGate>{(s) => <SettingsCard section="library" spec={SCRAPE_CARD} settings={s} />}</SettingsGate>
<ClassificationSection />
<OrganizeSourcesSection />
<ClassificationSection />
<NamingSection />
</div>
);
+30 -243
View File
@@ -1,16 +1,9 @@
import { DEFAULT_SETTINGS } from "@umt/contracts";
import { toast } from "sonner";
import { useClearImageCache, useClearTmdbCache, useImageCache, useTmdbCache } from "~/api/cache.ts";
import { useEnrichStats } from "~/api/identity.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { ENRICH_SOURCE_KIND } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { secretPatch } from "~/lib/secret.ts";
import { str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 元数据:TMDB API Key(只写,存 secret)、Base URL、语言回退链
// 元数据:TMDB 接入(API Key 只写,存 secret;Base URL)与语言回退、特别篇。缓存在「缓存」分区,资源识别在「获取」分区。
const DEFAULT_LANGUAGES = DEFAULT_SETTINGS.metadata.languages;
@@ -28,7 +21,7 @@ function parseLanguages(v: string): string[] {
return out.length ? [...new Set(out)].slice(0, 8) : [...DEFAULT_LANGUAGES];
}
const TMDB_CARD: CardSpec = {
export const TMDB_CARD: CardSpec = {
anchor: "tmdb",
title: "TMDB",
description: "留空时用环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。",
@@ -49,6 +42,28 @@ const TMDB_CARD: CardSpec = {
hint: "自建反代时填写;留空用官方地址",
wide: true,
},
],
fromSettings: (s) => ({
"metadata.tmdbApiKey": "",
"metadata.tmdbApiKey:set": !!s.metadata.tmdbApiKeySecretRef,
"metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
}),
toPatch: (v) => {
const key = secretPatch(v["metadata.tmdbApiKey"]);
return {
metadata: {
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
...(key !== undefined ? { tmdbApiKey: key } : {}),
},
};
},
};
export const LANGUAGE_CARD: CardSpec = {
anchor: "language",
title: "语言与季集",
description: "作品、季、集的标题和简介按语言回退链取值;特别篇决定是否拉取季 0。",
fields: [
{
name: "metadata.languages",
label: "语言回退链",
@@ -73,254 +88,26 @@ const TMDB_CARD: CardSpec = {
},
],
fromSettings: (s) => ({
"metadata.tmdbApiKey": "",
"metadata.tmdbApiKey:set": !!s.metadata.tmdbApiKeySecretRef,
"metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
"metadata.languages": (s.metadata.languages ?? DEFAULT_LANGUAGES).join(", "),
"metadata.includeSpecials": s.metadata.includeSpecials !== false,
"metadata.overviewNonChineseFallback": s.metadata.overviewNonChineseFallback === true,
}),
toPatch: (v) => {
const key = secretPatch(v["metadata.tmdbApiKey"]);
return {
metadata: {
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
languages: parseLanguages(str(v["metadata.languages"])),
includeSpecials: v["metadata.includeSpecials"] === true,
overviewNonChineseFallback: v["metadata.overviewNonChineseFallback"] === true,
...(key !== undefined ? { tmdbApiKey: key } : {}),
},
};
},
};
// TMDB 缓存(对照 MP:META_CACHE_EXPIRE / CONF.tmdb / EMPTY_RESULT_CACHE_TTL)
const TMDB_CACHE_CARD: CardSpec = {
anchor: "tmdb-cache",
title: "TMDB 缓存",
description:
"请求响应与识别结果存在数据库里,三个进程共用。超过条数上限时删最久未用的条目;有效期在「自动化 → 存储与保留」里设。",
fields: [{ name: "cache.tmdbMaxSize", label: "条数上限", kind: "number", hint: "请求缓存和识别缓存各自计数" }],
fromSettings: (s) => ({
"cache.tmdbMaxSize": n(s.cache.tmdbMaxSize),
}),
toPatch: (v) => ({
cache: {
tmdbMaxSize: Number(v["cache.tmdbMaxSize"]),
metadata: {
languages: parseLanguages(str(v["metadata.languages"])),
includeSpecials: v["metadata.includeSpecials"] === true,
overviewNonChineseFallback: v["metadata.overviewNonChineseFallback"] === true,
},
}),
extra: () => <TmdbCacheStatus />,
};
function TmdbCacheStatus() {
const q = useTmdbCache();
const clear = useClearTmdbCache();
const c = q.data?.counts;
return (
<div className="mb-2 flex flex-wrap items-center gap-2 text-meta text-muted-foreground">
<span className="tabular-nums">
{c ? `识别 ${c.recognize} 条 · 请求 ${c.request} 条 · 上限各 ${q.data?.maxSize} 条` : "读取中…"}
</span>
<Button
type="button"
variant="ghost"
size="xs"
className="hover:text-destructive"
disabled={clear.isPending}
onClick={async () => {
try {
const r = await clear.mutateAsync();
toast.success(`已清空 ${r.deleted} 条 TMDB 缓存`);
} catch (err) {
toast.error(errorMessage(err, "清空失败"));
}
}}
>
清空
</Button>
</div>
);
}
// 图片代理与缓存(对照 MP GLOBAL_IMAGE_CACHE / GLOBAL_IMAGE_CACHE_DAYS)
const IMAGES_CARD: CardSpec = {
anchor: "images",
title: "图片代理与缓存",
description: "海报经服务端从 TMDB 取回。开启缓存后保存到数据目录,下次直接读取。",
fields: [
{ name: "cache.globalImageCache", label: "缓存图片", kind: "switch" },
{ name: "cache.globalImageCacheDays", label: "保留天数", kind: "number", hint: "超过天数的图片每天清理一次" },
],
fromSettings: (s) => ({
"cache.globalImageCache": s.cache.globalImageCache,
"cache.globalImageCacheDays": n(s.cache.globalImageCacheDays),
}),
toPatch: (v) => ({
cache: {
globalImageCache: v["cache.globalImageCache"] === true,
globalImageCacheDays: Number(v["cache.globalImageCacheDays"]),
},
}),
extra: () => <ImageCacheStatus />,
};
function ImageCacheStatus() {
const q = useImageCache();
const clear = useClearImageCache();
const d = q.data;
return (
<div className="mb-2 flex flex-wrap items-center gap-2 text-meta text-muted-foreground">
<span className="tabular-nums">
{d ? `${d.files} 张 · ${(d.usedBytes / 1024 / 1024).toFixed(1)} MiB` : "读取中…"}
</span>
<Button
type="button"
variant="ghost"
size="xs"
className="hover:text-destructive"
disabled={clear.isPending}
onClick={async () => {
try {
const r = await clear.mutateAsync();
toast.success(`已删除 ${r.deleted} 张缓存图片`);
} catch (err) {
toast.error(errorMessage(err, "清空失败"));
}
}}
>
清空
</Button>
</div>
);
}
// 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)
const IDENTITY_CARD: CardSpec = {
anchor: "identity",
title: "资源识别",
description: "默认只在资源命中订阅、作品别名或新作品规则,或你手动识别和搜索时查 TMDB;其余放进未识别,不进待处理。",
fields: [
{
name: "identity.eagerTmdbLookup",
label: "每条新资源都查 TMDB",
kind: "switch",
hint: "每条新资源都查一次 TMDB。RSS 量大时每天可能上千次请求,站点独有的短剧和综艺多半查不到",
wide: true,
},
{
name: "identity.detailIdsPerSiteHour",
label: "识别时每站每小时最多抓详情页",
kind: "number",
hint: "标题认不准时,抓站点详情页取 IMDb、TMDB 或豆瓣 ID 再识别;只在搜索、订阅相关资源、补全和手动重新识别时抓,RSS 入库不抓。0 = 不抓",
},
{
name: "identity.autoEnrich",
label: "TMDB 查不到时自动补全元数据",
kind: "switch",
hint: "资源命中订阅或新作品规则、TMDB 却查不到时,搜索网页出一份补全建议(每条资源一次,不建作品)。每次最多读 3 个网页,调用一次模型服务",
wide: true,
},
{
name: "identity.enrichDailyBudget",
label: "元数据补全每日上限",
kind: "number",
hint: "手动和自动合计;7 天内同一标题直接用上次结果,不计次。0 = 停用",
},
{
name: "identity.enrichDomains",
label: "补全可读取的网站",
kind: "textarea",
hint: "每行一个域名,含子域名;只放平台和百科类网站。豆瓣只用搜索摘要,不读页面;robots.txt 不允许的页面不读。已登记站点的域名一律不读",
wide: true,
},
{
name: "identity.enrichSnippetAllSites",
label: "也用其它网站的搜索摘要",
kind: "switch",
hint: "短剧多半只有聚合站收录。打开后,白名单以外网站的搜索摘要也用来抽年份、集数、主演、简介(只看搜索引擎给的摘要,不打开这些网页)",
wide: true,
},
],
fromSettings: (s) => ({
"identity.eagerTmdbLookup": s.identity.eagerTmdbLookup,
"identity.autoEnrich": s.identity.autoEnrich,
"identity.enrichDailyBudget": n(s.identity.enrichDailyBudget),
"identity.enrichDomains": s.identity.enrichDomains.join("\n"),
"identity.enrichSnippetAllSites": s.identity.enrichSnippetAllSites ?? true,
"identity.detailIdsPerSiteHour": n(s.identity.detailIdsPerSiteHour ?? 10),
}),
toPatch: (v) => ({
identity: {
eagerTmdbLookup: v["identity.eagerTmdbLookup"] === true,
autoEnrich: v["identity.autoEnrich"] === true,
enrichDailyBudget: Number(v["identity.enrichDailyBudget"]),
enrichDomains: String(v["identity.enrichDomains"] ?? "")
.split(/[\s,,]+/)
.map((d) => d.trim().toLowerCase())
.filter(Boolean),
enrichSnippetAllSites: v["identity.enrichSnippetAllSites"] === true,
detailIdsPerSiteHour: Number(v["identity.detailIdsPerSiteHour"]),
},
}),
extra: () => <EnrichStatsTable />,
};
type StatRow = { kind: string; withResults: number; hits: number; rate: number | null };
/** 最近 30 天补全命中率(按来源类别,META-2) */
function EnrichStatsTable() {
const q = useEnrichStats(30);
const d = q.data;
return (
<div className="mb-3 space-y-2">
<p className="text-meta text-muted-foreground tabular-nums">
{d
? `最近 ${d.days} 天元数据补全 ${d.total} 次,出了建议 ${d.suggested} 次${d.tracked < d.total ? `;有来源明细的 ${d.tracked} 次` : ""}`
: "读取补全命中率…"}
</p>
{d && d.tracked > 0 ? (
<DataTable<StatRow>
rows={d.sources}
rowKey={(r) => r.kind}
empty={{ title: "还没有补全记录" }}
columns={[
{ key: "kind", header: "来源", cell: (r) => ENRICH_SOURCE_KIND[r.kind] ?? r.kind },
{
key: "withResults",
header: "有结果",
align: "right",
cell: (r) => <span className="tabular-nums">{r.withResults}</span>,
},
{
key: "hits",
header: "命中",
align: "right",
cell: (r) => <span className="tabular-nums">{r.hits}</span>,
},
{
key: "rate",
header: "命中率",
align: "right",
cell: (r) => (
<span className="tabular-nums">{r.rate === null ? "—" : `${Math.round(r.rate * 100)}%`}</span>
),
},
]}
/>
) : null}
</div>
);
}
export function MetadataSection() {
return (
<SettingsGate>
{(s) => (
<>
<SettingsCard section="metadata" spec={TMDB_CARD} settings={s} />
<SettingsCard section="metadata" spec={TMDB_CACHE_CARD} settings={s} />
<SettingsCard section="metadata" spec={IDENTITY_CARD} settings={s} />
<SettingsCard section="metadata" spec={IMAGES_CARD} settings={s} />
<SettingsCard section="metadata" spec={LANGUAGE_CARD} settings={s} />
</>
)}
</SettingsGate>
+3 -3
View File
@@ -52,7 +52,7 @@ function GlobalNaming() {
const r = await save.mutateAsync({ ...draft, lockFileNameForBound: lock });
setDraft({ movie: r.movie, tv: r.tv, variety: r.variety });
setLock(r.lockFileNameForBound);
toast.success("命名模板已保存");
toast.success("已保存「命名模板」");
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
@@ -120,7 +120,7 @@ function RootNaming() {
<SettingsBlock
section="library"
anchor="naming-roots"
title="媒体库目录命名覆盖"
title="目录命名覆盖"
description="按类型覆盖全局模板;留空的类型沿用全局。"
>
{roots.isPending ? (
@@ -177,7 +177,7 @@ function RootNamingEditor({ root }: { root: LibraryRoot }) {
try {
await save.mutateAsync({ id: root.id, naming: body });
setDraft({ ...(body ?? {}) });
toast.success(body ? "媒体库目录命名覆盖已保存" : "已清除媒体库目录命名覆盖");
toast.success(body ? "已保存「目录命名覆盖」" : "已清除「目录命名覆盖」");
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
@@ -85,22 +85,25 @@ export function ClassificationSection() {
2020-2025 范围)。
</p>
<SaveBar dirty={dirty}>
<Button variant="outline" disabled={!dirty || save.isPending} onClick={() => setDraft(null)}>
撤销
</Button>
{dirty ? (
<Button variant="ghost" size="sm" disabled={save.isPending} onClick={() => setDraft(null)}>
还原
</Button>
) : null}
<Button
size="sm"
disabled={!dirty || !yaml.trim() || save.isPending}
onClick={() =>
save.mutate(yaml, {
onSuccess: () => {
setDraft(null);
toast.success("分类规则已保存");
toast.success("已保存「分类规则」");
},
onError: (e) => toast.error(filesErrorText(e, "保存分类规则失败")),
})
}
>
{save.isPending ? "保存中…" : "保存规则"}
{save.isPending ? "保存中…" : "保存"}
</Button>
</SaveBar>
</SettingsBlock>
@@ -219,7 +222,7 @@ function SourceEditor({ initial, onClose }: { initial: OrganizeSourceView | null
onChange={(e) => patch({ action: e.target.value as "copy" | "move" })}
>
<option value="copy">复制,保留源文件</option>
<option value="move">移动,入库后删源</option>
<option value="move">移动,整理后删源</option>
</select>
</Field>
<Field label="触发方式">
@@ -0,0 +1,120 @@
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 整理与刮削分区:整理方式(复制 / 移动、跨卷、最小视频体积)与刮削(整理后自动刮削、已有文件的覆盖方式、图片)。
// 放哪个目录、怎么命名在「媒体库与目录」。
const MiB = 1024 ** 2;
export const ORGANIZE_CARD: CardSpec = {
anchor: "mode",
title: "整理方式",
description: "复制整理时源文件留在下载盘继续做种,保种天数在「下载器」里设置。",
fields: [
{
name: "organize.defaultMode",
label: "默认整理方式",
kind: "select",
options: [
{ value: "copy", label: "复制(源文件继续做种)" },
{ value: "move", label: "移动(移走源文件)" },
],
},
{
name: "organize.allowCrossVolumeMove",
label: "允许跨卷移动",
kind: "switch",
hint: "跨卷时按复制 → 核验 → 删源执行",
},
{
name: "organize.minSizeMiB",
label: "最小视频体积(MiB)",
kind: "number",
hint: "整理和文件管理识别时,小于这个体积的视频当作样片跳过;整理向导里单独填的值优先",
},
],
fromSettings: (s) => ({
"organize.defaultMode": s.organize.defaultMode,
"organize.allowCrossVolumeMove": s.organize.allowCrossVolumeMove,
"organize.minSizeMiB": n(Math.round((s.organize.minSizeBytes / MiB) * 100) / 100),
}),
toPatch: (v) => {
const mib = num(v["organize.minSizeMiB"]);
return {
organize: {
defaultMode: str(v["organize.defaultMode"]) as "copy" | "move",
allowCrossVolumeMove: v["organize.allowCrossVolumeMove"] === true,
minSizeBytes: mib === undefined ? undefined : Math.round(mib * MiB),
},
};
},
};
// SCR-1 刮削:整理后写 NFO 与图片到媒体库目录(Emby / Jellyfin / Kodi 读取)
export const SCRAPE_CARD: CardSpec = {
anchor: "scrape",
title: "刮削",
description:
"把作品信息写成 NFO,连同海报、背景图放进媒体库目录,Emby、Jellyfin、Kodi 直接读取。作品详情和文件管理里也能手动刮削。",
fields: [
{
name: "organize.scrapeAfterImport",
label: "整理后自动刮削",
kind: "switch",
hint: "只对认出作品(有 TMDB ID 或本地元数据)的整理排刮削;没刮削的在整理记录上标「未刮削」",
},
{
name: "organize.scrapeMode",
label: "已有文件",
kind: "select",
options: [
{ value: "update", label: "更新" },
{ value: "missing", label: "只补缺失" },
{ value: "overwrite", label: "全部覆盖" },
],
hint: "更新只替换本工具写过、之后没被改过的文件;全部覆盖会替换目录里已有的 NFO 和图片,包括其他工具写的",
},
{
name: "organize.scrapeImages",
label: "下载图片",
kind: "switch",
hint: "海报、背景图、季海报",
},
{
name: "organize.scrapeEpisodeThumbs",
label: "下载剧照",
kind: "switch",
hint: "每集一张,存为「视频名-thumb.jpg」;关闭下载图片时不生效",
},
],
fromSettings: (s) => ({
"organize.scrapeAfterImport": s.organize.scrapeAfterImport,
"organize.scrapeMode": s.organize.scrapeMode,
"organize.scrapeImages": s.organize.scrapeImages,
"organize.scrapeEpisodeThumbs": s.organize.scrapeEpisodeThumbs,
}),
toPatch: (v) => ({
organize: {
scrapeAfterImport: v["organize.scrapeAfterImport"] === true,
scrapeMode: scrapeModeOf(str(v["organize.scrapeMode"])),
scrapeImages: v["organize.scrapeImages"] === true,
scrapeEpisodeThumbs: v["organize.scrapeEpisodeThumbs"] === true,
},
}),
};
const scrapeModeOf = (v: string | null | undefined): "missing" | "overwrite" | "update" =>
v === "overwrite" || v === "missing" ? v : "update";
export function OrganizeSection() {
return (
<SettingsGate>
{(s) => (
<>
<SettingsCard section="organize" spec={ORGANIZE_CARD} settings={s} />
<SettingsCard section="organize" spec={SCRAPE_CARD} settings={s} />
</>
)}
</SettingsGate>
);
}
@@ -174,7 +174,7 @@ function UpstreamDrawer({
try {
await update.mutateAsync({ id: upstream.id, body: parsed.data });
setV((s) => ({ ...s, apiKey: "" }));
toast.success(`${v.name.trim()} 已保存`);
toast.success(`已保存「${v.name.trim()}」`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
@@ -0,0 +1,128 @@
import { SETTINGS_SECTIONS, type SettingsSectionId } from "@umt/contracts";
import { BUDGET_CARD, IDENTITY_CARD } from "./acquire.tsx";
import { ADVANCED_CARD, BUDGET_CARD as AGENT_BUDGET_CARD, INVESTIGATE_CARD, RUNTIME_CARD } from "./agent.tsx";
import { WEBSEARCH_CARD } from "./agent-websearch.tsx";
import { IMAGES_CARD, TMDB_CACHE_CARD, TTL_CARD } from "./cache.tsx";
import { CACHE_CARD, SEEDING_CARD } from "./downloaders.tsx";
import { LANGUAGE_CARD, TMDB_CARD } from "./metadata.tsx";
import { ORGANIZE_CARD, SCRAPE_CARD } from "./organize.tsx";
import type { CardSpec } from "./settings-card.tsx";
import { DEFAULTS_CARD, NETWORK_CARD } from "./sites.tsx";
import { STORAGE_BUDGET_CARD, STORAGE_RETENTION_CARD, WORKER_LANES_CARD } from "./system.tsx";
// 设置页搜索:分区名、节名、节的别名(contracts keywords)、各节表单字段名。
// 选中字段时页面滚到该节并聚焦字段(控件 id = `f-<字段名>`)。
/** 表单卡片 → 所在分区;新增卡片时在这里登记,测试会核对锚点存在且字段都在页面上 */
export const SETTINGS_CARDS: readonly [SettingsSectionId, CardSpec][] = [
["downloaders", CACHE_CARD],
["downloaders", SEEDING_CARD],
["sites", DEFAULTS_CARD],
["sites", NETWORK_CARD],
["metadata", TMDB_CARD],
["metadata", LANGUAGE_CARD],
["acquire", BUDGET_CARD],
["acquire", IDENTITY_CARD],
["organize", ORGANIZE_CARD],
["organize", SCRAPE_CARD],
["agent", AGENT_BUDGET_CARD],
["agent", INVESTIGATE_CARD],
["agent", RUNTIME_CARD],
["agent", WEBSEARCH_CARD],
["agent", ADVANCED_CARD],
["system", WORKER_LANES_CARD],
["system", STORAGE_BUDGET_CARD],
["system", STORAGE_RETENTION_CARD],
["cache", TMDB_CACHE_CARD],
["cache", IMAGES_CARD],
["cache", TTL_CARD],
];
export type SettingsSearchHit = {
key: string;
/** 命中的名字(字段名 / 节名 / 分区名) */
label: string;
section: SettingsSectionId;
anchor: string | null;
/** 表单字段名(控件 id 为 `f-<field>`) */
field: string | null;
/** 位置:分区 › 节 */
where: string;
};
type Entry = SettingsSearchHit & { terms: string[] };
function buildIndex(): Entry[] {
const out: Entry[] = [];
for (const s of SETTINGS_SECTIONS) {
out.push({
key: s.id,
label: s.label,
section: s.id,
anchor: null,
field: null,
where: s.description,
terms: [s.label],
});
for (const a of s.anchors) {
out.push({
key: `${s.id}/${a.id}`,
label: a.label,
section: s.id,
anchor: a.id,
field: null,
where: s.label,
terms: [a.label, ...(a.keywords ?? [])],
});
}
}
for (const [sid, card] of SETTINGS_CARDS) {
const s = SETTINGS_SECTIONS.find((x) => x.id === sid);
const a = s?.anchors.find((x) => x.id === card.anchor);
for (const f of card.fields) {
out.push({
key: `${sid}/${card.anchor}/${f.name}`,
label: f.label,
section: sid,
anchor: card.anchor,
field: f.name,
where: `${s?.label ?? sid} › ${a?.label ?? card.title}`,
terms: [f.label],
});
}
}
return out;
}
let INDEX: Entry[] | null = null;
const norm = (v: string) => v.toLowerCase().replace(/[\s()()「」::、,,]/g, "");
/** 按名字找设置:名字开头命中排前,其次包含;同一位置只留一条 */
export function searchSettings(query: string, limit = 8): SettingsSearchHit[] {
const q = norm(query);
if (!q) return [];
INDEX ??= buildIndex();
const scored: [number, Entry][] = [];
for (const e of INDEX) {
let best = -1;
for (const [i, t] of e.terms.entries()) {
const n = norm(t);
const score = n === q ? 3 : n.startsWith(q) ? 2 : n.includes(q) ? 1 : -1;
// 别名命中比名字本身命中低一档
const adj = score < 0 ? -1 : i === 0 ? score + 0.5 : score;
best = Math.max(best, adj);
}
if (best >= 0) scored.push([best, e]);
}
scored.sort((a, b) => b[0] - a[0]);
const seen = new Set<string>();
const hits: SettingsSearchHit[] = [];
for (const [, { terms: _t, ...hit }] of scored) {
if (seen.has(hit.key)) continue;
seen.add(hit.key);
hits.push(hit);
if (hits.length >= limit) break;
}
return hits;
}
+42 -20
View File
@@ -1,51 +1,73 @@
import { SETTINGS_SECTIONS, type SettingsSectionDef, type SettingsSectionId } from "@umt/contracts";
import {
resolveSettingsHash,
SETTINGS_GROUPS,
SETTINGS_SECTIONS,
type SettingsSectionDef,
type SettingsSectionId,
} from "@umt/contracts";
import {
Bot,
Database,
Download,
FolderTree,
KeyRound,
Globe,
type LucideIcon,
Monitor,
Server,
Search,
Settings2,
Shield,
Tags,
Tv,
UserCog,
Zap,
Wand2,
} from "lucide-react";
import type { ReactNode } from "react";
import { AccountSection } from "./account.tsx";
import { AcquireSection } from "./acquire.tsx";
import { AgentSection } from "./agent.tsx";
import { ApiKeysSection } from "./api-keys.tsx";
import { AutomationSection } from "./automation.tsx";
import { DownloadersSection, MediaServersSection } from "./instances.tsx";
import { CacheSection } from "./cache.tsx";
import { DownloadersSection } from "./downloaders.tsx";
import { MediaServersSection } from "./instances.tsx";
import { InterfaceSection } from "./interface.tsx";
import { LibrarySection } from "./library.tsx";
import { MetadataSection } from "./metadata.tsx";
import { OrganizeSection } from "./organize.tsx";
import { SitesSection } from "./sites.tsx";
import { SystemSection } from "./system.tsx";
// 设置分区:文案与字段归属在 contracts 的 SETTINGS_SECTIONS,这里只挂图标与渲染
// 设置分区:文案、分组与字段归属在 contracts 的 SETTINGS_SECTIONS,这里只挂图标与渲染
const UI: Record<SettingsSectionId, { icon: LucideIcon; render: () => ReactNode }> = {
automation: { icon: Zap, render: () => <AutomationSection /> },
sites: { icon: UserCog, render: () => <SitesSection /> },
downloaders: { icon: Server, render: () => <DownloadersSection /> },
library: { icon: FolderTree, render: () => <LibrarySection /> },
downloaders: { icon: Download, render: () => <DownloadersSection /> },
"media-servers": { icon: Tv, render: () => <MediaServersSection /> },
sites: { icon: Globe, render: () => <SitesSection /> },
metadata: { icon: Tags, render: () => <MetadataSection /> },
acquire: { icon: Search, render: () => <AcquireSection /> },
library: { icon: FolderTree, render: () => <LibrarySection /> },
organize: { icon: Wand2, render: () => <OrganizeSection /> },
agent: { icon: Bot, render: () => <AgentSection /> },
"api-keys": { icon: KeyRound, render: () => <ApiKeysSection /> },
interface: { icon: Monitor, render: () => <InterfaceSection /> },
system: { icon: Settings2, render: () => <SystemSection /> },
cache: { icon: Database, render: () => <CacheSection /> },
account: { icon: Shield, render: () => <AccountSection /> },
interface: { icon: Monitor, render: () => <InterfaceSection /> },
};
export type SectionView = SettingsSectionDef & { icon: LucideIcon; render: () => ReactNode };
export const SECTIONS: SectionView[] = SETTINGS_SECTIONS.map((s) => ({ ...s, ...UI[s.id] }));
/** URL hash:`#<分区>` 或 `#<分区>/<节>`;不认识的落到默认分区 */
export function parseSettingsHash(hash: string): { section: SectionView; anchor: string | null } {
const [id, anchor] = decodeURIComponent(hash.replace(/^#/, "")).split("/");
const section = SECTIONS.find((s) => s.id === id) ?? (SECTIONS[0] as SectionView);
return { section, anchor: section.id === id && anchor ? anchor : null };
/** 左栏分组:接入 / 流程 / 系统 */
export const SECTION_GROUPS = SETTINGS_GROUPS.map((g) => ({
...g,
sections: SECTIONS.filter((s) => s.group === g.id),
}));
/**
* URL hash:`#<分区>` 或 `#<分区>/<节>`;旧 hash 换成新位置(moved = true 时页面用 replace 改地址栏),
* 不认识的落到第一个分区
*/
export function parseSettingsHash(hash: string): { section: SectionView; anchor: string | null; moved: boolean } {
const r = resolveSettingsHash(hash);
const section = SECTIONS.find((s) => s.id === r.section) ?? (SECTIONS[0] as SectionView);
return { section, anchor: r.section ? r.anchor : null, moved: r.moved };
}
export const settingsHash = (section: string, anchor?: string | null) => `#${section}${anchor ? `/${anchor}` : ""}`;
@@ -79,7 +79,7 @@ export function SettingsCard({
try {
const next = await update.mutateAsync(patch);
setValues(spec.fromSettings(next));
toast.success(`${spec.title} 已保存`);
toast.success(`已保存「${spec.title}」`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
@@ -0,0 +1,216 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DEFAULT_SETTINGS, DEPRECATED_SETTINGS_FIELDS, type GlobalSettings, SETTINGS_SECTIONS } from "@umt/contracts";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { qk } from "~/lib/query-keys.ts";
import { cacheRow } from "./cache.tsx";
import { SETTINGS_CARDS, searchSettings } from "./search-index.ts";
import { parseSettingsHash, SECTION_GROUPS, SECTIONS, settingsHash } from "./sections.tsx";
import { anchorId } from "./settings-block.tsx";
// 设置页重排(2026-10-06):分组导航、旧锚点跳转、搜索定位、各分区渲染不丢字段。
// 渲染用 react-dom/server + 预置的设置数据;列表类接口在服务端渲染里保持加载态,只核对节锚点。
/** 表单字段名 → 它写回的存储字段(换了单位 / 拆了子字段的在这里对上) */
const FIELD_ALIASES: Record<string, string> = {
"budget.maxDownloadGiBPerDay": "budget.maxDownloadBytesPerDay",
"organize.minSizeMiB": "organize.minSizeBytes",
"metadata.tmdbApiKey": "metadata.tmdbApiKeySecretRef",
};
/** 不走表单卡片、由专门组件渲染的字段 → 所在节(只核对节在页面上) */
const CUSTOM_FIELDS: Record<string, string> = {
"organize.classificationYaml": "library/classification",
"organize.sources": "library/sources",
"naming.movie": "library/naming",
"naming.tv": "library/naming",
"naming.variety": "library/naming",
"naming.lockFileNameForBound": "library/naming",
"pi.defaultUpstreamId": "agent/upstream",
"search.defaultSiteMode": "acquire/search",
"search.siteIds": "acquire/search",
"automation.enabled": "system/switches",
"automation.readOnlyReconcile": "system/readonly",
};
/** 打开所有"开了才显示后续字段"的开关,让条件字段也渲染出来 */
const ALL_ON: GlobalSettings = {
...DEFAULT_SETTINGS,
pi: {
...DEFAULT_SETTINGS.pi,
subagentsEnabled: true,
subagents: { ...DEFAULT_SETTINGS.pi.subagents, enabled: true },
},
agent: {
...DEFAULT_SETTINGS.agent,
autoInvestigate: true,
autoInvestigateMode: "recover",
webSearch: { ...DEFAULT_SETTINGS.agent.webSearch, provider: "brave" },
},
};
function render(sectionId: string, settings: GlobalSettings = DEFAULT_SETTINGS): string {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
qc.setQueryData(qk.settings, settings);
const section = SECTIONS.find((s) => s.id === sectionId)!;
return renderToString(
createElement(
QueryClientProvider,
{ client: qc },
createElement(MemoryRouter, { initialEntries: [`/console/settings#${sectionId}`] }, section.render()),
),
);
}
/** 存储字段(`key.field`)是否被某个渲染出来的控件 id(`f-<表单字段名>`)覆盖 */
function covered(field: string, controlIds: string[]): boolean {
return controlIds.some((id) => {
const target = FIELD_ALIASES[id] ?? id;
return target === field || target.startsWith(`${field}.`);
});
}
describe("设置页分组导航", () => {
it("三组按 接入 → 流程 → 系统 排,每个分区都在某一组里,带标题和一句说明", () => {
expect(SECTION_GROUPS.map((g) => g.label)).toEqual(["接入", "流程", "系统"]);
expect(SECTION_GROUPS.flatMap((g) => g.sections.map((s) => s.id))).toEqual(SECTIONS.map((s) => s.id));
for (const s of SECTIONS) {
expect(s.label).not.toBe("");
expect(s.description).not.toBe("");
expect(typeof s.render).toBe("function");
}
});
it("接入对象与参数分开:实例类分区的第一节是对象列表", () => {
const first = (id: string) => SECTIONS.find((s) => s.id === id)?.anchors[0]?.id;
expect(first("downloaders")).toBe("instances");
expect(first("media-servers")).toBe("instances");
expect(first("library")).toBe("roots");
expect(first("agent")).toBe("upstream");
});
});
describe("旧锚点跳转", () => {
it.each([
["#automation/cache", "downloaders", "cache"],
["#automation/budget", "acquire", "budget"],
["#automation/advanced", "system", "readonly"],
["#automation", "system", null],
["#library/scrape", "organize", "scrape"],
["#metadata/identity", "acquire", "identity"],
["#metadata/tmdb-cache", "cache", "tmdb-cache"],
["#api-keys", "account", "api-keys"],
["#account/shortcuts", "interface", "shortcuts"],
])("%s → #%s/%s,并标记需要替换地址", (hash, section, anchor) => {
const r = parseSettingsHash(hash);
expect(r.section.id).toBe(section);
expect(r.anchor).toBe(anchor);
expect(r.moved).toBe(true);
});
it("新锚点不跳;#agent 等同名分区保持;不认识的落到第一个分区", () => {
expect(parseSettingsHash("#agent")).toMatchObject({ anchor: null, moved: false });
expect(parseSettingsHash("#agent").section.id).toBe("agent");
expect(parseSettingsHash("#downloaders/instances").section.id).toBe("downloaders");
expect(parseSettingsHash("#nope").section.id).toBe(SECTIONS[0]!.id);
expect(settingsHash("cache", "regions")).toBe("#cache/regions");
});
});
describe("搜索定位", () => {
it.each([
["H&R", "downloaders", "instances", null],
["刮削", "organize", "scrape", null],
["整理后自动刮削", "organize", "scrape", "organize.scrapeAfterImport"],
["已有文件", "organize", "scrape", "organize.scrapeMode"],
["单次工具调用上限", "agent", "budget", "agent.maxToolCalls"],
["整理识别不到作品", "agent", "investigate", "agent.autoIdentifyOrganize"],
["特别篇", "metadata", "language", "metadata.includeSpecials"],
["代理地址", "sites", "network", "sites.proxyUrl"],
["Cloudflare", "sites", "browser", null],
["保种天数", "downloaders", "seeding", "retention.seedRetentionDays"],
["豆瓣", "cache", "regions", null],
["Passkey", "account", "passkeys", null],
["API Key", "account", "api-keys", null],
])("「%s」能找到 %s/%s", (q, section, anchor, field) => {
const hits = searchSettings(q, 20);
const hit = hits.find((h) => h.section === section && h.anchor === anchor && (field === null || h.field === field));
expect(hit, JSON.stringify(hits.map((h) => h.key))).toBeDefined();
});
it("空查询没有结果;找不到时返回空", () => {
expect(searchSettings(" ")).toEqual([]);
expect(searchSettings("完全不存在的设置名")).toEqual([]);
});
it("搜索登记的卡片锚点都是所在分区的节", () => {
for (const [sid, card] of SETTINGS_CARDS) {
const s = SETTINGS_SECTIONS.find((x) => x.id === sid);
expect(
s?.anchors.map((a) => a.id),
`${sid}/${card.anchor}`,
).toContain(card.anchor);
}
});
});
describe("各分区渲染(不丢字段)", () => {
const html = Object.fromEntries(SECTIONS.map((s) => [s.id, render(s.id)]));
const htmlAllOn = Object.fromEntries(SECTIONS.map((s) => [s.id, render(s.id, ALL_ON)]));
it.each(SECTIONS.map((s) => [s.id]))("%s:每一节都渲染出来", (id) => {
const s = SECTIONS.find((x) => x.id === id)!;
for (const a of s.anchors) expect(html[id], `${id}/${a.id}`).toContain(`id="${anchorId(id, a.id)}"`);
});
it("每个在用的存储字段都出现在它所属分区的页面上", () => {
const deprecated = new Set<string>(DEPRECATED_SETTINGS_FIELDS);
const missing: string[] = [];
for (const s of SECTIONS) {
const page = `${html[s.id] ?? ""}\n${htmlAllOn[s.id] ?? ""}`;
const controlIds = [...page.matchAll(/id="f-([^"]+)"/g)].map((m) => m[1] as string);
for (const f of s.fields) {
if (deprecated.has(f)) continue;
const custom = CUSTOM_FIELDS[f];
if (custom) {
const [sid, anchor] = custom.split("/") as [string, string];
if (sid !== s.id || !page.includes(`id="${anchorId(sid, anchor)}"`)) missing.push(`${s.id}:${f}`);
continue;
}
if (!covered(f, controlIds)) missing.push(`${s.id}:${f}`);
}
}
expect(missing).toEqual([]);
});
it("近期新增的设置都在页面上:智能体防跑飞限制与用量、识别失败自动派智能体、刮削「更新」、整理后自动刮削、H&R 未知", () => {
expect(html.agent).toContain('id="f-agent.maxToolCalls"');
expect(html.agent).toContain('id="f-agent.maxDurationSec"');
expect(html.agent).toContain('id="f-agent.autoIdentifyOrganize"');
expect(html.organize).toContain('id="f-organize.scrapeAfterImport"');
expect(html.organize).toContain("更新");
// 实例抽屉的字段在打开抽屉后才渲染;这里核对搜索能定位到实例节
expect(searchSettings("H&R 未知也可清理")[0]).toMatchObject({ section: "downloaders", anchor: "instances" });
});
it("废弃字段不再显示", () => {
const all = Object.values(html).join("\n");
for (const f of DEPRECATED_SETTINGS_FIELDS) expect(all).not.toContain(`id="f-${f}"`);
expect(all).not.toContain("日志保留天数");
expect(all).not.toContain("每日费用");
});
});
describe("缓存区名", () => {
it("已知区显示中文名,未知区原样显示", () => {
const base = { entries: 1, expired: 0, bytes: 10, lastWriteAt: null };
expect(cacheRow({ region: "tmdb.request", ...base }).label).toBe("TMDB 请求");
expect(cacheRow({ region: "douban.subject", ...base }).label).toBe("豆瓣条目");
expect(cacheRow({ region: "bangumi.calendar", ...base }).label).toBe("Bangumi(calendar)");
expect(cacheRow({ region: "site.detail", ...base }).label).toBe("站点详情页");
expect(cacheRow({ region: "images", ...base }).label).toBe("图片");
expect(cacheRow({ region: "zzz.custom", ...base }).label).toBe("zzz.custom");
});
});
@@ -0,0 +1,116 @@
import { Search, X } from "lucide-react";
import { useId, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { Input } from "~/components/ui/input.tsx";
import { cn } from "~/lib/utils.ts";
import { type SettingsSearchHit, searchSettings } from "./search-index.ts";
import { settingsHash } from "./sections.tsx";
/** 设置页搜索框:按设置名、节名找位置;选中后跳到该节,字段会被聚焦 */
export function SettingsSearch({ className }: { className?: string }) {
const navigate = useNavigate();
const listId = useId();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const hits = useMemo(() => searchSettings(q), [q]);
const show = open && q.trim() !== "";
const pick = (h: SettingsSearchHit) => {
setOpen(false);
setQ("");
navigate({ hash: settingsHash(h.section, h.anchor) }, { state: h.field ? { field: h.field } : null });
};
return (
<div className={cn("relative", className)}>
<Search
className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
ref={inputRef}
type="text"
enterKeyHint="search"
role="combobox"
aria-expanded={show}
aria-controls={listId}
aria-autocomplete="list"
aria-label="搜索设置"
placeholder="搜索设置"
value={q}
className="pr-8 pl-8"
onChange={(e) => {
setQ(e.target.value);
setActive(0);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => Math.min(i + 1, hits.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
const h = hits[active];
if (h) {
e.preventDefault();
pick(h);
}
} else if (e.key === "Escape") {
setQ("");
setOpen(false);
}
}}
/>
{q ? (
<button
type="button"
aria-label="清除搜索"
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-full p-0.5 text-muted-foreground hover:text-foreground"
onClick={() => {
setQ("");
inputRef.current?.focus();
}}
>
<X className="size-3.5" />
</button>
) : null}
{show ? (
<div
id={listId}
role="listbox"
aria-label="搜索结果"
className="pg-float absolute inset-x-0 top-full z-30 mt-1.5 max-h-80 animate-pg-pop overflow-y-auto rounded-xl p-1"
>
{hits.length === 0 ? (
<p className="px-3 py-2 text-caption text-muted-foreground">没有匹配的设置</p>
) : (
hits.map((h, i) => (
<button
key={h.key}
type="button"
role="option"
aria-selected={i === active}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActive(i)}
onClick={() => pick(h)}
className={cn(
"block w-full rounded-lg px-3 py-1.5 text-left transition-colors duration-micro",
i === active ? "bg-foreground/[0.06]" : "",
)}
>
<span className="block truncate text-sm text-foreground">{h.label}</span>
<span className="block truncate text-meta text-muted-foreground">{h.where}</span>
</button>
))
)}
</div>
) : null}
</div>
);
}
+48 -27
View File
@@ -6,12 +6,12 @@ import { BrowserProvidersBlock } from "./browser-providers.tsx";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 站点默认(site-center §8):站点没单独设置时用的限流、快照周期、UA、代理;浏览器服务(BR-1)。
// 站点、凭证、账号在「站点」页管理;站点级覆盖在站点的"编辑"里。
// 站点与网络(site-center §8):站点没单独设置时用的限流、抓取周期等缺省值;UA 与代理;浏览器服务(BR-1)。
// 站点、凭证、账号、Cloudflare 处理在「站点」页管理;站点级覆盖在站点的「编辑」里。
const DEFAULTS_CARD: CardSpec = {
export const DEFAULTS_CARD: CardSpec = {
anchor: "defaults",
title: "默认值",
title: "站点缺省值",
description: "站点没有单独设置时使用。限流不会比站点定义自带的更快。",
fields: [
{
@@ -51,22 +51,6 @@ const DEFAULTS_CARD: CardSpec = {
kind: "number",
hint: "拉取用户数据时最多翻几页做种列表;1–50",
},
{
name: "sites.userAgent",
label: "User-Agent",
kind: "text",
placeholder: "跟随浏览器",
hint: "留空跟随浏览器(推荐),站点请求和浏览器用同一个 UA。填了则账号没单独填 UA 时都用它,和浏览器不一致",
wide: true,
},
{
name: "sites.proxyUrl",
label: "代理地址",
kind: "url",
placeholder: "http://127.0.0.1:7890",
hint: "只对开了「使用代理」的站点生效;留空不用代理",
wide: true,
},
],
fromSettings: (s) => ({
"sites.defaultRateLimit.requestsPerMinute": n(s.sites.defaultRateLimit.requestsPerMinute),
@@ -76,8 +60,6 @@ const DEFAULTS_CARD: CardSpec = {
"sites.defaultSnapshotMaxPages": n(s.sites.defaultSnapshotMaxPages),
"sites.defaultHealthCheckIntervalSec": n(s.sites.defaultHealthCheckIntervalSec / 3600),
"sites.defaultDetailCacheSec": n(s.sites.defaultDetailCacheSec / 3600),
"sites.userAgent": s.sites.userAgent,
"sites.proxyUrl": s.sites.proxyUrl ?? "",
}),
toPatch: (v, s) => {
const min = num(v["sites.defaultSnapshotIntervalSec"]);
@@ -97,20 +79,52 @@ const DEFAULTS_CARD: CardSpec = {
defaultSnapshotMaxPages: num(v["sites.defaultSnapshotMaxPages"]),
defaultHealthCheckIntervalSec: healthH === undefined ? undefined : Math.round(healthH * 3600),
defaultDetailCacheSec: detailH === undefined ? undefined : Math.round(detailH * 3600),
// 空串 = 跟随浏览器(要能清空,不能用 undefined 表示"不改")
userAgent: str(v["sites.userAgent"]),
proxyUrl: str(v["sites.proxyUrl"]) || null,
},
};
},
};
export const NETWORK_CARD: CardSpec = {
anchor: "network",
title: "UA 与代理",
description: "站点请求用的 User-Agent 和代理。单个站点在「编辑」里决定用不用代理。",
fields: [
{
name: "sites.userAgent",
label: "User-Agent",
kind: "text",
placeholder: "跟随浏览器",
hint: "留空跟随浏览器(推荐),站点请求和浏览器用同一个 UA。填了则账号没单独填 UA 时都用它,和浏览器不一致",
wide: true,
},
{
name: "sites.proxyUrl",
label: "代理地址",
kind: "url",
placeholder: "http://127.0.0.1:7890",
hint: "只对开了「使用代理」的站点生效;留空不用代理",
wide: true,
},
],
fromSettings: (s) => ({
"sites.userAgent": s.sites.userAgent,
"sites.proxyUrl": s.sites.proxyUrl ?? "",
}),
toPatch: (v) => ({
sites: {
// 空串 = 跟随浏览器(要能清空,不能用 undefined 表示"不改")
userAgent: str(v["sites.userAgent"]),
proxyUrl: str(v["sites.proxyUrl"]) || null,
},
}),
};
export function SitesSection() {
return (
<div className="grid grid-cols-1 gap-8">
<div className="pg-slab flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3">
<p className="text-caption text-muted-foreground">
站点和凭证在「站点」页管理;单个站点的限流、周期、代理在站点的"编辑"里改。
站点、凭证和 Cloudflare 验证在「站点」页管理;单个站点的限流、周期、代理在站点的「编辑」里改。
</p>
<Button asChild size="sm" variant="outline">
<Link to={consolePath("sites")}>
@@ -119,7 +133,14 @@ export function SitesSection() {
</Link>
</Button>
</div>
<SettingsGate>{(s) => <SettingsCard section="sites" spec={DEFAULTS_CARD} settings={s} />}</SettingsGate>
<SettingsGate>
{(s) => (
<>
<SettingsCard section="sites" spec={DEFAULTS_CARD} settings={s} />
<SettingsCard section="sites" spec={NETWORK_CARD} settings={s} />
</>
)}
</SettingsGate>
<BrowserProvidersBlock />
</div>
);
@@ -1,103 +1,25 @@
import { ChevronDown, Pause, Play, ShieldAlert } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useLocation } from "react-router";
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 { anchorId, SettingsBlock } from "./settings-block.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、后台任务并发(WK-1)、存储预算与保留(CACHE-2)
// 系统分区:自动化总开关、只读对账、后台任务并发(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)
// 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) => ({
const dayField = (group: "retentionDays", k: string, label: string, hint?: string) => ({
name: `storage.${group}.${k}`,
label,
kind: "number" as const,
@@ -108,7 +30,7 @@ const pick = (v: FormValues, prefix: string, keys: readonly string[]) =>
// 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 = {
export const WORKER_LANES_CARD: CardSpec = {
anchor: "worker-lanes",
title: "后台任务并发",
description:
@@ -134,7 +56,7 @@ const WORKER_LANES_CARD: CardSpec = {
};
const BUDGET_KEYS = ["pgDataGb", "pgDataWarnGb", "imagesGb", "backupsGb", "sessionsEvidenceGb"] as const;
const STORAGE_BUDGET_CARD: CardSpec = {
export const STORAGE_BUDGET_CARD: CardSpec = {
anchor: "storage",
title: "存储预算",
description: "超过预算时健康页告警;图片缓存超出容量按最久未访问删除。单位 GB。",
@@ -159,9 +81,9 @@ const RETENTION_KEYS = [
"capabilityStats",
"identityPoolArchive",
] as const;
const STORAGE_RETENTION_CARD: CardSpec = {
export const STORAGE_RETENTION_CARD: CardSpec = {
anchor: "storage-retention",
title: "流水数据保留",
title: "数据保留",
description: "到期由定时清理任务分批删除。单位天,心跳为小时。",
fields: [
dayField("retentionDays", "observation", "RSS 抓取记录"),
@@ -183,22 +105,6 @@ const STORAGE_RETENTION_CARD: CardSpec = {
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,
@@ -239,46 +145,23 @@ function ToggleRow({
);
}
/** 高级:只读对账、后台任务并发、存储预算、流水保留、缓存有效期;默认收起,从 #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="只读对账">
<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="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
description="只核对库存与路径,不做自动写入。"
checked={s.automation.readOnlyReconcile}
disabled={update.isPending}
onChange={(v) =>
@@ -307,7 +190,7 @@ function Switches() {
return (
<SettingsGate>
{(s) => (
<SettingsBlock section="automation" anchor="switches" title="开关" description="切换后立即生效。">
<SettingsBlock section="system" anchor="switches" title="自动化" description="切换后立即生效。">
<div className="divide-y divide-border/60">
<ToggleRow
id="tg-automation"
@@ -326,23 +209,17 @@ function Switches() {
);
}
export function AutomationSection() {
export function SystemSection() {
return (
<div className="grid grid-cols-1 gap-8">
<Switches />
<ReadOnlySwitch />
<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>
<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>
+124 -55
View File
@@ -2,9 +2,18 @@ import { settingsSectionForLegacyTab } from "@umt/contracts";
import { useEffect } from "react";
import { Link, redirect, useLocation, useNavigate } from "react-router";
import { PageHeader } from "~/components/console/page-header.tsx";
import { parseSettingsHash, SECTIONS, settingsHash } from "~/components/settings/sections.tsx";
import { parseSettingsHash, SECTION_GROUPS, settingsHash } from "~/components/settings/sections.tsx";
import { anchorId } from "~/components/settings/settings-block.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { SettingsSearch } from "~/components/settings/settings-search.tsx";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "~/components/ui/select.tsx";
import { cn } from "~/lib/utils.ts";
export const meta = () => [{ title: "设置 · UHD 媒体自动化控制台" }];
@@ -20,21 +29,32 @@ export function clientLoader({ request }: { request: Request }) {
return redirect(`${url.pathname}${rest ? `?${rest}` : ""}${hit ? settingsHash(hit.section, hit.anchor) : ""}`);
}
/** 搜索选中字段时经 location.state 带过来,对齐到节后聚焦这个字段 */
type NavState = { field?: string } | null;
export default function SettingsPage() {
const location = useLocation();
const navigate = useNavigate();
const { section, anchor } = parseSettingsHash(location.hash);
const { section, anchor, moved } = parseSettingsHash(location.hash);
const field = (location.state as NavState)?.field ?? null;
// 切分区回到顶部;带节锚点时对齐到该节。节内容多是异步加载的(上游列表、能力清单),
// 旧锚点(重排前的 #automation/cache 等)换成新位置,不留历史记录
useEffect(() => {
if (moved) navigate({ hash: settingsHash(section.id, anchor) }, { replace: true, state: location.state });
}, [moved, section.id, anchor, navigate, location.state]);
// 切分区回到顶部;带节锚点时对齐到该节(有字段时对齐到字段并聚焦)。节内容多是异步加载的(上游列表、能力清单),
// 上方内容变高会把目标挤下去,所以在 2.5 秒内反复校正,直到用户自己滚动为止。
// biome-ignore lint/correctness/useExhaustiveDependencies: location.key 让重复点同一锚点也能再跳一次
useEffect(() => {
if (moved) return;
if (!anchor) {
window.scrollTo({ top: 0 });
return;
}
const started = performance.now();
let stopped = false;
let focused = false;
let timer = 0;
const stop = () => {
stopped = true;
@@ -43,10 +63,20 @@ export default function SettingsPage() {
for (const e of events) window.addEventListener(e, stop, { passive: true });
const align = () => {
if (stopped) return;
const el = document.getElementById(anchorId(section.id, anchor));
if (el) {
const margin = Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
if (Math.abs(el.getBoundingClientRect().top - margin) > 2) el.scrollIntoView({ block: "start" });
const fieldEl = field ? document.getElementById(`f-${field}`) : null;
if (fieldEl) {
const r = fieldEl.getBoundingClientRect();
if (r.top < 80 || r.bottom > window.innerHeight - 40) fieldEl.scrollIntoView({ block: "center" });
if (!focused) {
focused = true;
fieldEl.focus({ preventScroll: true });
}
} else {
const el = document.getElementById(anchorId(section.id, anchor));
if (el) {
const margin = Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
if (Math.abs(el.getBoundingClientRect().top - margin) > 2) el.scrollIntoView({ block: "start" });
}
}
if (performance.now() - started < 2500) timer = window.setTimeout(align, 120);
};
@@ -55,72 +85,111 @@ export default function SettingsPage() {
window.clearTimeout(timer);
for (const e of events) window.removeEventListener(e, stop);
};
}, [section.id, anchor, location.key]);
}, [section.id, anchor, field, moved, location.key]);
return (
<>
<PageHeader title="设置" description="密钥保存后不再显示。" />
<div className="grid gap-6 lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-8">
{/* 窄屏:顶部下拉 */}
<div className="lg:hidden">
<PageHeader title="设置" description="按接入对象和业务环节分组。密钥保存后不再显示。" />
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-8">
{/* 窄屏:搜索 + 分区下拉 + 节横排 */}
<div className="grid gap-3 lg:hidden">
<SettingsSearch />
<Select value={section.id} onValueChange={(id) => navigate({ hash: settingsHash(id) })}>
<SelectTrigger className="w-full" aria-label="设置分区">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SECTIONS.map((s) => (
<SelectItem key={s.id} value={s.id}>
<s.icon />
{s.label}
</SelectItem>
{SECTION_GROUPS.map((g) => (
<SelectGroup key={g.id}>
<SelectLabel>{g.label}</SelectLabel>
{g.sections.map((s) => (
<SelectItem key={s.id} value={s.id}>
<s.icon />
{s.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
<p className="mt-2 text-caption text-muted-foreground">{section.description}</p>
<p className="text-caption text-muted-foreground">{section.description}</p>
{section.anchors.length > 1 ? (
<nav aria-label="本分区的节" className="scroll-bleed -mx-1 overflow-x-auto px-1 pb-1">
<ul className="flex w-max gap-1.5">
{section.anchors.map((a) => (
<li key={a.id}>
<Link
to={{ hash: settingsHash(section.id, a.id) }}
className={cn(
"block rounded-full border px-3 py-1 text-caption whitespace-nowrap transition-colors duration-micro",
anchor === a.id
? "pill-selected text-foreground"
: "border-border/70 text-muted-foreground hover:text-foreground",
)}
>
{a.label}
</Link>
</li>
))}
</ul>
</nav>
) : null}
</div>
{/* 宽屏:左侧分区导航 */}
<nav aria-label="设置分区" className="hidden lg:block">
<ul className="sticky top-20 grid max-h-[calc(100dvh-6rem)] gap-0.5 overflow-y-auto pb-2">
{SECTIONS.map((s) => {
const active = s.id === section.id;
return (
<li key={s.id}>
<Link
to={{ hash: settingsHash(s.id) }}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors duration-micro",
active
? "bg-foreground/[0.07] font-medium text-foreground"
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
)}
>
<s.icon className="size-4 shrink-0" aria-hidden />
{s.label}
</Link>
{active && s.anchors.length > 1 ? (
<ul className="mt-0.5 mb-1.5 ml-[1.375rem] grid gap-0.5 border-l border-border/60 pl-3">
{s.anchors.map((a) => (
<li key={a.id}>
{/* 宽屏:搜索 + 左侧分组导航 */}
<div className="hidden lg:block">
<div className="sticky top-20 grid max-h-[calc(100dvh-6rem)] gap-3 overflow-y-auto pb-2">
<SettingsSearch />
<nav aria-label="设置分区" className="grid gap-4">
{SECTION_GROUPS.map((g) => (
<div key={g.id}>
<p className="mb-1 px-3 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
{g.label}
</p>
<ul className="grid gap-0.5">
{g.sections.map((s) => {
const active = s.id === section.id;
return (
<li key={s.id}>
<Link
to={{ hash: settingsHash(s.id, a.id) }}
to={{ hash: settingsHash(s.id) }}
aria-current={active ? "page" : undefined}
className={cn(
"block rounded-md px-2 py-1 text-caption transition-colors duration-micro hover:text-foreground",
anchor === a.id ? "text-foreground" : "text-muted-foreground",
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors duration-micro",
active
? "bg-foreground/[0.07] font-medium text-foreground"
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
)}
>
{a.label}
<s.icon className="size-4 shrink-0" aria-hidden />
{s.label}
</Link>
{active && s.anchors.length > 1 ? (
<ul className="mt-0.5 mb-1.5 ml-[1.375rem] grid gap-0.5 border-l border-border/60 pl-3">
{s.anchors.map((a) => (
<li key={a.id}>
<Link
to={{ hash: settingsHash(s.id, a.id) }}
className={cn(
"block rounded-md px-2 py-1 text-caption transition-colors duration-micro hover:text-foreground",
anchor === a.id ? "text-foreground" : "text-muted-foreground",
)}
>
{a.label}
</Link>
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
</nav>
);
})}
</ul>
</div>
))}
</nav>
</div>
</div>
<div className="min-w-0">
{/* 分区页头:标题 + 一句说明;窄屏由上方下拉承担,标题只留给读屏 */}