- 下载器说明去掉「接管」,选下载器顺序写成一句;缓存盘统一为下载盘 - 「凭据」统一为「凭证」;去掉说明里的「同 MP」「待处理」等旧称 - 整理来源的基线、存量说法改成用户能懂的话,确认按钮写动作 - 数据保留里「站点快照」「待识别条目」按术语表改名 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { type CacheHitStats, 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;
|
|
/** 统一元数据缓存的区才有:最近 7 天命中统计 */
|
|
hits?: CacheHitStats | undefined;
|
|
};
|
|
|
|
/** 区名 → 行:有中文名的用中文名,没有的原样显示 region */
|
|
export function cacheRow(r: Omit<CacheRow, "label">): CacheRow {
|
|
return { ...r, label: cacheRegionLabel(r.region) ?? r.region };
|
|
}
|
|
|
|
/** 命中率文字:没有统计的区显示「—」,有统计没读取显示「暂无读取」 */
|
|
export function hitRateText(h: CacheHitStats | undefined): string {
|
|
if (!h) return "—";
|
|
if (h.hitRate === null) return "暂无读取";
|
|
return `${Math.round(h.hitRate * 1000) / 10}%`;
|
|
}
|
|
|
|
/** 命中明细:内存 / 数据库 / 旧值 / 未命中,有失败与后台刷新时附上 */
|
|
export function hitDetailText(h: CacheHitStats): string {
|
|
const parts = [`内存 ${h.l1Hits}`, `数据库 ${h.l2Hits}`];
|
|
if (h.staleHits) parts.push(`旧值 ${h.staleHits}`);
|
|
parts.push(`未命中 ${h.misses}`);
|
|
if (h.failures) parts.push(`回源失败 ${h.failures}`);
|
|
if (h.refreshes) parts.push(`后台刷新 ${h.refreshes}`);
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
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: "hitRate",
|
|
header: "命中率",
|
|
align: "right",
|
|
cell: (r) => (
|
|
<div className="tabular-nums">
|
|
{hitRateText(r.hits)}
|
|
{r.hits && r.hits.hitRate !== null ? (
|
|
<p className="text-meta text-muted-foreground">{hitDetailText(r.hits)}</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="清空后下次用到时重新请求,不影响作品、资源和文件。已过期的条目由定时任务清理。命中率按最近 7 天、三个进程合计。"
|
|
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",
|
|
"imageDays",
|
|
"metaSearchDays",
|
|
"metaDetailDays",
|
|
"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: "单位天。过期后先用旧值、后台刷新;关注中的作品命中详情缓存时自动续期,不会因条数上限被清掉。",
|
|
fields: [
|
|
ttlField("tmdbSearchDays", "TMDB 搜索与识别结果"),
|
|
ttlField("tmdbDetailDays", "TMDB 详情"),
|
|
ttlField("tmdbMissDays", "TMDB 空结果", "搜索无结果或没识别出的结果也缓存,期内不重复查"),
|
|
ttlField("imageDays", "TMDB 人物与图片"),
|
|
ttlField("metaSearchDays", "豆瓣 / Bangumi 搜索"),
|
|
ttlField("metaDetailDays", "豆瓣 / Bangumi 条目"),
|
|
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>
|
|
);
|
|
}
|