feat: 字体列表页 + 详情页 SSG + LazyTrigger 懒加载组件

- FontList.vue: 字体列表页,卡片网格布局,支持拼音搜索,点击进入详情
- FontDetail.vue: 字体详情页,SSG 模板 + 服务端占位符替换
- LazyTrigger.vue: 可复用懒触发组件,IntersectionObserver 进入视口回调
- 首页添加字体列表入口,FontDetail 返回列表
- placeholders.ts: 前后端共享的占位符常量
- 后端 font_detail.ts: 读取 SSG 模板并替换占位符
This commit is contained in:
崮生(子虚) 2026-07-30 20:32:42 +08:00
parent d064e9bc8f
commit cdffd4cb57
11 changed files with 681 additions and 2 deletions

View File

@ -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

View File

@ -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<Uint8Array> {
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<Response | null> {
/** 提取 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("</head>", '<meta name="robots" content="noindex"></head>');
}
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}`,
},
});
}

View File

@ -1,6 +1,8 @@
export interface FontInfo {
name: string;
dir: string;
dir?: string;
/** 是否为临时上传的字体 */
temporary?: boolean;
}
export interface ServerConfig {

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
/**
* 懒触发组件 子元素进入视口时触发 @appear 事件仅一次
*
* 用途字体列表卡片懒加载字体子集图片懒加载等场景
* 通过 IntersectionObserver 监听进入视口 rootMargin 预判范围后回调
*/
import { ref, onMounted, onUnmounted } from "vue";
const props = withDefaults(
defineProps<{
/** 视口预判距离,提前多少像素触发(默认 10px */
rootMargin?: string;
}>(),
{ rootMargin: "10px" },
);
const emit = defineEmits<{
/** 子元素首次进入视口范围时触发 */
appear: [];
}>();
const el = ref<HTMLElement>();
let observer: IntersectionObserver | null = null;
onMounted(() => {
if (!el.value) return;
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
emit("appear");
observer?.disconnect();
observer = null;
break;
}
}
},
{ rootMargin: props.rootMargin },
);
observer.observe(el.value);
});
onUnmounted(() => observer?.disconnect());
</script>
<template>
<!--
div 作为 IntersectionObserver 的观测目标
不用 display:contents会导致无盒模型observer 无法触发
而是让 slot 内容自然撑满
-->
<div ref="el">
<slot />
</div>
</template>

View File

@ -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",

View File

