From cdffd4cb57eb33f4b26c6228f3cd6abe465cb09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=AE=E7=94=9F=EF=BC=88=E5=AD=90=E8=99=9A=EF=BC=89?= <2234839456@qq.com> Date: Thu, 30 Jul 2026 20:32:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=97=E4=BD=93=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E9=A1=B5=20+=20=E8=AF=A6=E6=83=85=E9=A1=B5=20SSG=20+=20LazyTri?= =?UTF-8?q?gger=20=E6=87=92=E5=8A=A0=E8=BD=BD=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FontList.vue: 字体列表页,卡片网格布局,支持拼音搜索,点击进入详情 - FontDetail.vue: 字体详情页,SSG 模板 + 服务端占位符替换 - LazyTrigger.vue: 可复用懒触发组件,IntersectionObserver 进入视口回调 - 首页添加字体列表入口,FontDetail 返回列表 - placeholders.ts: 前后端共享的占位符常量 - 后端 font_detail.ts: 读取 SSG 模板并替换占位符 --- backend/app.ts | 13 ++ backend/routes/font_detail.ts | 97 +++++++++++++ src/api.ts | 4 +- src/components/LazyTrigger.vue | 56 ++++++++ src/i18n.ts | 4 + src/main.ts | 9 ++ src/pages/FontDetail.vue | 246 +++++++++++++++++++++++++++++++++ src/pages/FontList.vue | 222 +++++++++++++++++++++++++++++ src/pages/Home.vue | 3 + src/placeholders.ts | 27 ++++ vite.config.ts | 2 +- 11 files changed, 681 insertions(+), 2 deletions(-) create mode 100644 backend/routes/font_detail.ts create mode 100644 src/components/LazyTrigger.vue create mode 100644 src/pages/FontDetail.vue create mode 100644 src/pages/FontList.vue create mode 100644 src/placeholders.ts diff --git a/backend/app.ts b/backend/app.ts index b8c8881..28b5c2c 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -10,6 +10,7 @@ import { handleGetConfig } from "./routes/config"; import { handleStats } from "./routes/stats"; import { handleUpload } from "./routes/upload"; import { handleFontSubset } from "./routes/subset"; +import { handleFontDetail } from "./routes/font_detail"; import "./server/node"; import "./server/llrt"; @@ -74,6 +75,18 @@ const staticFileMiddleware: cMiddleware = async function (req, _res, next) { }, }); } catch { + /** + * 字体详情页 SSR:/fonts/xxx 路径无预渲染文件时, + * 读取 SSG 模板替换占位符后返回完整 HTML。 + * handleFontDetail 返回 null 时继续走 SPA fallback。 + */ + if (pathname.startsWith("/fonts/")) { + const ssrResponse = await handleFontDetail(pathname); + if (ssrResponse) { + newRes = ssrResponse; + return next(req, newRes); + } + } /** * 文件/目录不存在 → SPA fallback:返回根 index.html, * 交给前端 vue-router 接管路由(支持未预渲染的动态路由)。 diff --git a/backend/routes/font_detail.ts b/backend/routes/font_detail.ts new file mode 100644 index 0000000..aa705a6 --- /dev/null +++ b/backend/routes/font_detail.ts @@ -0,0 +1,97 @@ +/** + * 字体详情页 SSR —— 读取 SSG 模板 HTML,替换占位符后返回 + * + * 流程: + * 1. 从路由参数提取字体 slug + * 2. 查询字体元数据(是否存在、是否临时等) + * 3. 读取 dist/fonts/__FONT_NAME__/index.html 模板 + * 4. 替换所有占位符为实际值 + * 5. 返回完整 HTML + */ +import { readFile, stat } from "../interface"; +import { path_join } from "../interface"; +import { fontDirs } from "../config"; +import { FONT_NAME, FONT_SLUG, type PlaceholderValues } from "../../src/placeholders"; + +const ROOT_DIR = "dist"; + +/** 模板缓存:避免每次请求都读文件 */ +let templateCache: Uint8Array | null = null; + +/** 读取 SSG 模板 HTML(带缓存) */ +async function getTemplate(): Promise { + if (templateCache) return templateCache; + const templatePath = path_join(ROOT_DIR, "fonts", FONT_NAME, "index.html"); + templateCache = await readFile(templatePath); + return templateCache; +} + +/** + * 查询字体是否存在及其元数据 + * + * @returns 如果字体存在返回元数据,不存在返回 null + */ +async function getFontMeta(slug: string): Promise<{ exists: boolean; temporary: boolean } | null> { + for (const dir of fontDirs) { + try { + const filePath = path_join(dir, slug); + const s = await stat(filePath); + if (s.isFile()) { + return { exists: true, temporary: dir === "font/temp" }; + } + } catch {} + } + return null; +} + +/** + * 处理 /fonts/:slug 请求 —— SSR 返回字体详情页 HTML + * + * @returns Response 或 null(模板不存在时返回 null,交给 fallback) + */ +export async function handleFontDetail(pathname: string): Promise { + /** 提取 slug:/fonts/令东齐伋复刻体.ttf → 令东齐伋复刻体.ttf */ + const slug = decodeURIComponent(pathname.slice("/fonts/".length).replace(/\/$/, "")); + + /** 读取 SSG 模板 */ + let template: Uint8Array; + try { + template = await getTemplate(); + } catch { + /** 模板不存在(SSG 未构建),交给 SPA fallback */ + return null; + } + + /** 查询字体元数据 */ + const meta = await getFontMeta(slug); + + /** 占位符 → 实际值 */ + const values: PlaceholderValues = { + [FONT_NAME]: slug, + [FONT_SLUG]: slug, + }; + + /** 解码模板为字符串,替换所有占位符 */ + const decoder = new TextDecoder(); + let html = decoder.decode(template); + + for (const [placeholder, value] of Object.entries(values)) { + html = html.replaceAll(placeholder, value); + } + + /** 字体不存在时,在 body 开头注入 noindex 标签防止搜索引擎收录 */ + if (meta && !meta.exists) { + html = html.replace("", ''); + } + + const encoder = new TextEncoder(); + const htmlBytes = encoder.encode(html); + + return new Response(htmlBytes, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Length": `${htmlBytes.byteLength}`, + }, + }); +} diff --git a/src/api.ts b/src/api.ts index 0fc3d17..2d79cbb 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,8 @@ export interface FontInfo { name: string; - dir: string; + dir?: string; + /** 是否为临时上传的字体 */ + temporary?: boolean; } export interface ServerConfig { diff --git a/src/components/LazyTrigger.vue b/src/components/LazyTrigger.vue new file mode 100644 index 0000000..1b6b1be --- /dev/null +++ b/src/components/LazyTrigger.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/i18n.ts b/src/i18n.ts index 368885f..942c856 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -65,6 +65,8 @@ const messages = { viewSkill: "查看 AI Chinese Font Skill →", sponsor: "赞助支持", agentSkillDemo: "Agent Skill Demo", + /** 字体列表入口 */ + browseFonts: "字体列表", // FontSelector.vue selectFont: "选择字体", @@ -137,6 +139,8 @@ const messages = { viewSkill: "View AI Chinese Font Skill →", sponsor: "Sponsor", agentSkillDemo: "Agent Skill Demo", + /** Font list entry */ + browseFonts: "Font List", // FontSelector.vue selectFont: "Select font", diff --git a/src/main.ts b/src/main.ts index 4596275..cb256f5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,15 @@ import App from "./App.vue"; const routes = [ { path: "/", component: () => import("./pages/Home.vue") }, { path: "/demo", component: () => import("./pages/Demo.vue") }, + { path: "/fonts", component: () => import("./pages/FontList.vue") }, + /** + * 字体详情页 —— SSG 构建时以 __FONT_NAME__ 占位符渲染模板 HTML, + * 后端对 /fonts/* 请求做字符串替换返回动态页面。 + */ + { + path: "/fonts/:slug", + component: () => import("./pages/FontDetail.vue"), + }, ]; export const createApp = ViteSSG( diff --git a/src/pages/FontDetail.vue b/src/pages/FontDetail.vue new file mode 100644 index 0000000..c0f4292 --- /dev/null +++ b/src/pages/FontDetail.vue @@ -0,0 +1,246 @@ + + + diff --git a/src/pages/FontList.vue b/src/pages/FontList.vue new file mode 100644 index 0000000..652989b --- /dev/null +++ b/src/pages/FontList.vue @@ -0,0 +1,222 @@ + + + diff --git a/src/pages/Home.vue b/src/pages/Home.vue index 7774c70..55330f6 100644 --- a/src/pages/Home.vue +++ b/src/pages/Home.vue @@ -183,6 +183,9 @@ async function refreshFonts() { + + {{ t('browseFonts') }} + {{ t('agentSkillDemo') }} diff --git a/src/placeholders.ts b/src/placeholders.ts new file mode 100644 index 0000000..ff80a3c --- /dev/null +++ b/src/placeholders.ts @@ -0,0 +1,27 @@ +/** + * SSG 模板占位符 —— 前后端共享 + * + * Vue 组件用这些常量渲染模板(SSG 产出含占位符的 HTML), + * 后端读取模板 HTML 后用 replaceAll 替换为实际值。 + * + * 约定:占位符用双下划线包裹,避免和正常文本冲突。 + */ + +/** 字体名称占位符(如 "令东齐伋复刻体.ttf") */ +export const FONT_NAME = "__FONT_NAME__"; + +/** 字体 slug 占位符(URL 路径部分,如 "令东齐伋复刻体.ttf") */ +export const FONT_SLUG = "__FONT_SLUG__"; + +/** 站点 origin 占位符(如 "https://webfont.shenzilong.cn") */ +export const ORIGIN = "__ORIGIN__"; + +/** + * 占位符 → 实际值 的映射类型 + * 后端替换时传入此对象 + */ +export type PlaceholderValues = { + [FONT_NAME]: string; + [FONT_SLUG]: string; + [ORIGIN]: string; +}; diff --git a/vite.config.ts b/vite.config.ts index cc2d3b8..863d8be 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,7 +49,7 @@ export default defineConfig({ * vite-ssg 默认会过滤掉动态路由、保留静态路由, * 这里显式返回需要 SSG 的路由清单,新增内容页(/blog 等)时在此追加。 */ - includedRoutes: () => ["/", "/demo"], + includedRoutes: () => ["/", "/demo", "/fonts", "/fonts/__FONT_NAME__"], /** * 构建期(Node 环境)模拟浏览器全局变量, * 防止第三方库在 SSG 阶段访问 window/document 时崩溃。