From 8071ad33f96c85cb691861aaacd19b79bbfa7b5b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 18:01:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E6=8C=89=E6=96=B0=E5=88=86=E7=B1=BB=E9=87=8D=E6=8E=92,?= =?UTF-8?q?=E5=8A=A0=E6=90=9C=E7=B4=A2=E3=80=81=E7=BC=93=E5=AD=98=E5=88=86?= =?UTF-8?q?=E5=8C=BA=E4=B8=8E=E6=97=A7=E9=94=9A=E7=82=B9=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 左栏三组分区 + 当前分区的节;窄屏为搜索框、分组下拉和节的横排胶囊 - 搜索框按分区、节、节别名和字段名定位,选中字段时滚到该字段并聚焦 - 旧 hash(#automation/cache、#api-keys、#library/scrape 等)replace 到新位置 - 下载器分区放下载盘水位与保种缺省值,名字与实例抽屉统一(暂停线 / 恢复线 / 保种天数) - 新增获取、整理与刮削、系统、缓存分区;API Key 并入账户与安全,快捷键移到界面 - 补上缺的入口:最小视频体积、子会话并发与层数、搜索站点;日志保留天数(已废弃)不再显示 - 缓存分区按区列出条数、占用、最近写入,单区清空二次确认;TMDB 与图片缓存并入 - 保存提示统一为「已保存「节名」」;按入库口径把相关文案改成「整理」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/web/app/components/settings/account.tsx | 19 +- apps/web/app/components/settings/acquire.tsx | 213 ++++++++++++++ apps/web/app/components/settings/agent.tsx | 51 +++- apps/web/app/components/settings/api-keys.tsx | 8 +- .../components/settings/browser-providers.tsx | 2 +- apps/web/app/components/settings/cache.tsx | 266 +++++++++++++++++ .../app/components/settings/downloaders.tsx | 68 +++++ .../web/app/components/settings/instances.tsx | 26 +- .../web/app/components/settings/interface.tsx | 18 +- apps/web/app/components/settings/library.tsx | 105 +------ apps/web/app/components/settings/metadata.tsx | 273 ++---------------- apps/web/app/components/settings/naming.tsx | 6 +- .../components/settings/organize-sources.tsx | 15 +- apps/web/app/components/settings/organize.tsx | 120 ++++++++ .../app/components/settings/pi-upstreams.tsx | 2 +- .../app/components/settings/search-index.ts | 128 ++++++++ apps/web/app/components/settings/sections.tsx | 62 ++-- .../app/components/settings/settings-card.tsx | 2 +- .../components/settings/settings-page.test.ts | 216 ++++++++++++++ .../components/settings/settings-search.tsx | 116 ++++++++ apps/web/app/components/settings/sites.tsx | 75 +++-- .../settings/{automation.tsx => system.tsx} | 171 ++--------- apps/web/app/routes/console/settings.tsx | 179 ++++++++---- 23 files changed, 1499 insertions(+), 642 deletions(-) create mode 100644 apps/web/app/components/settings/acquire.tsx create mode 100644 apps/web/app/components/settings/cache.tsx create mode 100644 apps/web/app/components/settings/downloaders.tsx create mode 100644 apps/web/app/components/settings/organize.tsx create mode 100644 apps/web/app/components/settings/search-index.ts create mode 100644 apps/web/app/components/settings/settings-page.test.ts create mode 100644 apps/web/app/components/settings/settings-search.tsx rename apps/web/app/components/settings/{automation.tsx => system.tsx} (50%) diff --git a/apps/web/app/components/settings/account.tsx b/apps/web/app/components/settings/account.tsx index 8d7e5ff6..2fbaf3bc 100644 --- a/apps/web/app/components/settings/account.tsx +++ b/apps/web/app/components/settings/account.tsx @@ -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 ( - +
身份
@@ -194,10 +200,7 @@ export function AccountSection() { - {/* 快捷键说明自带标题,只包卡片与锚点 */} -
- -
+ ); } diff --git a/apps/web/app/components/settings/acquire.tsx b/apps/web/app/components/settings/acquire.tsx new file mode 100644 index 00000000..4359f53c --- /dev/null +++ b/apps/web/app/components/settings/acquire.tsx @@ -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 ( + setOpen(true)}> + 修改 + + } + > +
+

+ {SEARCH_MODE_LABEL[mode]} + {mode === "custom" ? ( + {cfg?.siteIds.length ?? 0} 个站点 + ) : null} +

+ +
+ {sites.data ? : null} +
+ ); +} + +// 身份识别(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: () => , +}; + +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 ( +
+

+ {d + ? `最近 ${d.days} 天元数据补全 ${d.total} 次,出了建议 ${d.suggested} 次${d.tracked < d.total ? `;有来源明细的 ${d.tracked} 次` : ""}` + : "读取补全命中率…"} +