@ -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(

246
src/pages/FontDetail.vue Normal file
View File

@ -0,0 +1,246 @@
<script setup lang="ts">
/**
* 字体详情页 展示单个字体的完整预览效果
*
* SSG 策略构建时预渲染路由 /fonts/__FONT_NAME__
* 产出完整 HTML 模板title/meta/body 全含占位符
* 后端收到 /fonts/实际字体名 时读取模板做字符串替换返回
*/
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useHead } from "@unhead/vue";
import { fetchFonts } from "../api";
import type { FontInfo } from "../api";
import { FONT_NAME, FONT_SLUG, ORIGIN } from "../placeholders";
import { SITE_NAME } from "../seo";
const route = useRoute();
const router = useRouter();
/** 字体名 —— SSG 时为占位符,客户端 hydrate 后为真实字体名 */
const fontName = computed(() => decodeURIComponent(String(route.params.slug || "")));
/** 站点 origin —— SSG 时为占位符,客户端为真实地址 */
const origin = ref(ORIGIN);
/** SEO —— 使用占位符值SSG 产出的 HTML 含占位符,后端替换 */
useHead({
title: `${FONT_NAME} 字体预览 | ${SITE_NAME}`,
meta: [
{
name: "description",
content: `${FONT_NAME} 在线预览 — 服务端按需裁剪字体子集woff2/ttf 格式免费使用。WebFont 提供增量加载 SDK轻松嵌入任何网站。`,
},
{ property: "og:title", content: `${FONT_NAME} 字体预览` },
{
property: "og:description",
content: `${FONT_NAME} 在线预览 — 按需裁剪,免费使用`,
},
{ property: "og:url", content: `${ORIGIN}/fonts/${FONT_SLUG}` },
{ property: "og:type", content: "website" },
],
});
const fonts = ref<FontInfo[]>([]);
const notFound = ref(false);
onMounted(async () => {
origin.value = location.origin;
const allFonts = await fetchFonts().catch(() => []);
fonts.value = allFonts;
notFound.value = allFonts.length > 0 && !allFonts.some((f) => f.name === fontName.value);
});
</script>
<template>
<div style="min-height: 100vh; background: #fafafa">
<!-- 字体加载SSG 时含占位符后端替换后即指向正确字体子集 -->
<link
rel="stylesheet"
:href="`${origin}/api?font=${fontName}&text=静心茶舍以茶为媒观自在一叶知秋山间晨露未晞茶人已入林深处指尖轻捻择其嫩芽天地无极乾坤借法&outType=woff2`"
/>
<!-- 顶部导航 -->
<div
style="
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-bottom: 1px solid #eee;
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
"
>
<button
style="
padding: 6px 16px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 14px;
"
@click="router.push('/fonts')"
>
返回列表
</button>
<span style="font-size: 13px; color: #888">
<a
href="https://github.com/2234839/web-font"
target="_blank"
style="color: #8b7355; text-decoration: none"
>WebFont </a
>
</span>
</div>
<!-- 字体名称 -->
<div style="text-align: center; padding: 60px 24px 40px">
<h1
:data-font="fontName"
style="font-size: 36px; font-weight: 700; color: #2c2c2c; margin: 0; line-height: 1.3"
>
{{ fontName }}
</h1>
<p v-if="notFound" style="font-size: 14px; color: #e74c3c; margin: 12px 0 0">
该字体不存在或已被删除
</p>
<p v-else style="font-size: 14px; color: #999; margin: 12px 0 0">
在线预览 · 按需裁剪 · 免费使用
</p>
</div>
<!-- 字体预览区域 -->
<div style="max-width: 800px; margin: 0 auto; padding: 0 24px 80px">
<!-- 大标题预览 -->
<div
style="
background: #fff;
border-radius: 12px;
padding: 48px 40px;
text-align: center;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
"
>
<div
:style="{
fontFamily: `'${fontName}', sans-serif`,
fontSize: '56px',
fontWeight: 600,
color: '#2c2c2c',
lineHeight: 1.3,
}"
>
静心茶舍
</div>
<p
:style="{
fontFamily: `'${fontName}', sans-serif`,
fontSize: '16px',
color: '#888',
margin: '20px 0 0',
letterSpacing: '0.1em',
}"
>
以茶为媒 · 静心观自在
</p>
</div>
<!-- 正文预览 -->
<div
style="
background: #fff;
border-radius: 12px;
padding: 40px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
"
>
<h2
:style="{
fontFamily: `'${fontName}', serif`,
fontSize: '24px',
fontWeight: 600,
margin: '0 0 20px',
color: '#3a3a3a',
}"
>
一叶知秋
</h2>
<p
:style="{
fontFamily: `'${fontName}', serif`,
fontSize: '16px',
lineHeight: 1.8,
color: '#4a4a4a',
textIndent: '2em',
margin: 0,
}"
>
山间晨露未晞茶人已入林深处指尖轻捻择其嫩芽一二置于竹篮之中此乃一年之始亦是一叶与万物的初遇
</p>
</div>
<!-- 字符集预览 -->
<div
style="
background: #fff;
border-radius: 12px;
padding: 32px 40px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
"
>
<div style="font-size: 13px; font-weight: 600; color: #999; margin-bottom: 16px">
字符预览
</div>
<div
:style="{
fontFamily: `'${fontName}', monospace`,
fontSize: '18px',
color: '#555',
lineHeight: 2,
wordBreak: 'break-all',
}"
>
天地无极乾坤借法0123456789 ABCDEF
</div>
</div>
<!-- 使用方法 -->
<div
style="
background: #fff;
border-radius: 12px;
padding: 32px 40px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
"
>
<div style="font-size: 13px; font-weight: 600; color: #999; margin-bottom: 16px">
使用方法
</div>
<pre
style="
background: #f5f5f5;
padding: 16px 20px;
border-radius: 8px;
font-size: 13px;
overflow-x: auto;
margin: 0;
color: #333;
"
><code>&lt;link rel="stylesheet"
href="{{ origin }}/api?font={{ fontName }}&amp;text=你的文字&amp;outType=woff2"&gt;
&lt;style&gt;
.my-title { font-family: "{{ fontName }}"; }
&lt;/style&gt;</code></pre>
</div>
</div>
</div>
</template>

222
src/pages/FontList.vue Normal file
View File

