diff --git a/backend/server/node.ts b/backend/server/node.ts
index 1a867ee..edb2b57 100644
--- a/backend/server/node.ts
+++ b/backend/server/node.ts
@@ -1,8 +1,9 @@
import { implInterface } from "../interface";
-import { stat, readFile, writeFile, readdir, mkdir, unlink } from "fs/promises";
+import { stat as fsStat, readFile, writeFile, readdir as fsReaddir, mkdir, unlink } from "fs/promises";
+
implInterface({
async stat(path) {
- const r = await stat(path);
+ const r = await fsStat(path);
return r;
},
readFile(path) {
@@ -11,12 +12,25 @@ implInterface({
writeFile(path, data) {
return writeFile(path, data);
},
+ /**
+ * readdir 返回 { name, isFile } 适配对象
+ *
+ * 不用 withFileTypes:LLRT 的 fs.readdir 不支持该选项,
+ * 返回的是纯字符串数组而非 Dirent 对象,调用 entry.isFile() 会抛 TypeError。
+ * 统一用 stat 判断,Node 和 LLRT 都兼容。
+ */
async readdir(path) {
- const entries = await readdir(path, { withFileTypes: true });
- return entries.map((entry) => ({
- isFile: () => entry.isFile(),
- name: entry.name,
- }));
+ const names = await fsReaddir(path);
+ const results: { isFile: () => boolean; name: string }[] = [];
+ for (const name of names) {
+ try {
+ const s = await fsStat(path + "/" + name);
+ results.push({ name, isFile: () => s.isFile() });
+ } catch {
+ /** stat 失败(符号链接断裂等)跳过 */
+ }
+ }
+ return results;
},
async mkdir(path) {
await mkdir(path, { recursive: true });
diff --git a/backend/temp_cleaner.ts b/backend/temp_cleaner.ts
index c79075e..f90e779 100644
--- a/backend/temp_cleaner.ts
+++ b/backend/temp_cleaner.ts
@@ -38,10 +38,12 @@ async function cleanOnce(): Promise {
let entries: Array<{ name: string; isFile: () => boolean }>;
try {
entries = await readdir(TEMP_DIR);
- } catch {
+ } catch (err) {
+ console.log("[temp-cleaner] readdir 失败:", err);
return;
}
+ let cleaned = 0;
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!/\.(ttf|otf|woff|woff2)$/i.test(entry.name)) continue;
@@ -55,12 +57,15 @@ async function cleanOnce(): Promise {
if (now - lastActive > retentionMs) {
await unlink(filePath);
lastUsedMap.delete(entry.name);
- console.log(`[temp-cleaner] 删除过期临时字体: ${entry.name}`);
+ cleaned++;
}
- } catch {
- /** 文件可能在扫描过程中被删除,忽略 */
+ } catch (err) {
+ console.log("[temp-cleaner] 处理文件失败:", entry.name, err);
}
}
+ if (cleaned > 0) {
+ console.log(`[temp-cleaner] 本次清理 ${cleaned} 个过期临时字体`);
+ }
}
/** 清理周期:保留时限的一半,最少 5 分钟 */
diff --git a/package.json b/package.json
index 7d59e70..b813f22 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "webfont",
"private": true,
- "version": "1.11.1",
+ "version": "1.12.1",
"type": "module",
"scripts": {
"dev": "pnpx tsx scripts/dev-all.ts",
diff --git a/scripts/bench.mjs b/scripts/bench.mjs
new file mode 100644
index 0000000..6c42b1b
--- /dev/null
+++ b/scripts/bench.mjs
@@ -0,0 +1,109 @@
+/**
+ * 线上高并发压测脚本 —— 每次请求随机字体+随机文字(不命中缓存)
+ *
+ * 用法:
+ * node scripts/bench.mjs [并发数] [总请求数]
+ * node scripts/bench.mjs # 默认 20 并发 100 请求
+ * node scripts/bench.mjs 50 200 # 50 并发 200 请求
+ * node scripts/bench.mjs 100 200 # 100 并发极限测试
+ */
+const FONTS = [
+ "令东齐伋复刻体.ttf",
+ "霞鹜文楷.ttf",
+ "得意黑.ttf",
+ "龙藏体.ttf",
+ "马善政楷体.ttf",
+ "钟齐志莽行书.ttf",
+ "悠哉字体.ttf",
+ "马克笔哥特体.ttf",
+];
+const CHARS =
+ "天地玄黄宇宙洪荒阴阳变化春夏秋冬风花雪月山川河海诗词歌赋琴棋书画梅兰竹菊龙凤呈祥福禄寿喜吉祥如意清风明月高山流水";
+const ORIGIN = "https://webfont.shenzilong.cn";
+
+/** 随机生成一条 /api 子集请求 URL(字体随机、文字随机) */
+function randUrl(): string {
+ const font = FONTS[Math.floor(Math.random() * FONTS.length)];
+ /** 随机 2~8 个字 */
+ const len = 2 + Math.floor(Math.random() * 7);
+ let text = "";
+ for (let i = 0; i < len; i++) {
+ text += CHARS[Math.floor(Math.random() * CHARS.length)];
+ }
+ return (
+ ORIGIN +
+ "/api?font=" +
+ encodeURIComponent(font) +
+ "&text=" +
+ encodeURIComponent(text) +
+ "&outType=woff2"
+ );
+}
+
+const concurrency = parseInt(process.argv[2] || "20");
+const total = parseInt(process.argv[3] || "100");
+
+/** 单次请求:返回状态码+耗时(ms) */
+async function fetchOne(
+ url: string,
+): Promise<{ status: number; ms: number }> {
+ const t0 = performance.now();
+ try {
+ const resp = await fetch(url);
+ await resp.arrayBuffer();
+ return { status: resp.status, ms: performance.now() - t0 };
+ } catch {
+ return { status: 0, ms: performance.now() - t0 };
+ }
+}
+
+async function main() {
+ console.log(
+ `=== ${concurrency} 并发, ${total} 请求, 每次随机字体+文字 ===`,
+ );
+
+ const results: { status: number; ms: number }[] = [];
+ let nextIdx = 0;
+
+ /** worker 线程:循环抢任务执行 */
+ async function worker() {
+ while (nextIdx < total) {
+ nextIdx++;
+ results.push(await fetchOne(randUrl()));
+ }
+ }
+
+ const t0 = performance.now();
+ await Promise.all(
+ Array.from({ length: concurrency }, () => worker()),
+ );
+ const elapsed = (performance.now() - t0) / 1000;
+
+ const ok = results.filter((r) => r.status === 200);
+ const fail = results.filter((r) => r.status !== 200);
+ const times = ok.map((r) => r.ms).sort((a, b) => a - b);
+
+ console.log(`总耗时: ${elapsed.toFixed(1)}s | QPS: ${(total / elapsed).toFixed(1)}`);
+ console.log(
+ `成功: ${ok.length}/${total} (${((ok.length / total) * 100).toFixed(0)}%) | 失败: ${fail.length}`,
+ );
+
+ if (times.length) {
+ const p50 = times[Math.floor(times.length * 0.5)];
+ const p95 = times[Math.floor(times.length * 0.95)];
+ const avg = times.reduce((a, b) => a + b, 0) / times.length;
+ console.log(
+ `延迟 avg=${avg.toFixed(0)}ms p50=${p50.toFixed(0)}ms p95=${p95.toFixed(0)}ms max=${times[times.length - 1].toFixed(0)}ms`,
+ );
+ }
+
+ if (fail.length) {
+ const codes: Record = {};
+ fail.forEach((r) => {
+ codes[r.status] = (codes[r.status] || 0) + 1;
+ });
+ console.log("失败状态码:", codes);
+ }
+}
+
+main();
diff --git a/src/pages/FontDetail.vue b/src/pages/FontDetail.vue
index b5b3078..d25d301 100644
--- a/src/pages/FontDetail.vue
+++ b/src/pages/FontDetail.vue
@@ -90,8 +90,21 @@ const previewLines = computed(() => {
};
});
-/** 字符预览行:来自 config.charsetPreview */
-const charsetPreview = computed(() => meta.value?.config?.charsetPreview ?? "");
+/** 字符预览:用户可实时编辑,初始值来自 config.charsetPreview */
+const charsetPreviewText = ref("");
+/** charsetPreview 初始值就绪后同步到可编辑 ref */
+watchEffect(() => {
+ const val = meta.value?.config?.charsetPreview ?? "";
+ if (val && !charsetPreviewText.value) charsetPreviewText.value = val;
+});
+
+/**
+ * 实时编辑用的 textLoader —— 通过 WebFont SDK 动态更新子集
+ *
+ * 用户在字符预览 textarea 中输入文字时,
+ * textLoader.update() 会请求新子集并自动注入 @font-face。
+ */
+let charsetLoader: { update: (text: string) => void; dispose: () => void } | null = null;
/** 所有需要预览的文字合并,用于字体子集加载 */
const allPreviewText = computed(() => {
@@ -124,6 +137,29 @@ watchEffect(() => {
onUnmounted(() => {
injectedStyle?.remove();
injectedStyle = null;
+ charsetLoader?.dispose();
+ charsetLoader = null;
+});
+
+/**
+ * 字符预览实时编辑:meta 就绪后初始化 textLoader,
+ * 后续用户编辑时仅调 update(增量请求子集)。
+ */
+watchEffect(() => {
+ if (typeof document === "undefined") return;
+ const font = fontName.value;
+ const text = charsetPreviewText.value;
+ if (!font || !text) return;
+ if (!charsetLoader) {
+ charsetLoader = (globalThis as any).WebFont?.loadText?.({
+ fontName: font,
+ text,
+ family: font,
+ outType: "woff2",
+ }) ?? null;
+ } else {
+ charsetLoader.update(text);
+ }
});
/** 使用方法示例代码(动态拼接 origin + fontName) */
@@ -235,9 +271,9 @@ const usageCode = computed(() =>
-
+
- 字符预览
+ 字符预览(可编辑)
-
- {{ charsetPreview }}
-
+ />