+ {d && d.tracked > 0 ? ( + + 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) => {r.withResults}, + }, + { + key: "hits", + header: "命中", + align: "right", + cell: (r) => {r.hits}, + }, + { + key: "rate", + header: "命中率", + align: "right", + cell: (r) => ( + {r.rate === null ? "—" : `${Math.round(r.rate * 100)}%`} + ), + }, + ]} + /> + ) : null} +
+ ); +} + +export function AcquireSection() { + return ( +
+ {(s) => } + + {(s) => } +
+ ); +} diff --git a/apps/web/app/components/settings/agent.tsx b/apps/web/app/components/settings/agent.tsx index 3d732b16..fbae8051 100644 --- a/apps/web/app/components/settings/agent.tsx +++ b/apps/web/app/components/settings/agent.tsx @@ -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() { {(s) => ( <> - + - )} + {(s) => } ); } diff --git a/apps/web/app/components/settings/api-keys.tsx b/apps/web/app/components/settings/api-keys.tsx index c39e9d9c..29a800be 100644 --- a/apps/web/app/components/settings/api-keys.tsx +++ b/apps/web/app/components/settings/api-keys.tsx @@ -102,10 +102,10 @@ export function ApiKeysSection() { return ( setOpen(true)}> diff --git a/apps/web/app/components/settings/browser-providers.tsx b/apps/web/app/components/settings/browser-providers.tsx index 417b0f20..a906222b 100644 --- a/apps/web/app/components/settings/browser-providers.tsx +++ b/apps/web/app/components/settings/browser-providers.tsx @@ -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;没设就留空", diff --git a/apps/web/app/components/settings/cache.tsx b/apps/web/app/components/settings/cache.tsx new file mode 100644 index 00000000..a6c5bf27 --- /dev/null +++ b/apps/web/app/components/settings/cache.tsx @@ -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 { + 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(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[] = [ + { + key: "label", + header: "缓存", + cell: (r) => ( +
+

{r.label}

+ {r.label !== r.region ?

{r.region}

: null} +
+ ), + }, + { + key: "entries", + header: "条数", + align: "right", + cell: (r) => ( +
+ {r.entries} + {r.expired > 0 ?

另有 {r.expired} 条已过期

: null} +
+ ), + }, + { + key: "bytes", + header: "占用", + align: "right", + cell: (r) => {formatBytes(r.bytes, 1)}, + }, + { + key: "last", + header: "最近写入", + hideBelow: "md", + cell: (r) => {formatRelative(r.lastWriteAt)}, + }, + { + key: "actions", + header: 操作, + align: "right", + cell: (r) => ( + + ), + }, + ]; + + return ( + + + rows={rows} + rowKey={(r) => r.region} + loading={regions.isPending} + error={regions.error} + onRetry={() => regions.refetch()} + empty={{ title: "还没有缓存", description: "用到 TMDB、豆瓣、站点详情等数据后会出现在这里。" }} + columns={columns} + /> + !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; + } + }} + /> + + ); +} + +// 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: () => , +}; + +function TmdbCacheStatus() { + const q = useTmdbCache(); + const clear = useClearTmdbCache(); + const c = q.data?.counts; + return ( +
+ + {c ? `识别 ${c.recognize} 条 · 请求 ${c.request} 条 · 上限各 ${q.data?.maxSize} 条` : "读取中…"} + + + 清空 + + } + 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; + } + }} + /> +
+ ); +} + +// 图片代理与缓存(对照 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 ( +
+ + + {(s) => ( + <> + + + + + )} + +
+ ); +} diff --git a/apps/web/app/components/settings/downloaders.tsx b/apps/web/app/components/settings/downloaders.tsx new file mode 100644 index 00000000..8a7eb492 --- /dev/null +++ b/apps/web/app/components/settings/downloaders.tsx @@ -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 ( +
+ + + + + {(s) => ( + <> + + + + )} + +
+ ); +} diff --git a/apps/web/app/components/settings/instances.tsx b/apps/web/app/components/settings/instances.tsx index e9f6b474..28d9ca89 100644 --- a/apps/web/app/components/settings/instances.tsx +++ b/apps/web/app/components/settings/instances.tsx @@ -82,15 +82,7 @@ function authKindOf(r: DownloaderInstance): string { return r.username ? "password" : r.secretRef ? "api_key" : "none"; } -export function DownloadersSection() { - return ( - - - - ); -} - -/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用全局 */ +/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用「下载器」分区的缺省值 */ const POLICY_FIELDS = [ ["hardPct", "hardThresholdPercent"], ["resumePct", "resumePercent"], @@ -114,7 +106,7 @@ function policyBody(v: FormValues): Record { 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", diff --git a/apps/web/app/components/settings/interface.tsx b/apps/web/app/components/settings/interface.tsx index d78cdb61..c07cf245 100644 --- a/apps/web/app/components/settings/interface.tsx +++ b/apps/web/app/components/settings/interface.tsx @@ -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 ( +
+ + {/* 快捷键说明自带标题,只包卡片与锚点 */} +
+ +
+
+ ); +} + +function DisplayBlock() { const prefs = useUiPrefs(); return ( @@ -18,6 +31,7 @@ export function InterfaceSection() {

setUiPref("techInfoExpanded", v)} diff --git a/apps/web/app/components/settings/library.tsx b/apps/web/app/components/settings/library.tsx index dfdf130d..fd9ac570 100644 --- a/apps/web/app/components/settings/library.tsx +++ b/apps/web/app/components/settings/library.tsx @@ -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 (
- + - {(s) => } - {(s) => } - +
); diff --git a/apps/web/app/components/settings/metadata.tsx b/apps/web/app/components/settings/metadata.tsx index 42562ce7..a4ffe8eb 100644 --- a/apps/web/app/components/settings/metadata.tsx +++ b/apps/web/app/components/settings/metadata.tsx @@ -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: () => , }; -function TmdbCacheStatus() { - const q = useTmdbCache(); - const clear = useClearTmdbCache(); - const c = q.data?.counts; - return ( -
- - {c ? `识别 ${c.recognize} 条 · 请求 ${c.request} 条 · 上限各 ${q.data?.maxSize} 条` : "读取中…"} - - -
- ); -} - -// 图片代理与缓存(对照 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: () => , -}; - -function ImageCacheStatus() { - const q = useImageCache(); - const clear = useClearImageCache(); - const d = q.data; - return ( -
- - {d ? `${d.files} 张 · ${(d.usedBytes / 1024 / 1024).toFixed(1)} MiB` : "读取中…"} - - -
- ); -} - -// 身份识别(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: () => , -}; - -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 ( -
-

