feat: 离线裁剪统计 + 企业服务板块 + SDK文档独立页(/docs) + 示例改用observeFont

This commit is contained in:
崮生(子虚) 2026-08-06 08:57:48 +08:00
parent 09f434bbcf
commit d16aa46227
12 changed files with 239 additions and 21 deletions

View File

@ -7,7 +7,7 @@ import { flushStatsSyncSafe } from "./stats_store";
import { enableTempUpload, adminApiKey } from "./config"; import { enableTempUpload, adminApiKey } from "./config";
import { handleListFonts } from "./routes/fonts"; import { handleListFonts } from "./routes/fonts";
import { handleGetConfig } from "./routes/config"; import { handleGetConfig } from "./routes/config";
import { handleStats } from "./routes/stats"; import { handleStats, handleStatsEvent } from "./routes/stats";
import { handleUpload } from "./routes/upload"; import { handleUpload } from "./routes/upload";
import { handleFontSubset } from "./routes/subset"; import { handleFontSubset } from "./routes/subset";
import { handleFontDetail } from "./routes/font_detail"; import { handleFontDetail } from "./routes/font_detail";
@ -157,6 +157,9 @@ const fontApiMiddleware: cMiddleware = async (req, res, next) => {
if (url.pathname === "/api/stats" && req.method === "GET") { if (url.pathname === "/api/stats" && req.method === "GET") {
return handleStats(req, res); return handleStats(req, res);
} }
if (url.pathname === "/api/stats/event" && req.method === "POST") {
return handleStatsEvent(req, res);
}
if (url.pathname === "/api/upload" && req.method === "POST") { if (url.pathname === "/api/upload" && req.method === "POST") {
return handleUpload(req, res); return handleUpload(req, res);
} }

View File

@ -1,4 +1,4 @@
import { jsonResponse, stats, subsetCache, fontBufferCache } from "../shared"; import { jsonResponse, stats, subsetCache, fontBufferCache, markStatsDirty } from "../shared";
/** GET /api/stats — 返回运行时统计 */ /** GET /api/stats — 返回运行时统计 */
export async function handleStats(req: Request, _res: Response) { export async function handleStats(req: Request, _res: Response) {
@ -11,8 +11,40 @@ export async function handleStats(req: Request, _res: Response) {
subsetCacheHits: stats.subsetCacheHits, subsetCacheHits: stats.subsetCacheHits,
totalChars: stats.totalChars, totalChars: stats.totalChars,
tempUploads: stats.tempUploads, tempUploads: stats.tempUploads,
offlineSubsets: stats.offlineSubsets,
offlineDownloads: stats.offlineDownloads,
subsetCacheEntries: subsetCache.size, subsetCacheEntries: subsetCache.size,
fontBufferCacheEntries: fontBufferCache.size, fontBufferCacheEntries: fontBufferCache.size,
}), }),
}; };
} }
/** 允许上报的匿名事件类型 —— 白名单校验,防止任意字段注入 */
const OFFLINE_EVENTS = new Set(["offline_subset", "offline_download"]);
/**
* POST /api/stats/event 线
*
* 线
*
* { event: "offline_subset" | "offline_download" }
*/
export async function handleStatsEvent(req: Request, _res: Response) {
try {
/** 服务器把 body 挂在 _bodyBuffer 上LLRT 下手写 Request 不支持 req.json() */
const buf = (req as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer;
if (!buf || buf.byteLength === 0) {
return { req, res: jsonResponse({ success: false, error: "请求体为空" }, 400) };
}
const body = JSON.parse(new TextDecoder().decode(buf)) as { event?: string };
if (!body || typeof body.event !== "string" || !OFFLINE_EVENTS.has(body.event)) {
return { req, res: jsonResponse({ success: false, error: "unknown event" }, 400) };
}
if (body.event === "offline_subset") stats.offlineSubsets++;
else stats.offlineDownloads++;
markStatsDirty();
return { req, res: jsonResponse({ success: true }) };
} catch {
return { req, res: jsonResponse({ success: false, error: "invalid body" }, 400) };
}
}

View File

@ -30,6 +30,10 @@ export const stats = {
totalChars: 0, totalChars: 0,
/** 临时文件上传次数(持久化累计) */ /** 临时文件上传次数(持久化累计) */
tempUploads: 0, tempUploads: 0,
/** 离线裁剪完成次数(纯浏览器端裁剪的匿名上报;持久化累计) */
offlineSubsets: 0,
/** 离线裁剪字体下载次数(匿名上报;持久化累计) */
offlineDownloads: 0,
}; };
/** /**
@ -46,6 +50,8 @@ export async function initStats(): Promise<void> {
stats.subsetCacheHits = persisted.subsetCacheHits; stats.subsetCacheHits = persisted.subsetCacheHits;
stats.totalChars = persisted.totalChars; stats.totalChars = persisted.totalChars;
stats.tempUploads = persisted.tempUploads; stats.tempUploads = persisted.tempUploads;
stats.offlineSubsets = persisted.offlineSubsets;
stats.offlineDownloads = persisted.offlineDownloads;
} }
/** 取出需要持久化的累计计数字段 */ /** 取出需要持久化的累计计数字段 */
@ -56,6 +62,8 @@ export function snapshotStats(): PersistedStats {
subsetCacheHits: stats.subsetCacheHits, subsetCacheHits: stats.subsetCacheHits,
totalChars: stats.totalChars, totalChars: stats.totalChars,
tempUploads: stats.tempUploads, tempUploads: stats.tempUploads,
offlineSubsets: stats.offlineSubsets,
offlineDownloads: stats.offlineDownloads,
}; };
} }

