From 81dca5d66472ecdb98a6f5b2ff5ec03ef25d1daf Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 19:37:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E8=AE=BE=E7=BD=AE=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=88=86=E5=8C=BA=E6=98=BE=E7=A4=BA=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缓存区表加「命中率」列(近 7 天、三个进程合计)与内存 / 数据库 / 旧值 / 未命中明细; 缓存有效期卡片补 TMDB 人物与图片、豆瓣 / Bangumi 搜索与条目。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../web/app/components/settings/cache.test.ts | 45 ++++++++++++++++ apps/web/app/components/settings/cache.tsx | 52 +++++++++++++++++-- 2 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/components/settings/cache.test.ts diff --git a/apps/web/app/components/settings/cache.test.ts b/apps/web/app/components/settings/cache.test.ts new file mode 100644 index 00000000..88439ab0 --- /dev/null +++ b/apps/web/app/components/settings/cache.test.ts @@ -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 天"); + }); +}); diff --git a/apps/web/app/components/settings/cache.tsx b/apps/web/app/components/settings/cache.tsx index a6c5bf27..abe188a5 100644 --- a/apps/web/app/components/settings/cache.tsx +++ b/apps/web/app/components/settings/cache.tsx @@ -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 { 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() { ), }, + { + key: "hitRate", + header: "命中率", + align: "right", + cell: (r) => ( +
+ {hitRateText(r.hits)} + {r.hits && r.hits.hitRate !== null ? ( +

{hitDetailText(r.hits)}

+ ) : null} +
+ ), + }, { key: "bytes", header: "占用", @@ -121,7 +153,7 @@ function CacheRegionsBlock() { section="cache" anchor="regions" title="缓存区" - description="清空后下次用到时重新请求,不影响作品、资源和文件。已过期的条目由定时任务清理。" + description="清空后下次用到时重新请求,不影响作品、资源和文件。已过期的条目由定时任务清理。命中率按最近 7 天、三个进程合计。" plain > @@ -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", "网页搜索结果"), ],