- {d - ? `最近 ${d.days} 天元数据补全 ${d.total} 次,出了建议 ${d.suggested} 次${d.tracked < d.total ? `;有来源明细的 ${d.tracked} 次` : ""}` - : "读取补全命中率…"} -

- {d && d.tracked > 0 ? ( - - 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) => {r.withResults}, - }, - { - key: "hits", - header: "命中", - align: "right", - cell: (r) => {r.hits}, - }, - { - key: "rate", - header: "命中率", - align: "right", - cell: (r) => ( - {r.rate === null ? "—" : `${Math.round(r.rate * 100)}%`} - ), - }, - ]} - /> - ) : null} -
- ); -} - export function MetadataSection() { return ( {(s) => ( <> - - - + )} diff --git a/apps/web/app/components/settings/naming.tsx b/apps/web/app/components/settings/naming.tsx index fb05c366..5a558a46 100644 --- a/apps/web/app/components/settings/naming.tsx +++ b/apps/web/app/components/settings/naming.tsx @@ -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() { {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); diff --git a/apps/web/app/components/settings/organize-sources.tsx b/apps/web/app/components/settings/organize-sources.tsx index 38656809..4410f7e8 100644 --- a/apps/web/app/components/settings/organize-sources.tsx +++ b/apps/web/app/components/settings/organize-sources.tsx @@ -85,22 +85,25 @@ export function ClassificationSection() { 2020-2025 范围)。

- + {dirty ? ( + + ) : null}
@@ -219,7 +222,7 @@ function SourceEditor({ initial, onClose }: { initial: OrganizeSourceView | null onChange={(e) => patch({ action: e.target.value as "copy" | "move" })} > - + diff --git a/apps/web/app/components/settings/organize.tsx b/apps/web/app/components/settings/organize.tsx new file mode 100644 index 00000000..abae5672 --- /dev/null +++ b/apps/web/app/components/settings/organize.tsx @@ -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 ( + + {(s) => ( + <> + + + + )} + + ); +} diff --git a/apps/web/app/components/settings/pi-upstreams.tsx b/apps/web/app/components/settings/pi-upstreams.tsx index c626e4ac..c74e688f 100644 --- a/apps/web/app/components/settings/pi-upstreams.tsx +++ b/apps/web/app/components/settings/pi-upstreams.tsx @@ -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, "保存失败")); } diff --git a/apps/web/app/components/settings/search-index.ts b/apps/web/app/components/settings/search-index.ts new file mode 100644 index 00000000..578268b0 --- /dev/null +++ b/apps/web/app/components/settings/search-index.ts @@ -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: 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(); + 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; +} diff --git a/apps/web/app/components/settings/sections.tsx b/apps/web/app/components/settings/sections.tsx index 6cb5d110..b3dee462 100644 --- a/apps/web/app/components/settings/sections.tsx +++ b/apps/web/app/components/settings/sections.tsx @@ -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 ReactNode }> = { - automation: { icon: Zap, render: () => }, - sites: { icon: UserCog, render: () => }, - downloaders: { icon: Server, render: () => }, - library: { icon: FolderTree, render: () => }, + downloaders: { icon: Download, render: () => }, "media-servers": { icon: Tv, render: () => }, + sites: { icon: Globe, render: () => }, metadata: { icon: Tags, render: () => }, + acquire: { icon: Search, render: () => }, + library: { icon: FolderTree, render: () => }, + organize: { icon: Wand2, render: () => }, agent: { icon: Bot, render: () => }, - "api-keys": { icon: KeyRound, render: () => }, - interface: { icon: Monitor, render: () => }, + system: { icon: Settings2, render: () => }, + cache: { icon: Database, render: () => }, account: { icon: Shield, render: () => }, + interface: { icon: Monitor, render: () => }, }; 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}` : ""}`; diff --git a/apps/web/app/components/settings/settings-card.tsx b/apps/web/app/components/settings/settings-card.tsx index 07bd88b9..196f497b 100644 --- a/apps/web/app/components/settings/settings-card.tsx +++ b/apps/web/app/components/settings/settings-card.tsx @@ -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, "保存失败")); } diff --git a/apps/web/app/components/settings/settings-page.test.ts b/apps/web/app/components/settings/settings-page.test.ts new file mode 100644 index 00000000..f6a94599 --- /dev/null +++ b/apps/web/app/components/settings/settings-page.test.ts @@ -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 = { + "budget.maxDownloadGiBPerDay": "budget.maxDownloadBytesPerDay", + "organize.minSizeMiB": "organize.minSizeBytes", + "metadata.tmdbApiKey": "metadata.tmdbApiKeySecretRef", +}; + +/** 不走表单卡片、由专门组件渲染的字段 → 所在节(只核对节在页面上) */ +const CUSTOM_FIELDS: Record = { + "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(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"); + }); +}); diff --git a/apps/web/app/components/settings/settings-search.tsx b/apps/web/app/components/settings/settings-search.tsx new file mode 100644 index 00000000..d79020fc --- /dev/null +++ b/apps/web/app/components/settings/settings-search.tsx @@ -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(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 ( +
+ + { + 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 ? ( + + ) : null} + {show ? ( +
+ {hits.length === 0 ? ( +

没有匹配的设置

+ ) : ( + hits.map((h, i) => ( + + )) + )} +
+ ) : null} +
+ ); +} diff --git a/apps/web/app/components/settings/sites.tsx b/apps/web/app/components/settings/sites.tsx index 761c765d..4c13be28 100644 --- a/apps/web/app/components/settings/sites.tsx +++ b/apps/web/app/components/settings/sites.tsx @@ -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 (

- 站点和凭证在「站点」页管理;单个站点的限流、周期、代理在站点的"编辑"里改。 + 站点、凭证和 Cloudflare 验证在「站点」页管理;单个站点的限流、周期、代理在站点的「编辑」里改。

- {(s) => } + + {(s) => ( + <> + + + + )} +
); diff --git a/apps/web/app/components/settings/automation.tsx b/apps/web/app/components/settings/system.tsx similarity index 50% rename from apps/web/app/components/settings/automation.tsx rename to apps/web/app/components/settings/system.tsx index 1b2ad5d2..e1abee29 100644 --- a/apps/web/app/components/settings/automation.tsx +++ b/apps/web/app/components/settings/system.tsx @@ -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 ( -
- -

- 只读对账、后台任务并发、存储预算、数据保留和缓存有效期。一般不用改。 -

- {open ?
{children}
: null} -
- ); -} - function ReadOnlySwitch() { const update = useUpdateSettings(); return ( {(s) => ( - + } 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 ( {(s) => ( - +
+ {(s) => ( <> - - - - - - - - - - + + + )} diff --git a/apps/web/app/routes/console/settings.tsx b/apps/web/app/routes/console/settings.tsx index 508dee79..fba42b21 100644 --- a/apps/web/app/routes/console/settings.tsx +++ b/apps/web/app/routes/console/settings.tsx @@ -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 ( <> - -
- {/* 窄屏:顶部下拉 */} -
+ +
+ {/* 窄屏:搜索 + 分区下拉 + 节横排 */} +
+ -

{section.description}

+

{section.description}

+ {section.anchors.length > 1 ? ( + + ) : null}
- {/* 宽屏:左侧分区导航 */} - + ); + })} + +
+ ))} + +
+
{/* 分区页头:标题 + 一句说明;窄屏由上方下拉承担,标题只留给读屏 */}