mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 14:53:32 +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 { 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);
|
||||
}
|
||||
|
||||
@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,10 @@ export const stats = {
|
||||
totalChars: 0,
|
||||
/** 临时文件上传次数(持久化累计) */
|
||||
tempUploads: 0,
|
||||
/** 离线裁剪完成次数(纯浏览器端裁剪的匿名上报;持久化累计) */
|
||||
offlineSubsets: 0,
|
||||
/** 离线裁剪字体下载次数(匿名上报;持久化累计) */
|
||||
offlineDownloads: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -46,6 +50,8 @@ export async function initStats(): Promise<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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<PersistedStats & { startTime
|
||||
subsetCacheHits: parsed.subsetCacheHits ?? 0,
|
||||
totalChars: parsed.totalChars ?? 0,
|
||||
tempUploads: parsed.tempUploads ?? 0,
|
||||
offlineSubsets: parsed.offlineSubsets ?? 0,
|
||||
offlineDownloads: parsed.offlineDownloads ?? 0,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
} catch {
|
||||
/** 首次启动 / 文件缺失 / 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('chars') }}</b> {{ data.totalChars }} {{ t('charUnit') }}</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>
|
||||
</div>
|
||||
<!-- 底部进度条:每轮询周期走一轮,走完触发下次刷新 -->
|
||||
|
||||
23
src/api.ts
23
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<ServerStats> {
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
48
src/i18n.ts
48
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",
|
||||
|
||||
@ -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,
|
||||
|
||||
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) */
|
||||
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>';
|
||||
});
|
||||
/** 基础用法代码示例已迁移至 /docs 页面 */
|
||||
|
||||
let textLoader: { update: (text: string) => void; dispose: () => void } | null = null;
|
||||
|
||||
@ -237,7 +229,7 @@ async function refreshFonts() {
|
||||
<section style="margin-bottom: 28px">
|
||||
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">
|
||||
{{ 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>
|
||||
<textarea
|
||||
id="webfont-preview"
|
||||
@ -274,13 +266,35 @@ async function refreshFonts() {
|
||||
|
||||
<StatsPanel />
|
||||
|
||||
<section id="sdk-doc" style="margin-bottom: 28px; font-size: 12px; color: #aaa; line-height: 1.8">
|
||||
<p><b>{{ t('principle') }}</b>{{ t('principleText') }}</p>
|
||||
<p><b>{{ t('basicUsage') }}</b>{{ t('basicUsageText') }}</p>
|
||||
<div style="margin-top: 4px"><CodeBlock :code="basicUsageCode" lang="html" /></div>
|
||||
<p style="margin-top: 12px"><b>{{ t('jsSdk') }}</b>{{ t('jsSdkText') }}<a href="/webfont-sdk.js" download="webfont-sdk.js">{{ t('downloadSdk') }}</a></p>
|
||||
<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>
|
||||
<!-- SDK 集成:首页简要提及,跳转独立文档页 -->
|
||||
<section style="margin-bottom: 28px; font-size: 13px; color: #888; line-height: 1.8">
|
||||
<b style="color: #555">{{ t('docsEntryTitle') }}</b>{{ t('docsEntryText') }}
|
||||
<router-link to="/docs" style="color: #1677ff; text-decoration: none">{{ t('docsEntryLink') }}</router-link>
|
||||
</section>
|
||||
|
||||
<!-- 企业服务:讲清商业价值,为作者带来收入的付费支持入口 -->
|
||||
<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>
|
||||
|
||||
<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);
|
||||
import { usePageSeo } from "../useSeo";
|
||||
import { t, toggleLocale, locale } from "../i18n";
|
||||
import { reportOfflineEvent } from "../api";
|
||||
|
||||
usePageSeo({
|
||||
title: "离线字体裁剪 | 纯浏览器端 | 隐私安全",
|
||||
@ -178,6 +179,8 @@ async function doFullSubset() {
|
||||
const blob = new Blob([result.buffer as ArrayBuffer], { type: "font/ttf" });
|
||||
subsetUrl.value = URL.createObjectURL(blob);
|
||||
subsetSize.value = result.byteLength;
|
||||
/** 匿名上报:一次完整裁剪完成(不含字体/文字内容) */
|
||||
reportOfflineEvent("offline_subset");
|
||||
} catch (err) {
|
||||
errorMsg.value = `裁剪失败:${err instanceof Error ? err.message : String(err)}`;
|
||||
console.error("[offline-subset]", err);
|
||||
@ -207,6 +210,8 @@ function downloadSubset() {
|
||||
const baseName = fontFileName.value.replace(/\.[^.]+$/, "");
|
||||
a.download = `${baseName}_subset.ttf`;
|
||||
a.click();
|
||||
/** 匿名上报:下载动作 */
|
||||
reportOfflineEvent("offline_download");
|
||||
}
|
||||
|
||||
/** 压缩率百分比 */
|
||||
|
||||
@ -20,6 +20,7 @@ import type {} from "vite-ssg";
|
||||
const sitemapRoutes = [
|
||||
{ path: "/", changefreq: "weekly" as const, priority: 1.0 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
@ -172,7 +173,7 @@ export default { isInited: () => false, init: () => Promise.resolve(), encode: (
|
||||
* vite-ssg 默认会过滤掉动态路由、保留静态路由,
|
||||
* 这里显式返回需要 SSG 的路由清单,新增内容页(/blog 等)时在此追加。
|
||||
*/
|
||||
includedRoutes: () => ["/", "/offline-subset", "/demo", "/fonts", "/fonts/__FONT_NAME__"],
|
||||
includedRoutes: () => ["/", "/offline-subset", "/demo", "/docs", "/fonts", "/fonts/__FONT_NAME__"],
|
||||
/**
|
||||
* 构建期(Node 环境)模拟浏览器全局变量,
|
||||
* 防止第三方库在 SSG 阶段访问 window/document 时崩溃。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user