- 下载器说明去掉「接管」,选下载器顺序写成一句;缓存盘统一为下载盘 - 「凭据」统一为「凭证」;去掉说明里的「同 MP」「待处理」等旧称 - 整理来源的基线、存量说法改成用户能懂的话,确认按钮写动作 - 数据保留里「站点快照」「待识别条目」按术语表改名 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
214 lines
8.6 KiB
TypeScript
214 lines
8.6 KiB
TypeScript
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: "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>
|
||
);
|
||
}
|