diff --git a/README.md b/README.md index 6077239..6ccc373 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ services: - TEMP_MAX_FILES=10 # 临时上传最大文件数(默认 10) - TEMP_MAX_TOTAL_SIZE=209715200 # 临时上传目录总体积上限,单位字节(默认 209715200,即 200MB) - ADMIN_API_KEY=你的管理员密钥 # 设置后开启管理员上传,不设置则不可用 + - SUBSET_CACHE_MAX_SIZE=10485760 # 字体裁剪结果内存缓存容量上限,单位字节(默认 10MB) deploy: resources: limits: diff --git a/backend/app.ts b/backend/app.ts index 1f5fe2a..9be0e46 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -1,17 +1,14 @@ -/** 解析请求 URL(req.url 只有路径,需要补全协议和主机才能用 URL API) */ -function parseUrl(req: Request): URL { - return new URL(req.url, "http://localhost"); -} - -import { fontSubset } from "./font_util/font"; -import type { FontEditor } from "../vendor/fonteditor-core/lib/ttf/font.js"; import { mimeTypes } from "./server/mime_type"; import type { cMiddleware } from "./server/req_res"; import { SimpleHttpServer } from "./server/server"; import { path_join, readFile, stat, readdir, mkdir } from "./interface"; -import { enableTempUpload, adminApiKey, fontDirs } from "./config"; -import { parseMultipart } from "./multipart"; -import { handleTempUpload, handleAdminUpload } from "./upload"; +import { parseUrl, jsonResponse, stats } from "./shared"; +import { enableTempUpload, adminApiKey } from "./config"; +import { handleListFonts } from "./routes/fonts"; +import { handleGetConfig } from "./routes/config"; +import { handleStats } from "./routes/stats"; +import { handleUpload } from "./routes/upload"; +import { handleFontSubset } from "./routes/subset"; let release_name = globalThis?.process?.release?.name; @@ -37,65 +34,8 @@ async function ensureDirectories() { } } -/** - * 在所有字体目录中查找字体文件 - * 匹配优先级:精确匹配 > 前缀匹配 > 包含匹配 - * @returns 找到的字体完整路径,未找到则返回 null - */ -async function findFontPath(filename: string): Promise { - // 先尝试精确匹配 - for (const dir of fontDirs) { - const filePath = path_join(dir, filename); - try { - const s = await stat(filePath); - if (s.isFile()) return filePath; - } catch { - // 继续搜索 - } - } - - // 收集所有字体文件名(不含扩展名)和完整路径 - const allFonts: Array<{ basename: string; path: string }> = []; - for (const dir of fontDirs) { - try { - const entries = await readdir(dir); - for (const entry of entries) { - if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) { - allFonts.push({ - basename: entry.name.replace(/\.[^.]+$/, ""), - path: path_join(dir, entry.name), - }); - } - } - } catch { - // 目录不存在,跳过 - } - } - - const query = filename.replace(/\.[^.]+$/, "").toLowerCase(); - - // 前缀匹配 - for (const f of allFonts) { - if (f.basename.toLowerCase().startsWith(query)) return f.path; - } - - // 包含匹配 - for (const f of allFonts) { - if (f.basename.toLowerCase().includes(query)) return f.path; - } - - return null; -} - -/** JSON 响应工具 */ -function jsonResponse(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - const logMiddleware: cMiddleware = async (req, res, next) => { + stats.totalRequests++; const t1 = Date.now(); const r = await next(req, res); const t2 = Date.now(); @@ -118,16 +58,16 @@ const staticFileMiddleware: cMiddleware = async function (req, res, next) { return next(req, newRes); } try { - const stats = await stat(filePath); + const fileStat = await stat(filePath); - if (stats.isFile()) { + if (fileStat.isFile()) { const fileContent = await readFile(filePath); const extname = filePath.split(".").pop() ?? ""; newRes = new Response(fileContent, { status: 200, headers: { "Content-Type": mimeTypes[extname] || "application/octet-stream", - "Content-Length": `${stats.size}`, + "Content-Length": `${fileStat.size}`, }, }); } else { @@ -177,167 +117,6 @@ const corsMiddleware: cMiddleware = async (req, res, next) => { } }; -/** GET /api/fonts — 列出所有可用字体 */ -async function handleListFonts(req: Request, res: Response) { - const allFonts: Array<{ name: string; dir: string }> = []; - - for (const dir of fontDirs) { - try { - const entries = await readdir(dir); - for (const entry of entries) { - if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) { - allFonts.push({ name: entry.name, dir }); - } - } - } catch { - // 目录不存在,跳过 - } - } - - return { req, res: jsonResponse(allFonts) }; -} - -/** GET /api/config — 返回公开配置 */ -async function handleGetConfig(req: Request, res: Response) { - return { - req, - res: jsonResponse({ - enableTempUpload, - adminUploadEnabled: !!adminApiKey, - supportedOutTypes: ["woff2", "ttf"], - }), - }; -} - -/** POST /api/upload?mode=temp|admin — 上传字体 */ -async function handleUpload(req: Request, res: Response) { - const url = parseUrl(req); - const mode = url.searchParams.get("mode") ?? "temp"; - - const contentType = req.headers.get("Content-Type") ?? ""; - console.log("[upload] mode:", mode, "contentType:", contentType); - - const body = (req as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer; - if (!body || body.byteLength === 0) { - return { req, res: jsonResponse({ success: false, error: "请求体为空" }, 400) }; - } - console.log("[upload] body size:", body.byteLength); - - let parsed; - try { - parsed = parseMultipart(contentType, body); - console.log("[upload] parsed files:", parsed.files.length); - } catch (err) { - console.log("[upload] parse error:", err); - return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) }; - } - - if (!parsed.files || parsed.files.length === 0) { - return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) }; - } - - const file = parsed.files[0]; - console.log("[upload] file:", file.name, "filename:", file.filename, "data size:", file.data.length); - - let result; - if (mode === "admin") { - const authHeader = req.headers.get("Authorization") ?? ""; - const apiKey = authHeader.replace("Bearer ", ""); - result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey); - console.log("[upload] admin result:", result); - return { req, res: jsonResponse(result, result.success ? 200 : 403) }; - } - - // 默认:临时上传 - result = await handleTempUpload({ data: file.data, filename: file.filename }); - console.log("[upload] temp result:", result); - return { req, res: jsonResponse(result, result.success ? 200 : 400) }; -} - -/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer */ -const fontBufferCache = new Map(); -const FONT_CACHE_MAX = 3; - -/** 从缓存或磁盘读取字体 buffer */ -async function readFontBuffer(fontPath: string): Promise { - const cached = fontBufferCache.get(fontPath); - if (cached) { - /** LRU:命中时移到末尾(最近使用) */ - fontBufferCache.delete(fontPath); - fontBufferCache.set(fontPath, cached); - return cached; - } - const buffer = new Uint8Array(await readFile(fontPath)).buffer; - if (fontBufferCache.size >= FONT_CACHE_MAX) { - /** 淘汰最久未使用的条目 */ - const oldest = fontBufferCache.keys().next().value!; - fontBufferCache.delete(oldest); - } - fontBufferCache.set(fontPath, buffer); - return buffer; -} - -/** GET /api?font=...&text=... — 字体裁剪 */ -async function handleFontSubset(req: Request, res: Response) { - const url = parseUrl(req); - const params = new URLSearchParams(url.search); - const font = params.get("font") || ""; - const text = params.get("text") || ""; - if (text.length === 0) { - return { req, res }; - } - - const fontPath = await findFontPath(font); - if (!fontPath) { - return { - req, - res: new Response(`Font not found: ${font}`, { - status: 404, - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }), - }; - } - - const fontType = fontPath.split(".").pop() as FontEditor.FontType; - let oldFontBuffer: ArrayBuffer; - try { - oldFontBuffer = await readFontBuffer(fontPath); - } catch { - return { - req, - res: new Response(`Font read error: ${font}`, { - status: 500, - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }), - }; - } - - /** 默认 ttf(兼容性最好) */ - const outTypeParam = params.get("outType") || ""; - const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf"; - - const newFont = await fontSubset(oldFontBuffer, text, { - outType: outType, - sourceType: fontType, - }); - - const contentTypes: Record = { - ttf: "font/ttf", - woff2: "font/woff2", - }; - - return { - req, - res: new Response(newFont, { - status: 200, - headers: { - "Content-Type": contentTypes[outType] || "font/ttf", - "Cache-Control": "public, max-age=86400", - }, - }), - }; -} - /** 统一的 API 路由中间件 */ const fontApiMiddleware: cMiddleware = async (req, res, next) => { const url = parseUrl(req); @@ -349,6 +128,9 @@ const fontApiMiddleware: cMiddleware = async (req, res, next) => { if (url.pathname === "/api/config" && req.method === "GET") { return handleGetConfig(req, res); } + if (url.pathname === "/api/stats" && req.method === "GET") { + return handleStats(req, res); + } if (url.pathname === "/api/upload" && req.method === "POST") { return handleUpload(req, res); } diff --git a/backend/config.ts b/backend/config.ts index ef79e9f..4f16573 100644 --- a/backend/config.ts +++ b/backend/config.ts @@ -15,5 +15,8 @@ export const tempMaxFiles = parseInt(env.TEMP_MAX_FILES ?? "10", 10) || 10; /** 临时上传目录总体积上限(字节),默认 200MB */ export const tempMaxTotalSize = parseInt(env.TEMP_MAX_TOTAL_SIZE ?? `${200 * 1024 * 1024}`, 10) || 200 * 1024 * 1024; +/** 字体裁剪结果内存缓存容量上限(字节),默认 10MB */ +export const subsetCacheMaxSize = parseInt(env.SUBSET_CACHE_MAX_SIZE ?? `${10 * 1024 * 1024}`, 10) || 10 * 1024 * 1024; + /** 字体搜索目录(按优先级排序:admin > 普通 > 临时) */ export const fontDirs = ["font/admin", "font", "font/temp"] as const; diff --git a/backend/lru_cache.ts b/backend/lru_cache.ts new file mode 100644 index 0000000..cb92da3 --- /dev/null +++ b/backend/lru_cache.ts @@ -0,0 +1,80 @@ +/** + * 基于 Map 插入顺序的通用 LRU 缓存 + * 支持两种淘汰策略:按条目数、按字节容量 + */ +export class LruCache { + private cache = new Map(); + + /** 计算条目字节大小(仅按容量淘汰时需要) */ + private readonly sizeFn?: (value: V) => number; + + /** 最大条目数(按条目淘汰时使用) */ + private readonly maxSize?: number; + + /** 最大字节容量(按容量淘汰时使用) */ + private readonly maxBytes?: number; + + /** 当前已用字节 */ + private usedBytes = 0; + + /** 当前缓存条目数 */ + get size() { + return this.cache.size; + } + + constructor(options: { maxSize: number } | { maxBytes: number; sizeFn: (value: V) => number }) { + if ("maxSize" in options) { + this.maxSize = options.maxSize; + } else { + this.maxBytes = options.maxBytes; + this.sizeFn = options.sizeFn; + } + } + + /** 获取缓存值,命中时移到末尾(LRU) */ + get(key: string): V | undefined { + const value = this.cache.get(key); + if (value === undefined) return undefined; + this.cache.delete(key); + this.cache.set(key, value); + return value; + } + + /** 写入缓存,超限时自动淘汰最久未使用的条目 */ + set(key: string, value: V): void { + /** 如果 key 已存在,先移除旧值(更新大小) */ + const existing = this.cache.get(key); + if (existing !== undefined) { + this.cache.delete(key); + if (this.sizeFn) { + this.usedBytes -= this.sizeFn(existing); + } + } + + this.cache.set(key, value); + if (this.sizeFn) { + this.usedBytes += this.sizeFn(value); + this.evictByBytes(); + } else if (this.maxSize !== undefined) { + this.evictByCount(); + } + } + + /** 按条目数淘汰 */ + private evictByCount() { + while (this.cache.size > this.maxSize!) { + const oldest = this.cache.keys().next().value!; + this.cache.delete(oldest); + } + } + + /** 按字节容量淘汰 */ + private evictByBytes() { + while (this.usedBytes > this.maxBytes! && this.cache.size > 0) { + const oldest = this.cache.keys().next().value!; + const entry = this.cache.get(oldest)!; + this.usedBytes -= this.sizeFn!(entry); + this.cache.delete(oldest); + } + } +} diff --git a/backend/routes/config.ts b/backend/routes/config.ts new file mode 100644 index 0000000..ce19786 --- /dev/null +++ b/backend/routes/config.ts @@ -0,0 +1,14 @@ +import { jsonResponse } from "../shared"; +import { enableTempUpload, adminApiKey } from "../config"; + +/** GET /api/config — 返回公开配置 */ +export async function handleGetConfig(req: Request, res: Response) { + return { + req, + res: jsonResponse({ + enableTempUpload, + adminUploadEnabled: !!adminApiKey, + supportedOutTypes: ["woff2", "ttf"], + }), + }; +} diff --git a/backend/routes/fonts.ts b/backend/routes/fonts.ts new file mode 100644 index 0000000..a093c1c --- /dev/null +++ b/backend/routes/fonts.ts @@ -0,0 +1,21 @@ +import { jsonResponse, parseUrl } from "../shared"; +import { readdir, stat } from "../interface"; +import { fontDirs } from "../config"; + +/** GET /api/fonts — 列出所有可用字体 */ +export async function handleListFonts(req: Request, res: Response) { + const allFonts: Array<{ name: string; dir: string }> = []; + + for (const dir of fontDirs) { + try { + const entries = await readdir(dir); + for (const entry of entries) { + if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) { + allFonts.push({ name: entry.name, dir }); + } + } + } catch {} + } + + return { req, res: jsonResponse(allFonts) }; +} diff --git a/backend/routes/stats.ts b/backend/routes/stats.ts new file mode 100644 index 0000000..96d0570 --- /dev/null +++ b/backend/routes/stats.ts @@ -0,0 +1,17 @@ +import { jsonResponse, stats, subsetCache, fontBufferCache } from "../shared"; + +/** GET /api/stats — 返回运行时统计 */ +export async function handleStats(req: Request, res: Response) { + return { + req, + res: jsonResponse({ + uptime: Math.floor((Date.now() - stats.startTime) / 1000), + totalRequests: stats.totalRequests, + subsetRequests: stats.subsetRequests, + subsetCacheHits: stats.subsetCacheHits, + totalChars: stats.totalChars, + subsetCacheEntries: subsetCache.size, + fontBufferCacheEntries: fontBufferCache.size, + }), + }; +} diff --git a/backend/routes/subset.ts b/backend/routes/subset.ts new file mode 100644 index 0000000..cb0a55f --- /dev/null +++ b/backend/routes/subset.ts @@ -0,0 +1,86 @@ +import { fontSubset } from "../font_util/font"; +import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js"; +import { parseUrl, jsonResponse, stats, subsetCache, findFontPath, readFontBuffer } from "../shared"; + +/** GET /api?font=...&text=... — 字体裁剪 */ +export async function handleFontSubset(req: Request, res: Response) { + const url = parseUrl(req); + const params = new URLSearchParams(url.search); + const font = params.get("font") || ""; + const text = params.get("text") || ""; + if (text.length === 0) { + return { req, res }; + } + + const fontPath = await findFontPath(font); + if (!fontPath) { + return { + req, + res: new Response(`Font not found: ${font}`, { + status: 404, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }), + }; + } + + /** 默认 ttf(兼容性最好) */ + const outTypeParam = params.get("outType") || ""; + const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf"; + + /** 查询裁剪结果缓存 */ + const cacheKey = `${fontPath}:${outType}:${text}`; + stats.subsetRequests++; + stats.totalChars += text.length; + const cached = subsetCache.get(cacheKey); + if (cached) { + stats.subsetCacheHits++; + const contentTypes: Record = { ttf: "font/ttf", woff2: "font/woff2" }; + return { + req, + res: new Response(cached, { + status: 200, + headers: { + "Content-Type": contentTypes[outType] || "font/ttf", + "Cache-Control": "public, max-age=86400", + "X-Cache": "HIT", + }, + }), + }; + } + + const fontType = fontPath.split(".").pop() as FontEditor.FontType; + let oldFontBuffer: ArrayBuffer; + try { + oldFontBuffer = await readFontBuffer(fontPath); + } catch { + return { + req, + res: new Response(`Font read error: ${font}`, { + status: 500, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }), + }; + } + + const newFont = await fontSubset(oldFontBuffer, text, { + outType: outType, + sourceType: fontType, + }); + + /** 写入裁剪结果缓存 */ + subsetCache.set(cacheKey, newFont as ArrayBuffer); + + const contentTypes: Record = { ttf: "font/ttf", woff2: "font/woff2" }; + + return { + req, + res: new Response(newFont, { + status: 200, + headers: { + "Content-Type": contentTypes[outType] || "font/ttf", + "Cache-Control": "public, max-age=86400", + "X-Cache": "MISS", + }, + }), + }; +} diff --git a/backend/routes/upload.ts b/backend/routes/upload.ts new file mode 100644 index 0000000..0cec3d6 --- /dev/null +++ b/backend/routes/upload.ts @@ -0,0 +1,47 @@ +import { jsonResponse, parseUrl } from "../shared"; +import { parseMultipart } from "../multipart"; +import { handleTempUpload, handleAdminUpload } from "../upload"; + +/** POST /api/upload?mode=temp|admin — 上传字体 */ +export async function handleUpload(req: Request, res: Response) { + const url = parseUrl(req); + const mode = url.searchParams.get("mode") ?? "temp"; + + const contentType = req.headers.get("Content-Type") ?? ""; + console.log("[upload] mode:", mode, "contentType:", contentType); + + const body = (req as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer; + if (!body || body.byteLength === 0) { + return { req, res: jsonResponse({ success: false, error: "请求体为空" }, 400) }; + } + console.log("[upload] body size:", body.byteLength); + + let parsed; + try { + parsed = parseMultipart(contentType, body); + console.log("[upload] parsed files:", parsed.files.length); + } catch (err) { + console.log("[upload] parse error:", err); + return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) }; + } + + if (!parsed.files || parsed.files.length === 0) { + return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) }; + } + + const file = parsed.files[0]; + console.log("[upload] file:", file.name, "filename:", file.filename, "data size:", file.data.length); + + let result; + if (mode === "admin") { + const authHeader = req.headers.get("Authorization") ?? ""; + const apiKey = authHeader.replace("Bearer ", ""); + result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey); + console.log("[upload] admin result:", result); + return { req, res: jsonResponse(result, result.success ? 200 : 403) }; + } + + result = await handleTempUpload({ data: file.data, filename: file.filename }); + console.log("[upload] temp result:", result); + return { req, res: jsonResponse(result, result.success ? 200 : 400) }; +} diff --git a/backend/shared.ts b/backend/shared.ts new file mode 100644 index 0000000..c0d6a21 --- /dev/null +++ b/backend/shared.ts @@ -0,0 +1,86 @@ +import { fontDirs, subsetCacheMaxSize } from "./config"; +import { LruCache } from "./lru_cache"; +import { path_join, readFile, stat, readdir } from "./interface"; + +/** 解析请求 URL(req.url 只有路径,需要补全协议和主机才能用 URL API) */ +export function parseUrl(req: Request): URL { + return new URL(req.url, "http://localhost"); +} + +/** JSON 响应工具 */ +export function jsonResponse(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** 运行时统计 */ +export const stats = { + /** 服务启动时间戳 */ + startTime: Date.now(), + /** 总请求数 */ + totalRequests: 0, + /** 字体裁剪请求次数(含缓存命中) */ + subsetRequests: 0, + /** 字体裁剪缓存命中次数 */ + subsetCacheHits: 0, + /** 累计裁剪文字字符数 */ + totalChars: 0, +}; + +/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer(按条目数淘汰) */ +export const fontBufferCache = new LruCache({ maxSize: 3 }); + +/** 字体裁剪结果 LRU 缓存(按字节容量淘汰) */ +export const subsetCache = new LruCache({ maxBytes: subsetCacheMaxSize, sizeFn: (v) => v.byteLength }); + +/** + * 在所有字体目录中查找字体文件 + * 匹配优先级:精确匹配 > 前缀匹配 > 包含匹配 + */ +export async function findFontPath(filename: string): Promise { + for (const dir of fontDirs) { + const filePath = path_join(dir, filename); + try { + const s = await stat(filePath); + if (s.isFile()) return filePath; + } catch {} + } + + const allFonts: Array<{ basename: string; path: string }> = []; + for (const dir of fontDirs) { + try { + const entries = await readdir(dir); + for (const entry of entries) { + if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) { + allFonts.push({ + basename: entry.name.replace(/\.[^.]+$/, ""), + path: path_join(dir, entry.name), + }); + } + } + } catch {} + } + + const query = filename.replace(/\.[^.]+$/, "").toLowerCase(); + + for (const f of allFonts) { + if (f.basename.toLowerCase().startsWith(query)) return f.path; + } + + for (const f of allFonts) { + if (f.basename.toLowerCase().includes(query)) return f.path; + } + + return null; +} + +/** 从缓存或磁盘读取字体 buffer */ +export async function readFontBuffer(fontPath: string): Promise { + const cached = fontBufferCache.get(fontPath); + if (cached) return cached; + const buffer = new Uint8Array(await readFile(fontPath)).buffer; + fontBufferCache.set(fontPath, buffer); + return buffer; +} diff --git a/package.json b/package.json index dcb163a..e8dca75 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "webfont", "private": true, - "version": "1.6.0", + "version": "1.7.0", "type": "module", "scripts": { "dev": "pnpx tsx scripts/dev-all.ts", diff --git a/src/App.tsx b/src/App.tsx index 49be6dd..7642724 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { createMemo, createSignal, createEffect, onMount, Show } from "solid-js"; import { fetchFonts, fetchConfig, type FontInfo, type ServerConfig } from "./api"; import UploadSection from "./UploadSection"; +import StatsPanel from "./StatsPanel"; import { SelectorRow } from "./FontSelector"; import FontDebugPreview from "./FontDebugPreview"; @@ -283,6 +284,8 @@ function App() { + +

