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 { 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(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[] = [ { key: "label", header: "缓存", cell: (r) => (

{r.label}

{r.label !== r.region ?

{r.region}

: null}
), }, { key: "entries", header: "条数", align: "right", cell: (r) => (
{r.entries} {r.expired > 0 ?

另有 {r.expired} 条已过期

: null}
), }, { key: "hitRate", header: "命中率", align: "right", cell: (r) => (
{hitRateText(r.hits)} {r.hits && r.hits.hitRate !== null ? (

{hitDetailText(r.hits)}

) : null}
), }, { key: "bytes", header: "占用", align: "right", cell: (r) => {formatBytes(r.bytes, 1)}, }, { key: "last", header: "最近写入", hideBelow: "md", cell: (r) => {formatRelative(r.lastWriteAt)}, }, { key: "actions", header: 操作, align: "right", cell: (r) => ( ), }, ]; return ( rows={rows} rowKey={(r) => r.region} loading={regions.isPending} error={regions.error} onRetry={() => regions.refetch()} empty={{ title: "还没有缓存", description: "用到 TMDB、豆瓣、站点详情等数据后会出现在这里。" }} columns={columns} /> !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; } }} /> ); } // 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: () => , }; function TmdbCacheStatus() { const q = useTmdbCache(); const clear = useClearTmdbCache(); const c = q.data?.counts; return (
{c ? `识别 ${c.recognize} 条 · 请求 ${c.request} 条 · 上限各 ${q.data?.maxSize} 条` : "读取中…"} 清空 } 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; } }} />
); } // 图片代理与缓存(对照 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 (
{(s) => ( <> )}
); }