mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 14:53:32 +08:00
feat: 字体列表页 + 详情页 SSG + LazyTrigger 懒加载组件
- FontList.vue: 字体列表页,卡片网格布局,支持拼音搜索,点击进入详情 - FontDetail.vue: 字体详情页,SSG 模板 + 服务端占位符替换 - LazyTrigger.vue: 可复用懒触发组件,IntersectionObserver 进入视口回调 - 首页添加字体列表入口,FontDetail 返回列表 - placeholders.ts: 前后端共享的占位符常量 - 后端 font_detail.ts: 读取 SSG 模板并替换占位符
This commit is contained in:
parent
d064e9bc8f
commit
cdffd4cb57
@ -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 接管路由(支持未预渲染的动态路由)。
|
||||
|
||||
97
backend/routes/font_detail.ts
Normal file
97
backend/routes/font_detail.ts
Normal 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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
export interface FontInfo {
|
||||
name: string;
|
||||
dir: string;
|
||||
dir?: string;
|
||||
/** 是否为临时上传的字体 */
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
|
||||
56
src/components/LazyTrigger.vue
Normal file
56
src/components/LazyTrigger.vue
Normal 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>
|
||||
@ -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",
|
||||
|
||||
@ -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
246
src/pages/FontDetail.vue
Normal 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><link rel="stylesheet"
|
||||
href="{{ origin }}/api?font={{ fontName }}&text=你的文字&outType=woff2">
|
||||
|
||||
<style>
|
||||
.my-title { font-family: "{{ fontName }}"; }
|
||||
</style></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
222
src/pages/FontList.vue
Normal file
222
src/pages/FontList.vue
Normal 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>
|
||||
@ -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
27
src/placeholders.ts
Normal 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;
|
||||
};
|
||||
@ -49,7 +49,7 @@ export default defineConfig({
|
||||
* vite-ssg 默认会过滤掉动态路由、保留静态路由,
|
||||
* 这里显式返回需要 SSG 的路由清单,新增内容页(/blog 等)时在此追加。
|
||||
*/
|
||||
includedRoutes: () => ["/", "/demo"],
|
||||
includedRoutes: () => ["/", "/demo", "/fonts", "/fonts/__FONT_NAME__"],
|
||||
/**
|
||||
* 构建期(Node 环境)模拟浏览器全局变量,
|
||||
* 防止第三方库在 SSG 阶段访问 window/document 时崩溃。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user