feat: 详情页全面接入 font-config + 防闪烁

- font-config.json: 新增 bodyTitle/bodyText/charsetPreview 字段
  为每个字体配置通过 lint 检查的专属文案
- FontDetail.vue: 正文/字符预览使用 config 字段替代写死文案
  大标题/正文/字符预览加 v-if=meta 守卫,消除 hydrate 闪烁
  h1 标题使用 displayName
- lint-font-config.ts: 扩展检查范围至所有文本字段
- FontUserConfig 类型同步更新(后端+前端)
This commit is contained in:
崮生(子虚) 2026-07-30 23:30:40 +08:00
parent 9397e10aad
commit a095cd0590
4 changed files with 87 additions and 44 deletions

View File

@ -396,6 +396,12 @@ export interface FontUserConfig {
homepage?: string; homepage?: string;
/** 默认预览文字 */ /** 默认预览文字 */
previewText?: string; previewText?: string;
/** 详情页正文标题 */
bodyTitle?: string;
/** 详情页正文段落 */
bodyText?: string;
/** 详情页字符预览行 */
charsetPreview?: string;
} }
/** 字体元数据结果 */ /** 字体元数据结果 */

View File

@ -1,12 +1,12 @@
/** /**
* lint-font-config.ts * lint-font-config.ts
* *
* font/font-config.json previewText * font/font-config.json previewText / bodyTitle / bodyText / charsetPreview
* previewText *
* *
* pnpm tsx scripts/lint-font-config.ts * pnpm lint:font-config
* *
* font-config.json previewText * font-config.json
*/ */
import { readFile, readdir } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
@ -15,8 +15,19 @@ import { extractCodePoints } from "../backend/font_util/font_meta.ts";
interface FontUserConfig { interface FontUserConfig {
displayName?: string; displayName?: string;
previewText?: string; previewText?: string;
bodyTitle?: string;
bodyText?: string;
charsetPreview?: string;
} }
/** 需要检查的文本字段及显示名 */
const TEXT_FIELDS: Array<{ key: keyof FontUserConfig; label: string }> = [
{ key: "previewText", label: "previewText" },
{ key: "bodyTitle", label: "bodyTitle" },
{ key: "bodyText", label: "bodyText" },
{ key: "charsetPreview", label: "charsetPreview" },
];
type FontConfig = Record<string, FontUserConfig>; type FontConfig = Record<string, FontUserConfig>;
const FONT_DIR = "font"; const FONT_DIR = "font";
@ -36,58 +47,69 @@ async function main() {
} }
} }
/** 字体 codepoints 缓存(同一字体只解析一次) */
const cpCache = new Map<string, Set<number>>();
let hasError = false; let hasError = false;
for (const [fileName, cfg] of Object.entries(config)) { for (const [fileName, cfg] of Object.entries(config)) {
const previewText = cfg.previewText;
if (!previewText) {
/** 没配 previewText跳过用默认值无法检查 */
continue;
}
const fontPath = fontPathMap.get(fileName); const fontPath = fontPathMap.get(fileName);
if (!fontPath) { if (!fontPath) {
console.warn(`⚠️ [${fileName}] 字体文件不存在,跳过`); console.warn(`⚠️ [${fileName}] 字体文件不存在,跳过`);
continue; continue;
} }
/** 读取字体并提取 codepoints */ /** 读取字体 codepoints有缓存则复用 */
const raw = await readFile(fontPath); let supportedCps = cpCache.get(fileName);
const fontBuffer = raw.buffer.slice( if (!supportedCps) {
raw.byteOffset, const raw = await readFile(fontPath);
raw.byteOffset + raw.byteLength, const fontBuffer = raw.buffer.slice(
); raw.byteOffset,
const supportedCps = extractCodePoints(fontBuffer); raw.byteOffset + raw.byteLength,
);
/** 逐字符检查 previewText */ supportedCps = extractCodePoints(fontBuffer);
const chars = [...previewText]; cpCache.set(fileName, supportedCps);
/** 空格/换行等空白字符不检查(字体通常都有,但即使没有也不影响预览) */
const missing: string[] = [];
for (const ch of chars) {
if (/\s/.test(ch)) continue;
const cp = ch.codePointAt(0)!;
if (!supportedCps.has(cp)) {
missing.push(ch);
}
} }
const displayName = cfg.displayName ?? fileName; const displayName = cfg.displayName ?? fileName;
if (missing.length > 0) { let fontHasError = false;
hasError = true;
console.error( /** 逐字段检查 */
`❌ [${displayName}] (${fileName}) previewText 含 ${missing.length} 个不支持的字符:${missing.join(" ")}`, for (const { key, label } of TEXT_FIELDS) {
); const text = cfg[key];
console.error(` previewText: "${previewText}"`); if (!text) continue;
} else {
console.log(`✅ [${displayName}] (${fileName}) previewText 检查通过`); const chars = [...text];
const missing: string[] = [];
for (const ch of chars) {
/** 空白字符不检查 */
if (/\s/.test(ch)) continue;
const cp = ch.codePointAt(0)!;
if (!supportedCps!.has(cp)) {
missing.push(ch);
}
}
if (missing.length > 0) {
fontHasError = true;
hasError = true;
console.error(
`❌ [${displayName}] ${label}${missing.length} 个不支持的字符:${missing.join(" ")}`,
);
console.error(` ${label}: "${text}"`);
}
}
if (!fontHasError) {
console.log(`✅ [${displayName}] (${fileName}) 所有文本字段检查通过`);
} }
} }
if (hasError) { if (hasError) {
console.error("\n💔 存在不支持的字符,请修正 previewText"); console.error("\n💔 存在不支持的字符,请修正相关文本字段");
process.exit(1); process.exit(1);
} else { } else {
console.log("\n🎉 所有 previewText 检查通过"); console.log("\n🎉 所有文本字段检查通过");
} }
} }

