mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 23:02:27 +08:00
feat: 离线裁剪统计 + 企业服务板块 + SDK文档独立页(/docs) + 示例改用observeFont
This commit is contained in:
parent
09f434bbcf
commit
d16aa46227
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
<!-- 底部进度条:每轮询周期走一轮,走完触发下次刷新 -->
|
<!-- 底部进度条:每轮询周期走一轮,走完触发下次刷新 -->
|
||||||
|
|||||||
23
src/api.ts
23
src/api.ts
@ -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(() => {});
|
||||||
|
}
|
||||||
|
|||||||
48
src/i18n.ts
48
src/i18n.ts
@ -106,6 +106,30 @@ const messages = {
|
|||||||
chars: "文字",
|
chars: "文字",
|
||||||
charUnit: "字",
|
charUnit: "字",
|
||||||
cacheHit: "缓存命中",
|
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
|
// 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 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
|
// TypographyDemo.vue
|
||||||
demoSlogan: "Same content, different fonts, completely different feel",
|
demoSlogan: "Same content, different fonts, completely different feel",
|
||||||
|
|||||||
@ -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
75
src/pages/Docs.vue
Normal 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>
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 压缩率百分比 */
|
/** 压缩率百分比 */
|
||||||
|
|||||||
@ -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 时崩溃。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user