View File

@ -24,6 +24,10 @@ export interface PersistedStats {
totalChars: number; totalChars: number;
/** 临时文件上传次数(持久化累计) */ /** 临时文件上传次数(持久化累计) */
tempUploads: number; tempUploads: number;
/** 离线裁剪完成次数(匿名上报累计) */
offlineSubsets: number;
/** 离线裁剪字体下载次数(匿名上报累计) */
offlineDownloads: number;
} }
/** 落盘防抖间隔(毫秒):在请求热路径之外的最低频写盘,平衡丢失量与 IO */ /** 落盘防抖间隔(毫秒):在请求热路径之外的最低频写盘,平衡丢失量与 IO */
@ -51,11 +55,13 @@ export async function loadPersistedStats(): Promise<PersistedStats & { startTime
subsetCacheHits: parsed.subsetCacheHits ?? 0, subsetCacheHits: parsed.subsetCacheHits ?? 0,
totalChars: parsed.totalChars ?? 0, totalChars: parsed.totalChars ?? 0,
tempUploads: parsed.tempUploads ?? 0, tempUploads: parsed.tempUploads ?? 0,
offlineSubsets: parsed.offlineSubsets ?? 0,
offlineDownloads: parsed.offlineDownloads ?? 0,
startTime: Date.now(), startTime: Date.now(),
}; };
} catch { } catch {
/** 首次启动 / 文件缺失 / JSON 损坏:从 0 起步 */ /** 首次启动 / 文件缺失 / JSON 损坏:从 0 起步 */
return { totalRequests: 0, subsetRequests: 0, subsetCacheHits: 0, totalChars: 0, tempUploads: 0, startTime: Date.now() }; return { totalRequests: 0, subsetRequests: 0, subsetCacheHits: 0, totalChars: 0, tempUploads: 0, offlineSubsets: 0, offlineDownloads: 0, startTime: Date.now() };
} }
} }

View File