原理:服务端根据 text 参数裁剪字体,只返回所需字符的子集。相同 URL 的请求会被浏览器自动缓存。

基础用法:将 CSS 复制到你的页面,修改 text 参数中的文字即可:

diff --git a/src/StatsPanel.tsx b/src/StatsPanel.tsx new file mode 100644 index 0000000..6eec5b7 --- /dev/null +++ b/src/StatsPanel.tsx @@ -0,0 +1,72 @@ +import { createSignal, onMount, onCleanup } from "solid-js"; +import { fetchStats, type ServerStats } from "./api"; + +/** 将秒数格式化为可读时长 */ +function formatUptime(seconds: number): string { + if (seconds < 60) return `${seconds}秒`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}分${seconds % 60}秒`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h < 24) return `${h}时${m}分${s}秒`; + const d = Math.floor(h / 24); + return `${d}天${h % 24}时${m}分`; +} + +export default function StatsPanel() { + const [data, setData] = createSignal(null); + let timer: ReturnType | null = null; + + const load = async () => { + const s = await fetchStats().catch(() => null); + if (s) setData(s); + }; + + const startPolling = () => { + if (timer) return; + load(); + timer = setInterval(load, 10_000); + }; + + const stopPolling = () => { + if (timer) { + clearInterval(timer); + timer = null; + } + }; + + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + startPolling(); + } else { + stopPolling(); + } + }; + + onMount(() => { + document.addEventListener("visibilitychange", onVisibilityChange); + startPolling(); + }); + + onCleanup(() => { + stopPolling(); + document.removeEventListener("visibilitychange", onVisibilityChange); + }); + + const s = data(); + if (!s) return null; + + const hitRate = s.subsetRequests > 0 ? ((s.subsetCacheHits / s.subsetRequests) * 100).toFixed(1) : "0.0"; + + return ( +
+
+ 运行 {formatUptime(s.uptime)} + 请求 {s.totalRequests} 次 + 裁剪 {s.subsetRequests} 次 + 文字 {s.totalChars} 字 + 缓存命中 {hitRate}% +
+
+ ); +} diff --git a/src/api.ts b/src/api.ts index 4c7eb42..0fc3d17 100644 --- a/src/api.ts +++ b/src/api.ts @@ -14,6 +14,16 @@ export interface UploadResult { error?: string; } +export interface ServerStats { + uptime: number; + totalRequests: number; + subsetRequests: number; + subsetCacheHits: number; + totalChars: number; + subsetCacheEntries: number; + fontBufferCacheEntries: number; +} + export async function fetchFonts(): Promise { const res = await fetch("/api/fonts"); return res.json(); @@ -44,3 +54,8 @@ export async function uploadFont( }); return res.json(); } + +export async function fetchStats(): Promise { + const res = await fetch("/api/stats"); + return res.json(); +}