Files
UHD_Media_Tool/apps/web/app/components/settings/cache.tsx
T
rootandClaude Opus 5.5 81dca5d664 feat(web): 设置缓存分区显示命中率
缓存区表加「命中率」列(近 7 天、三个进程合计)与内存 / 数据库 / 旧值 / 未命中明细;
缓存有效期卡片补 TMDB 人物与图片、豆瓣 / Bangumi 搜索与条目。

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

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>
);
}