View File

@ -73,6 +73,12 @@ export interface FontUserConfig {
homepage?: string; homepage?: string;
/** 默认预览文字 */ /** 默认预览文字 */
previewText?: string; previewText?: string;
/** 详情页正文标题 */
bodyTitle?: string;
/** 详情页正文段落 */
bodyText?: string;
/** 详情页字符预览行 */
charsetPreview?: string;
} }
/** 字体元数据 */ /** 字体元数据 */

View File

@ -92,8 +92,14 @@ const previewLines = computed(() => {
}; };
}); });
/** 显示名:优先 config.displayName */ /** 正文标题:优先 config.bodyTitle */
const displayName = computed(() => meta.value?.config?.displayName ?? fontName.value); const bodyTitle = computed(() => meta.value?.config?.bodyTitle ?? "清风明月");
/** 正文段落:优先 config.bodyText */
const bodyText = computed(() => meta.value?.config?.bodyText ?? "山中有桂树,常伴青云飞。");
/** 字符预览行:优先 config.charsetPreview */
const charsetPreview = computed(() => meta.value?.config?.charsetPreview ?? "天地玄黄宇宙洪荒0123456789 ABCDEF");
</script> </script>
<template> <template>
@ -148,7 +154,7 @@ const displayName = computed(() => meta.value?.config?.displayName ?? fontName.v
:data-font="fontName" :data-font="fontName"
style="font-size: 36px; font-weight: 700; color: #2c2c2c; margin: 0; line-height: 1.3" style="font-size: 36px; font-weight: 700; color: #2c2c2c; margin: 0; line-height: 1.3"
> >
{{ fontName }} {{ meta?.config?.displayName ?? fontName }}
</h1> </h1>
<p v-if="notFound" style="font-size: 14px; color: #e74c3c; margin: 12px 0 0"> <p v-if="notFound" style="font-size: 14px; color: #e74c3c; margin: 12px 0 0">
该字体不存在或已被删除 该字体不存在或已被删除
@ -162,6 +168,7 @@ const displayName = computed(() => meta.value?.config?.displayName ?? fontName.v
<div style="max-width: 800px; margin: 0 auto; padding: 0 24px 80px"> <div style="max-width: 800px; margin: 0 auto; padding: 0 24px 80px">
<!-- 大标题预览 --> <!-- 大标题预览 -->
<div <div
v-if="meta"
style=" style="
background: #fff; background: #fff;
border-radius: 12px; border-radius: 12px;
@ -198,6 +205,7 @@ const displayName = computed(() => meta.value?.config?.displayName ?? fontName.v
<!-- 正文预览 --> <!-- 正文预览 -->
<div <div
v-if="meta"
style=" style="
background: #fff; background: #fff;
border-radius: 12px; border-radius: 12px;
@ -215,7 +223,7 @@ const displayName = computed(() => meta.value?.config?.displayName ?? fontName.v
color: '#3a3a3a', color: '#3a3a3a',
}" }"
> >
一叶知秋 {{ bodyTitle }}
</h2> </h2>
<p <p
:style="{ :style="{
@ -227,12 +235,13 @@ const displayName = computed(() => meta.value?.config?.displayName ?? fontName.v
margin: 0, margin: 0,
}" }"
> >
山间晨露未晞茶人已入林深处指尖轻捻择其嫩芽一二置于竹篮之中此乃一年之始亦是一叶与万物的初遇 {{ bodyText }}
</p> </p>
</div> </div>
<!-- 字符集预览 --> <!-- 字符集预览 -->
<div <div
v-if="meta"
style=" style="
background: #fff; background: #fff;
border-radius: 12px; border-radius: 12px;
@ -253,7 +262,7 @@ const displayName = computed(() => meta.value?.config?.displayName ?? fontName.v
wordBreak: 'break-all', wordBreak: 'break-all',
}" }"
> >
天地无极乾坤借法0123456789 ABCDEF {{ charsetPreview }}
</div> </div>
</div> </div>