@ -0,0 +1,222 @@
<script setup lang="ts">
/**
* 字体列表页 浏览所有可用字体支持拼音搜索点击进入详情页
*
* SSG 预渲染首屏 HTML 含静态外壳标题/搜索框/说明
* 字体列表在客户端 hydrate 后从 API 加载
* 字体预览使用 LazyTrigger 组件懒加载只有进入视口的卡片才请求字体子集
*/
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { pinyin } from "pinyin-pro";
import { useHead } from "@unhead/vue";
import { fetchFonts } from "../api";
import type { FontInfo } from "../api";
import { SITE_NAME } from "../seo";
import LazyTrigger from "../components/LazyTrigger.vue";
useHead({
title: `字体列表 | ${SITE_NAME}`,
meta: [
{
name: "description",
content: "浏览所有可用字体,支持拼音搜索。点击字体查看完整预览效果,获取按需裁剪链接。",
},
],
});
const router = useRouter();
const fonts = ref<FontInfo[]>([]);
const loading = ref(true);
/** 搜索关键词 */
const query = ref("");
/** 预览文字内容(与 loadText 参数一致) */
const PREVIEW_TEXT = "静心茶舍 天地无极 ABCDEF";
/**
* 过滤后的字体列表 FontSelector 的搜索逻辑
* 空格分隔多关键词AND每个关键词匹配文件名 + 拼音
*/
const filteredFonts = computed(() => {
const raw = query.value.trim().toLowerCase();
if (!raw) return fonts.value;
const keywords = raw.split(/\s+/);
return fonts.value.filter((f) => {
const name = f.name.toLowerCase();
const pinyinStr = pinyin(f.name, { toneType: "none", type: "array", nonZh: "consecutive" }).join("").toLowerCase();
return keywords.every((kw) => name.includes(kw) || pinyinStr.includes(kw));
});
});
onMounted(async () => {
fonts.value = await fetchFonts().catch(() => []);
loading.value = false;
});
/**
* LazyTrigger @appear 回调 卡片进入视口时按需加载字体子集
* WebFont SDK 内部有去重无需额外缓存
*/
function onCardAppear(fontName: string) {
(globalThis as any).WebFont?.loadText?.({
fontName,
text: PREVIEW_TEXT,
family: fontName,
});
}
/** 点击字体卡片 → 跳转详情页 */
function goToDetail(name: string) {
router.push(`/fonts/${encodeURIComponent(name)}`);
}
</script>
<template>
<div style="min-height: 100vh; background: #fafafa">
<!-- 顶部导航 -->
<div
style="
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-bottom: 1px solid #eee;
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
"
>
<button
style="
padding: 6px 16px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 14px;
"
@click="router.push('/')"
>
首页
</button>
<span style="font-size: 13px; color: #888">
<a
href="https://github.com/2234839/web-font"
target="_blank"
style="color: #8b7355; text-decoration: none"
>WebFont </a
>
</span>
</div>
<!-- 标题 + 搜索 -->
<div style="max-width: 960px; margin: 0 auto; padding: 40px 24px 24px">
<h1 style="font-size: 28px; font-weight: 700; color: #2c2c2c; margin: 0 0 8px">字体列表</h1>
<p style="font-size: 14px; color: #999; margin: 0 0 24px">
{{ loading ? "..." : fonts.length }} 个字体 · 点击查看完整预览
</p>
<!-- 搜索框 -->
<input
v-model="query"
type="text"
placeholder="搜索字体(支持拼音)..."
style="
width: 100%;
max-width: 480px;
padding: 10px 16px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
outline: none;
box-sizing: border-box;
"
/>
</div>
<!-- 字体卡片网格 -->
<div style="max-width: 960px; margin: 0 auto; padding: 0 24px 80px">
<div v-if="loading" style="text-align: center; padding: 60px; color: #999">加载中...</div>
<div
v-else-if="filteredFonts.length === 0"
style="text-align: center; padding: 60px; color: #999"
>
未找到匹配的字体
</div>
<div
v-else
data-font-grid
style="
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
"
>
<LazyTrigger
v-for="font in filteredFonts"
:key="font.name"
@appear="onCardAppear(font.name)"
>
<div
:data-font="font.name"
@click="goToDetail(font.name)"
style="
background: #fff;
border-radius: 12px;
padding: 24px;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.1s;
border: 1px solid transparent;
"
onmouseover="this.style.boxShadow='0 4px 16px rgba(0,0,0,0.08)';this.style.borderColor='#1677ff'"
onmouseout="this.style.boxShadow='0 2px 8px rgba(0,0,0,0.04)';this.style.borderColor='transparent'"
>
<!-- 字体名 -->
<div style="font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px">
{{ font.name }}
<span
v-if="font.temporary"
style="
font-size: 11px;
color: #e8a030;
background: #fff8e8;
padding: 2px 6px;
border-radius: 4px;
margin-left: 6px;
"
>临时</span
>
</div>
<!-- 字体预览 -->
<div
:style="{
fontFamily: `'${font.name}', serif`,
fontSize: '32px',
color: '#2c2c2c',
lineHeight: 1.4,
marginBottom: '8px',
}"
>
静心茶舍
</div>
<div
:style="{
fontFamily: `'${font.name}', monospace`,
fontSize: '13px',
color: '#999',
}"
>
天地无极 ABCDEF
</div>
</div>
</LazyTrigger>
</div>
</div>
</div>
</template>

View File

@ -183,6 +183,9 @@ async function refreshFonts() {
<button @click="toggleLocale" style="font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 10px; cursor: pointer; background: #fff; color: #333; min-width: 42px">
{{ locale === 'zh' ? 'EN' : '中' }}
</button>
<router-link to="/fonts" style="font-size: 13px; color: #8b7355; text-decoration: none; border: 1px solid #8b7355; border-radius: 6px; padding: 4px 12px; display: inline-flex; align-items: center; gap: 4px">
{{ t('browseFonts') }}
</router-link>
<router-link to="/demo" style="font-size: 13px; color: #8b7355; text-decoration: none; border: 1px solid #8b7355; border-radius: 6px; padding: 4px 12px; display: inline-flex; align-items: center; gap: 4px">
{{ t('agentSkillDemo') }}
</router-link>

27
src/placeholders.ts Normal file
View File

@ -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;
};

View File

@ -49,7 +49,7 @@ export default defineConfig({
* vite-ssg
* SSG /blog
*/
includedRoutes: () => ["/", "/demo"],
includedRoutes: () => ["/", "/demo", "/fonts", "/fonts/__FONT_NAME__"],
/**
* Node
* SSG 访 window/document