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:
commit
83bb4c939a
38 files changed
+2044
-773
No files matched your search
+33
-2
@@ -8,6 +8,7 @@ import {
|
|||||||
type TmdbCacheItem,
|
type TmdbCacheItem,
|
||||||
type TmdbCacheList,
|
type TmdbCacheList,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
|
import { sql } from "@umt/db";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { dataPaths } from "../../config.ts";
|
import { dataPaths } from "../../config.ts";
|
||||||
import { externalFetch } from "../../egress.ts";
|
import { externalFetch } from "../../egress.ts";
|
||||||
@@ -40,10 +41,11 @@ type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
|
|||||||
function route<D extends EndpointDef>(
|
function route<D extends EndpointDef>(
|
||||||
app: ModuleApp,
|
app: ModuleApp,
|
||||||
def: D,
|
def: D,
|
||||||
handler: (i: { query: Infer<D["query"]> }) => Promise<unknown>,
|
handler: (i: { query: Infer<D["query"]>; params: Infer<D["params"]> }) => Promise<unknown>,
|
||||||
) {
|
) {
|
||||||
const request: RouteConfig["request"] = {};
|
const request: RouteConfig["request"] = {};
|
||||||
if (def.query) request.query = def.query as never;
|
if (def.query) request.query = def.query as never;
|
||||||
|
if (def.params) request.params = def.params as never;
|
||||||
const r = createRoute({
|
const r = createRoute({
|
||||||
method: def.method,
|
method: def.method,
|
||||||
path: def.path,
|
path: def.path,
|
||||||
@@ -57,7 +59,12 @@ function route<D extends EndpointDef>(
|
|||||||
});
|
});
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
|
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
|
||||||
app.openapi(r, (async (c: any) => {
|
app.openapi(r, (async (c: any) => {
|
||||||
const data = def.response.parse(await handler({ query: def.query ? c.req.valid("query") : {} } as never));
|
const data = def.response.parse(
|
||||||
|
await handler({
|
||||||
|
query: def.query ? c.req.valid("query") : {},
|
||||||
|
params: def.params ? c.req.valid("param") : {},
|
||||||
|
} as never),
|
||||||
|
);
|
||||||
return c.json({ ok: true, data }, 200);
|
return c.json({ ok: true, data }, 200);
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||||
}) as any);
|
}) as any);
|
||||||
@@ -178,6 +185,30 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
route(app, Api.cache.imagesClear, async () => ({ deleted: await images(ctx).clear() }));
|
route(app, Api.cache.imagesClear, async () => ({ deleted: await images(ctx).clear() }));
|
||||||
|
// 通用缓存按区统计与清空(设置 → 缓存);图片缓存在磁盘上,走上面两个接口
|
||||||
|
route(app, Api.cache.regions, async () => {
|
||||||
|
const r = await sql<{ region: string; entries: number; expired: number; bytes: string; last: Date | null }>`
|
||||||
|
select region,
|
||||||
|
count(*) filter (where expires_at > now())::int as entries,
|
||||||
|
count(*) filter (where expires_at <= now())::int as expired,
|
||||||
|
coalesce(sum(pg_column_size(value) + pg_column_size(key)), 0)::text as bytes,
|
||||||
|
max(updated_at) as last
|
||||||
|
from kv_cache group by region order by region`.execute(ctx.db);
|
||||||
|
return {
|
||||||
|
items: r.rows.map((x) => ({
|
||||||
|
region: x.region,
|
||||||
|
entries: Number(x.entries),
|
||||||
|
expired: Number(x.expired),
|
||||||
|
bytes: Number(x.bytes),
|
||||||
|
lastWriteAt: x.last ? new Date(x.last).toISOString() : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
route(app, Api.cache.regionClear, async ({ params }) => {
|
||||||
|
const deleted = await sharedCache(ctx).clear(params.region);
|
||||||
|
ctx.logger.info({ region: params.region, deleted }, "缓存区已清空");
|
||||||
|
return { deleted };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
registerTasks() {},
|
registerTasks() {},
|
||||||
start(ctx: ServerContext) {
|
start(ctx: ServerContext) {
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
import { PgCache } from "@umt/db";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { createCacheModule } from "../../src/modules/cache/index.ts";
|
||||||
|
import { bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
|
||||||
|
import { DEV_KEY, json } from "../helpers.ts";
|
||||||
|
|
||||||
|
// 设置 → 缓存:通用缓存(kv_cache)按区统计与单区清空(GET /cache/regions、POST /cache/regions/{region}/clear)
|
||||||
|
|
||||||
|
const hasPg = await pgAvailable();
|
||||||
|
|
||||||
|
describe.skipIf(!hasPg)("通用缓存按区管理(真实 PG)", () => {
|
||||||
|
let h: Harness;
|
||||||
|
let admin: ReturnType<typeof bearer>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
h = await createHarness({ extraModules: [createCacheModule()] });
|
||||||
|
admin = bearer(h.app, DEV_KEY);
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("按区列出未过期条数、已过期条数、占用字节、最近写入;清空一个区不影响其它区", async () => {
|
||||||
|
const cache = new PgCache(h.db);
|
||||||
|
await cache.set("douban.subject", "1", { title: "a" });
|
||||||
|
await cache.set("douban.subject", "2", { title: "b" });
|
||||||
|
await cache.set("site.detail", "x", { body: "y".repeat(200) });
|
||||||
|
// 写入后立刻过期的一条(ttl 1 秒,等它过期)
|
||||||
|
await cache.set("site.detail", "old", { v: 1 }, { ttl: 1 });
|
||||||
|
await new Promise((r) => setTimeout(r, 1100));
|
||||||
|
|
||||||
|
const res = await admin("GET", "/cache/regions");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const items = (await json(res)).data.items as {
|
||||||
|
region: string;
|
||||||
|
entries: number;
|
||||||
|
expired: number;
|
||||||
|
bytes: number;
|
||||||
|
lastWriteAt: string | null;
|
||||||
|
}[];
|
||||||
|
const douban = items.find((x) => x.region === "douban.subject")!;
|
||||||
|
const site = items.find((x) => x.region === "site.detail")!;
|
||||||
|
expect(douban).toMatchObject({ entries: 2, expired: 0 });
|
||||||
|
expect(douban.bytes).toBeGreaterThan(0);
|
||||||
|
expect(Date.parse(douban.lastWriteAt!)).toBeGreaterThan(Date.now() - 60_000);
|
||||||
|
expect(site).toMatchObject({ entries: 1, expired: 1 });
|
||||||
|
expect(site.bytes).toBeGreaterThan(200);
|
||||||
|
|
||||||
|
const cleared = await json(await admin("POST", "/cache/regions/site.detail/clear"));
|
||||||
|
expect(cleared.data.deleted).toBe(2);
|
||||||
|
const after = (await json(await admin("GET", "/cache/regions"))).data.items as { region: string }[];
|
||||||
|
expect(after.map((x) => x.region)).toContain("douban.subject");
|
||||||
|
expect(after.map((x) => x.region)).not.toContain("site.detail");
|
||||||
|
expect(await cache.count("douban.subject")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("区名不合法时拒绝(422),不执行删除", async () => {
|
||||||
|
const res = await admin("POST", "/cache/regions/BAD%20NAME/clear");
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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";
|
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() {
|
export function useTmdbCache() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -17,7 +21,7 @@ export function useClearTmdbCache() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => apiData<CacheClearResult>("/cache/tmdb/clear", { method: "POST" }),
|
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 }),
|
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"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useChangePassword, useLogout, useSession } from "~/api/auth.ts";
|
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 { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||||
import { SecretInput } from "~/components/console/secret-input.tsx";
|
import { SecretInput } from "~/components/console/secret-input.tsx";
|
||||||
import { Button } from "~/components/ui/button.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 { formatDateTime } from "~/lib/utils.ts";
|
||||||
import { PasskeyBlock } from "./account-passkeys.tsx";
|
import { PasskeyBlock } from "./account-passkeys.tsx";
|
||||||
import { TotpBlock } from "./account-totp.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, {
|
const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, {
|
||||||
message: "两次输入的新密码不一致",
|
message: "两次输入的新密码不一致",
|
||||||
@@ -122,7 +123,12 @@ function SessionBlock() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-caption">
|
||||||
<dt className="text-muted-foreground">身份</dt>
|
<dt className="text-muted-foreground">身份</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -194,10 +200,7 @@ export function AccountSection() {
|
|||||||
<TotpBlock disabled={!cookie} />
|
<TotpBlock disabled={!cookie} />
|
||||||
<PasskeyBlock disabled={!cookie} />
|
<PasskeyBlock disabled={!cookie} />
|
||||||
<SessionBlock />
|
<SessionBlock />
|
||||||
{/* 快捷键说明自带标题,只包卡片与锚点 */}
|
<ApiKeysSection />
|
||||||
<section id={anchorId("account", "shortcuts")} className="pg-slab min-w-0 scroll-mt-24 rounded-xl p-5">
|
|
||||||
<AgentShortcutsHelp />
|
|
||||||
</section>
|
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,9 +7,10 @@ import type { FieldSpec } from "./resource-section.tsx";
|
|||||||
import { num, str } from "./resource-section.tsx";
|
import { num, str } from "./resource-section.tsx";
|
||||||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.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",
|
anchor: "runtime",
|
||||||
title: "上下文与子会话",
|
title: "上下文与子会话",
|
||||||
fields: [
|
fields: [
|
||||||
@@ -20,7 +21,26 @@ const RUNTIME_CARD: CardSpec = {
|
|||||||
tokens: { min: 4000, max: 2_000_000, required: true },
|
tokens: { min: 4000, max: 2_000_000, required: true },
|
||||||
hint: "4K–2000K,超过模型窗口时按窗口算",
|
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.enabled", label: "上下文压缩", kind: "switch" },
|
||||||
{
|
{
|
||||||
name: "pi.compaction.thresholdRatio",
|
name: "pi.compaction.thresholdRatio",
|
||||||
@@ -32,7 +52,9 @@ const RUNTIME_CARD: CardSpec = {
|
|||||||
],
|
],
|
||||||
fromSettings: (s) => ({
|
fromSettings: (s) => ({
|
||||||
"pi.contextBudget": tokensToK(s.pi.contextBudget),
|
"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.enabled": s.pi.compaction.enabled,
|
||||||
"pi.compaction.thresholdRatio": n(s.pi.compaction.thresholdRatio),
|
"pi.compaction.thresholdRatio": n(s.pi.compaction.thresholdRatio),
|
||||||
}),
|
}),
|
||||||
@@ -40,6 +62,11 @@ const RUNTIME_CARD: CardSpec = {
|
|||||||
pi: {
|
pi: {
|
||||||
contextBudget: kToTokens(str(v["pi.contextBudget"])),
|
contextBudget: kToTokens(str(v["pi.contextBudget"])),
|
||||||
subagentsEnabled: v["pi.subagentsEnabled"] === true,
|
subagentsEnabled: v["pi.subagentsEnabled"] === true,
|
||||||
|
subagents: {
|
||||||
|
enabled: v["pi.subagentsEnabled"] === true,
|
||||||
|
maxConcurrent: num(v["pi.subagents.maxConcurrent"]),
|
||||||
|
maxDepth: num(v["pi.subagents.maxDepth"]),
|
||||||
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
enabled: v["pi.compaction.enabled"] === true,
|
enabled: v["pi.compaction.enabled"] === true,
|
||||||
thresholdRatio: num(v["pi.compaction.thresholdRatio"]),
|
thresholdRatio: num(v["pi.compaction.thresholdRatio"]),
|
||||||
@@ -50,7 +77,7 @@ const RUNTIME_CARD: CardSpec = {
|
|||||||
|
|
||||||
// 2026-10-06:不设每日次数 / 每日花费 / 单次花费 / 单次 token 上限(旧值兼容读取、不生效),
|
// 2026-10-06:不设每日次数 / 每日花费 / 单次花费 / 单次 token 上限(旧值兼容读取、不生效),
|
||||||
// 只保留单次工具调用次数与时长两个防跑飞限制;花费在这里与运行状态里可见
|
// 只保留单次工具调用次数与时长两个防跑飞限制;花费在这里与运行状态里可见
|
||||||
const BUDGET_CARD: CardSpec = {
|
export const BUDGET_CARD: CardSpec = {
|
||||||
anchor: "budget",
|
anchor: "budget",
|
||||||
title: "用量与限制",
|
title: "用量与限制",
|
||||||
description: "不限次数和花费。单次运行只限制工具调用次数和时长,防止跑飞。",
|
description: "不限次数和花费。单次运行只限制工具调用次数和时长,防止跑飞。",
|
||||||
@@ -75,10 +102,10 @@ const BUDGET_CARD: CardSpec = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const INVESTIGATE_CARD: CardSpec = {
|
export const INVESTIGATE_CARD: CardSpec = {
|
||||||
anchor: "investigate",
|
anchor: "investigate",
|
||||||
title: "自动调查",
|
title: "自动处理",
|
||||||
description: "下载完识别不到作品时叫智能体识别;下载失败、集数对不上等问题出现时,自动发起一次有界调查。",
|
description: "整理时识别不到作品就叫智能体识别;下载失败、集数对不上等问题出现时,自动发起一次有界调查。",
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "agent.autoIdentifyOrganize",
|
name: "agent.autoIdentifyOrganize",
|
||||||
@@ -144,13 +171,13 @@ const INVESTIGATE_CARD: CardSpec = {
|
|||||||
|
|
||||||
const DEV_ONLY_FIELDS = new Set(["agent.sessionDir", "agent.fakeScript"]);
|
const DEV_ONLY_FIELDS = new Set(["agent.sessionDir", "agent.fakeScript"]);
|
||||||
|
|
||||||
const ADVANCED_CARD: CardSpec = {
|
export const ADVANCED_CARD: CardSpec = {
|
||||||
anchor: "advanced",
|
anchor: "advanced",
|
||||||
title: "高级",
|
title: "高级",
|
||||||
// 会话目录与 fake 脚本是开发调试项,生产构建不显示(DEAI-2)
|
// 会话目录与 fake 脚本是开发调试项,生产构建不显示(DEAI-2)
|
||||||
fields: (
|
fields: (
|
||||||
[
|
[
|
||||||
{ name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" },
|
{ name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、整理结束时的检查间隔" },
|
||||||
{
|
{
|
||||||
name: "agent.goalWaitHours",
|
name: "agent.goalWaitHours",
|
||||||
label: "目标最长等待(小时)",
|
label: "目标最长等待(小时)",
|
||||||
@@ -223,15 +250,15 @@ export function AgentSection() {
|
|||||||
<SettingsGate>
|
<SettingsGate>
|
||||||
{(s) => (
|
{(s) => (
|
||||||
<>
|
<>
|
||||||
<SettingsCard section="agent" spec={RUNTIME_CARD} settings={s} />
|
|
||||||
<SettingsCard section="agent" spec={BUDGET_CARD} settings={s} />
|
<SettingsCard section="agent" spec={BUDGET_CARD} settings={s} />
|
||||||
<SettingsCard section="agent" spec={INVESTIGATE_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={WEBSEARCH_CARD} settings={s} />
|
||||||
<SettingsCard section="agent" spec={ADVANCED_CARD} settings={s} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SettingsGate>
|
</SettingsGate>
|
||||||
<AgentToolsBlock />
|
<AgentToolsBlock />
|
||||||
|
<SettingsGate>{(s) => <SettingsCard section="agent" spec={ADVANCED_CARD} settings={s} />}</SettingsGate>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -102,10 +102,10 @@ export function ApiKeysSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsBlock
|
<SettingsBlock
|
||||||
section="api-keys"
|
section="account"
|
||||||
anchor="keys"
|
anchor="api-keys"
|
||||||
title="已签发的 Key"
|
title="API Key"
|
||||||
description="给媒体服务器插件、脚本等外部程序用,按权限范围授权。本机 Token 在「账户与安全」。"
|
description="签发给媒体服务器插件、脚本等外部程序,按权限范围授权。浏览器自用的本机 Token 在上面的「会话」里。"
|
||||||
plain
|
plain
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" onClick={() => setOpen(true)}>
|
<Button size="sm" onClick={() => setOpen(true)}>
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export function BrowserProvidersBlock() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "accessKey",
|
name: "accessKey",
|
||||||
label: "访问凭据",
|
label: "访问凭证",
|
||||||
kind: "secret",
|
kind: "secret",
|
||||||
secret: { set: (v) => v["accessKey:set"] === true, clearable: true },
|
secret: { set: (v) => v["accessKey:set"] === true, clearable: true },
|
||||||
hint: "camofox 的 CAMOFOX_ACCESS_KEY;没设就留空",
|
hint: "camofox 的 CAMOFOX_ACCESS_KEY;没设就留空",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,15 +82,7 @@ function authKindOf(r: DownloaderInstance): string {
|
|||||||
return r.username ? "password" : r.secretRef ? "api_key" : "none";
|
return r.username ? "password" : r.secretRef ? "api_key" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadersSection() {
|
/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用「下载器」分区的缺省值 */
|
||||||
return (
|
|
||||||
<SettingsBlock section="downloaders" anchor="instances" title="qBittorrent 实例" plain>
|
|
||||||
<DownloadersTable />
|
|
||||||
</SettingsBlock>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用全局 */
|
|
||||||
const POLICY_FIELDS = [
|
const POLICY_FIELDS = [
|
||||||
["hardPct", "hardThresholdPercent"],
|
["hardPct", "hardThresholdPercent"],
|
||||||
["resumePct", "resumePercent"],
|
["resumePct", "resumePercent"],
|
||||||
@@ -114,7 +106,7 @@ function policyBody(v: FormValues): Record<string, number | boolean> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DownloadersTable() {
|
export function DownloadersTable() {
|
||||||
const list = downloaders.useList();
|
const list = downloaders.useList();
|
||||||
const create = downloaders.useCreate();
|
const create = downloaders.useCreate();
|
||||||
const update = downloaders.useUpdate();
|
const update = downloaders.useUpdate();
|
||||||
@@ -192,19 +184,19 @@ function DownloadersTable() {
|
|||||||
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
|
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
|
||||||
{
|
{
|
||||||
name: "hardPct",
|
name: "hardPct",
|
||||||
label: "硬阈值(%)",
|
label: "暂停线(%)",
|
||||||
kind: "number",
|
kind: "number",
|
||||||
placeholder: "留空用全局",
|
placeholder: "留空用缺省值",
|
||||||
hint: "这个下载器的缓存盘用到多少就停止新增下载;多个下载器共用一块盘时取最严格的",
|
hint: "这个下载器的下载盘用到多少就停止新增下载;多个下载器共用一块盘时取最严格的",
|
||||||
},
|
},
|
||||||
{ name: "resumePct", label: "恢复水位(%)", kind: "number", placeholder: "留空用全局" },
|
{ name: "resumePct", label: "恢复线(%)", kind: "number", placeholder: "留空用缺省值" },
|
||||||
{ name: "targetPct", label: "清理目标(%)", kind: "number", placeholder: "留空用全局" },
|
{ name: "targetPct", label: "清理目标(%)", kind: "number", placeholder: "留空用缺省值" },
|
||||||
{
|
{
|
||||||
name: "seedDays",
|
name: "seedDays",
|
||||||
label: "保种天数",
|
label: "保种天数",
|
||||||
kind: "number",
|
kind: "number",
|
||||||
placeholder: "留空用全局",
|
placeholder: "留空用缺省值",
|
||||||
hint: "入库后至少保留几天再允许清理;公网 BT 可填 0(站点 H&R 要求仍然遵守)",
|
hint: "整理后至少保留几天再允许清理;公网 BT 可填 0(站点 H&R 要求仍然遵守)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hnrUnknownCleanable",
|
name: "hnrUnknownCleanable",
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
|
import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { setUiPref, useUiPrefs } from "~/lib/ui-prefs.ts";
|
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() {
|
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();
|
const prefs = useUiPrefs();
|
||||||
return (
|
return (
|
||||||
<SettingsBlock section="interface" anchor="display" title="显示">
|
<SettingsBlock section="interface" anchor="display" title="显示">
|
||||||
@@ -18,6 +31,7 @@ export function InterfaceSection() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
id="f-ui.techInfoExpanded"
|
||||||
aria-labelledby="ui-techinfo-label"
|
aria-labelledby="ui-techinfo-label"
|
||||||
checked={prefs.techInfoExpanded}
|
checked={prefs.techInfoExpanded}
|
||||||
onCheckedChange={(v) => setUiPref("techInfoExpanded", v)}
|
onCheckedChange={(v) => setUiPref("techInfoExpanded", v)}
|
||||||
|
|||||||
@@ -1,110 +1,25 @@
|
|||||||
import { LibraryRootsTable } from "./instances.tsx";
|
import { LibraryRootsTable } from "./instances.tsx";
|
||||||
import { NamingSection } from "./naming.tsx";
|
import { NamingSection } from "./naming.tsx";
|
||||||
import { ClassificationSection, OrganizeSourcesSection } from "./organize-sources.tsx";
|
import { ClassificationSection, OrganizeSourcesSection } from "./organize-sources.tsx";
|
||||||
import { str } from "./resource-section.tsx";
|
|
||||||
import { SettingsBlock } from "./settings-block.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() {
|
export function LibrarySection() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-8">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
<SettingsBlock section="library" anchor="roots" title="媒体库目录" plain>
|
<SettingsBlock
|
||||||
|
section="library"
|
||||||
|
anchor="roots"
|
||||||
|
title="媒体库目录"
|
||||||
|
description="整理把作品放进这些目录;媒体服务器扫的也是它们。"
|
||||||
|
plain
|
||||||
|
>
|
||||||
<LibraryRootsTable />
|
<LibraryRootsTable />
|
||||||
</SettingsBlock>
|
</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 />
|
<OrganizeSourcesSection />
|
||||||
|
<ClassificationSection />
|
||||||
<NamingSection />
|
<NamingSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import { DEFAULT_SETTINGS } from "@umt/contracts";
|
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 { secretPatch } from "~/lib/secret.ts";
|
||||||
import { str } from "./resource-section.tsx";
|
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;
|
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];
|
return out.length ? [...new Set(out)].slice(0, 8) : [...DEFAULT_LANGUAGES];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TMDB_CARD: CardSpec = {
|
export const TMDB_CARD: CardSpec = {
|
||||||
anchor: "tmdb",
|
anchor: "tmdb",
|
||||||
title: "TMDB",
|
title: "TMDB",
|
||||||
description: "留空时用环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。",
|
description: "留空时用环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。",
|
||||||
@@ -49,6 +42,28 @@ const TMDB_CARD: CardSpec = {
|
|||||||
hint: "自建反代时填写;留空用官方地址",
|
hint: "自建反代时填写;留空用官方地址",
|
||||||
wide: true,
|
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",
|
name: "metadata.languages",
|
||||||
label: "语言回退链",
|
label: "语言回退链",
|
||||||
@@ -73,244 +88,18 @@ const TMDB_CARD: CardSpec = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
fromSettings: (s) => ({
|
fromSettings: (s) => ({
|
||||||
"metadata.tmdbApiKey": "",
|
|
||||||
"metadata.tmdbApiKey:set": !!s.metadata.tmdbApiKeySecretRef,
|
|
||||||
"metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
|
|
||||||
"metadata.languages": (s.metadata.languages ?? DEFAULT_LANGUAGES).join(", "),
|
"metadata.languages": (s.metadata.languages ?? DEFAULT_LANGUAGES).join(", "),
|
||||||
"metadata.includeSpecials": s.metadata.includeSpecials !== false,
|
"metadata.includeSpecials": s.metadata.includeSpecials !== false,
|
||||||
"metadata.overviewNonChineseFallback": s.metadata.overviewNonChineseFallback === true,
|
"metadata.overviewNonChineseFallback": s.metadata.overviewNonChineseFallback === true,
|
||||||
}),
|
}),
|
||||||
toPatch: (v) => {
|
toPatch: (v) => ({
|
||||||
const key = secretPatch(v["metadata.tmdbApiKey"]);
|
|
||||||
return {
|
|
||||||
metadata: {
|
metadata: {
|
||||||
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
|
|
||||||
languages: parseLanguages(str(v["metadata.languages"])),
|
languages: parseLanguages(str(v["metadata.languages"])),
|
||||||
includeSpecials: v["metadata.includeSpecials"] === true,
|
includeSpecials: v["metadata.includeSpecials"] === true,
|
||||||
overviewNonChineseFallback: v["metadata.overviewNonChineseFallback"] === 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"]),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
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() {
|
export function MetadataSection() {
|
||||||
return (
|
return (
|
||||||
@@ -318,9 +107,7 @@ export function MetadataSection() {
|
|||||||
{(s) => (
|
{(s) => (
|
||||||
<>
|
<>
|
||||||
<SettingsCard section="metadata" spec={TMDB_CARD} settings={s} />
|
<SettingsCard section="metadata" spec={TMDB_CARD} settings={s} />
|
||||||
<SettingsCard section="metadata" spec={TMDB_CACHE_CARD} settings={s} />
|
<SettingsCard section="metadata" spec={LANGUAGE_CARD} settings={s} />
|
||||||
<SettingsCard section="metadata" spec={IDENTITY_CARD} settings={s} />
|
|
||||||
<SettingsCard section="metadata" spec={IMAGES_CARD} settings={s} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SettingsGate>
|
</SettingsGate>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function GlobalNaming() {
|
|||||||
const r = await save.mutateAsync({ ...draft, lockFileNameForBound: lock });
|
const r = await save.mutateAsync({ ...draft, lockFileNameForBound: lock });
|
||||||
setDraft({ movie: r.movie, tv: r.tv, variety: r.variety });
|
setDraft({ movie: r.movie, tv: r.tv, variety: r.variety });
|
||||||
setLock(r.lockFileNameForBound);
|
setLock(r.lockFileNameForBound);
|
||||||
toast.success("命名模板已保存");
|
toast.success("已保存「命名模板」");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const split = splitNamingErrors(errorMessage(err));
|
const split = splitNamingErrors(errorMessage(err));
|
||||||
if (split) setErrors(split);
|
if (split) setErrors(split);
|
||||||
@@ -120,7 +120,7 @@ function RootNaming() {
|
|||||||
<SettingsBlock
|
<SettingsBlock
|
||||||
section="library"
|
section="library"
|
||||||
anchor="naming-roots"
|
anchor="naming-roots"
|
||||||
title="媒体库目录命名覆盖"
|
title="目录命名覆盖"
|
||||||
description="按类型覆盖全局模板;留空的类型沿用全局。"
|
description="按类型覆盖全局模板;留空的类型沿用全局。"
|
||||||
>
|
>
|
||||||
{roots.isPending ? (
|
{roots.isPending ? (
|
||||||
@@ -177,7 +177,7 @@ function RootNamingEditor({ root }: { root: LibraryRoot }) {
|
|||||||
try {
|
try {
|
||||||
await save.mutateAsync({ id: root.id, naming: body });
|
await save.mutateAsync({ id: root.id, naming: body });
|
||||||
setDraft({ ...(body ?? {}) });
|
setDraft({ ...(body ?? {}) });
|
||||||
toast.success(body ? "媒体库目录命名覆盖已保存" : "已清除媒体库目录命名覆盖");
|
toast.success(body ? "已保存「目录命名覆盖」" : "已清除「目录命名覆盖」");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const split = splitNamingErrors(errorMessage(err));
|
const split = splitNamingErrors(errorMessage(err));
|
||||||
if (split) setErrors(split);
|
if (split) setErrors(split);
|
||||||
|
|||||||
@@ -85,22 +85,25 @@ export function ClassificationSection() {
|
|||||||
2020-2025 范围)。
|
2020-2025 范围)。
|
||||||
</p>
|
</p>
|
||||||
<SaveBar dirty={dirty}>
|
<SaveBar dirty={dirty}>
|
||||||
<Button variant="outline" disabled={!dirty || save.isPending} onClick={() => setDraft(null)}>
|
{dirty ? (
|
||||||
撤销
|
<Button variant="ghost" size="sm" disabled={save.isPending} onClick={() => setDraft(null)}>
|
||||||
|
还原
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
size="sm"
|
||||||
disabled={!dirty || !yaml.trim() || save.isPending}
|
disabled={!dirty || !yaml.trim() || save.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
save.mutate(yaml, {
|
save.mutate(yaml, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
toast.success("分类规则已保存");
|
toast.success("已保存「分类规则」");
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(filesErrorText(e, "保存分类规则失败")),
|
onError: (e) => toast.error(filesErrorText(e, "保存分类规则失败")),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{save.isPending ? "保存中…" : "保存规则"}
|
{save.isPending ? "保存中…" : "保存"}
|
||||||
</Button>
|
</Button>
|
||||||
</SaveBar>
|
</SaveBar>
|
||||||
</SettingsBlock>
|
</SettingsBlock>
|
||||||
@@ -219,7 +222,7 @@ function SourceEditor({ initial, onClose }: { initial: OrganizeSourceView | null
|
|||||||
onChange={(e) => patch({ action: e.target.value as "copy" | "move" })}
|
onChange={(e) => patch({ action: e.target.value as "copy" | "move" })}
|
||||||
>
|
>
|
||||||
<option value="copy">复制,保留源文件</option>
|
<option value="copy">复制,保留源文件</option>
|
||||||
<option value="move">移动,入库后删源</option>
|
<option value="move">移动,整理后删源</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="触发方式">
|
<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 {
|
try {
|
||||||
await update.mutateAsync({ id: upstream.id, body: parsed.data });
|
await update.mutateAsync({ id: upstream.id, body: parsed.data });
|
||||||
setV((s) => ({ ...s, apiKey: "" }));
|
setV((s) => ({ ...s, apiKey: "" }));
|
||||||
toast.success(`${v.name.trim()} 已保存`);
|
toast.success(`已保存「${v.name.trim()}」`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errorMessage(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;
|
||||||
|
}
|
||||||
@@ -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 {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
|
Database,
|
||||||
|
Download,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
KeyRound,
|
Globe,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Monitor,
|
Monitor,
|
||||||
Server,
|
Search,
|
||||||
|
Settings2,
|
||||||
Shield,
|
Shield,
|
||||||
Tags,
|
Tags,
|
||||||
Tv,
|
Tv,
|
||||||
UserCog,
|
Wand2,
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AccountSection } from "./account.tsx";
|
import { AccountSection } from "./account.tsx";
|
||||||
|
import { AcquireSection } from "./acquire.tsx";
|
||||||
import { AgentSection } from "./agent.tsx";
|
import { AgentSection } from "./agent.tsx";
|
||||||
import { ApiKeysSection } from "./api-keys.tsx";
|
import { CacheSection } from "./cache.tsx";
|
||||||
import { AutomationSection } from "./automation.tsx";
|
import { DownloadersSection } from "./downloaders.tsx";
|
||||||
import { DownloadersSection, MediaServersSection } from "./instances.tsx";
|
import { MediaServersSection } from "./instances.tsx";
|
||||||
import { InterfaceSection } from "./interface.tsx";
|
import { InterfaceSection } from "./interface.tsx";
|
||||||
import { LibrarySection } from "./library.tsx";
|
import { LibrarySection } from "./library.tsx";
|
||||||
import { MetadataSection } from "./metadata.tsx";
|
import { MetadataSection } from "./metadata.tsx";
|
||||||
|
import { OrganizeSection } from "./organize.tsx";
|
||||||
import { SitesSection } from "./sites.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 }> = {
|
const UI: Record<SettingsSectionId, { icon: LucideIcon; render: () => ReactNode }> = {
|
||||||
automation: { icon: Zap, render: () => <AutomationSection /> },
|
downloaders: { icon: Download, render: () => <DownloadersSection /> },
|
||||||
sites: { icon: UserCog, render: () => <SitesSection /> },
|
|
||||||
downloaders: { icon: Server, render: () => <DownloadersSection /> },
|
|
||||||
library: { icon: FolderTree, render: () => <LibrarySection /> },
|
|
||||||
"media-servers": { icon: Tv, render: () => <MediaServersSection /> },
|
"media-servers": { icon: Tv, render: () => <MediaServersSection /> },
|
||||||
|
sites: { icon: Globe, render: () => <SitesSection /> },
|
||||||
metadata: { icon: Tags, render: () => <MetadataSection /> },
|
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 /> },
|
agent: { icon: Bot, render: () => <AgentSection /> },
|
||||||
"api-keys": { icon: KeyRound, render: () => <ApiKeysSection /> },
|
system: { icon: Settings2, render: () => <SystemSection /> },
|
||||||
interface: { icon: Monitor, render: () => <InterfaceSection /> },
|
cache: { icon: Database, render: () => <CacheSection /> },
|
||||||
account: { icon: Shield, render: () => <AccountSection /> },
|
account: { icon: Shield, render: () => <AccountSection /> },
|
||||||
|
interface: { icon: Monitor, render: () => <InterfaceSection /> },
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SectionView = SettingsSectionDef & { icon: LucideIcon; render: () => ReactNode };
|
export type SectionView = SettingsSectionDef & { icon: LucideIcon; render: () => ReactNode };
|
||||||
export const SECTIONS: SectionView[] = SETTINGS_SECTIONS.map((s) => ({ ...s, ...UI[s.id] }));
|
export const SECTIONS: SectionView[] = SETTINGS_SECTIONS.map((s) => ({ ...s, ...UI[s.id] }));
|
||||||
|
|
||||||
/** URL hash:`#<分区>` 或 `#<分区>/<节>`;不认识的落到默认分区 */
|
/** 左栏分组:接入 / 流程 / 系统 */
|
||||||
export function parseSettingsHash(hash: string): { section: SectionView; anchor: string | null } {
|
export const SECTION_GROUPS = SETTINGS_GROUPS.map((g) => ({
|
||||||
const [id, anchor] = decodeURIComponent(hash.replace(/^#/, "")).split("/");
|
...g,
|
||||||
const section = SECTIONS.find((s) => s.id === id) ?? (SECTIONS[0] as SectionView);
|
sections: SECTIONS.filter((s) => s.group === g.id),
|
||||||
return { section, anchor: section.id === id && anchor ? anchor : null };
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}` : ""}`;
|
export const settingsHash = (section: string, anchor?: string | null) => `#${section}${anchor ? `/${anchor}` : ""}`;
|
||||||
@@ -79,7 +79,7 @@ export function SettingsCard({
|
|||||||
try {
|
try {
|
||||||
const next = await update.mutateAsync(patch);
|
const next = await update.mutateAsync(patch);
|
||||||
setValues(spec.fromSettings(next));
|
setValues(spec.fromSettings(next));
|
||||||
toast.success(`${spec.title} 已保存`);
|
toast.success(`已保存「${spec.title}」`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errorMessage(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,12 +6,12 @@ import { BrowserProvidersBlock } from "./browser-providers.tsx";
|
|||||||
import { num, str } from "./resource-section.tsx";
|
import { num, str } from "./resource-section.tsx";
|
||||||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.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",
|
anchor: "defaults",
|
||||||
title: "默认值",
|
title: "站点缺省值",
|
||||||
description: "站点没有单独设置时使用。限流不会比站点定义自带的更快。",
|
description: "站点没有单独设置时使用。限流不会比站点定义自带的更快。",
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -51,22 +51,6 @@ const DEFAULTS_CARD: CardSpec = {
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
hint: "拉取用户数据时最多翻几页做种列表;1–50",
|
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) => ({
|
fromSettings: (s) => ({
|
||||||
"sites.defaultRateLimit.requestsPerMinute": n(s.sites.defaultRateLimit.requestsPerMinute),
|
"sites.defaultRateLimit.requestsPerMinute": n(s.sites.defaultRateLimit.requestsPerMinute),
|
||||||
@@ -76,8 +60,6 @@ const DEFAULTS_CARD: CardSpec = {
|
|||||||
"sites.defaultSnapshotMaxPages": n(s.sites.defaultSnapshotMaxPages),
|
"sites.defaultSnapshotMaxPages": n(s.sites.defaultSnapshotMaxPages),
|
||||||
"sites.defaultHealthCheckIntervalSec": n(s.sites.defaultHealthCheckIntervalSec / 3600),
|
"sites.defaultHealthCheckIntervalSec": n(s.sites.defaultHealthCheckIntervalSec / 3600),
|
||||||
"sites.defaultDetailCacheSec": n(s.sites.defaultDetailCacheSec / 3600),
|
"sites.defaultDetailCacheSec": n(s.sites.defaultDetailCacheSec / 3600),
|
||||||
"sites.userAgent": s.sites.userAgent,
|
|
||||||
"sites.proxyUrl": s.sites.proxyUrl ?? "",
|
|
||||||
}),
|
}),
|
||||||
toPatch: (v, s) => {
|
toPatch: (v, s) => {
|
||||||
const min = num(v["sites.defaultSnapshotIntervalSec"]);
|
const min = num(v["sites.defaultSnapshotIntervalSec"]);
|
||||||
@@ -97,12 +79,44 @@ const DEFAULTS_CARD: CardSpec = {
|
|||||||
defaultSnapshotMaxPages: num(v["sites.defaultSnapshotMaxPages"]),
|
defaultSnapshotMaxPages: num(v["sites.defaultSnapshotMaxPages"]),
|
||||||
defaultHealthCheckIntervalSec: healthH === undefined ? undefined : Math.round(healthH * 3600),
|
defaultHealthCheckIntervalSec: healthH === undefined ? undefined : Math.round(healthH * 3600),
|
||||||
defaultDetailCacheSec: detailH === undefined ? undefined : Math.round(detailH * 3600),
|
defaultDetailCacheSec: detailH === undefined ? undefined : Math.round(detailH * 3600),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 表示"不改")
|
// 空串 = 跟随浏览器(要能清空,不能用 undefined 表示"不改")
|
||||||
userAgent: str(v["sites.userAgent"]),
|
userAgent: str(v["sites.userAgent"]),
|
||||||
proxyUrl: str(v["sites.proxyUrl"]) || null,
|
proxyUrl: str(v["sites.proxyUrl"]) || null,
|
||||||
},
|
},
|
||||||
};
|
}),
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SitesSection() {
|
export function SitesSection() {
|
||||||
@@ -110,7 +124,7 @@ export function SitesSection() {
|
|||||||
<div className="grid grid-cols-1 gap-8">
|
<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">
|
<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">
|
<p className="text-caption text-muted-foreground">
|
||||||
站点和凭证在「站点」页管理;单个站点的限流、周期、代理在站点的"编辑"里改。
|
站点、凭证和 Cloudflare 验证在「站点」页管理;单个站点的限流、周期、代理在站点的「编辑」里改。
|
||||||
</p>
|
</p>
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button asChild size="sm" variant="outline">
|
||||||
<Link to={consolePath("sites")}>
|
<Link to={consolePath("sites")}>
|
||||||
@@ -119,7 +133,14 @@ export function SitesSection() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 />
|
<BrowserProvidersBlock />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+24
-147
@@ -1,103 +1,25 @@
|
|||||||
import { ChevronDown, Pause, Play, ShieldAlert } from "lucide-react";
|
import { Pause, Play, ShieldAlert } from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useState } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useLocation } from "react-router";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useUpdateSettings } from "~/api/settings.ts";
|
import { useUpdateSettings } from "~/api/settings.ts";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { type FormValues, num } from "./resource-section.tsx";
|
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";
|
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||||
|
|
||||||
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、后台任务并发(WK-1)、存储预算与保留(CACHE-2)
|
// 系统分区:自动化总开关、只读对账、后台任务并发(WK-1)、存储预算与流水数据保留(CACHE-2)。
|
||||||
|
// 下载盘水位与保种在「下载器」,每日下载预算在「获取」,缓存有效期在「缓存」。
|
||||||
|
|
||||||
const GiB = 1024 ** 3;
|
// CACHE-2:存储预算、流水保留期(settings.storage,docs/runbook/storage.md)
|
||||||
|
|
||||||
const CACHE_CARD: CardSpec = {
|
|
||||||
anchor: "cache",
|
|
||||||
title: "下载盘",
|
|
||||||
description: "下载盘用量到暂停线时不再新增下载,并暂停本工具的下载任务;降到恢复线以下只恢复因此暂停的任务。",
|
|
||||||
fields: [
|
|
||||||
{ name: "cache.hardThresholdPercent", label: "暂停线(%)", kind: "number" },
|
|
||||||
{ name: "cache.resumePercent", label: "恢复线(%)", kind: "number", hint: "低于暂停线" },
|
|
||||||
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复线" },
|
|
||||||
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
|
|
||||||
],
|
|
||||||
fromSettings: (s) => ({
|
|
||||||
"cache.hardThresholdPercent": n(s.cache.hardThresholdPercent),
|
|
||||||
"cache.resumePercent": n(s.cache.resumePercent),
|
|
||||||
"cache.cleanupTargetPercent": n(s.cache.cleanupTargetPercent),
|
|
||||||
"cache.checkIntervalSec": n(s.cache.checkIntervalSec),
|
|
||||||
}),
|
|
||||||
toPatch: (v) => ({
|
|
||||||
cache: {
|
|
||||||
hardThresholdPercent: num(v["cache.hardThresholdPercent"]),
|
|
||||||
resumePercent: num(v["cache.resumePercent"]),
|
|
||||||
cleanupTargetPercent: num(v["cache.cleanupTargetPercent"]),
|
|
||||||
checkIntervalSec: num(v["cache.checkIntervalSec"]),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const BUDGET_CARD: CardSpec = {
|
|
||||||
anchor: "budget",
|
|
||||||
title: "每日预算",
|
|
||||||
description: "任一项到上限后不再开始新的下载;前两项按自然日计。",
|
|
||||||
fields: [
|
|
||||||
{ name: "budget.maxNewTitlesPerDay", label: "每日新作品数", kind: "number" },
|
|
||||||
{ name: "budget.maxDownloadGiBPerDay", label: "每日下载量(GiB)", kind: "number" },
|
|
||||||
{ name: "budget.maxConcurrentDownloads", label: "最大并发下载", kind: "number", hint: "1–100" },
|
|
||||||
],
|
|
||||||
fromSettings: (s) => ({
|
|
||||||
"budget.maxNewTitlesPerDay": n(s.budget.maxNewTitlesPerDay),
|
|
||||||
"budget.maxDownloadGiBPerDay": n(Math.round((s.budget.maxDownloadBytesPerDay / GiB) * 100) / 100),
|
|
||||||
"budget.maxConcurrentDownloads": n(s.budget.maxConcurrentDownloads),
|
|
||||||
}),
|
|
||||||
toPatch: (v) => {
|
|
||||||
const gib = num(v["budget.maxDownloadGiBPerDay"]);
|
|
||||||
return {
|
|
||||||
budget: {
|
|
||||||
maxNewTitlesPerDay: num(v["budget.maxNewTitlesPerDay"]),
|
|
||||||
maxDownloadBytesPerDay: gib === undefined ? undefined : Math.round(gib * GiB),
|
|
||||||
maxConcurrentDownloads: num(v["budget.maxConcurrentDownloads"]),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const RETENTION_CARD: CardSpec = {
|
|
||||||
anchor: "retention",
|
|
||||||
title: "保留",
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: "retention.seedRetentionDays",
|
|
||||||
label: "源文件保留天数",
|
|
||||||
kind: "number",
|
|
||||||
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
|
|
||||||
},
|
|
||||||
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number" },
|
|
||||||
],
|
|
||||||
fromSettings: (s) => ({
|
|
||||||
"retention.seedRetentionDays": n(s.retention.seedRetentionDays),
|
|
||||||
"retention.logRetentionDays": n(s.retention.logRetentionDays),
|
|
||||||
}),
|
|
||||||
toPatch: (v) => ({
|
|
||||||
retention: {
|
|
||||||
seedRetentionDays: num(v["retention.seedRetentionDays"]),
|
|
||||||
logRetentionDays: num(v["retention.logRetentionDays"]),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// CACHE-2:存储预算、流水保留期、缓存有效期(settings.storage,docs/runbook/storage.md)
|
|
||||||
const gbField = (k: string, label: string, hint?: string) => ({
|
const gbField = (k: string, label: string, hint?: string) => ({
|
||||||
name: `storage.budgets.${k}`,
|
name: `storage.budgets.${k}`,
|
||||||
label,
|
label,
|
||||||
kind: "number" as const,
|
kind: "number" as const,
|
||||||
hint,
|
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}`,
|
name: `storage.${group}.${k}`,
|
||||||
label,
|
label,
|
||||||
kind: "number" as const,
|
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 分道」)
|
// WK-1:io worker 三条道的并发(settings.automation.workerLanes,docs/runbook/dev.md「IO Worker 分道」)
|
||||||
const LANE_KEYS = ["file", "network", "maintenance"] as const;
|
const LANE_KEYS = ["file", "network", "maintenance"] as const;
|
||||||
const WORKER_LANES_CARD: CardSpec = {
|
export const WORKER_LANES_CARD: CardSpec = {
|
||||||
anchor: "worker-lanes",
|
anchor: "worker-lanes",
|
||||||
title: "后台任务并发",
|
title: "后台任务并发",
|
||||||
description:
|
description:
|
||||||
@@ -134,7 +56,7 @@ const WORKER_LANES_CARD: CardSpec = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const BUDGET_KEYS = ["pgDataGb", "pgDataWarnGb", "imagesGb", "backupsGb", "sessionsEvidenceGb"] as const;
|
const BUDGET_KEYS = ["pgDataGb", "pgDataWarnGb", "imagesGb", "backupsGb", "sessionsEvidenceGb"] as const;
|
||||||
const STORAGE_BUDGET_CARD: CardSpec = {
|
export const STORAGE_BUDGET_CARD: CardSpec = {
|
||||||
anchor: "storage",
|
anchor: "storage",
|
||||||
title: "存储预算",
|
title: "存储预算",
|
||||||
description: "超过预算时健康页告警;图片缓存超出容量按最久未访问删除。单位 GB。",
|
description: "超过预算时健康页告警;图片缓存超出容量按最久未访问删除。单位 GB。",
|
||||||
@@ -159,9 +81,9 @@ const RETENTION_KEYS = [
|
|||||||
"capabilityStats",
|
"capabilityStats",
|
||||||
"identityPoolArchive",
|
"identityPoolArchive",
|
||||||
] as const;
|
] as const;
|
||||||
const STORAGE_RETENTION_CARD: CardSpec = {
|
export const STORAGE_RETENTION_CARD: CardSpec = {
|
||||||
anchor: "storage-retention",
|
anchor: "storage-retention",
|
||||||
title: "流水数据保留",
|
title: "数据保留",
|
||||||
description: "到期由定时清理任务分批删除。单位天,心跳为小时。",
|
description: "到期由定时清理任务分批删除。单位天,心跳为小时。",
|
||||||
fields: [
|
fields: [
|
||||||
dayField("retentionDays", "observation", "RSS 抓取记录"),
|
dayField("retentionDays", "observation", "RSS 抓取记录"),
|
||||||
@@ -183,22 +105,6 @@ const STORAGE_RETENTION_CARD: CardSpec = {
|
|||||||
toPatch: (v) => ({ storage: { retentionDays: pick(v, "storage.retentionDays", RETENTION_KEYS) } }),
|
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({
|
function ToggleRow({
|
||||||
id,
|
id,
|
||||||
icon,
|
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() {
|
function ReadOnlySwitch() {
|
||||||
const update = useUpdateSettings();
|
const update = useUpdateSettings();
|
||||||
return (
|
return (
|
||||||
<SettingsGate>
|
<SettingsGate>
|
||||||
{(s) => (
|
{(s) => (
|
||||||
<SettingsBlock section="automation" anchor="readonly" title="只读对账">
|
<SettingsBlock
|
||||||
|
section="system"
|
||||||
|
anchor="readonly"
|
||||||
|
title="只读对账"
|
||||||
|
description="从备份恢复后先开启,核对完再关闭。切换后立即生效。"
|
||||||
|
>
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
id="tg-readonly"
|
id="tg-readonly"
|
||||||
icon={<ShieldAlert className="h-4 w-4" />}
|
icon={<ShieldAlert className="h-4 w-4" />}
|
||||||
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
|
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
|
||||||
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
|
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
|
||||||
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
|
description="只核对库存与路径,不做自动写入。"
|
||||||
checked={s.automation.readOnlyReconcile}
|
checked={s.automation.readOnlyReconcile}
|
||||||
disabled={update.isPending}
|
disabled={update.isPending}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
@@ -307,7 +190,7 @@ function Switches() {
|
|||||||
return (
|
return (
|
||||||
<SettingsGate>
|
<SettingsGate>
|
||||||
{(s) => (
|
{(s) => (
|
||||||
<SettingsBlock section="automation" anchor="switches" title="开关" description="切换后立即生效。">
|
<SettingsBlock section="system" anchor="switches" title="自动化" description="切换后立即生效。">
|
||||||
<div className="divide-y divide-border/60">
|
<div className="divide-y divide-border/60">
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
id="tg-automation"
|
id="tg-automation"
|
||||||
@@ -326,23 +209,17 @@ function Switches() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutomationSection() {
|
export function SystemSection() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-8">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
<Switches />
|
<Switches />
|
||||||
|
<ReadOnlySwitch />
|
||||||
<SettingsGate>
|
<SettingsGate>
|
||||||
{(s) => (
|
{(s) => (
|
||||||
<>
|
<>
|
||||||
<SettingsCard section="automation" spec={CACHE_CARD} settings={s} />
|
<SettingsCard section="system" spec={WORKER_LANES_CARD} settings={s} />
|
||||||
<SettingsCard section="automation" spec={BUDGET_CARD} settings={s} />
|
<SettingsCard section="system" spec={STORAGE_BUDGET_CARD} settings={s} />
|
||||||
<SettingsCard section="automation" spec={RETENTION_CARD} settings={s} />
|
<SettingsCard section="system" spec={STORAGE_RETENTION_CARD} settings={s} />
|
||||||
<Advanced>
|
|
||||||
<ReadOnlySwitch />
|
|
||||||
<SettingsCard section="automation" spec={WORKER_LANES_CARD} settings={s} />
|
|
||||||
<SettingsCard section="automation" spec={STORAGE_BUDGET_CARD} settings={s} />
|
|
||||||
<SettingsCard section="automation" spec={STORAGE_RETENTION_CARD} settings={s} />
|
|
||||||
<SettingsCard section="automation" spec={STORAGE_TTL_CARD} settings={s} />
|
|
||||||
</Advanced>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SettingsGate>
|
</SettingsGate>
|
||||||
@@ -2,9 +2,18 @@ import { settingsSectionForLegacyTab } from "@umt/contracts";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Link, redirect, useLocation, useNavigate } from "react-router";
|
import { Link, redirect, useLocation, useNavigate } from "react-router";
|
||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
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 { 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";
|
import { cn } from "~/lib/utils.ts";
|
||||||
|
|
||||||
export const meta = () => [{ title: "设置 · UHD 媒体自动化控制台" }];
|
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) : ""}`);
|
return redirect(`${url.pathname}${rest ? `?${rest}` : ""}${hit ? settingsHash(hit.section, hit.anchor) : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 搜索选中字段时经 location.state 带过来,对齐到节后聚焦这个字段 */
|
||||||
|
type NavState = { field?: string } | null;
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
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 秒内反复校正,直到用户自己滚动为止。
|
// 上方内容变高会把目标挤下去,所以在 2.5 秒内反复校正,直到用户自己滚动为止。
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: location.key 让重复点同一锚点也能再跳一次
|
// biome-ignore lint/correctness/useExhaustiveDependencies: location.key 让重复点同一锚点也能再跳一次
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (moved) return;
|
||||||
if (!anchor) {
|
if (!anchor) {
|
||||||
window.scrollTo({ top: 0 });
|
window.scrollTo({ top: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
let focused = false;
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
@@ -43,11 +63,21 @@ export default function SettingsPage() {
|
|||||||
for (const e of events) window.addEventListener(e, stop, { passive: true });
|
for (const e of events) window.addEventListener(e, stop, { passive: true });
|
||||||
const align = () => {
|
const align = () => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
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));
|
const el = document.getElementById(anchorId(section.id, anchor));
|
||||||
if (el) {
|
if (el) {
|
||||||
const margin = Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
|
const margin = Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
|
||||||
if (Math.abs(el.getBoundingClientRect().top - margin) > 2) el.scrollIntoView({ block: "start" });
|
if (Math.abs(el.getBoundingClientRect().top - margin) > 2) el.scrollIntoView({ block: "start" });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (performance.now() - started < 2500) timer = window.setTimeout(align, 120);
|
if (performance.now() - started < 2500) timer = window.setTimeout(align, 120);
|
||||||
};
|
};
|
||||||
timer = window.setTimeout(align, 0);
|
timer = window.setTimeout(align, 0);
|
||||||
@@ -55,34 +85,69 @@ export default function SettingsPage() {
|
|||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
for (const e of events) window.removeEventListener(e, stop);
|
for (const e of events) window.removeEventListener(e, stop);
|
||||||
};
|
};
|
||||||
}, [section.id, anchor, location.key]);
|
}, [section.id, anchor, field, moved, location.key]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="设置" description="密钥保存后不再显示。" />
|
<PageHeader title="设置" description="按接入对象和业务环节分组。密钥保存后不再显示。" />
|
||||||
<div className="grid gap-6 lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-8">
|
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-8">
|
||||||
{/* 窄屏:顶部下拉 */}
|
{/* 窄屏:搜索 + 分区下拉 + 节横排 */}
|
||||||
<div className="lg:hidden">
|
<div className="grid gap-3 lg:hidden">
|
||||||
|
<SettingsSearch />
|
||||||
<Select value={section.id} onValueChange={(id) => navigate({ hash: settingsHash(id) })}>
|
<Select value={section.id} onValueChange={(id) => navigate({ hash: settingsHash(id) })}>
|
||||||
<SelectTrigger className="w-full" aria-label="设置分区">
|
<SelectTrigger className="w-full" aria-label="设置分区">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper">
|
<SelectContent position="popper">
|
||||||
{SECTIONS.map((s) => (
|
{SECTION_GROUPS.map((g) => (
|
||||||
|
<SelectGroup key={g.id}>
|
||||||
|
<SelectLabel>{g.label}</SelectLabel>
|
||||||
|
{g.sections.map((s) => (
|
||||||
<SelectItem key={s.id} value={s.id}>
|
<SelectItem key={s.id} value={s.id}>
|
||||||
<s.icon />
|
<s.icon />
|
||||||
{s.label}
|
{s.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 宽屏:左侧分区导航 */}
|
{/* 宽屏:搜索 + 左侧分组导航 */}
|
||||||
<nav aria-label="设置分区" className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<ul className="sticky top-20 grid max-h-[calc(100dvh-6rem)] gap-0.5 overflow-y-auto pb-2">
|
<div className="sticky top-20 grid max-h-[calc(100dvh-6rem)] gap-3 overflow-y-auto pb-2">
|
||||||
{SECTIONS.map((s) => {
|
<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;
|
const active = s.id === section.id;
|
||||||
return (
|
return (
|
||||||
<li key={s.id}>
|
<li key={s.id}>
|
||||||
@@ -120,7 +185,11 @@ export default function SettingsPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{/* 分区页头:标题 + 一句说明;窄屏由上方下拉承担,标题只留给读屏 */}
|
{/* 分区页头:标题 + 一句说明;窄屏由上方下拉承担,标题只留给读屏 */}
|
||||||
|
|||||||
+46
-39
@@ -147,33 +147,32 @@
|
|||||||
|
|
||||||
重定向统一写在各自路由模块的 `clientLoader` 里(或新增 `routes/console/redirects.ts` 集中表),`nav.test.ts` 增加"旧链接都能落到新路由"的用例。
|
重定向统一写在各自路由模块的 `clientLoader` 里(或新增 `routes/console/redirects.ts` 集中表),`nav.test.ts` 增加"旧链接都能落到新路由"的用例。
|
||||||
|
|
||||||
### 3.4 设置页分区(按接入对象)
|
### 3.4 设置页分区(2026-10-06 重排,替换 IA-2 方案)
|
||||||
|
|
||||||
| 分区 `section` | 字段 | 来自现有 | 存储 |
|
沿用 MP 的两条逻辑:**按「接入对象 / 业务环节」分区**,**对象管理与参数设定分开**;不照抄它「系统」Tab 过载的做法(下载器、媒体服务器、AI、TMDB 全塞一页再套子 Tab)。左栏三组,组内按用户找东西的顺序排;每个分区一句说明;接入对象类分区第一节是对象列表(实例 / 目录 / 模型服务),之后才是全局参数。分区、节、字段归属登记在 `packages/contracts/src/settings.ts` 的 `SETTINGS_SECTIONS`,测试断言每个在用的存储字段恰好归属一个分区、且出现在该分区页面上。
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 自动化 `automation` | 总开关、只读对账;**缓存**:硬阈值/恢复/清理目标/检查间隔;**每日预算**:新作品数、下载量、并发;**保留**:做种保留天数、日志保留天数 | 自动化 + 缓存与入库(①③ + 保留) | `automation` `cache` `budget.下载三项` `retention` |
|
| 组 | 分区 `id` | 一句说明 | 节(锚点) | 存储 / 对象 |
|
||||||
| 站点与账号 `sites` | 站点登记(名称、类型、地址、启用)、账号凭证(标签/类型/登录名/凭证)、浏览器服务(provider、能力 supported/verified 只读) | 站点凭证 + **新增站点表单**(C1)+ `browser.ts` | sites / secrets / browser API |
|
| --- | --- | --- | --- | --- |
|
||||||
| 下载器 `downloaders` | qB 实例全字段 + 健康 + 测试 | 下载器 | instances |
|
| 接入 | 下载器 `downloaders` | qBittorrent 实例与路径映射;下载盘水位和保种天数的缺省值,实例里可以单独改 | instances 实例 · cache 下载盘 · seeding 保种 | 实例(含路径映射、单实例暂停线 / 恢复线 / 清理目标 / 保种天数 / H&R 未知也可清理);`cache` 水位四项;`retention.seedRetentionDays` |
|
||||||
| 媒体库与目录 `library` | 锚点 `#roots` 库根;`#mappings` 路径映射(下挂在库根下展示);`#organize` 整理默认:默认 Copy/Move、跨卷 Move | 库根 + 路径映射 + 缓存与入库② | instances + `organize` |
|
| 接入 | 媒体服务器 `media-servers` | Emby / Jellyfin 实例与路径映射,只用来查作品有没有 | instances | 实例 |
|
||||||
| 媒体服务器 `media-servers` | Emby/Jellyfin 实例 | 媒体服务器 | instances |
|
| 接入 | 站点与网络 `sites` | 站点缺省值、User-Agent 与代理、浏览器服务;站点、凭证和 Cloudflare 在站点页 | defaults 站点缺省值 · network UA 与代理 · browser 浏览器服务 | `sites.*`;浏览器服务 |
|
||||||
| 元数据 `metadata` | TMDB Key(只写)、TMDB Base URL、状态(已配置 / 回退环境变量 / 未配置) | **新增 UI**(C5) | `metadata` |
|
| 接入 | 元数据 `metadata` | TMDB 接入、语言回退与特别篇 | tmdb · language 语言与季集 | `metadata.*` |
|
||||||
| Agent `agent` | `#upstream` 上游与模型(provider/model/baseUrl/Key/思考/上下文/压缩/子代理);`#budget` 每日次数/费用 + 单次上限;`#investigate` 自动调查开关/模式/次数/核实时长/工具上限;`#advanced` 目标轮询、会话目录、fake 脚本 | Pi 上游(3 卡) | `pi` `agent` `budget.agent*` |
|
| 流程 | 获取 `acquire` | 每日下载预算、搜索站点、资源识别;订阅和规则在各自页面 | budget 每日预算 · search 搜索站点 · identity 资源识别 | `budget.*`、`search.*`、`identity.*` |
|
||||||
| API Key `api-keys` | 签发给外部程序的 Key(scope、撤销);页首说明"浏览器自用 token 在右上角账户菜单"(C9) | API Key | api-keys |
|
| 流程 | 媒体库与目录 `library` | 媒体库目录、整理来源、分类规则、命名模板 | roots · sources · classification · naming · naming-roots | 媒体库目录;`organize.classificationYaml / sources`;`naming.*` |
|
||||||
| 账户与安全 `account` | 修改密码、会话注销、本机 token | 账户菜单(菜单保留快捷入口) | auth |
|
| 流程 | 整理与刮削 `organize` | 整理方式、最小视频体积、整理后自动刮削和已有文件的覆盖方式 | mode 整理方式 · scrape 刮削 | `organize.defaultMode / allowCrossVolumeMove / minSizeBytes / scrape*`(覆盖方式:更新 / 只补缺失 / 全部覆盖) |
|
||||||
|
| 流程 | 智能体 `agent` | 模型服务、用量与防跑飞限制、自动处理、网页搜索、MCP | upstream · budget 用量与限制 · investigate 自动处理 · runtime 上下文与子会话 · websearch · mcp · capabilities · advanced | 模型服务;`pi.*`;`agent.*`(不设预算,只有单次工具调用次数与时长两个上限 + 花费统计;识别失败自动派智能体在「自动处理」) |
|
||||||
|
| 系统 | 系统 `system` | 自动化总开关、只读对账、后台任务并发、存储预算、数据保留 | switches 自动化 · readonly · worker-lanes · storage · storage-retention | `automation.*`、`storage.budgets / retentionDays` |
|
||||||
|
| 系统 | 缓存 `cache` | 各类缓存的条数、占用和有效期,可以按区清空 | regions 缓存区 · tmdb-cache · images · ttl 缓存有效期 | kv_cache 各区 + 图片缓存(`GET /cache/regions`、`POST /cache/regions/{region}/clear`、`/cache/tmdb*`、`/cache/images*`);`cache.tmdbMaxSize / globalImageCache*`;`storage.cacheTtl` |
|
||||||
|
| 系统 | 账户与安全 `account` | 密码、二次验证、通行密钥、会话与本机 Token、API Key | password · totp · passkeys · session · api-keys | auth;API Key |
|
||||||
|
| 系统 | 界面 `interface` | 只影响当前浏览器 | display · shortcuts | 浏览器本地偏好 |
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
- "上游列表 / 默认上游"目前 contracts 只有单一 `pi.provider`,本次只把它放进 Agent 分区,多上游等 Agent 侧需求确定再做。
|
- **对象 vs 参数**:实例上可覆盖的项(下载盘水位、保种天数)的缺省值放在同一分区的对象列表下面,两处用同一组名字(暂停线、恢复线、清理目标、保种天数)。
|
||||||
- 备份与系统信息(版本、数据目录、备份/恢复入口)暂无后端,先不建空分区;只读对账已在"自动化"。
|
- **预算按消耗谁归属**:下载预算归「获取」;智能体不设预算,只有防跑飞限制,归「智能体」。
|
||||||
- "预算"按**消耗谁**归属:下载预算归自动化,Agent 预算归 Agent,不再跨 Tab 拆半。
|
- **不进设置页**:站点凭证与 Cloudflare 处理(站点页)、订阅与规则(各自页面)、主密钥(部署环境变量)、通知(暂无渠道设置,不建空分区)。
|
||||||
|
- **废弃字段**(`DEPRECATED_SETTINGS_FIELDS`)不显示:`pi` 旧上游字段、`cache.tmdbTtlHours / emptyResultTtlSec`、`identity.poolArchiveDays`、`retention.logRetentionDays`、智能体四个预算字段。
|
||||||
### 3.5 `contracts/settings.ts` 调整建议
|
- **搜索**:左栏顶部(窄屏在下拉上方)的搜索框按分区名、节名、节别名(`anchors[].keywords`,如「H&R」「Cloudflare」「Passkey」)和表单字段名定位;选中字段时滚到该字段并聚焦。
|
||||||
|
- **旧锚点**:`SETTINGS_HASH_ALIASES` 把重排前的 hash 换到新位置(地址栏 replace,不留历史),见 §6。
|
||||||
**不改存储 key**(8 个 key 已在线上 settings 表,PATCH 语义按 key 合并)。改动只在"展示分区":
|
|
||||||
|
|
||||||
1. 新增 `SETTINGS_SECTIONS` 常量(UI 与服务端共用):`{ id, label, description, legacyTab[], fields: ["cache.hardThresholdPercent", …] }`,前端表单和重定向都读它,测试断言 8 个 key 的每个字段恰好归属一个分区。
|
|
||||||
2. `budget` 保留单 key,但在 `SETTINGS_SECTIONS` 里按前缀拆成两处展示(`maxNewTitlesPerDay/maxDownloadBytesPerDay/maxConcurrentDownloads` → 自动化;`agentMax*` → Agent)。
|
|
||||||
3. 若后续确要把 `budget.agentMax*` 挪到 `agent.daily.*`:加一次读时迁移(`{...DEFAULT, ...stored}` 之前把旧字段搬过去并写回),`GlobalSettingsUpdate` 两个版本各保留一个发行周期,审计里记一条 `settings.migrated`。本次**不做**。
|
|
||||||
4. `pi` 在 UI 文案一律叫"Agent 上游",key 名不变(避免 secret 引用与 PATCH 兼容问题)。
|
|
||||||
|
|
||||||
### 3.6 页面内布局规范
|
### 3.6 页面内布局规范
|
||||||
|
|
||||||
@@ -270,24 +269,32 @@
|
|||||||
- `components/agent/quick-goals.ts` 里的"待识别池"文案(W7-B 范围)。
|
- `components/agent/quick-goals.ts` 里的"待识别池"文案(W7-B 范围)。
|
||||||
- 原站视图仍是占位。
|
- 原站视图仍是占位。
|
||||||
|
|
||||||
## 6. 设置分区已实施对照(IA-2,2026-09-28)
|
## 6. 设置分区已实施对照(2026-10-06 重排;替换 IA-2)
|
||||||
|
|
||||||
路由保持 `/console/settings`(routes.ts 归 IA-1),分区用 URL hash 定位:`#<分区>` 或 `#<分区>/<节>`;旧 `?tab=` 由设置页 `clientLoader` 跳到对应 hash。
|
路由仍是 `/console/settings`,分区用 URL hash 定位:`#<分区>` 或 `#<分区>/<节>`;旧 `?tab=` 由设置页 `clientLoader` 跳到对应 hash,旧 hash 由 `resolveSettingsHash` 换到新位置。分区与说明见 §3.4;组件在 `apps/web/app/components/settings/`(每个分区一个文件:`downloaders` `instances`(媒体服务器)`sites` `metadata` `acquire` `library` `organize` `agent` `system` `cache` `account` `interface`),搜索索引在 `search-index.ts`。
|
||||||
分区定义在 `packages/contracts/src/settings.ts` 的 `SETTINGS_SECTIONS`(label、说明、锚点、字段、`legacyTabs`),测试断言 8 个存储 key 的每个字段恰好归属一个分区。组件在 `apps/web/app/components/settings/`。
|
|
||||||
|
|
||||||
| 分区 | 节(锚点) | 实际内容 | 与 §3.4 的差异 |
|
页面:宽屏左栏 = 搜索框 + 三组分区(当前分区展开节列表),右侧分区标题 + 一句说明 + 各节;窄屏 = 搜索框 + 分组下拉 + 一句说明 + 节的横排胶囊。每节有标题与说明;全局设置类的节有「未保存」标记、节尾保存条(还原 / 保存),保存后提示「已保存「节名」」;实例、站点、模型服务类在抽屉里保存;危险操作(清空缓存、删除)二次确认。
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 自动化 `automation` | switches / cache / budget / retention | 总开关、只读对账(切换即生效);缓存水位 4 项;每日新作品数、下载量、并发;源文件保留天数、日志保留天数 | 无 |
|
|
||||||
| 站点与账号 `sites` | sites / accounts | 站点列表(启停开关、快照周期与新鲜度、账号数、健康);抽屉改地址、定时快照、快照周期、限流三项,并录入本站凭证;从内置站点定义登记新站点;账号列表(启停、删除)+ 录入凭证抽屉 | 浏览器服务能力未放进来(无对应写接口) |
|
|
||||||
| 下载器 `downloaders` | instances | qB 实例 CRUD + 连通测试;路径映射并入实例抽屉(只列本实例的映射,可加、校验、删) | §3.4 把映射放在"媒体库与目录",按 IA-2 任务改为挂在实例抽屉;`?tab=path-mappings` → `#downloaders` |
|
|
||||||
| 媒体库与目录 `library` | roots / organize | 库根 CRUD + 哨兵检查;默认整理方式(复制/移动)、允许跨卷移动 | 没有命名模板字段,"整理默认"只有这两项 |
|
|
||||||
| 媒体服务器 `media-servers` | instances | Emby/Jellyfin CRUD + 测试;抽屉里同样挂本实例的路径映射 | 无 |
|
|
||||||
| 元数据 `metadata` | tmdb | TMDB API Key(只写,显示已保存/清除)、Base URL | 语言、刷新周期在 contracts 里没有字段,未做 |
|
|
||||||
| Agent `agent` | upstream / runtime / budget / investigate / advanced | 上游列表(名称、provider、认证、模型、状态、默认)+ 新建向导(选 provider → 凭据与模型 → 登录/测试/设默认)+ 编辑抽屉(改配置、登录/重新登录/退出、测试、设默认、删除);上下文预算、压缩、子代理;每日与单次预算;自动调查;高级(轮询、等待、会话目录、fake 脚本) | 多上游已由 W7-A 落地,§3.4 说明里"多上游等 Agent 侧需求"作废;旧 `pi.provider/model/baseUrl/apiKey` 字段不再展示 |
|
|
||||||
| API Key `api-keys` | keys | 列表、创建(明文只显示一次)、撤销、scope 说明 | 无 |
|
|
||||||
| 账户与安全 `account` | password / totp / session / shortcuts | 改密、TOTP 开启/关闭、当前会话到期与退出、本机 Token、Agent 快捷键 | 后端没有会话列表/吊销接口,只能看当前会话;改密会注销其它会话 |
|
|
||||||
|
|
||||||
每节独立保存:全局设置类的节有"未保存"标记、保存/还原按钮,保存后 sonner 一句;实例、站点、上游类在抽屉里保存。窄屏左栏折叠为顶部下拉。
|
旧锚点映射(`SETTINGS_HASH_ALIASES`;同名的 `#downloaders`、`#agent/*`、`#sites/*`、`#library/{roots,sources,classification,naming}`、`#metadata/tmdb` 不变):
|
||||||
|
|
||||||
|
| 旧 | 新 |
|
||||||
|
| --- | --- |
|
||||||
|
| `#automation`、`#automation/switches` | `#system`、`#system/switches` |
|
||||||
|
| `#automation/cache`(`?tab=storage`) | `#downloaders/cache` |
|
||||||
|
| `#automation/budget` | `#acquire/budget` |
|
||||||
|
| `#automation/retention` | `#downloaders/seeding`(日志保留天数已废弃) |
|
||||||
|
| `#automation/advanced`、`#automation/readonly` | `#system/readonly` |
|
||||||
|
| `#automation/{worker-lanes,storage,storage-retention}` | `#system/` 同名节 |
|
||||||
|
| `#automation/storage-ttl` | `#cache/ttl` |
|
||||||
|
| `#automation/<其它>` | `#system` |
|
||||||
|
| `#library/organize`、`#library/scrape` | `#organize/mode`、`#organize/scrape` |
|
||||||
|
| `#metadata/identity` | `#acquire/identity` |
|
||||||
|
| `#metadata/tmdb-cache`、`#metadata/images` | `#cache/tmdb-cache`、`#cache/images` |
|
||||||
|
| `#api-keys`、`#api-keys/keys`(`?tab=api-keys`) | `#account/api-keys` |
|
||||||
|
| `#account/shortcuts` | `#interface/shortcuts` |
|
||||||
|
| `#sites/accounts` | `#sites` |
|
||||||
|
|
||||||
|
遗留:`components/console/nav.ts` 的 `SettingsSection` 类型仍是旧分区名(`automation`、`api-keys` 靠别名跳转可用),新分区 `acquire / organize / system / cache / interface` 要等 nav.ts 的 owner 补进类型后才能用 `settingsPath()` 直接深链。
|
||||||
|
|
||||||
## 7. IA-3 已实施对照(FE-1,2026-09-28)
|
## 7. IA-3 已实施对照(FE-1,2026-09-28)
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ docker compose --env-file .env.deploy up -d app
|
|||||||
2. 下载器:下载页核对 qB 里的任务与库里的下载任务(托管标签)一致;路径映射"验证"。
|
2. 下载器:下载页核对 qB 里的任务与库里的下载任务(托管标签)一致;路径映射"验证"。
|
||||||
3. 整理记录 / 资产位置:抽查最近的整理记录,目标文件确实存在。
|
3. 整理记录 / 资产位置:抽查最近的整理记录,目标文件确实存在。
|
||||||
4. 凭证:打开一个站点/下载器"测试",能通过说明主密钥一致。
|
4. 凭证:打开一个站点/下载器"测试",能通过说明主密钥一致。
|
||||||
5. 确认无误后关闭:设置 → 自动化 → 关闭"只读对账",或
|
5. 确认无误后关闭:设置 → 系统 → 关闭「只读对账」,或
|
||||||
`PATCH /api/v1/settings {"automation":{"readOnlyReconcile":false}}`。
|
`PATCH /api/v1/settings {"automation":{"readOnlyReconcile":false}}`。
|
||||||
|
|
||||||
## 开发机上用
|
## 开发机上用
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ API:`GET /api/v1/cache/tmdb?kind=recognize|request&limit=` 列出条目(键 /
|
|||||||
- 清理:定时任务 `cache.images_clean`(每天)删除 mtime 早于 `cache.globalImageCacheDays`(缺省 7)天的文件。
|
- 清理:定时任务 `cache.images_clean`(每天)删除 mtime 早于 `cache.globalImageCacheDays`(缺省 7)天的文件。
|
||||||
- 容量 LRU(CACHE-2):`cache.images_lru`(30 分钟)在总量超过 `storage.budgets.imagesGb`(缺省 20 GB)时按最久未访问删到 90%;
|
- 容量 LRU(CACHE-2):`cache.images_lru`(30 分钟)在总量超过 `storage.budgets.imagesGb`(缺省 20 GB)时按最久未访问删到 90%;
|
||||||
读盘命中会刷新文件 atime。见 [storage.md](storage.md)。
|
读盘命中会刷新文件 atime。见 [storage.md](storage.md)。
|
||||||
- 用量 / 清空:`GET /api/v1/cache/images`、`POST /api/v1/cache/images/clear`(仅 admin);设置 → 元数据 → 图片代理与缓存。
|
- 用量 / 清空:`GET /api/v1/cache/images`、`POST /api/v1/cache/images/clear`(仅 admin);设置 → 缓存(缓存区表里的「图片」行可清空;「图片缓存」节开关与保留天数)。按区统计与清空通用缓存:`GET /api/v1/cache/regions`、`POST /api/v1/cache/regions/{region}/clear`(仅 admin),设置 → 缓存 → 缓存区。
|
||||||
|
|
||||||
前端:作品库列表、作品详情、搜索面板的 TMDB 候选在有 `posterPath`(metadata.posterPath / TMDB 候选)时显示海报,
|
前端:作品库列表、作品详情、搜索面板的 TMDB 候选在有 `posterPath`(metadata.posterPath / TMDB 候选)时显示海报,
|
||||||
加载失败回退原来的无彩占位。TMDB 详情刷新会把 posterPath / backdropPath 写进 metadata_field。
|
加载失败回退原来的无彩占位。TMDB 详情刷新会把 posterPath / backdropPath 写进 metadata_field。
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ docker compose --env-file .env.deploy logs app | grep 'setup token'
|
|||||||
| `PUID` / `PGID` | `10001` | 运行用户 |
|
| `PUID` / `PGID` | `10001` | 运行用户 |
|
||||||
| `DATA_DIR` | `/data` | 应用数据目录(开发机缺省 `./data`) |
|
| `DATA_DIR` | `/data` | 应用数据目录(开发机缺省 `./data`) |
|
||||||
| `AGENT_SESSION_DIR` | `DATA_DIR/agent-sessions` | Agent 会话目录 |
|
| `AGENT_SESSION_DIR` | `DATA_DIR/agent-sessions` | Agent 会话目录 |
|
||||||
| `WORKER_FILE_CONCURRENCY` / `WORKER_NETWORK_CONCURRENCY` / `WORKER_MAINTENANCE_CONCURRENCY` | 空 | io worker 文件 / 网络 / 维护三条道的并发;空 = 用 设置 → 自动化 → 高级 →「后台任务并发」(缺省 2 / 4 / 1,热生效),设了以变量为准。见 dev.md「IO Worker 分道」 |
|
| `WORKER_FILE_CONCURRENCY` / `WORKER_NETWORK_CONCURRENCY` / `WORKER_MAINTENANCE_CONCURRENCY` | 空 | io worker 文件 / 网络 / 维护三条道的并发;空 = 用 设置 → 系统 →「后台任务并发」(缺省 2 / 4 / 1,热生效),设了以变量为准。见 dev.md「IO Worker 分道」 |
|
||||||
| `WORKER_CONCURRENCY` | 空 | 旧变量(原 io 组总并发),现映射到网络道(`WORKER_NETWORK_CONCURRENCY` 未设时) |
|
| `WORKER_CONCURRENCY` | 空 | 旧变量(原 io 组总并发),现映射到网络道(`WORKER_NETWORK_CONCURRENCY` 未设时) |
|
||||||
| `WORKER_AGENT_CONCURRENCY` | `2` | agent 组 worker 并发槽(每个 Agent 会话占一槽直到结束) |
|
| `WORKER_AGENT_CONCURRENCY` | `2` | agent 组 worker 并发槽(每个 Agent 会话占一槽直到结束) |
|
||||||
| `ORGANIZE_WALK_MAX_FILES` | `5000` | 手动按路径整理时递归遍历的文件数上限;dryRun 超出返回 `truncated: true`,正式执行拒绝 |
|
| `ORGANIZE_WALK_MAX_FILES` | `5000` | 手动按路径整理时递归遍历的文件数上限;dryRun 超出返回 `truncated: true`,正式执行拒绝 |
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ io worker 进程内按任务声明的 `lane` 起三个引擎 worker,各自独立
|
|||||||
判断有 1s 滞后,够用且简单。
|
判断有 1s 滞后,够用且简单。
|
||||||
- **调度器内联的维护任务**:`ScheduledJobDef.lane = "maintenance"` 的定时任务(下表)在调度器里直接执行,与维护道共用并发与让路规则:
|
- **调度器内联的维护任务**:`ScheduledJobDef.lane = "maintenance"` 的定时任务(下表)在调度器里直接执行,与维护道共用并发与让路规则:
|
||||||
进不了维护道就保持到期、下一轮再试;在计划页点「立即执行」不让路。其余定时任务只入队或很快结束,不占道。
|
进不了维护道就保持到期、下一轮再试;在计划页点「立即执行」不让路。其余定时任务只入队或很快结束,不占道。
|
||||||
- **并发配置**:设置 → 自动化 → 高级 →「后台任务并发」(`settings.automation.workerLanes`),保存后约 5 秒内热生效(每道开 16 个槽,
|
- **并发配置**:设置 → 系统 →「后台任务并发」(`settings.automation.workerLanes`),保存后约 5 秒内热生效(每道开 16 个槽,
|
||||||
按设置值收)。环境变量 `WORKER_FILE_CONCURRENCY` / `WORKER_NETWORK_CONCURRENCY` / `WORKER_MAINTENANCE_CONCURRENCY` 设了就以变量为准
|
按设置值收)。环境变量 `WORKER_FILE_CONCURRENCY` / `WORKER_NETWORK_CONCURRENCY` / `WORKER_MAINTENANCE_CONCURRENCY` 设了就以变量为准
|
||||||
(运行状态页悬停提示"由环境变量指定")。旧变量 `WORKER_CONCURRENCY` 映射到**网络道**(`WORKER_NETWORK_CONCURRENCY` 未设时):它原来
|
(运行状态页悬停提示"由环境变量指定")。旧变量 `WORKER_CONCURRENCY` 映射到**网络道**(`WORKER_NETWORK_CONCURRENCY` 未设时):它原来
|
||||||
是 io 组总并发,缺省 4,与网络道缺省相同;文件 / 维护道按设置页走。compose 不再给 `WORKER_CONCURRENCY` 缺省值。
|
是 io 组总并发,缺省 4,与网络道缺省相同;文件 / 维护道按设置页走。compose 不再给 `WORKER_CONCURRENCY` 缺省值。
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
| RSS / 列表批量入库(`observed`) | 否。订阅相关的推迟(`not_now:batch_ingest`,15 分钟内复查时再抓);其余不抓 |
|
| RSS / 列表批量入库(`observed`) | 否。订阅相关的推迟(`not_now:batch_ingest`,15 分钟内复查时再抓);其余不抓 |
|
||||||
| 上次因限额推迟(`pending`)的复查 | 是 |
|
| 上次因限额推迟(`pending`)的复查 | 是 |
|
||||||
|
|
||||||
每站每小时上限 `identity.detailIdsPerSiteHour`(设置 → 元数据 → 资源识别「识别时每站每小时最多抓详情页」,缺省 10,0 = 不抓),
|
每站每小时上限 `identity.detailIdsPerSiteHour`(设置 → 获取 → 资源识别「识别时每站每小时最多抓详情页」,缺省 10,0 = 不抓),
|
||||||
计数在 `kv_cache` region `identity.detail_ids`(key `<siteId>:<小时>`,原子自增,2 小时过期)。达到上限记 `deferred:cap_reached`、
|
计数在 `kv_cache` region `identity.detail_ids`(key `<siteId>:<小时>`,原子自增,2 小时过期)。达到上限记 `deferred:cap_reached`、
|
||||||
15 分钟内复查。这些详情请求同样计入站点请求统计(`site_request_count`)。
|
15 分钟内复查。这些详情请求同样计入站点请求统计(`site_request_count`)。
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ TMDB 默认季有时和发布者、媒体服务器的分季不一样(最常见:
|
|||||||
|
|
||||||
## 设置
|
## 设置
|
||||||
|
|
||||||
设置 → 元数据 → 资源识别:
|
设置 → 获取 → 资源识别:
|
||||||
|
|
||||||
- `identity.eagerTmdbLookup`(缺省关):打开即旧行为,每条新资源都查 TMDB。每条约 1–2 次搜索 + 命中时 1 次详情
|
- `identity.eagerTmdbLookup`(缺省关):打开即旧行为,每条新资源都查 TMDB。每条约 1–2 次搜索 + 命中时 1 次详情
|
||||||
(识别缓存命中为 0);每小时 100 条的 RSS 约每天 2400–7200 次请求,站点独有的短剧 / 综艺多半查不到。
|
(识别缓存命中为 0);每小时 100 条的 RSS 约每天 2400–7200 次请求,站点独有的短剧 / 综艺多半查不到。
|
||||||
@@ -344,7 +344,7 @@ TMDB 查不到的作品(短剧、平台独播剧)按标题查网页,出一份带
|
|||||||
- 网页:网页标题(去掉「_高清完整版…」「_百度百科」等后缀)必须对得上;只看本作品主体(「相关短剧」「热门短剧」「猜你喜欢」之后是别的作品);集数取正文句式,没有时取选集列表「1 2 … N」;平台页的平台名按域名;海报只取 og:image。
|
- 网页:网页标题(去掉「_高清完整版…」「_百度百科」等后缀)必须对得上;只看本作品主体(「相关短剧」「热门短剧」「猜你喜欢」之后是别的作品);集数取正文句式,没有时取选集列表「1 2 … N」;平台页的平台名按域名;海报只取 og:image。
|
||||||
- 模型(可选):默认 Agent 上游做一次补全,输入是读过的网页和用上的摘要;模型给的值必须以本次的网页或摘要为出处,原文里有的按该来源置信度减 0.05,改写 / 归纳的按 0.3;海报不是网页 og:image 的丢弃。fake 上游、没有上游、失败或输出不是 JSON 时只用规则抽取(`extractor: rule`)。
|
- 模型(可选):默认 Agent 上游做一次补全,输入是读过的网页和用上的摘要;模型给的值必须以本次的网页或摘要为出处,原文里有的按该来源置信度减 0.05,改写 / 归纳的按 0.3;海报不是网页 og:image 的丢弃。fake 上游、没有上游、失败或输出不是 JSON 时只用规则抽取(`extractor: rule`)。
|
||||||
8. 合并:每个字段把候选值按归一化值分组,同组不同网站的置信度按 noisy-OR 叠加(同一网站只算一次),取最高的一组,低于 0.3 的丢弃;主演 / 类型 / 别名保留过线的前 10 / 5 / 10 个。每个值带 `sourceKind`、`confidence`、`hosts`(给出同一值的网站数)。证据等级:原文里有、由规则抽出且出处不是「搜索摘要」类 → `rule_confirmed`;两个以上不同网站给出 → `multi_evidence`;其余 `needs_review`。
|
8. 合并:每个字段把候选值按归一化值分组,同组不同网站的置信度按 noisy-OR 叠加(同一网站只算一次),取最高的一组,低于 0.3 的丢弃;主演 / 类型 / 别名保留过线的前 10 / 5 / 10 个。每个值带 `sourceKind`、`confidence`、`hosts`(给出同一值的网站数)。证据等级:原文里有、由规则抽出且出处不是「搜索摘要」类 → `rule_confirmed`;两个以上不同网站给出 → `multi_evidence`;其余 `needs_review`。
|
||||||
9. 命中率:`usage.attempts` 记每类来源的 `results`(结果标题对得上的条数)、`fetched`、`hit`(建议里至少一个字段有它支持)、`fields`。`GET /identity/enrich-stats?days=30`(read)返回最近 N 天发起过搜索的补全次数、出建议次数、各类来源的「有结果 / 命中 / 命中率」;设置 → 元数据 → 资源识别 下方有这张表。命中 7 天缓存的不新建记录,不计入。
|
9. 命中率:`usage.attempts` 记每类来源的 `results`(结果标题对得上的条数)、`fetched`、`hit`(建议里至少一个字段有它支持)、`fields`。`GET /identity/enrich-stats?days=30`(read)返回最近 N 天发起过搜索的补全次数、出建议次数、各类来源的「有结果 / 命中 / 命中率」;设置 → 获取 → 资源识别 下方有这张表。命中 7 天缓存的不新建记录,不计入。
|
||||||
10. 存 `metadata_enrichment`(status suggested / failed、suggestion、usage:搜索 / 读取次数与 token),不写作品表。
|
10. 存 `metadata_enrichment`(status suggested / failed、suggestion、usage:搜索 / 读取次数与 token),不写作品表。
|
||||||
|
|
||||||
`web.fetch` 输出新增 `image`(页面 og:image / twitter:image,绝对地址,已脱敏)。
|
`web.fetch` 输出新增 `image`(页面 og:image / twitter:image,绝对地址,已脱敏)。
|
||||||
@@ -366,7 +366,7 @@ TMDB 查不到的作品(短剧、平台独播剧)按标题查网页,出一份带
|
|||||||
|
|
||||||
作品详情「元数据」里来源显示「网页」/「Agent」,下面一行是证据等级和出处链接;网页海报经 `GET /api/v1/img/enrich/{记录id}` 代理(只取该记录里白名单网页给出的图片,出处仍须在白名单;不接受任意 URL;地址守卫同 web.fetch,最多 3 跳、5 MB、只收图片)。
|
作品详情「元数据」里来源显示「网页」/「Agent」,下面一行是证据等级和出处链接;网页海报经 `GET /api/v1/img/enrich/{记录id}` 代理(只取该记录里白名单网页给出的图片,出处仍须在白名单;不接受任意 URL;地址守卫同 web.fetch,最多 3 跳、5 MB、只收图片)。
|
||||||
|
|
||||||
### 设置(设置 → 元数据 → 资源识别)
|
### 设置(设置 → 获取 → 资源识别)
|
||||||
|
|
||||||
| 字段 | 缺省 | 说明 |
|
| 字段 | 缺省 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
## 语言回退链
|
## 语言回退链
|
||||||
|
|
||||||
- 设置 → 元数据 → TMDB →「语言回退链」,即 `settings.metadata.languages`,缺省 `zh-CN, zh-SG, zh-TW, zh-HK, en-US`。
|
- 设置 → 元数据 → 语言与季集 →「语言回退链」,即 `settings.metadata.languages`,缺省 `zh-CN, zh-SG, zh-TW, zh-HK, en-US`。
|
||||||
- 作品标题:按链逐个语言查 TMDB `translations`(语言 + 地区精确匹配);该语言译名为空时查同地区、文字相符的 `alternative_titles`(类型为空的优先);原始语言等于链中语言时原名也算。全部落空用请求语言下的 name / 原名。对照 MP `__update_tmdbinfo_cn_title`(中文名 → CN 别名 → SG 译名)。
|
- 作品标题:按链逐个语言查 TMDB `translations`(语言 + 地区精确匹配);该语言译名为空时查同地区、文字相符的 `alternative_titles`(类型为空的优先);原始语言等于链中语言时原名也算。全部落空用请求语言下的 name / 原名。对照 MP `__update_tmdbinfo_cn_title`(中文名 → CN 别名 → SG 译名)。
|
||||||
- 中文名 `titleZh`:只在链里的中文语言中按顺序取(zh-CN 优先),值必须含汉字。链首改成 en-US 时标题变英文,`titleZh` 不变。
|
- 中文名 `titleZh`:只在链里的中文语言中按顺序取(zh-CN 优先),值必须含汉字。链首改成 en-US 时标题变英文,`titleZh` 不变。
|
||||||
- 简介 / 标语(MD-2):只按「简介链」取 `translations` 的第一个非空值,取不到留空(界面显示「暂无简介」)。简介链 = 回退链里第一个中文之前的语言 + 链里全部中文,之后的非中文去掉;缺省链得到 `zh-CN, zh-SG, zh-TW, zh-HK`。链里没有中文时简介链就是原链。设置 → 元数据 → TMDB「简介回退到非中文」(`metadata.overviewNonChineseFallback`,缺省关)打开后简介链 = 原链(旧行为)。原因:TMDB 上很多剧集(如《心动的信号》103147)只有英文"简介",而且常是标题英译;MP 每集信息只按配置语言取,简介为空就为空。请求语言下的原值若等于链外某语言的译文(TMDB / 反代把缺失字段回退了),不当作链内的值。标题 / 别名不受影响。
|
- 简介 / 标语(MD-2):只按「简介链」取 `translations` 的第一个非空值,取不到留空(界面显示「暂无简介」)。简介链 = 回退链里第一个中文之前的语言 + 链里全部中文,之后的非中文去掉;缺省链得到 `zh-CN, zh-SG, zh-TW, zh-HK`。链里没有中文时简介链就是原链。设置 → 元数据 → 语言与季集「简介回退到非中文」(`metadata.overviewNonChineseFallback`,缺省关)打开后简介链 = 原链(旧行为)。原因:TMDB 上很多剧集(如《心动的信号》103147)只有英文"简介",而且常是标题英译;MP 每集信息只按配置语言取,简介为空就为空。请求语言下的原值若等于链外某语言的译文(TMDB / 反代把缺失字段回退了),不当作链内的值。标题 / 别名不受影响。
|
||||||
- 季 / 集:先按链首请求 `/tv/{id}/season/{n}`;已播出的集缺标题(TMDB 占位名"第 N 集""Episode N")或缺简介时,按链逐个语言补拉同一端点,补齐即停(简介只从简介链里的语言取;只缺简介且剩下的语言都不在简介链里时不再补拉);链走完仍没有标题的集保留占位名(语言记为空)。未播出的集不触发补拉。
|
- 季 / 集:先按链首请求 `/tv/{id}/season/{n}`;已播出的集缺标题(TMDB 占位名"第 N 集""Episode N")或缺简介时,按链逐个语言补拉同一端点,补齐即停(简介只从简介链里的语言取;只缺简介且剩下的语言都不在简介链里时不再补拉);链走完仍没有标题的集保留占位名(语言记为空)。未播出的集不触发补拉。
|
||||||
- 每个字段的来源值写 `metadata_field`,`language` 记实际取到的语言;同字段语言变了,旧语言的 tmdb 行删掉。
|
- 每个字段的来源值写 `metadata_field`,`language` 记实际取到的语言;同字段语言变了,旧语言的 tmdb 行删掉。
|
||||||
- `translations` 与 `alternative_titles` 写 `title_alias`(来源 tmdb,标语言;别名只在地区与文字相符时标,如 CN + 汉字 → zh-CN)。已有同文本别名(繁简归一化后同键)不重复登记,只给语言为空的 tmdb 行补语言。
|
- `translations` 与 `alternative_titles` 写 `title_alias`(来源 tmdb,标语言;别名只在地区与文字相符时标,如 CN + 汉字 → zh-CN)。已有同文本别名(繁简归一化后同键)不重复登记,只给语言为空的 tmdb 行补语言。
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
- 新建档时标题 / 简介当场按语言链取(富详情失败沿用基础详情),每集信息由随后入队的 full 刷新补齐,不用手动刷新。自动识别(RSS)新建的作品不入队 full,仍按追踪刷新。
|
- 新建档时标题 / 简介当场按语言链取(富详情失败沿用基础详情),每集信息由随后入队的 full 刷新补齐,不用手动刷新。自动识别(RSS)新建的作品不入队 full,仍按追踪刷新。
|
||||||
- 刷新是后台任务 `refreshMetadata`(输入 `{mediaEntityId, full?}`),结果里 `titles` 是本次取到的标题 / 语言 / 中文名,`episodes[]` 是各季写入统计(`created` 新建单元数、`changedUnits` 有变化的单元数)。
|
- 刷新是后台任务 `refreshMetadata`(输入 `{mediaEntityId, full?}`),结果里 `titles` 是本次取到的标题 / 语言 / 中文名,`episodes[]` 是各季写入统计(`created` 新建单元数、`changedUnits` 有变化的单元数)。
|
||||||
- 增量:来源值和语言都没变的字段不写;只投影有变化的单元。不改编号、路径绑定和资产覆盖。
|
- 增量:来源值和语言都没变的字段不写;只投影有变化的单元。不改编号、路径绑定和资产覆盖。
|
||||||
- 季 0(特别篇,DH-1):设置 → 元数据 → TMDB「包含特别篇(季 0)」(`metadata.includeSpecials`,缺省开)。开时 full 刷新和存量回刷
|
- 季 0(特别篇,DH-1):设置 → 元数据 → 语言与季集「包含特别篇(季 0)」(`metadata.includeSpecials`,缺省开)。开时 full 刷新和存量回刷
|
||||||
在 TMDB 有季 0 时一并拉取每集信息并建季 0 的季 / 集单元(对照 MP);季 0 不进正片季总集数账本,不影响订阅完成判定。关掉后
|
在 TMDB 有季 0 时一并拉取每集信息并建季 0 的季 / 集单元(对照 MP);季 0 不进正片季总集数账本,不影响订阅完成判定。关掉后
|
||||||
已建的季 0 单元保留,只是不再主动拉取。
|
已建的季 0 单元保留,只是不再主动拉取。
|
||||||
- 临时转正式(DH-1):刷新时与 TMDB 季集结构对得上的临时(provisional)季 / 集单元转正式(`status = confirmed`、`source = tmdb`),
|
- 临时转正式(DH-1):刷新时与 TMDB 季集结构对得上的临时(provisional)季 / 集单元转正式(`status = confirmed`、`source = tmdb`),
|
||||||
@@ -70,8 +70,8 @@ DATABASE_URL=… deno run -A scripts/overview-recompute.ts --apply
|
|||||||
|
|
||||||
## TMDB 请求与缓存
|
## TMDB 请求与缓存
|
||||||
|
|
||||||
- 富详情一次请求:`/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations`;季详情每语言一次。都走 `tmdb.request` PG 缓存(TTL / 空结果短 TTL / 条数上限见设置 → 元数据 → TMDB 缓存;对照 MP `@cached`:成功才缓存、业务失败不缓存)。
|
- 富详情一次请求:`/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations`;季详情每语言一次。都走 `tmdb.request` PG 缓存(TTL / 空结果短 TTL / 条数上限见设置 → 缓存 → TMDB 缓存;对照 MP `@cached`:成功才缓存、业务失败不缓存)。
|
||||||
- 想立刻拿到 TMDB 刚改的数据:设置 → 元数据 → TMDB 缓存「清空」,再刷新。
|
- 想立刻拿到 TMDB 刚改的数据:设置 → 缓存 → TMDB 缓存「清空」,再刷新。
|
||||||
- 仅 TMDB 作品详情页(`/console/media/tmdb/{type}/{id}`)的季在展开时拉 `GET /api/v1/media/tmdb/tv/{id}/season/{n}`,不落库。
|
- 仅 TMDB 作品详情页(`/console/media/tmdb/{type}/{id}`)的季在展开时拉 `GET /api/v1/media/tmdb/tv/{id}/season/{n}`,不落库。
|
||||||
|
|
||||||
## 媒体详情页
|
## 媒体详情页
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
## 设置 `settings.storage`
|
## 设置 `settings.storage`
|
||||||
|
|
||||||
三组,都在「设置 → 自动化 → 存储与保留」(分区锚点 `storage`,UI 待接)。PATCH 可以只给子字段:
|
三组,在「设置 → 系统」(存储预算、数据保留)与「设置 → 缓存」(缓存有效期)。PATCH 可以只给子字段:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}}
|
PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}}
|
||||||
|
|||||||
@@ -95,6 +95,21 @@ export const platformApi = {
|
|||||||
summary: "清空图片代理缓存",
|
summary: "清空图片代理缓存",
|
||||||
response: cache.CacheClearResult,
|
response: cache.CacheClearResult,
|
||||||
}),
|
}),
|
||||||
|
regions: ep({
|
||||||
|
method: "get",
|
||||||
|
path: "/cache/regions",
|
||||||
|
scope: "admin",
|
||||||
|
summary: "通用缓存(kv_cache)按区统计:条目数、已过期数、占用字节、最近写入",
|
||||||
|
response: cache.CacheRegionList,
|
||||||
|
}),
|
||||||
|
regionClear: ep({
|
||||||
|
method: "post",
|
||||||
|
path: "/cache/regions/{region}/clear",
|
||||||
|
scope: "admin",
|
||||||
|
summary: "清空通用缓存的一个区",
|
||||||
|
params: cache.CacheRegionParam,
|
||||||
|
response: cache.CacheClearResult,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 网页搜索(WEB-1,docs/runbook/websearch.md) ──
|
// ── 网页搜索(WEB-1,docs/runbook/websearch.md) ──
|
||||||
|
|||||||
@@ -75,3 +75,59 @@ export function tmdbImageUrl(path: string | null | undefined, size: TmdbImageSiz
|
|||||||
if (!/^[A-Za-z0-9_-]+\.(jpg|jpeg|png|webp)$/.test(p)) return null;
|
if (!/^[A-Za-z0-9_-]+\.(jpg|jpeg|png|webp)$/.test(p)) return null;
|
||||||
return `/api/v1/img/tmdb/${size}/${p}`;
|
return `/api/v1/img/tmdb/${size}/${p}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 通用缓存(kv_cache)按 region 管理(设置 → 缓存) ──
|
||||||
|
|
||||||
|
/** region 名:小写字母、数字、`.`、`_`、`-` */
|
||||||
|
export const CacheRegionName = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/, "缓存区名不合法");
|
||||||
|
export const CacheRegionParam = z.object({ region: CacheRegionName });
|
||||||
|
|
||||||
|
export const CacheRegionStat = z.object({
|
||||||
|
region: z.string(),
|
||||||
|
/** 未过期条目数 */
|
||||||
|
entries: NonNegInt,
|
||||||
|
/** 已过期、等清理任务删除的条目数 */
|
||||||
|
expired: NonNegInt,
|
||||||
|
/** 键与值占用字节(pg_column_size 之和,含已过期) */
|
||||||
|
bytes: NonNegInt,
|
||||||
|
/** 最近一次写入 */
|
||||||
|
lastWriteAt: Timestamp.nullable(),
|
||||||
|
});
|
||||||
|
export type CacheRegionStat = z.infer<typeof CacheRegionStat>;
|
||||||
|
|
||||||
|
export const CacheRegionList = z.object({ items: z.array(CacheRegionStat) });
|
||||||
|
export type CacheRegionList = z.infer<typeof CacheRegionList>;
|
||||||
|
|
||||||
|
/** region → 界面名;精确匹配优先,其次按前缀(`bangumi.*`);都不中返回 null(界面原样显示 region) */
|
||||||
|
const CACHE_REGION_LABELS: Record<string, string> = {
|
||||||
|
"tmdb.request": "TMDB 请求",
|
||||||
|
"tmdb.recognize": "TMDB 识别",
|
||||||
|
"douban.request": "豆瓣请求",
|
||||||
|
"douban.subject": "豆瓣条目",
|
||||||
|
"bangumi.request": "Bangumi 请求",
|
||||||
|
"bangumi.subject": "Bangumi 条目",
|
||||||
|
"meta.subject_miss": "豆瓣 / Bangumi 查无结果",
|
||||||
|
"discover.list": "发现列表",
|
||||||
|
"site.detail": "站点详情页",
|
||||||
|
"identity.detail_ids": "识别抓详情页计数",
|
||||||
|
enrich: "元数据补全",
|
||||||
|
"enrich.robots": "元数据补全 robots.txt",
|
||||||
|
websearch: "网页搜索",
|
||||||
|
"websearch.allow": "可读取的网页",
|
||||||
|
images: "图片",
|
||||||
|
};
|
||||||
|
const CACHE_REGION_PREFIX_LABELS: [string, string][] = [
|
||||||
|
["tmdb.", "TMDB"],
|
||||||
|
["douban.", "豆瓣"],
|
||||||
|
["bangumi.", "Bangumi"],
|
||||||
|
["discover.", "发现"],
|
||||||
|
["site.", "站点"],
|
||||||
|
["enrich.", "元数据补全"],
|
||||||
|
["websearch.", "网页搜索"],
|
||||||
|
];
|
||||||
|
export function cacheRegionLabel(region: string): string | null {
|
||||||
|
const exact = CACHE_REGION_LABELS[region];
|
||||||
|
if (exact) return exact;
|
||||||
|
const hit = CACHE_REGION_PREFIX_LABELS.find(([p]) => region.startsWith(p));
|
||||||
|
return hit ? `${hit[1]}(${region.slice(hit[0].length)})` : null;
|
||||||
|
}
|
||||||
+256
-112
@@ -848,7 +848,7 @@ export const SettingEntry = z.object({
|
|||||||
});
|
});
|
||||||
export type SettingEntry = z.infer<typeof SettingEntry>;
|
export type SettingEntry = z.infer<typeof SettingEntry>;
|
||||||
|
|
||||||
/** 已弃用、不归属任何设置分区的旧字段(见 PiSettings 注释) */
|
/** 已弃用、不归属任何设置分区的旧字段(见 PiSettings 注释);设置页不显示 */
|
||||||
export const DEPRECATED_SETTINGS_FIELDS = [
|
export const DEPRECATED_SETTINGS_FIELDS = [
|
||||||
"pi.provider",
|
"pi.provider",
|
||||||
"pi.baseUrl",
|
"pi.baseUrl",
|
||||||
@@ -859,6 +859,8 @@ export const DEPRECATED_SETTINGS_FIELDS = [
|
|||||||
"cache.tmdbTtlHours",
|
"cache.tmdbTtlHours",
|
||||||
"cache.emptyResultTtlSec",
|
"cache.emptyResultTtlSec",
|
||||||
"identity.poolArchiveDays",
|
"identity.poolArchiveDays",
|
||||||
|
// CACHE-2 起流水数据按 storage.retentionDays 分类清理,这个总的日志保留天数没有任何地方读取
|
||||||
|
"retention.logRetentionDays",
|
||||||
// 2026-10-06:智能体不设硬性预算上限,只保留单次工具调用次数与时长两个防跑飞限制
|
// 2026-10-06:智能体不设硬性预算上限,只保留单次工具调用次数与时长两个防跑飞限制
|
||||||
"budget.agentMaxRunsPerDay",
|
"budget.agentMaxRunsPerDay",
|
||||||
"budget.agentMaxCostUsdPerDay",
|
"budget.agentMaxCostUsdPerDay",
|
||||||
@@ -866,32 +868,52 @@ export const DEPRECATED_SETTINGS_FIELDS = [
|
|||||||
"agent.maxCostUsdPerRun",
|
"agent.maxCostUsdPerRun",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// ── 设置页分区(docs/design/console-ia.md §3.4) ──
|
// ── 设置页分区(docs/design/console-ia.md §3.4,2026-10-06 重排) ──
|
||||||
// 只是"展示分区 → 字段"的映射,不改存储 key;除 DEPRECATED_SETTINGS_FIELDS 外每个字段恰好归属一个分区(测试断言)。
|
// 只是"展示分区 → 字段"的映射,不改存储 key 与缺省值;除 DEPRECATED_SETTINGS_FIELDS 外每个字段恰好归属一个分区(测试断言)。
|
||||||
// 非 settings 表的对象(实例、站点、API Key、Pi 上游、账户)用 resources 标注,不进 fields。
|
// 非 settings 表的对象(实例、站点、API Key、模型服务、账户)用 resources 标注,不进 fields。
|
||||||
|
// 分区按三组排:接入对象(外部服务与数据源)→ 业务环节(获取 → 落盘 → 整理 → 智能体)→ 系统与账户。
|
||||||
|
|
||||||
export const SettingsSectionId = z.enum([
|
export const SettingsSectionId = z.enum([
|
||||||
"automation",
|
|
||||||
"sites",
|
|
||||||
"downloaders",
|
"downloaders",
|
||||||
"library",
|
|
||||||
"media-servers",
|
"media-servers",
|
||||||
|
"sites",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"acquire",
|
||||||
|
"library",
|
||||||
|
"organize",
|
||||||
"agent",
|
"agent",
|
||||||
"api-keys",
|
"system",
|
||||||
"interface",
|
"cache",
|
||||||
"account",
|
"account",
|
||||||
|
"interface",
|
||||||
]);
|
]);
|
||||||
export type SettingsSectionId = z.infer<typeof SettingsSectionId>;
|
export type SettingsSectionId = z.infer<typeof SettingsSectionId>;
|
||||||
|
|
||||||
|
export const SettingsGroupId = z.enum(["connect", "flow", "system"]);
|
||||||
|
export type SettingsGroupId = z.infer<typeof SettingsGroupId>;
|
||||||
|
export const SETTINGS_GROUPS: readonly { id: SettingsGroupId; label: string }[] = [
|
||||||
|
{ id: "connect", label: "接入" },
|
||||||
|
{ id: "flow", label: "流程" },
|
||||||
|
{ id: "system", label: "系统" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type SettingsAnchorDef = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** 搜索用的别名:不在表单字段里的设置(实例抽屉的字段、登录方式等) */
|
||||||
|
keywords?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type SettingsSectionDef = {
|
export type SettingsSectionDef = {
|
||||||
id: SettingsSectionId;
|
id: SettingsSectionId;
|
||||||
|
group: SettingsGroupId;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** 一句说明:这里管什么 */
|
||||||
description: string;
|
description: string;
|
||||||
/** 旧 `?tab=` 值 → 本分区(可带分区内锚点) */
|
/** 旧 `?tab=` 值 → 本分区(可带分区内锚点) */
|
||||||
legacyTabs: { tab: string; anchor?: string }[];
|
legacyTabs: { tab: string; anchor?: string }[];
|
||||||
/** 分区内锚点(节),顺序即页面顺序 */
|
/** 分区内锚点(节),顺序即页面顺序 */
|
||||||
anchors: { id: string; label: string }[];
|
anchors: SettingsAnchorDef[];
|
||||||
/** 归属本分区的 settings 字段(`key.field`,compaction 这类嵌套对象按整体算) */
|
/** 归属本分区的 settings 字段(`key.field`,compaction 这类嵌套对象按整体算) */
|
||||||
fields: string[];
|
fields: string[];
|
||||||
/** 本分区管理的非 settings 对象 */
|
/** 本分区管理的非 settings 对象 */
|
||||||
@@ -900,44 +922,58 @@ export type SettingsSectionDef = {
|
|||||||
|
|
||||||
export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
||||||
{
|
{
|
||||||
id: "automation",
|
id: "downloaders",
|
||||||
label: "自动化",
|
group: "connect",
|
||||||
description: "总开关、下载盘、每日预算、保留天数",
|
label: "下载器",
|
||||||
legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }],
|
description: "qBittorrent 实例与路径映射;下载盘水位和保种天数的缺省值,实例里可以单独改",
|
||||||
|
legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }, { tab: "storage", anchor: "cache" }],
|
||||||
anchors: [
|
anchors: [
|
||||||
{ id: "switches", label: "开关" },
|
{
|
||||||
{ id: "cache", label: "下载盘" },
|
id: "instances",
|
||||||
{ id: "budget", label: "每日预算" },
|
label: "实例",
|
||||||
{ id: "retention", label: "保留" },
|
keywords: [
|
||||||
{ id: "advanced", label: "高级" },
|
"qBittorrent",
|
||||||
|
"WebUI",
|
||||||
|
"管理标签",
|
||||||
|
"缓存卷标识",
|
||||||
|
"默认下载器",
|
||||||
|
"路径映射",
|
||||||
|
"H&R 未知也可清理",
|
||||||
|
"认证方式",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "cache", label: "下载盘", keywords: ["水位", "缓存盘"] },
|
||||||
|
{ id: "seeding", label: "保种", keywords: ["做种"] },
|
||||||
],
|
],
|
||||||
fields: [
|
fields: [
|
||||||
"automation.enabled",
|
|
||||||
"automation.readOnlyReconcile",
|
|
||||||
"automation.workerLanes",
|
|
||||||
"cache.hardThresholdPercent",
|
"cache.hardThresholdPercent",
|
||||||
"cache.resumePercent",
|
"cache.resumePercent",
|
||||||
"cache.checkIntervalSec",
|
"cache.checkIntervalSec",
|
||||||
"cache.cleanupTargetPercent",
|
"cache.cleanupTargetPercent",
|
||||||
"budget.maxNewTitlesPerDay",
|
|
||||||
"budget.maxDownloadBytesPerDay",
|
|
||||||
"budget.maxConcurrentDownloads",
|
|
||||||
"retention.seedRetentionDays",
|
"retention.seedRetentionDays",
|
||||||
"retention.logRetentionDays",
|
|
||||||
"storage.budgets",
|
|
||||||
"storage.retentionDays",
|
|
||||||
"storage.cacheTtl",
|
|
||||||
],
|
],
|
||||||
resources: [],
|
resources: ["downloader-instances", "path-mappings"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "media-servers",
|
||||||
|
group: "connect",
|
||||||
|
label: "媒体服务器",
|
||||||
|
description: "Emby / Jellyfin 实例与路径映射,只用来查作品有没有",
|
||||||
|
legacyTabs: [{ tab: "media-servers" }],
|
||||||
|
anchors: [{ id: "instances", label: "实例", keywords: ["Emby", "Jellyfin", "用户 ID"] }],
|
||||||
|
fields: [],
|
||||||
|
resources: ["media-server-instances", "path-mappings"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sites",
|
id: "sites",
|
||||||
label: "站点默认",
|
group: "connect",
|
||||||
description: "新站点的限流、快照周期、UA 和代理,以及原站视图用的浏览器服务;站点和凭证在站点页管理",
|
label: "站点与网络",
|
||||||
|
description: "站点缺省值、User-Agent 与代理、浏览器服务;站点、凭证和 Cloudflare 在站点页",
|
||||||
legacyTabs: [{ tab: "site-credentials" }],
|
legacyTabs: [{ tab: "site-credentials" }],
|
||||||
anchors: [
|
anchors: [
|
||||||
{ id: "defaults", label: "站点默认" },
|
{ id: "defaults", label: "站点缺省值", keywords: ["限流", "抓取周期", "健康检测"] },
|
||||||
{ id: "browser", label: "浏览器服务" },
|
{ id: "network", label: "UA 与代理", keywords: ["User-Agent", "代理", "出口"] },
|
||||||
|
{ id: "browser", label: "浏览器服务", keywords: ["原站视图", "Cloudflare"] },
|
||||||
],
|
],
|
||||||
fields: [
|
fields: [
|
||||||
"sites.defaultRateLimit",
|
"sites.defaultRateLimit",
|
||||||
@@ -948,43 +984,70 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
|||||||
"sites.defaultSnapshotMaxPages",
|
"sites.defaultSnapshotMaxPages",
|
||||||
"sites.defaultHealthCheckIntervalSec",
|
"sites.defaultHealthCheckIntervalSec",
|
||||||
"sites.defaultDetailCacheSec",
|
"sites.defaultDetailCacheSec",
|
||||||
"search.defaultSiteMode",
|
],
|
||||||
"search.siteIds",
|
resources: ["browser-providers"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "metadata",
|
||||||
|
group: "connect",
|
||||||
|
label: "元数据",
|
||||||
|
description: "TMDB 接入、语言回退与特别篇;缓存在「缓存」里管理",
|
||||||
|
legacyTabs: [],
|
||||||
|
anchors: [
|
||||||
|
{ id: "tmdb", label: "TMDB", keywords: ["API Key", "Base URL"] },
|
||||||
|
{ id: "language", label: "语言与季集", keywords: ["季 0", "特别篇", "简介"] },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
"metadata.tmdbApiKeySecretRef",
|
||||||
|
"metadata.tmdbBaseUrl",
|
||||||
|
"metadata.languages",
|
||||||
|
"metadata.overviewNonChineseFallback",
|
||||||
|
"metadata.includeSpecials",
|
||||||
],
|
],
|
||||||
resources: [],
|
resources: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "downloaders",
|
id: "acquire",
|
||||||
label: "下载器",
|
group: "flow",
|
||||||
description: "qBittorrent 实例、连通测试、路径映射",
|
label: "获取",
|
||||||
legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }],
|
description: "每日下载预算、搜索站点、资源识别;订阅和规则在各自页面",
|
||||||
anchors: [{ id: "instances", label: "实例" }],
|
legacyTabs: [],
|
||||||
fields: [],
|
anchors: [
|
||||||
resources: ["downloader-instances", "path-mappings"],
|
{ id: "budget", label: "每日预算", keywords: ["并发下载"] },
|
||||||
|
{ id: "search", label: "搜索站点", keywords: ["默认参与搜索"] },
|
||||||
|
{ id: "identity", label: "资源识别", keywords: ["元数据补全", "TMDB"] },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
"budget.maxNewTitlesPerDay",
|
||||||
|
"budget.maxDownloadBytesPerDay",
|
||||||
|
"budget.maxConcurrentDownloads",
|
||||||
|
"search.defaultSiteMode",
|
||||||
|
"search.siteIds",
|
||||||
|
"identity.eagerTmdbLookup",
|
||||||
|
"identity.autoEnrich",
|
||||||
|
"identity.enrichDailyBudget",
|
||||||
|
"identity.enrichDomains",
|
||||||
|
"identity.enrichSnippetAllSites",
|
||||||
|
"identity.detailIdsPerSiteHour",
|
||||||
|
],
|
||||||
|
resources: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "library",
|
id: "library",
|
||||||
|
group: "flow",
|
||||||
label: "媒体库与目录",
|
label: "媒体库与目录",
|
||||||
description: "媒体库目录、默认整理方式、刮削、命名模板",
|
description: "媒体库目录、整理来源、分类规则、命名模板",
|
||||||
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
|
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
|
||||||
anchors: [
|
anchors: [
|
||||||
{ id: "roots", label: "媒体库目录" },
|
{ id: "roots", label: "媒体库目录", keywords: ["标记文件", "检查目录"] },
|
||||||
{ id: "classification", label: "分类规则" },
|
|
||||||
{ id: "sources", label: "整理来源" },
|
{ id: "sources", label: "整理来源" },
|
||||||
{ id: "organize", label: "整理方式" },
|
{ id: "classification", label: "分类规则", keywords: ["二级分类"] },
|
||||||
{ id: "scrape", label: "刮削" },
|
{ id: "naming", label: "命名模板", keywords: ["重命名"] },
|
||||||
{ id: "naming", label: "命名模板" },
|
{ id: "naming-roots", label: "目录命名覆盖" },
|
||||||
],
|
],
|
||||||
fields: [
|
fields: [
|
||||||
"organize.classificationYaml",
|
"organize.classificationYaml",
|
||||||
"organize.sources",
|
"organize.sources",
|
||||||
"organize.defaultMode",
|
|
||||||
"organize.allowCrossVolumeMove",
|
|
||||||
"organize.minSizeBytes",
|
|
||||||
"organize.scrapeAfterImport",
|
|
||||||
"organize.scrapeMode",
|
|
||||||
"organize.scrapeImages",
|
|
||||||
"organize.scrapeEpisodeThumbs",
|
|
||||||
"naming.movie",
|
"naming.movie",
|
||||||
"naming.tv",
|
"naming.tv",
|
||||||
"naming.variety",
|
"naming.variety",
|
||||||
@@ -993,57 +1056,41 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
|||||||
resources: ["library-roots"],
|
resources: ["library-roots"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "media-servers",
|
id: "organize",
|
||||||
label: "媒体服务器",
|
group: "flow",
|
||||||
description: "Emby / Jellyfin 实例、连通测试、路径映射",
|
label: "整理与刮削",
|
||||||
legacyTabs: [{ tab: "media-servers" }],
|
description: "整理方式、最小视频体积、整理后自动刮削和已有文件的覆盖方式",
|
||||||
anchors: [{ id: "instances", label: "实例" }],
|
|
||||||
fields: [],
|
|
||||||
resources: ["media-server-instances", "path-mappings"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "metadata",
|
|
||||||
label: "元数据",
|
|
||||||
description: "TMDB API Key 与地址、TMDB 缓存、资源识别、图片代理",
|
|
||||||
legacyTabs: [],
|
legacyTabs: [],
|
||||||
anchors: [
|
anchors: [
|
||||||
{ id: "tmdb", label: "TMDB" },
|
{ id: "mode", label: "整理方式", keywords: ["复制", "移动", "跨卷"] },
|
||||||
{ id: "tmdb-cache", label: "TMDB 缓存" },
|
{ id: "scrape", label: "刮削", keywords: ["NFO", "海报", "剧照", "更新", "只补缺失", "全部覆盖"] },
|
||||||
{ id: "identity", label: "资源识别" },
|
|
||||||
{ id: "images", label: "图片代理与缓存" },
|
|
||||||
],
|
],
|
||||||
fields: [
|
fields: [
|
||||||
"metadata.tmdbApiKeySecretRef",
|
"organize.defaultMode",
|
||||||
"metadata.tmdbBaseUrl",
|
"organize.allowCrossVolumeMove",
|
||||||
"metadata.languages",
|
"organize.minSizeBytes",
|
||||||
"metadata.overviewNonChineseFallback",
|
"organize.scrapeAfterImport",
|
||||||
"metadata.includeSpecials",
|
"organize.scrapeMode",
|
||||||
"cache.tmdbMaxSize",
|
"organize.scrapeImages",
|
||||||
"identity.eagerTmdbLookup",
|
"organize.scrapeEpisodeThumbs",
|
||||||
"identity.autoEnrich",
|
|
||||||
"identity.enrichDailyBudget",
|
|
||||||
"identity.enrichDomains",
|
|
||||||
"identity.enrichSnippetAllSites",
|
|
||||||
"identity.detailIdsPerSiteHour",
|
|
||||||
"cache.globalImageCache",
|
|
||||||
"cache.globalImageCacheDays",
|
|
||||||
],
|
],
|
||||||
resources: [],
|
resources: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "agent",
|
id: "agent",
|
||||||
|
group: "flow",
|
||||||
label: "智能体",
|
label: "智能体",
|
||||||
description: "模型服务、用量与限制、自动调查、网页搜索",
|
description: "模型服务、用量与防跑飞限制、自动处理、网页搜索、MCP",
|
||||||
legacyTabs: [{ tab: "pi" }],
|
legacyTabs: [{ tab: "pi" }],
|
||||||
anchors: [
|
anchors: [
|
||||||
{ id: "upstream", label: "模型服务" },
|
{ id: "upstream", label: "模型服务", keywords: ["默认模型服务", "订阅登录"] },
|
||||||
{ id: "runtime", label: "上下文与子会话" },
|
{ id: "budget", label: "用量与限制", keywords: ["花费", "预算"] },
|
||||||
{ id: "budget", label: "用量与限制" },
|
{ id: "investigate", label: "自动处理", keywords: ["自动调查", "识别失败"] },
|
||||||
{ id: "investigate", label: "自动调查" },
|
{ id: "runtime", label: "上下文与子会话", keywords: ["压缩"] },
|
||||||
{ id: "websearch", label: "网页搜索" },
|
{ id: "websearch", label: "网页搜索", keywords: ["Brave"] },
|
||||||
{ id: "advanced", label: "高级" },
|
|
||||||
{ id: "mcp", label: "MCP 服务端" },
|
{ id: "mcp", label: "MCP 服务端" },
|
||||||
{ id: "capabilities", label: "能力清单" },
|
{ id: "capabilities", label: "能力清单" },
|
||||||
|
{ id: "advanced", label: "高级", keywords: ["方案", "审批"] },
|
||||||
],
|
],
|
||||||
fields: [
|
fields: [
|
||||||
"pi.defaultUpstreamId",
|
"pi.defaultUpstreamId",
|
||||||
@@ -1071,37 +1118,74 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
|||||||
resources: ["pi-upstreams"],
|
resources: ["pi-upstreams"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "api-keys",
|
id: "system",
|
||||||
label: "API Key",
|
group: "system",
|
||||||
description: "签发给外部程序的 Key",
|
label: "系统",
|
||||||
legacyTabs: [{ tab: "api-keys" }],
|
description: "自动化总开关、只读对账、后台任务并发、存储预算、数据保留",
|
||||||
anchors: [{ id: "keys", label: "API Key" }],
|
legacyTabs: [{ tab: "automation" }],
|
||||||
fields: [],
|
anchors: [
|
||||||
resources: ["api-keys"],
|
{ id: "switches", label: "自动化", keywords: ["总开关", "暂停"] },
|
||||||
|
{ id: "readonly", label: "只读对账" },
|
||||||
|
{ id: "worker-lanes", label: "后台任务并发" },
|
||||||
|
{ id: "storage", label: "存储预算" },
|
||||||
|
{ id: "storage-retention", label: "数据保留", keywords: ["日志保留"] },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
"automation.enabled",
|
||||||
|
"automation.readOnlyReconcile",
|
||||||
|
"automation.workerLanes",
|
||||||
|
"storage.budgets",
|
||||||
|
"storage.retentionDays",
|
||||||
|
],
|
||||||
|
resources: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "interface",
|
id: "cache",
|
||||||
label: "界面",
|
group: "system",
|
||||||
description: "只影响当前浏览器",
|
label: "缓存",
|
||||||
|
description: "各类缓存的条数、占用和有效期,可以按区清空",
|
||||||
legacyTabs: [],
|
legacyTabs: [],
|
||||||
anchors: [{ id: "display", label: "显示" }],
|
anchors: [
|
||||||
fields: [],
|
{
|
||||||
resources: ["ui-preferences"],
|
id: "regions",
|
||||||
|
label: "缓存区",
|
||||||
|
keywords: ["清空缓存", "豆瓣", "Bangumi", "站点详情", "发现", "网页搜索"],
|
||||||
|
},
|
||||||
|
{ id: "tmdb-cache", label: "TMDB 缓存", keywords: ["条数上限"] },
|
||||||
|
{ id: "images", label: "图片缓存", keywords: ["海报"] },
|
||||||
|
{ id: "ttl", label: "缓存有效期" },
|
||||||
|
],
|
||||||
|
fields: ["cache.tmdbMaxSize", "cache.globalImageCache", "cache.globalImageCacheDays", "storage.cacheTtl"],
|
||||||
|
resources: ["kv-cache", "image-cache"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "account",
|
id: "account",
|
||||||
|
group: "system",
|
||||||
label: "账户与安全",
|
label: "账户与安全",
|
||||||
description: "密码、二次验证、会话、快捷键",
|
description: "密码、二次验证、通行密钥、会话与本机 Token、API Key",
|
||||||
legacyTabs: [],
|
legacyTabs: [{ tab: "api-keys", anchor: "api-keys" }],
|
||||||
anchors: [
|
anchors: [
|
||||||
{ id: "password", label: "密码" },
|
{ id: "password", label: "密码" },
|
||||||
{ id: "totp", label: "二次验证" },
|
{ id: "totp", label: "二次验证", keywords: ["TOTP", "恢复码"] },
|
||||||
{ id: "passkeys", label: "通行密钥" },
|
{ id: "passkeys", label: "通行密钥", keywords: ["Passkey"] },
|
||||||
{ id: "session", label: "会话" },
|
{ id: "session", label: "会话", keywords: ["本机 Token", "退出登录"] },
|
||||||
|
{ id: "api-keys", label: "API Key", keywords: ["外部程序"] },
|
||||||
|
],
|
||||||
|
fields: [],
|
||||||
|
resources: ["auth", "api-keys"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "interface",
|
||||||
|
group: "system",
|
||||||
|
label: "界面",
|
||||||
|
description: "只影响当前浏览器",
|
||||||
|
legacyTabs: [],
|
||||||
|
anchors: [
|
||||||
|
{ id: "display", label: "显示", keywords: ["技术信息"] },
|
||||||
{ id: "shortcuts", label: "快捷键" },
|
{ id: "shortcuts", label: "快捷键" },
|
||||||
],
|
],
|
||||||
fields: [],
|
fields: [],
|
||||||
resources: ["auth"],
|
resources: ["ui-preferences"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1113,3 +1197,63 @@ export function settingsSectionForLegacyTab(tab: string): { section: SettingsSec
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧 hash(2026-10-06 重排前的 `#分区[/节]`)→ 新位置。先查整条,再查只有分区的那一条;
|
||||||
|
* 新旧同名的(`#downloaders`、`#sites/browser`、`#agent/budget` 等)不用登记。
|
||||||
|
*/
|
||||||
|
export const SETTINGS_HASH_ALIASES: Readonly<Record<string, string>> = {
|
||||||
|
automation: "system",
|
||||||
|
"automation/switches": "system/switches",
|
||||||
|
"automation/cache": "downloaders/cache",
|
||||||
|
"automation/budget": "acquire/budget",
|
||||||
|
"automation/retention": "downloaders/seeding",
|
||||||
|
"automation/advanced": "system/readonly",
|
||||||
|
"automation/readonly": "system/readonly",
|
||||||
|
"automation/worker-lanes": "system/worker-lanes",
|
||||||
|
"automation/storage": "system/storage",
|
||||||
|
"automation/storage-retention": "system/storage-retention",
|
||||||
|
"automation/storage-ttl": "cache/ttl",
|
||||||
|
"system/storage-ttl": "cache/ttl",
|
||||||
|
"metadata/tmdb-cache": "cache/tmdb-cache",
|
||||||
|
"metadata/images": "cache/images",
|
||||||
|
"library/organize": "organize/mode",
|
||||||
|
"library/scrape": "organize/scrape",
|
||||||
|
"metadata/identity": "acquire/identity",
|
||||||
|
"api-keys": "account/api-keys",
|
||||||
|
"api-keys/keys": "account/api-keys",
|
||||||
|
"account/shortcuts": "interface/shortcuts",
|
||||||
|
"sites/accounts": "sites",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析设置页 hash:`#分区[/节]`,旧 hash 按 SETTINGS_HASH_ALIASES 换成新位置(moved = true 时调用方应 replace 地址栏);
|
||||||
|
* 不认识的分区返回 null 分区(调用方落到第一个分区),不认识的节丢掉。
|
||||||
|
*/
|
||||||
|
export function resolveSettingsHash(hash: string): {
|
||||||
|
section: SettingsSectionId | null;
|
||||||
|
anchor: string | null;
|
||||||
|
moved: boolean;
|
||||||
|
} {
|
||||||
|
let raw = hash.replace(/^#/, "");
|
||||||
|
try {
|
||||||
|
raw = decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
// 保持原样
|
||||||
|
}
|
||||||
|
const [head = "", tail] = raw.split("/");
|
||||||
|
const full = tail ? `${head}/${tail}` : head;
|
||||||
|
const alias = SETTINGS_HASH_ALIASES[full];
|
||||||
|
let target = alias ?? full;
|
||||||
|
let moved = alias !== undefined;
|
||||||
|
if (!alias && SETTINGS_HASH_ALIASES[head] && !SETTINGS_SECTIONS.some((s) => s.id === head)) {
|
||||||
|
// 旧分区里没登记的节:落到新分区顶部
|
||||||
|
target = SETTINGS_HASH_ALIASES[head] as string;
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
const [sid, anchor] = target.split("/");
|
||||||
|
const section = SETTINGS_SECTIONS.find((s) => s.id === sid);
|
||||||
|
if (!section) return { section: null, anchor: null, moved: false };
|
||||||
|
const known = anchor && section.anchors.some((a) => a.id === anchor) ? anchor : null;
|
||||||
|
return { section: section.id, anchor: known, moved };
|
||||||
|
}
|
||||||
@@ -2,17 +2,28 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
DEPRECATED_SETTINGS_FIELDS,
|
DEPRECATED_SETTINGS_FIELDS,
|
||||||
|
resolveSettingsHash,
|
||||||
|
SETTINGS_GROUPS,
|
||||||
|
SETTINGS_HASH_ALIASES,
|
||||||
SETTINGS_SECTIONS,
|
SETTINGS_SECTIONS,
|
||||||
SettingsSectionId,
|
SettingsSectionId,
|
||||||
settingsSectionForLegacyTab,
|
settingsSectionForLegacyTab,
|
||||||
} from "../src/settings.ts";
|
} from "../src/settings.ts";
|
||||||
|
|
||||||
describe("SETTINGS_SECTIONS", () => {
|
describe("SETTINGS_SECTIONS", () => {
|
||||||
it("10 个分区,顺序与 SettingsSectionId 一致", () => {
|
it("分区顺序与 SettingsSectionId 一致,按接入 → 流程 → 系统三组排", () => {
|
||||||
expect(SETTINGS_SECTIONS.map((s) => s.id)).toEqual(SettingsSectionId.options);
|
expect(SETTINGS_SECTIONS.map((s) => s.id)).toEqual(SettingsSectionId.options);
|
||||||
|
const groups = SETTINGS_SECTIONS.map((s) => s.group);
|
||||||
|
const order = SETTINGS_GROUPS.map((g) => g.id);
|
||||||
|
expect([...groups].sort((a, b) => order.indexOf(a) - order.indexOf(b))).toEqual(groups);
|
||||||
|
for (const s of SETTINGS_SECTIONS) {
|
||||||
|
expect(s.description, s.id).not.toBe("");
|
||||||
|
expect(s.anchors.length, s.id).toBeGreaterThan(0);
|
||||||
|
expect(new Set(s.anchors.map((a) => a.id)).size, s.id).toBe(s.anchors.length);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("8 个存储 key 的每个字段(弃用字段除外)恰好归属一个分区", () => {
|
it("每个存储 key 的每个字段(弃用字段除外)恰好归属一个分区", () => {
|
||||||
const deprecated = new Set<string>(DEPRECATED_SETTINGS_FIELDS);
|
const deprecated = new Set<string>(DEPRECATED_SETTINGS_FIELDS);
|
||||||
const all = Object.entries(DEFAULT_SETTINGS)
|
const all = Object.entries(DEFAULT_SETTINGS)
|
||||||
.flatMap(([key, v]) => Object.keys(v).map((f) => `${key}.${f}`))
|
.flatMap(([key, v]) => Object.keys(v).map((f) => `${key}.${f}`))
|
||||||
@@ -22,6 +33,23 @@ describe("SETTINGS_SECTIONS", () => {
|
|||||||
expect([...owned].sort()).toEqual([...all].sort());
|
expect([...owned].sort()).toEqual([...all].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("近期新增的设置归到用户找得到的分区", () => {
|
||||||
|
const owner = (f: string) => SETTINGS_SECTIONS.find((s) => s.fields.includes(f))?.id;
|
||||||
|
expect(owner("organize.scrapeMode")).toBe("organize");
|
||||||
|
expect(owner("organize.scrapeAfterImport")).toBe("organize");
|
||||||
|
expect(owner("organize.minSizeBytes")).toBe("organize");
|
||||||
|
expect(owner("agent.autoIdentifyOrganize")).toBe("agent");
|
||||||
|
expect(owner("agent.maxToolCalls")).toBe("agent");
|
||||||
|
expect(owner("metadata.includeSpecials")).toBe("metadata");
|
||||||
|
expect(owner("cache.hardThresholdPercent")).toBe("downloaders");
|
||||||
|
expect(owner("retention.seedRetentionDays")).toBe("downloaders");
|
||||||
|
expect(owner("budget.maxConcurrentDownloads")).toBe("acquire");
|
||||||
|
expect(owner("sites.proxyUrl")).toBe("sites");
|
||||||
|
expect(owner("storage.cacheTtl")).toBe("cache");
|
||||||
|
expect(owner("cache.tmdbMaxSize")).toBe("cache");
|
||||||
|
expect(DEPRECATED_SETTINGS_FIELDS).toContain("retention.logRetentionDays");
|
||||||
|
});
|
||||||
|
|
||||||
it("旧 ?tab= 值都能落到分区", () => {
|
it("旧 ?tab= 值都能落到分区", () => {
|
||||||
for (const tab of [
|
for (const tab of [
|
||||||
"automation",
|
"automation",
|
||||||
@@ -36,8 +64,59 @@ describe("SETTINGS_SECTIONS", () => {
|
|||||||
]) {
|
]) {
|
||||||
expect(settingsSectionForLegacyTab(tab), tab).not.toBeNull();
|
expect(settingsSectionForLegacyTab(tab), tab).not.toBeNull();
|
||||||
}
|
}
|
||||||
expect(settingsSectionForLegacyTab("storage")).toEqual({ section: "automation", anchor: "cache" });
|
expect(settingsSectionForLegacyTab("storage")).toEqual({ section: "downloaders", anchor: "cache" });
|
||||||
expect(settingsSectionForLegacyTab("path-mappings")).toEqual({ section: "downloaders", anchor: undefined });
|
expect(settingsSectionForLegacyTab("api-keys")).toEqual({ section: "account", anchor: "api-keys" });
|
||||||
expect(settingsSectionForLegacyTab("nope")).toBeNull();
|
expect(settingsSectionForLegacyTab("nope")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveSettingsHash", () => {
|
||||||
|
it("新 hash 原样解析", () => {
|
||||||
|
expect(resolveSettingsHash("#agent/budget")).toEqual({ section: "agent", anchor: "budget", moved: false });
|
||||||
|
expect(resolveSettingsHash("#downloaders")).toEqual({ section: "downloaders", anchor: null, moved: false });
|
||||||
|
expect(resolveSettingsHash("")).toEqual({ section: null, anchor: null, moved: false });
|
||||||
|
expect(resolveSettingsHash("#nope")).toEqual({ section: null, anchor: null, moved: false });
|
||||||
|
// 不认识的节丢掉
|
||||||
|
expect(resolveSettingsHash("#agent/zzz")).toEqual({ section: "agent", anchor: null, moved: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("旧锚点跳到新位置", () => {
|
||||||
|
const cases: [string, string, string | null][] = [
|
||||||
|
["#agent", "agent", null],
|
||||||
|
["#downloaders", "downloaders", null],
|
||||||
|
["#automation", "system", null],
|
||||||
|
["#automation/cache", "downloaders", "cache"],
|
||||||
|
["#automation/budget", "acquire", "budget"],
|
||||||
|
["#automation/retention", "downloaders", "seeding"],
|
||||||
|
["#automation/advanced", "system", "readonly"],
|
||||||
|
["#automation/storage-ttl", "cache", "ttl"],
|
||||||
|
["#automation/whatever", "system", null],
|
||||||
|
["#library/organize", "organize", "mode"],
|
||||||
|
["#library/scrape", "organize", "scrape"],
|
||||||
|
["#metadata/identity", "acquire", "identity"],
|
||||||
|
["#metadata/tmdb-cache", "cache", "tmdb-cache"],
|
||||||
|
["#metadata/images", "cache", "images"],
|
||||||
|
["#api-keys", "account", "api-keys"],
|
||||||
|
["#api-keys/keys", "account", "api-keys"],
|
||||||
|
["#account/shortcuts", "interface", "shortcuts"],
|
||||||
|
];
|
||||||
|
for (const [hash, section, anchor] of cases) {
|
||||||
|
const r = resolveSettingsHash(hash);
|
||||||
|
expect({ section: r.section, anchor: r.anchor }, hash).toEqual({ section, anchor });
|
||||||
|
}
|
||||||
|
expect(resolveSettingsHash("#automation/cache").moved).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("别名的目标都是存在的分区与节", () => {
|
||||||
|
for (const [from, to] of Object.entries(SETTINGS_HASH_ALIASES)) {
|
||||||
|
const [sid, anchor] = to.split("/");
|
||||||
|
const s = SETTINGS_SECTIONS.find((x) => x.id === sid);
|
||||||
|
expect(s, from).toBeDefined();
|
||||||
|
if (anchor)
|
||||||
|
expect(
|
||||||
|
s?.anchors.map((a) => a.id),
|
||||||
|
from,
|
||||||
|
).toContain(anchor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user