import { 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; }; /** 区名 → 行:有中文名的用中文名,没有的原样显示 region */ export function cacheRow(r: Omit): CacheRow { return { ...r, label: cacheRegionLabel(r.region) ?? r.region }; } 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: "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", "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: "单位天。追踪中的作品命中 TMDB 详情缓存时自动续期。", fields: [ ttlField("tmdbSearchDays", "TMDB 搜索与识别结果"), ttlField("tmdbDetailDays", "TMDB 详情"), ttlField("tmdbMissDays", "TMDB 空结果", "搜索无结果、未识别时的负缓存"), 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) => ( <> )}
); }