@ -120,6 +120,8 @@ onUnmounted(() => {
<span><b style="color: #333">{{ t('subset') }}</b> {{ data.subsetRequests }} {{ t('times') }}</span> <span><b style="color: #333">{{ t('subset') }}</b> {{ data.subsetRequests }} {{ t('times') }}</span>
<span><b style="color: #333">{{ t('chars') }}</b> {{ data.totalChars }} {{ t('charUnit') }}</span> <span><b style="color: #333">{{ t('chars') }}</b> {{ data.totalChars }} {{ t('charUnit') }}</span>
<span><b style="color: #333">上传</b> {{ data.tempUploads ?? 0 }} </span> <span><b style="color: #333">上传</b> {{ data.tempUploads ?? 0 }} </span>
<span><b style="color: #333">{{ t('offlineSubset') }}</b> {{ data.offlineSubsets ?? 0 }} {{ t('times') }}</span>
<span><b style="color: #333">{{ t('offlineDownload') }}</b> {{ data.offlineDownloads ?? 0 }} {{ t('times') }}</span>
<span><b style="color: #333">{{ t('cacheHit') }}</b> {{ data.subsetRequests > 0 ? ((data.subsetCacheHits / data.subsetRequests) * 100).toFixed(1) : '0.0' }}%</span> <span><b style="color: #333">{{ t('cacheHit') }}</b> {{ data.subsetRequests > 0 ? ((data.subsetCacheHits / data.subsetRequests) * 100).toFixed(1) : '0.0' }}%</span>
</div> </div>
<!-- 底部进度条每轮询周期走一轮走完触发下次刷新 --> <!-- 底部进度条每轮询周期走一轮走完触发下次刷新 -->

View File

@ -30,6 +30,12 @@ export interface ServerStats {
totalChars: number; totalChars: number;
subsetCacheEntries: number; subsetCacheEntries: number;
fontBufferCacheEntries: number; fontBufferCacheEntries: number;
/** 临时文件上传次数 */
tempUploads?: number;
/** 离线裁剪完成次数 */
offlineSubsets?: number;
/** 离线裁剪字体下载次数 */
offlineDownloads?: number;
} }
/** 字符集覆盖率 */ /** 字符集覆盖率 */
@ -132,3 +138,20 @@ export async function fetchStats(): Promise<ServerStats> {
const res = await fetch("/api/stats"); const res = await fetch("/api/stats");
return res.json(); return res.json();
} }
/**
* 线
*
* /
* sendBeacon
*/
export function reportOfflineEvent(event: "offline_subset" | "offline_download"): void {
const body = JSON.stringify({ event });
if (navigator.sendBeacon?.("/api/stats/event", new Blob([body], { type: "application/json" }))) return;
fetch("/api/stats/event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {});
}

View File

@ -106,6 +106,30 @@ const messages = {
chars: "文字", chars: "文字",
charUnit: "字", charUnit: "字",
cacheHit: "缓存命中", cacheHit: "缓存命中",
offlineSubset: "离线裁剪",
offlineDownload: "离线下载",
// 企业服务板块
enterpriseTitle: "企业技术支持与服务",
enterpriseValue: "中文字体动辄 520MB用户首屏要白等好几秒。裁剪后只剩页面实际用到的几十 KB为你的业务带来实实在在的价值",
enterprisePoint1: "💰 省流量费:字体体积减少 95%+,高流量站点的 CDN / 带宽成本大幅下降",
enterprisePoint2: "⚡ 首屏秒开字体不再阻塞渲染告别文字闪烁FOIT/FOUT移动端体验大幅提升",
enterprisePoint3: "📈 提升转化:加载更快 → 跳出率更低 → 留存与转化随之提升",
enterpriseDesc: "本项目免费开源,同时提供付费的企业级支持,助力你的业务稳定落地:",
enterpriseItem1: "私有部署 / 企业内网部署与调优",
enterpriseItem2: "字体子集化方案定制与授权合规咨询",
enterpriseItem3: "SDK 集成、性能优化与故障排查技术支持",
enterpriseItem4: "功能定制开发",
enterpriseContact: "联系崮生洽谈",
enterpriseEmailHint: "或发邮件至",
// 文档页 / 首页文档入口
docsTitle: "SDK 集成文档",
docsSubtitle: "为你的网站接入中文字体按需加载",
docsEntryTitle: "接入你的网站:",
docsEntryText: "支持 @font-face 与 JS SDK 增量加载,只加载页面用到的字符。",
docsEntryLink: "查看集成文档 →",
inputHintIncremental: "输入即时触发增量加载,只裁剪用到的字符,非全量下载。",
// TypographyDemo.vue // TypographyDemo.vue
demoSlogan: "字体不同,体验天壤之别", demoSlogan: "字体不同,体验天壤之别",
@ -183,6 +207,30 @@ const messages = {
chars: "Chars", chars: "Chars",
charUnit: "chars", charUnit: "chars",
cacheHit: "Cache Hit", cacheHit: "Cache Hit",
offlineSubset: "Offline Subset",
offlineDownload: "Offline Download",
// Enterprise services section
enterpriseTitle: "Enterprise Support & Services",
enterpriseValue: "Chinese fonts are often 520MB, forcing users to wait seconds on a blank first screen. After subsetting, only the tens of KB actually used remain — bringing real value to your business:",
enterprisePoint1: "💰 Cut bandwidth costs: 95%+ smaller fonts dramatically reduce CDN / traffic bills for high-traffic sites",
enterprisePoint2: "⚡ Instant first paint: fonts no longer block rendering — no FOIT/FOUT flash, far better mobile UX",
enterprisePoint3: "📈 Boost conversion: faster load → lower bounce → higher retention & conversion",
enterpriseDesc: "This project is free and open source. Paid enterprise-grade support is also available to help your business land smoothly:",
enterpriseItem1: "Private / intranet deployment and tuning",
enterpriseItem2: "Font subsetting solution customization & license compliance consulting",
enterpriseItem3: "SDK integration, performance optimization & troubleshooting",
enterpriseItem4: "Custom feature development",
enterpriseContact: "Contact Gushsheng",
enterpriseEmailHint: "or email",
// Docs page / home docs entry
docsTitle: "SDK Integration Docs",
docsSubtitle: "Add on-demand Chinese font loading to your site",
docsEntryTitle: "Integrate into your site: ",
docsEntryText: "Supports @font-face and JS SDK incremental loading — only the characters used on the page are loaded.",
docsEntryLink: "View integration docs →",
inputHintIncremental: "Typing triggers incremental loading — only the characters used are subset, not the full font.",
// TypographyDemo.vue // TypographyDemo.vue
demoSlogan: "Same content, different fonts, completely different feel", demoSlogan: "Same content, different fonts, completely different feel",

View File

@ -15,6 +15,7 @@ const routes = [
{ path: "/", component: () => import("./pages/Home.vue") }, { path: "/", component: () => import("./pages/Home.vue") },
{ path: "/offline-subset", component: () => import("./pages/OfflineSubset.vue") }, { path: "/offline-subset", component: () => import("./pages/OfflineSubset.vue") },
{ path: "/demo", component: () => import("./pages/Demo.vue") }, { path: "/demo", component: () => import("./pages/Demo.vue") },
{ path: "/docs", component: () => import("./pages/Docs.vue") },
{ path: "/fonts", component: () => import("./pages/FontList.vue") }, { path: "/fonts", component: () => import("./pages/FontList.vue") },
/** /**
* SSG __FONT_NAME__ HTML * SSG __FONT_NAME__ HTML

75
src/pages/Docs.vue Normal file
View File

@ -0,0 +1,75 @@
<script setup lang="ts">
/**
* /docs SDK
*
* 由首页 sdk-doc section 拆分而来便于单独分享被搜索引擎索引
* 代码示例依赖 origin客户端挂载后修正为 location.origin
*/
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { usePageSeo } from "../useSeo";
import { t, toggleLocale, locale, syncLocaleFromStorage } from "../i18n";
import CodeBlock from "../components/CodeBlock.vue";
/** 文档页 SEO独立 title/description可被搜索引擎单独索引 */
usePageSeo({
title: "SDK 集成文档 | 按需加载中文字体",
description:
"WebFont SDK 集成文档 — @font-face 基础用法与 JS SDK 增量加载,只加载页面用到的字符,轻松为任何网站接入中文字体子集化。",
path: "/docs",
priority: 0.8,
changefreq: "weekly",
});
const router = useRouter();
/** 站点 origin —— SSG 构建期为空串,客户端挂载后修正 */
const origin = ref("");
onMounted(() => {
syncLocaleFromStorage();
origin.value = location.origin;
});
/** 基础用法代码示例(依赖 origin需 computed */
const basicUsageCode = computed(() => {
return '<style>\n@font-face {\n font-family: "MyFont";\n src: url("' + origin.value + '/api?font=字体名&text=你的文字") format("woff2");\n}\n.title { font-family: "MyFont"; }\n</style>\n<h1 class="title">你的文字</h1>';
});
/** JS SDK 代码示例 */
const jsSdkCode = computed(() => {
return '<script src="' + origin.value + '/webfont-sdk.js"><\/script>\n\n<h1 class="title">你的文字</h1>\n<p class="content">输入任意文字SDK 自动裁剪加载</p>\n\n<script>\n WebFont.observeFont({\n fontName: "字体文件名.ttf",\n selector: ".title, .content",\n family: "MyFont",\n });\n<\/script>';
});
</script>
<template>
<div style="max-width: 720px; margin: 0 auto; padding: 48px 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; line-height: 1.6">
<!-- 标题栏 -->
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 24px">
<div>
<h1 style="font-size: 22px; font-weight: 600; margin: 0 0 4px 0">{{ t('docsTitle') }}</h1>
<p style="font-size: 13px; color: #999; margin: 0">{{ t('docsSubtitle') }}</p>
</div>
<div style="display: flex; gap: 8px; align-items: center">
<button @click="toggleLocale" style="font-size: 13px; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 12px; cursor: pointer; background: #fff; color: #333">
{{ locale === 'zh' ? 'EN' : '中' }}
</button>
<button @click="router.push('/')" style="font-size: 13px; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 12px; cursor: pointer; background: #fff; color: #333">
{{ t('back') }}
</button>
</div>
</div>
<div style="font-size: 14px; color: #444; line-height: 1.9">
<p><b>{{ t('principle') }}</b>{{ t('principleText') }}</p>
<p style="margin-top: 16px"><b>{{ t('basicUsage') }}</b>{{ t('basicUsageText') }}</p>
<div style="margin-top: 4px"><CodeBlock :code="basicUsageCode" lang="html" /></div>
<p style="margin-top: 20px"><b>{{ t('jsSdk') }}</b>{{ t('jsSdkText') }}<a href="/webfont-sdk.js" download="webfont-sdk.js" style="color: #1677ff">{{ t('downloadSdk') }}</a></p>
<div style="margin-top: 4px"><CodeBlock :code="jsSdkCode" lang="html" /></div>
<p style="margin-top: 12px">{{ t('sdkModes') }}<code>WebFont.observeFont()</code>{{ t('observeFont') }}<code>WebFont.loadText()</code>{{ t('loadText') }}</p>
</div>
</div>
</template>

View File

@ -103,15 +103,7 @@ const cssStyle = computed(() => {
}`; }`;
}); });
/** 基础用法代码示例(依赖 origin需 computed */ /** 基础用法代码示例已迁移至 /docs 页面 */
const basicUsageCode = computed(() => {
return '<style>\n@font-face {\n font-family: "MyFont";\n src: url("' + origin.value + '/api?font=\u5b57\u4f53\u540d&text=\u4f60\u7684\u6587\u5b57") format("woff2");\n}\n.title { font-family: "MyFont"; }\n</style>\n<h1 class="title">\u4f60\u7684\u6587\u5b57</h1>';
});
/** JS SDK 代码示例 */
const jsSdkCode = computed(() => {
return '<script src="' + origin.value + '/webfont-sdk.js"><\/script>\n\n<h1 class="title">你的文字</h1>\n<p class="content">输入任意文字SDK 自动裁剪加载</p>\n\n<script>\n WebFont.loadFont({\n fontName: "字体文件名.ttf",\n selector: ".title, .content",\n family: "MyFont",\n interval: 1000,\n });\n<\/script>';
});
let textLoader: { update: (text: string) => void; dispose: () => void } | null = null; let textLoader: { update: (text: string) => void; dispose: () => void } | null = null;
@ -237,7 +229,7 @@ async function refreshFonts() {
<section style="margin-bottom: 28px"> <section style="margin-bottom: 28px">
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px"> <label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">
{{ t('inputLabel') }} {{ t('inputLabel') }}
<span style="font-size: 12px; color: #999; font-weight: 400"> 输入即时触发<a href="#sdk-doc" style="color: #1677ff; text-decoration: none">增量加载</a>只裁剪用到的字符非全量下载</span> <span style="font-size: 12px; color: #999; font-weight: 400"> {{ t('inputHintIncremental') }}<router-link to="/docs" style="color: #1677ff; text-decoration: none">{{ t('docsEntryLink') }}</router-link></span>
</label> </label>
<textarea <textarea
id="webfont-preview" id="webfont-preview"
@ -274,13 +266,35 @@ async function refreshFonts() {
<StatsPanel /> <StatsPanel />
<section id="sdk-doc" style="margin-bottom: 28px; font-size: 12px; color: #aaa; line-height: 1.8"> <!-- SDK 集成首页简要提及跳转独立文档页 -->
<p><b>{{ t('principle') }}</b>{{ t('principleText') }}</p> <section style="margin-bottom: 28px; font-size: 13px; color: #888; line-height: 1.8">
<p><b>{{ t('basicUsage') }}</b>{{ t('basicUsageText') }}</p> <b style="color: #555">{{ t('docsEntryTitle') }}</b>{{ t('docsEntryText') }}
<div style="margin-top: 4px"><CodeBlock :code="basicUsageCode" lang="html" /></div> <router-link to="/docs" style="color: #1677ff; text-decoration: none">{{ t('docsEntryLink') }}</router-link>
<p style="margin-top: 12px"><b>{{ t('jsSdk') }}</b>{{ t('jsSdkText') }}<a href="/webfont-sdk.js" download="webfont-sdk.js">{{ t('downloadSdk') }}</a></p> </section>
<div style="margin-top: 4px"><CodeBlock :code="jsSdkCode" lang="html" /></div>
<p style="margin-top: 8px">{{ t('sdkModes') }}<code>WebFont.observeFont()</code>{{ t('observeFont') }}<code>WebFont.loadText()</code>{{ t('loadText') }}</p> <!-- 企业服务讲清商业价值为作者带来收入的付费支持入口 -->
<section style="margin-bottom: 28px; padding: 16px; border: 1px solid #f0e6d2; border-radius: 8px; background: #fffdf8">
<div style="font-size: 14px; font-weight: 600; color: #333; margin-bottom: 6px">{{ t('enterpriseTitle') }}</div>
<p style="font-size: 13px; color: #666; line-height: 1.8; margin: 0 0 10px 0">{{ t('enterpriseValue') }}</p>
<ul style="font-size: 13px; color: #555; line-height: 2; margin: 0 0 10px 0; padding-left: 20px">
<li>{{ t('enterprisePoint1') }}</li>
<li>{{ t('enterprisePoint2') }}</li>
<li>{{ t('enterprisePoint3') }}</li>
</ul>
<p style="font-size: 13px; color: #666; line-height: 1.8; margin: 0 0 6px 0; font-weight: 500">{{ t('enterpriseDesc') }}</p>
<ul style="font-size: 13px; color: #555; line-height: 2; margin: 0 0 12px 0; padding-left: 20px">
<li>{{ t('enterpriseItem1') }}</li>
<li>{{ t('enterpriseItem2') }}</li>
<li>{{ t('enterpriseItem3') }}</li>
<li>{{ t('enterpriseItem4') }}</li>
</ul>
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center; font-size: 13px; color: #8b7355">
<a
href="mailto:admin@shenzilong.cn"
style="padding: 6px 16px; font-size: 13px; background: #1677ff; color: #fff; border-radius: 6px; text-decoration: none"
>{{ t('enterpriseContact') }}</a>
<span>{{ t('enterpriseEmailHint') }} <a href="mailto:admin@shenzilong.cn" style="color: #8b7355; text-decoration: underline">admin@shenzilong.cn</a></span>
</div>
</section> </section>
<footer style="margin-top: 48px; padding-top: 16px; border-top: 1px solid #eee; font-size: 12px; color: #999; text-align: center"> <footer style="margin-top: 48px; padding-top: 16px; border-top: 1px solid #eee; font-size: 12px; color: #999; text-align: center">

View File

@ -18,6 +18,7 @@ const fileInput = ref<HTMLInputElement | null>(null);
const dragActive = ref(false); const dragActive = ref(false);
import { usePageSeo } from "../useSeo"; import { usePageSeo } from "../useSeo";
import { t, toggleLocale, locale } from "../i18n"; import { t, toggleLocale, locale } from "../i18n";
import { reportOfflineEvent } from "../api";
usePageSeo({ usePageSeo({
title: "离线字体裁剪 | 纯浏览器端 | 隐私安全", title: "离线字体裁剪 | 纯浏览器端 | 隐私安全",
@ -178,6 +179,8 @@ async function doFullSubset() {
const blob = new Blob([result.buffer as ArrayBuffer], { type: "font/ttf" }); const blob = new Blob([result.buffer as ArrayBuffer], { type: "font/ttf" });
subsetUrl.value = URL.createObjectURL(blob); subsetUrl.value = URL.createObjectURL(blob);
subsetSize.value = result.byteLength; subsetSize.value = result.byteLength;
/** 匿名上报:一次完整裁剪完成(不含字体/文字内容) */
reportOfflineEvent("offline_subset");
} catch (err) { } catch (err) {
errorMsg.value = `裁剪失败:${err instanceof Error ? err.message : String(err)}`; errorMsg.value = `裁剪失败:${err instanceof Error ? err.message : String(err)}`;
console.error("[offline-subset]", err); console.error("[offline-subset]", err);
@ -207,6 +210,8 @@ function downloadSubset() {
const baseName = fontFileName.value.replace(/\.[^.]+$/, ""); const baseName = fontFileName.value.replace(/\.[^.]+$/, "");
a.download = `${baseName}_subset.ttf`; a.download = `${baseName}_subset.ttf`;
a.click(); a.click();
/** 匿名上报:下载动作 */
reportOfflineEvent("offline_download");
} }
/** 压缩率百分比 */ /** 压缩率百分比 */

View File

@ -20,6 +20,7 @@ import type {} from "vite-ssg";
const sitemapRoutes = [ const sitemapRoutes = [
{ path: "/", changefreq: "weekly" as const, priority: 1.0 }, { path: "/", changefreq: "weekly" as const, priority: 1.0 },
{ path: "/offline-subset", changefreq: "weekly" as const, priority: 0.8 }, { path: "/offline-subset", changefreq: "weekly" as const, priority: 0.8 },
{ path: "/docs", changefreq: "weekly" as const, priority: 0.8 },
{ path: "/demo", changefreq: "monthly" as const, priority: 0.6 }, { path: "/demo", changefreq: "monthly" as const, priority: 0.6 },
]; ];
@ -172,7 +173,7 @@ export default { isInited: () => false, init: () => Promise.resolve(), encode: (
* vite-ssg * vite-ssg
* SSG /blog * SSG /blog
*/ */
includedRoutes: () => ["/", "/offline-subset", "/demo", "/fonts", "/fonts/__FONT_NAME__"], includedRoutes: () => ["/", "/offline-subset", "/demo", "/docs", "/fonts", "/fonts/__FONT_NAME__"],
/** /**
* Node * Node
* SSG 访 window/document * SSG 访 window/document