merge: feat/y-meta-cache

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 19:51:50 +08:00
commit 6d37a337c9
51 files changed
+2407 -200

No files matched your search

@@ -260,6 +260,7 @@ export const ENTITY_FIELD_LABEL: Record<string, string> = {
cast: "演员",
crew: "导演 / 主创",
externalIds: "外部 ID",
logoPath: "标志",
};
/** 季 / 集字段(contracts UnitMetadataField) */
@@ -37,6 +37,7 @@ const ROLE: Record<string, string> = {
episode_nfo: "集 NFO",
poster: "海报",
fanart: "背景图",
clearlogo: "标志",
season_poster: "季海报",
episode_thumb: "剧照",
};
@@ -0,0 +1,45 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type CacheRegionList, DEFAULT_SETTINGS } from "@umt/contracts";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { qk } from "~/lib/query-keys.ts";
import { CacheSection, hitDetailText, hitRateText } from "./cache.tsx";
// 设置 → 缓存:缓存区表显示统一元数据缓存的命中率(最近 7 天、三个进程合计)。
const hits = { l1Hits: 50, l2Hits: 30, staleHits: 4, misses: 12, failures: 1, refreshes: 4, hitRate: 84 / 96 };
describe("缓存区命中率", () => {
it("命中率按百分比显示一位小数;没有统计的区显示「—」,没读取过显示「暂无读取」", () => {
expect(hitRateText(hits)).toBe("87.5%");
expect(hitRateText(undefined)).toBe("—");
expect(hitRateText({ ...hits, hitRate: null })).toBe("暂无读取");
expect(hitDetailText(hits)).toBe("内存 50 · 数据库 30 · 旧值 4 · 未命中 12 · 回源失败 1 · 后台刷新 4");
expect(hitDetailText({ ...hits, staleHits: 0, failures: 0, refreshes: 0 })).toBe("内存 50 · 数据库 30 · 未命中 12");
});
it("缓存区表有「命中率」列,元数据区显示命中率与明细", () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
qc.setQueryData(qk.settings, DEFAULT_SETTINGS);
const regions: CacheRegionList = {
items: [
{ region: "tmdb.request", entries: 120, expired: 0, bytes: 4096, lastWriteAt: null, hits },
{ region: "site.detail", entries: 3, expired: 0, bytes: 100, lastWriteAt: null },
],
};
qc.setQueryData(["cache", "regions"], regions);
const html = renderToString(
createElement(
QueryClientProvider,
{ client: qc },
createElement(MemoryRouter, { initialEntries: ["/console/settings#cache"] }, createElement(CacheSection)),
),
);
expect(html).toContain("命中率");
expect(html).toContain("87.5%");
expect(html).toContain("内存 50 · 数据库 30");
expect(html).toContain("最近 7 天");
});
});
+48 -4
View File
@@ -1,4 +1,4 @@
import { cacheRegionLabel } from "@umt/contracts";
import { type CacheHitStats, cacheRegionLabel } from "@umt/contracts";
import { useState } from "react";
import { toast } from "sonner";
import {
@@ -32,6 +32,8 @@ export type CacheRow = {
expired: number;
bytes: number;
lastWriteAt: string | null;
/** 统一元数据缓存的区才有:最近 7 天命中统计 */
hits?: CacheHitStats | undefined;
};
/** 区名 → 行:有中文名的用中文名,没有的原样显示 region */
@@ -39,6 +41,23 @@ 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();
@@ -85,6 +104,19 @@ function CacheRegionsBlock() {
</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: "占用",
@@ -121,7 +153,7 @@ function CacheRegionsBlock() {
section="cache"
anchor="regions"
title="缓存区"
description="清空后下次用到时重新请求,不影响作品、资源和文件。已过期的条目由定时任务清理。"
description="清空后下次用到时重新请求,不影响作品、资源和文件。已过期的条目由定时任务清理。命中率按最近 7 天、三个进程合计。"
plain
>
<DataTable<CacheRow>
@@ -224,7 +256,16 @@ export const IMAGES_CARD: CardSpec = {
}),
};
const TTL_KEYS = ["tmdbSearchDays", "tmdbDetailDays", "tmdbMissDays", "siteDetailDays", "webSearchDays"] as const;
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,
@@ -234,11 +275,14 @@ const ttlField = (k: string, label: string, hint?: string) => ({
export const TTL_CARD: CardSpec = {
anchor: "ttl",
title: "缓存有效期",
description: "单位天。追踪中的作品命中 TMDB 详情缓存时自动续期。",
description: "单位天。过期后先用旧值、后台刷新;关注中的作品命中详情缓存时自动续期,不会因条数上限被清掉。",
fields: [
ttlField("tmdbSearchDays", "TMDB 搜索与识别结果"),
ttlField("tmdbDetailDays", "TMDB 详情"),
ttlField("tmdbMissDays", "TMDB 空结果", "搜索无结果、未识别时的负缓存"),
ttlField("imageDays", "TMDB 人物与图片"),
ttlField("metaSearchDays", "豆瓣 / Bangumi 搜索"),
ttlField("metaDetailDays", "豆瓣 / Bangumi 条目"),
ttlField("siteDetailDays", "站点详情页内容"),
ttlField("webSearchDays", "网页搜索结果"),
],
+1
View File
@@ -39,6 +39,7 @@ export const WORKFLOW_LABEL: Record<string, string> = {
verifyAndImport: "核验整理",
scrapeMedia: "刮削",
refreshMetadata: "刷新元数据",
prewarmMetadata: "预热元数据缓存",
reconcileLibrary: "文件核对",
cleanupDownloadCache: "清理缓存",
resolveDecision: "处理待处理事项",