From d16aa46227eb06ccecb72a47b477121a5929dcfc 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, 6 Aug 2026 08:57:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A6=BB=E7=BA=BF=E8=A3=81=E5=89=AA?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=20+=20=E4=BC=81=E4=B8=9A=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E6=9D=BF=E5=9D=97=20+=20SDK=E6=96=87=E6=A1=A3=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E9=A1=B5(/docs)=20+=20=E7=A4=BA=E4=BE=8B=E6=94=B9?= =?UTF-8?q?=E7=94=A8observeFont?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app.ts | 5 ++- backend/routes/stats.ts | 34 ++++++++++++++++- backend/shared.ts | 8 ++++ backend/stats_store.ts | 8 +++- src/StatsPanel.vue | 2 + src/api.ts | 23 ++++++++++++ src/i18n.ts | 48 ++++++++++++++++++++++++ src/main.ts | 1 + src/pages/Docs.vue | 75 +++++++++++++++++++++++++++++++++++++ src/pages/Home.vue | 48 +++++++++++++++--------- src/pages/OfflineSubset.vue | 5 +++ vite.config.ts | 3 +- 12 files changed, 239 insertions(+), 21 deletions(-) create mode 100644 src/pages/Docs.vue diff --git a/backend/app.ts b/backend/app.ts index e3ebec1..c052016 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -7,7 +7,7 @@ import { flushStatsSyncSafe } from "./stats_store"; import { enableTempUpload, adminApiKey } from "./config"; import { handleListFonts } from "./routes/fonts"; import { handleGetConfig } from "./routes/config"; -import { handleStats } from "./routes/stats"; +import { handleStats, handleStatsEvent } from "./routes/stats"; import { handleUpload } from "./routes/upload"; import { handleFontSubset } from "./routes/subset"; 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") { 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") { return handleUpload(req, res); } diff --git a/backend/routes/stats.ts b/backend/routes/stats.ts index d3f0fd1..eb09c3e 100644 --- a/backend/routes/stats.ts +++ b/backend/routes/stats.ts @@ -1,4 +1,4 @@ -import { jsonResponse, stats, subsetCache, fontBufferCache } from "../shared"; +import { jsonResponse, stats, subsetCache, fontBufferCache, markStatsDirty } from "../shared"; /** GET /api/stats — 返回运行时统计 */ export async function handleStats(req: Request, _res: Response) { @@ -11,8 +11,40 @@ export async function handleStats(req: Request, _res: Response) { subsetCacheHits: stats.subsetCacheHits, totalChars: stats.totalChars, tempUploads: stats.tempUploads, + offlineSubsets: stats.offlineSubsets, + offlineDownloads: stats.offlineDownloads, subsetCacheEntries: subsetCache.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) }; + } +} diff --git a/backend/shared.ts b/backend/shared.ts index 680d924..11a34b0 100644 --- a/backend/shared.ts +++ b/backend/shared.ts @@ -30,6 +30,10 @@ export const stats = { totalChars: 0, /** 临时文件上传次数(持久化累计) */ tempUploads: 0, + /** 离线裁剪完成次数(纯浏览器端裁剪的匿名上报;持久化累计) */ + offlineSubsets: 0, + /** 离线裁剪字体下载次数(匿名上报;持久化累计) */ + offlineDownloads: 0, }; /** @@ -46,6 +50,8 @@ export async function initStats(): Promise { stats.subsetCacheHits = persisted.subsetCacheHits; stats.totalChars = persisted.totalChars; stats.tempUploads = persisted.tempUploads; + stats.offlineSubsets = persisted.offlineSubsets; + stats.offlineDownloads = persisted.offlineDownloads; } /** 取出需要持久化的累计计数字段 */ @@ -56,6 +62,8 @@ export function snapshotStats(): PersistedStats { subsetCacheHits: stats.subsetCacheHits, totalChars: stats.totalChars, tempUploads: stats.tempUploads, + offlineSubsets: stats.offlineSubsets, + offlineDownloads: stats.offlineDownloads, }; } diff --git a/backend/stats_store.ts b/backend/stats_store.ts index 5a5590c..af6f56e 100644 --- a/backend/stats_store.ts +++ b/backend/stats_store.ts @@ -24,6 +24,10 @@ export interface PersistedStats { totalChars: number; /** 临时文件上传次数(持久化累计) */ tempUploads: number; + /** 离线裁剪完成次数(匿名上报累计) */ + offlineSubsets: number; + /** 离线裁剪字体下载次数(匿名上报累计) */ + offlineDownloads: number; } /** 落盘防抖间隔(毫秒):在请求热路径之外的最低频写盘,平衡丢失量与 IO */ @@ -51,11 +55,13 @@ export async function loadPersistedStats(): Promise { {{ t('subset') }} {{ data.subsetRequests }} {{ t('times') }} {{ t('chars') }} {{ data.totalChars }} {{ t('charUnit') }} 上传 {{ data.tempUploads ?? 0 }} 次 + {{ t('offlineSubset') }} {{ data.offlineSubsets ?? 0 }} {{ t('times') }} + {{ t('offlineDownload') }} {{ data.offlineDownloads ?? 0 }} {{ t('times') }} {{ t('cacheHit') }} {{ data.subsetRequests > 0 ? ((data.subsetCacheHits / data.subsetRequests) * 100).toFixed(1) : '0.0' }}% diff --git a/src/api.ts b/src/api.ts index a342acd..02e9cc2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -30,6 +30,12 @@ export interface ServerStats { totalChars: number; subsetCacheEntries: number; fontBufferCacheEntries: number; + /** 临时文件上传次数 */ + tempUploads?: number; + /** 离线裁剪完成次数 */ + offlineSubsets?: number; + /** 离线裁剪字体下载次数 */ + offlineDownloads?: number; } /** 字符集覆盖率 */ @@ -132,3 +138,20 @@ export async function fetchStats(): Promise { const res = await fetch("/api/stats"); 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(() => {}); +} diff --git a/src/i18n.ts b/src/i18n.ts index 27c8673..6bd9e0b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -106,6 +106,30 @@ const messages = { chars: "文字", charUnit: "字", cacheHit: "缓存命中", + offlineSubset: "离线裁剪", + offlineDownload: "离线下载", + + // 企业服务板块 + enterpriseTitle: "企业技术支持与服务", + enterpriseValue: "中文字体动辄 5~20MB,用户首屏要白等好几秒。裁剪后只剩页面实际用到的几十 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 demoSlogan: "字体不同,体验天壤之别", @@ -183,6 +207,30 @@ const messages = { chars: "Chars", charUnit: "chars", cacheHit: "Cache Hit", + offlineSubset: "Offline Subset", + offlineDownload: "Offline Download", + + // Enterprise services section + enterpriseTitle: "Enterprise Support & Services", + enterpriseValue: "Chinese fonts are often 5–20MB, 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 demoSlogan: "Same content, different fonts, completely different feel", diff --git a/src/main.ts b/src/main.ts index 1a29c31..089ef8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ const routes = [ { path: "/", component: () => import("./pages/Home.vue") }, { path: "/offline-subset", component: () => import("./pages/OfflineSubset.vue") }, { path: "/demo", component: () => import("./pages/Demo.vue") }, + { path: "/docs", component: () => import("./pages/Docs.vue") }, { path: "/fonts", component: () => import("./pages/FontList.vue") }, /** * 字体详情页 —— SSG 构建时以 __FONT_NAME__ 占位符渲染模板 HTML, diff --git a/src/pages/Docs.vue b/src/pages/Docs.vue new file mode 100644 index 0000000..8f8258d --- /dev/null +++ b/src/pages/Docs.vue @@ -0,0 +1,75 @@ + + + diff --git a/src/pages/Home.vue b/src/pages/Home.vue index 7d363a3..5d543fb 100644 --- a/src/pages/Home.vue +++ b/src/pages/Home.vue @@ -103,15 +103,7 @@ const cssStyle = computed(() => { }`; }); -/** 基础用法代码示例(依赖 origin,需 computed) */ -const basicUsageCode = computed(() => { - return '\n

\u4f60\u7684\u6587\u5b57

'; -}); - -/** JS SDK 代码示例 */ -const jsSdkCode = computed(() => { - return '