Files
UHD_Media_Tool/apps/web/app/components/settings/acquire.tsx
T
rootandClaude Opus 5.5 8071ad33f9 feat(web): 设置页按新分类重排,加搜索、缓存分区与旧锚点跳转
- 左栏三组分区 + 当前分区的节;窄屏为搜索框、分组下拉和节的横排胶囊
- 搜索框按分区、节、节别名和字段名定位,选中字段时滚到该字段并聚焦
- 旧 hash(#automation/cache、#api-keys、#library/scrape 等)replace 到新位置
- 下载器分区放下载盘水位与保种缺省值,名字与实例抽屉统一(暂停线 / 恢复线 / 保种天数)
- 新增获取、整理与刮削、系统、缓存分区;API Key 并入账户与安全,快捷键移到界面
- 补上缺的入口:最小视频体积、子会话并发与层数、搜索站点;日志保留天数(已废弃)不再显示
- 缓存分区按区列出条数、占用、最近写入,单区清空二次确认;TMDB 与图片缓存并入
- 保存提示统一为「已保存「节名」」;按入库口径把相关文案改成「整理」

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 18:01:26 +08:00

214 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}