feat: 前端迁移至 Vue3 + TS,后端构建迁移至 tsdown

- 前端从 SolidJS 迁移到 Vue3 Composition API(<script setup lang="ts">)
- 后端构建从 tsup 迁移到 tsdown,动态 import 改为静态以支持单文件输出
- FontSelector 添加 defineProps 修复下拉无选项问题
- StatsPanel 添加"服务状态"标题

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-04-30 20:46:30 +08:00
parent 68df9db2da
commit 53d170dc90
25 changed files with 1305 additions and 2107 deletions

View File

@ -9,18 +9,9 @@ import { handleGetConfig } from "./routes/config";
import { handleStats } from "./routes/stats";
import { handleUpload } from "./routes/upload";
import { handleFontSubset } from "./routes/subset";
import "./server/node";
import "./server/llrt";
let release_name = globalThis?.process?.release?.name;
let runtimeReady: Promise<void>;
if (release_name === "node" || release_name === "llrt") {
runtimeReady = import("./server/node").then(() => {});
} else {
runtimeReady = Promise.resolve();
}
if (release_name === "llrt") {
runtimeReady = runtimeReady.then(() => import("./server/llrt").then(() => {}));
}
const ROOT_DIR = "dist";
/** 启动时确保必要目录存在 */
@ -158,7 +149,6 @@ const uploadSizeMiddleware: cMiddleware = async (req, res, next) => {
};
async function main() {
await runtimeReady;
await ensureDirectories();
const server = new SimpleHttpServer({ port: 8087 });

View File

@ -1,4 +1,5 @@
import { cMiddleware, cRequest, cResponse, type cNext } from "./req_res";
import { createTcpServer } from "./tcp_server";
// 配置
// 路由器类
export class cRouter {
@ -30,13 +31,11 @@ export class SimpleHttpServer {
const release_name = globalThis?.process?.release?.name;
console.log("[release.name]", release_name);
if (release_name === "llrt" || release_name === "node") {
import("./tcp_server").then((m) => {
const server = m.createTcpServer((socket) => {
connectionHandle(socket, (req, res) => this.router.handle(req, res));
});
server.listen(options.port, options.hostname, () => {
console.log(`Server is listening on port ${options.port}`);
});
const server = createTcpServer((socket) => {
connectionHandle(socket, (req, res) => this.router.handle(req, res));
});
server.listen(options.port, options.hostname, () => {
console.log(`Server is listening on port ${options.port}`);
});
}
}

View File

@ -1,4 +1,4 @@
import { writeFile, unlink, readdir, stat, path_join } from "./interface";
import { writeFile, unlink, readdir, stat, mkdir, path_join } from "./interface";
import { enableTempUpload, adminApiKey, tempMaxFiles, tempMaxTotalSize } from "./config";
/** 允许的字体文件扩展名 */
@ -22,7 +22,6 @@ function sanitizeFilename(filename: string) {
/** 确保目录存在,不存在则创建 */
async function ensureDir(dir: string) {
const { stat, mkdir } = await import("./interface");
try {
await stat(dir);
} catch {

View File

@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="在线字体裁剪工具 — 服务端按需裁剪字体子集,大小无限制,免费开源。支持自定义裁剪、增量加载 SDK轻松嵌入任何网站。" />
<title>WebFont — 在线字体裁剪 | 按需加载 | 免费开源</title>
@ -10,6 +10,6 @@
<body>
<div id="root"></div>
<script src="/webfont-sdk.js"></script>
<script type="module" src="/src/index.tsx"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -15,21 +15,21 @@
"release": "pnpm build && pnpm build_backend && pnpm docker_build && pnpm docker_push"
},
"dependencies": {
"solid-js": "^1.9.12",
"vue": "3.6.0-beta.10",
"web-streams-polyfill": "^4.2.0"
},
"devDependencies": {
"@types/node": "^25.5.2",
"@xmldom/xmldom": "^0.9.9",
"jsdom": "^29.0.2",
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^6.0.6",
"@xmldom/xmldom": "^0.9.10",
"jsdom": "^29.1.1",
"pngjs": "^7.0.0",
"puppeteer": "^24.40.0",
"tsup": "^8.5.1",
"typescript": "^6.0.2",
"undici": "^8.0.2",
"vite": "^8.0.7",
"vite-plugin-pilot": "^1.0.19",
"vite-plugin-solid": "^2.11.12",
"vitest": "^4.1.3"
"puppeteer": "^24.42.0",
"tsdown": "^0.21.10",
"typescript": "^6.0.3",
"undici": "^8.1.0",
"vite": "^8.0.10",
"vite-plugin-pilot": "^1.0.24",
"vitest": "^4.1.5"
}
}

1906
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

22
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -94,10 +94,10 @@ async function ensureLlrt() {
console.log(`LLRT ${version} installed successfully.`);
}
/** 运行 tsup 编译 */
function runTsup() {
console.log("\n--- Running tsup build ---");
execSync("pnpm tsup", { stdio: "inherit", cwd: ROOT_DIR });
/** 运行 tsdown 编译 */
function runTsdown() {
console.log("\n--- Running tsdown build ---");
execSync("pnpm tsdown", { stdio: "inherit", cwd: ROOT_DIR });
}
/** woff2 已使用纯 JS 实现vendor/fonteditor-core/woff2/index.js无需复制 wasm */
@ -119,7 +119,7 @@ function runLlrtCompile() {
/** 主流程 */
async function main() {
await ensureLlrt();
runTsup();
runTsdown();
runLlrtCompile();
}

View File

@ -2,7 +2,7 @@
* (vite) (tsx backend/app.ts)
* Ctrl+C
*/
import { spawn } from "node:child_process";
import { execSync, spawn } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@ -19,6 +19,15 @@ function cleanup() {
}
}
/** 杀掉占用指定端口的进程 */
function killPort(port: number) {
try {
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { stdio: "ignore" });
} catch {
/** 端口没有被占用,忽略 */
}
}
process.on("SIGINT", () => {
cleanup();
process.exit(0);
@ -28,6 +37,7 @@ process.on("SIGTERM", () => {
process.exit(0);
});
killPort(8087);
console.log("Starting frontend and backend dev servers...\n");
const backend = spawn("pnpx", ["tsx", "watch", "backend/app.ts"], {

View File

@ -1,320 +0,0 @@
import { createMemo, createSignal, createEffect, onMount, Show } from "solid-js";
import { fetchFonts, fetchConfig, type FontInfo, type ServerConfig } from "./api";
import UploadSection from "./UploadSection";
import StatsPanel from "./StatsPanel";
import { SelectorRow } from "./FontSelector";
import FontDebugPreview from "./FontDebugPreview";
const s = {
wrap: {
"max-width": "720px",
margin: "0 auto",
padding: "48px 24px",
"font-family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
color: "#1a1a1a",
"line-height": "1.6",
} as const,
h1: {
"font-size": "22px",
"font-weight": 600,
margin: "0 0 4px 0",
} as const,
desc: {
"font-size": "24px",
color: "#888",
margin: "0 0 36px 0",
} as const,
label: {
display: "block",
"font-size": "13px",
color: "#555",
"margin-bottom": "6px",
} as const,
select: {
width: "100%",
padding: "8px 12px",
"font-size": "15px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
outline: "none",
"box-sizing": "border-box",
} as const,
textarea: {
width: "100%",
padding: "8px 12px",
"font-size": "32px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
resize: "none",
"box-sizing": "border-box",
outline: "none",
color: "#e74c3c",
"line-height": "1.4",
} as const,
pre: {
background: "#f7f7f8",
padding: "16px",
"border-radius": "6px",
"font-size": "13px",
"font-family": "'SF Mono', Menlo, Consolas, monospace",
overflow: "auto",
"white-space": "pre-wrap",
"word-break": "break-all",
"line-height": "1.5",
margin: "0",
} as const,
section: {
"margin-bottom": "28px",
} as const,
card: {
padding: "16px",
border: "1px solid #e8e8e8",
"border-radius": "8px",
"margin-bottom": "16px",
} as const,
btn: {
padding: "6px 20px",
"font-size": "14px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
cursor: "pointer",
background: "#fff",
color: "#333",
} as const,
input: {
padding: "6px 12px",
"font-size": "14px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
outline: "none",
"box-sizing": "border-box",
} as const,
};
function App() {
const [text, set_text] = createSignal("天地无极,乾坤借法");
const [fonts, set_fonts] = createSignal<FontInfo[]>([]);
const [selectedFont, set_selectedFont] = createSignal("");
const [outType, set_outType] = createSignal<"woff2" | "ttf">("ttf");
const [serverConfig, set_serverConfig] = createSignal<ServerConfig>({
enableTempUpload: false,
adminUploadEnabled: false,
supportedOutTypes: ["woff2", "ttf"],
});
const SLOGAN = "如清风似闪电,超级快的字体子集化裁剪";
onMount(async () => {
const [fontList, config] = await Promise.all([fetchFonts().catch(() => []), fetchConfig().catch(() => ({ enableTempUpload: false, adminUploadEnabled: false }))]);
set_fonts(fontList);
set_serverConfig(config);
/** 服务端不支持当前 outType 时自动回退 */
if (!config.supportedOutTypes?.includes(outType())) {
set_outType(config.supportedOutTypes?.[0] || "ttf");
}
if (fontList.length > 0) {
/** 标语随机使用一个可用字体展示 */
const usableFonts = fontList.filter((f) => /\.(ttf)$/i.test(f.name));
const randomFont = usableFonts[Math.floor(Math.random() * usableFonts.length)];
(globalThis as any).WebFont?.loadText({
fontName: randomFont.name,
text: SLOGAN,
family: "SloganFont",
});
const sloganEl = document.getElementById("slogan");
if (sloganEl) {
sloganEl.style.fontFamily = '"SloganFont", sans-serif';
sloganEl.title = randomFont.name;
}
set_selectedFont(fontList[0].name);
}
});
const cssStyle = createMemo(() => {
const font = selectedFont();
const ot = outType();
if (!font) return "";
const formatStr = ot === "woff2" ? "woff2" : "truetype";
return `@font-face {
font-family: "CustomFont";
src: url("${location.origin}/api?font=${font}&text=${encodeURIComponent(text())}&outType=${ot}") format("${formatStr}");
}
.custom-font {
color: red;
font-family: "CustomFont";
}`;
});
/** loadText loader 引用,字体或文本变化时增量加载 */
let textLoader: { update: (text: string) => void; dispose: () => void } | null = null;
/** 文本变化时增量加载字体 */
const onTextChange = (value: string) => {
set_text(value);
if (!textLoader) return;
textLoader.update(value);
};
/** 根据文本行数动态计算 textarea 高度 */
const textareaRows = createMemo(() => {
const lines = text().split("\n").length;
return Math.max(2, Math.min(lines, 10));
});
/** 记录上次加载的 font 和 outType避免重复加载 */
let lastLoadKey = "";
/** 字体切换或格式切换时重新加载字体 */
const reloadFont = (font: string, ot: "woff2" | "ttf") => {
const key = `${font}|${ot}`;
if (!font || key === lastLoadKey) return;
lastLoadKey = key;
if (textLoader) textLoader.dispose();
textLoader = (globalThis as any).WebFont?.loadText({
fontName: font,
text: text(),
family: "CustomFont",
outType: ot,
}) ?? null;
const el = document.getElementById("webfont-preview");
if (el) el.style.fontFamily = '"CustomFont", sans-serif';
};
const onFontChange = (font: string) => {
set_selectedFont(font);
};
/** 字体或输出格式变化时重新加载 */
createEffect(() => {
reloadFont(selectedFont(), outType());
});
async function refreshFonts() {
const fontList = await fetchFonts();
set_fonts(fontList);
if (fontList.length > 0 && !selectedFont()) {
onFontChange(fontList[0].name);
}
}
return (
<div style={s.wrap}>
<div style={{ display: "flex", "align-items": "center", "justify-content": "space-between" }}>
<h1 style={s.h1}>Web Font</h1>
<a
href="https://github.com/2234839/web-font"
target="_blank"
rel="noopener noreferrer"
style={{ display: "inline-flex", "align-items": "center", "gap": "4px", "font-size": "13px", color: "#888", "text-decoration": "none", border: "1px solid #d9d9d9", "border-radius": "6px", padding: "4px 10px" }}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
Star on GitHub
</a>
</div>
<p id="slogan" style={s.desc}>{SLOGAN}</p>
<section style={s.section}>
<SelectorRow
fonts={fonts()}
selectedFont={selectedFont()}
onFontChange={onFontChange}
supportedOutTypes={serverConfig().supportedOutTypes || ["woff2", "ttf"]}
outType={outType()}
onOutTypeChange={set_outType}
/>
</section>
<section style={s.section}>
<label style={s.label}></label>
<textarea
id="webfont-preview"
style={{
...s.textarea,
}}
rows={textareaRows()}
value={text()}
onInput={(e) => onTextChange(e.target.value)}
placeholder="在此输入文本..."
/>
</section>
<Show when={selectedFont()}>
<section style={s.section}>
<div style={{ display: "flex", "justify-content": "space-between", "align-items": "center", "margin-bottom": "6px" }}>
<label style={{ ...s.label, margin: "0" }}>CSS </label>
<div style={{ display: "flex", gap: "6px" }}>
<button
style={{ ...s.btn, padding: "3px 12px", "font-size": "12px" }}
onClick={() => {
const ot = outType();
const a = document.createElement("a");
a.href = `/api?font=${selectedFont()}&text=${encodeURIComponent(text())}&outType=${ot}`;
a.download = selectedFont().replace(/\.[^.]+$/, "") + `_subset.${ot}`;
a.click();
}}
>
</button>
<button
style={{ ...s.btn, padding: "3px 12px", "font-size": "12px" }}
onClick={async (e) => {
const btn = e.currentTarget;
try {
await navigator.clipboard.writeText(cssStyle());
btn.textContent = "已复制";
setTimeout(() => { btn.textContent = "复制 CSS"; }, 1500);
} catch {
btn.textContent = "复制失败";
setTimeout(() => { btn.textContent = "复制 CSS"; }, 1500);
}
}}
>
CSS
</button>
</div>
</div>
<pre style={s.pre}>{cssStyle()}</pre>
</section>
</Show>
<Show when={import.meta.env.DEV}>
<FontDebugPreview />
</Show>
<UploadSection config={serverConfig()} onUploaded={refreshFonts} />
<StatsPanel />
<section style={{ ...s.section, "font-size": "12px", color: "#aaa", "line-height": "1.8" }}>
<p><b></b> text URL </p>
<p><b></b> CSS text </p>
<pre style={{ ...s.pre, "font-size": "12px", "margin-top": "4px" }}>{`<style>
@font-face {
font-family: "MyFont";
src: url("${location.origin}/api?font=字体名&text=你的文字") format("woff2");
}
.title { font-family: "MyFont"; }
</style>
<h1 class="title"></h1>`}</pre>
<p style={{ "margin-top": "12px" }}><b>JS SDK</b><a href="/webfont-sdk.js" download="webfont-sdk.js"> SDK</a></p>
<pre style={{ ...s.pre, "font-size": "12px", "margin-top": "4px" }}>{`<script src="${location.origin}/webfont-sdk.js"><\/script>
<script>
WebFont.loadFont({
fontName: "字体文件名.ttf",
selector: ".my-element",
family: "MyFont",
interval: 1000,
});
<\/script>`}</pre>
<p style={{ "margin-top": "8px" }}> <code>WebFont.observeFont()</code>MutationObserver <code>WebFont.loadText()</code>使SDK </p>
</section>
<footer style={{ "margin-top": "48px", "padding-top": "16px", "border-top": "1px solid #eee", "font-size": "12px", color: "#999", "text-align": "center" }}>
<p> <a href="https://www.ruanyifeng.com/blog/2020/03/weekly-issue-100.html" target="_blank" rel="noopener noreferrer" style={{ color: "#999" }}> 100 </a> </p>
</footer>
</div>
);
}
export default App;

221
src/App.vue Normal file
View File

@ -0,0 +1,221 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { fetchFonts, fetchConfig } from "./api";
import type { FontInfo, ServerConfig } from "./api";
const isDev = import.meta.env.DEV;
const origin = location.origin;
import UploadSection from "./UploadSection.vue";
import StatsPanel from "./StatsPanel.vue";
import SelectorRow from "./FontSelector.vue";
import FontDebugPreview from "./FontDebugPreview.vue";
const SLOGAN = "如清风似闪电,超级快的字体子集化裁剪";
const text = ref("天地无极,乾坤借法");
const fonts = ref<FontInfo[]>([]);
const selectedFont = ref("");
const outType = ref<"woff2" | "ttf">("ttf");
const serverConfig = ref<ServerConfig>({
enableTempUpload: false,
adminUploadEnabled: false,
supportedOutTypes: ["woff2", "ttf"],
});
onMounted(async () => {
const [fontList, config] = await Promise.all([
fetchFonts().catch(() => [] as FontInfo[]),
fetchConfig().catch((): ServerConfig => ({ enableTempUpload: false, adminUploadEnabled: false })),
]);
fonts.value = fontList;
serverConfig.value = config;
if (!config.supportedOutTypes?.includes(outType.value)) {
outType.value = config.supportedOutTypes?.[0] || "ttf";
}
if (fontList.length > 0) {
const usableFonts = fontList.filter((f) => /\.(ttf)$/i.test(f.name));
const randomFont = usableFonts[Math.floor(Math.random() * usableFonts.length)];
(globalThis as any).WebFont?.loadText({
fontName: randomFont.name,
text: SLOGAN,
family: "SloganFont",
});
const sloganEl = document.getElementById("slogan");
if (sloganEl) {
sloganEl.style.fontFamily = '"SloganFont", sans-serif';
sloganEl.title = randomFont.name;
}
selectedFont.value = fontList[0].name;
}
});
const cssStyle = computed(() => {
const font = selectedFont.value;
const ot = outType.value;
if (!font) return "";
const formatStr = ot === "woff2" ? "woff2" : "truetype";
return `@font-face {
font-family: "CustomFont";
src: url("${origin}/api?font=${font}&text=${encodeURIComponent(text.value)}&outType=${ot}") format("${formatStr}");
}
.custom-font {
color: red;
font-family: "CustomFont";
}`;
});
let textLoader: { update: (text: string) => void; dispose: () => void } | null = null;
function onTextChange(value: string) {
text.value = value;
textLoader?.update(value);
}
const textareaRows = computed(() => {
const lines = text.value.split("\n").length;
return Math.max(2, Math.min(lines, 10));
});
let lastLoadKey = "";
function reloadFont(font: string, ot: "woff2" | "ttf") {
const key = `${font}|${ot}`;
if (!font || key === lastLoadKey) return;
lastLoadKey = key;
if (textLoader) textLoader.dispose();
textLoader = (globalThis as any).WebFont?.loadText({
fontName: font,
text: text.value,
family: "CustomFont",
outType: ot,
}) ?? null;
const el = document.getElementById("webfont-preview");
if (el) el.style.fontFamily = '"CustomFont", sans-serif';
}
function onFontChange(font: string) {
selectedFont.value = font;
}
watch([selectedFont, outType], ([font, ot]) => {
reloadFont(font, ot as "woff2" | "ttf");
});
async function refreshFonts() {
const fontList = await fetchFonts();
fonts.value = fontList;
if (fontList.length > 0 && !selectedFont.value) {
onFontChange(fontList[0].name);
}
}
</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">
<h1 style="font-size: 22px; font-weight: 600; margin: 0 0 4px 0">Web Font</h1>
<a
href="https://github.com/2234839/web-font"
target="_blank"
rel="noopener noreferrer"
style="display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: #888; text-decoration: none; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 10px"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
Star on GitHub
</a>
</div>
<p id="slogan" style="font-size: 24px; color: #888; margin: 0 0 36px 0">{{ SLOGAN }}</p>
<section style="margin-bottom: 28px">
<SelectorRow
:fonts="fonts"
:selectedFont="selectedFont"
:onFontChange="onFontChange"
:supportedOutTypes="serverConfig.supportedOutTypes || ['woff2', 'ttf']"
:outType="outType"
:onOutTypeChange="(v: 'woff2' | 'ttf') => outType = v"
/>
</section>
<section style="margin-bottom: 28px">
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">输入文本预览效果</label>
<textarea
id="webfont-preview"
:rows="textareaRows"
:value="text"
@input="onTextChange(($event.target as HTMLTextAreaElement).value)"
placeholder="在此输入文本..."
style="width: 100%; padding: 8px 12px; font-size: 32px; border: 1px solid #d9d9d9; border-radius: 6px; resize: none; box-sizing: border-box; outline: none; color: #e74c3c; line-height: 1.4"
/>
</section>
<section v-if="selectedFont" style="margin-bottom: 28px">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px">
<label style="display: block; font-size: 13px; color: #555; margin: 0">CSS 代码</label>
<div style="display: flex; gap: 6px">
<button
style="padding: 3px 12px; font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333"
@click="() => {
const a = document.createElement('a');
a.href = `/api?font=${selectedFont}&text=${encodeURIComponent(text)}&outType=${outType}`;
a.download = selectedFont.replace(/\.[^.]+$/, '') + `_subset.${outType}`;
a.click();
}"
>
下载字体
</button>
<button
style="padding: 3px 12px; font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333"
@click="async (e: MouseEvent) => {
const btn = e.currentTarget as HTMLButtonElement;
try {
await navigator.clipboard.writeText(cssStyle);
btn.textContent = '已复制';
setTimeout(() => { btn.textContent = '复制 CSS'; }, 1500);
} catch {
btn.textContent = '复制失败';
setTimeout(() => { btn.textContent = '复制 CSS'; }, 1500);
}
}"
>
复制 CSS
</button>
</div>
</div>
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin: 0">{{ cssStyle }}</pre>
</section>
<FontDebugPreview v-if="isDev" />
<UploadSection :config="serverConfig" :onUploaded="refreshFonts" />
<StatsPanel />
<section style="margin-bottom: 28px; font-size: 12px; color: #aaa; line-height: 1.8">
<p><b>原理</b>服务端根据 text 参数裁剪字体只返回所需字符的子集相同 URL 的请求会被浏览器自动缓存</p>
<p><b>基础用法</b> CSS 复制到你的页面修改 text 参数中的文字即可</p>
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin-top: 4px">{{ `&lt;style&gt;\n@font-face {\n font-family: "MyFont";\n src: url("${origin}/api?font=字体名&text=你的文字") format("woff2");\n}\n.title { font-family: "MyFont"; }\n&lt;/style&gt;\n&lt;h1 class="title"&gt;你的文字&lt;/h1&gt;` }}</pre>
<p style="margin-top: 12px"><b>JS SDK推荐</b>增量加载字体片段按需请求不会出现全量字体闪烁<a href="/webfont-sdk.js" download="webfont-sdk.js">下载 SDK</a></p>
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin-top: 4px">{{ `&lt;script src="${origin}/webfont-sdk.js"&gt;&lt;/script&gt;\n&lt;script&gt;\n WebFont.loadFont({\n fontName: "字体文件名.ttf",\n selector: ".my-element",\n family: "MyFont",\n interval: 1000,\n });\n&lt;/script&gt;` }}</pre>
<p style="margin-top: 8px">还支持 <code>WebFont.observeFont()</code>MutationObserver 事件驱动 <code>WebFont.loadText()</code>手动传文本两种方式多种方式可同时使用SDK 内部自动按字体去重增量加载</p>
</section>
<footer style="margin-top: 48px; padding-top: 16px; border-top: 1px solid #eee; font-size: 12px; color: #999; text-align: center">
<p>感谢 <a href="https://www.ruanyifeng.com/blog/2020/03/weekly-issue-100.html" target="_blank" rel="noopener noreferrer" style="color: #999">阮一峰科技爱好者周刊 100 </a> 收录本项目</p>
<p style="margin-top: 8px">觉得好用<a href="https://shenzilong.cn/%E5%85%B3%E4%BA%8E/%E8%B5%9E%E5%8A%A9.html#20241227142305-g5225eu" target="_blank" rel="noopener noreferrer" style="color: #e6a700; text-decoration: underline">请作者喝杯咖啡</a>支持持续开发</p>
</footer>
<a
href="https://shenzilong.cn/%E5%85%B3%E4%BA%8E/%E8%B5%9E%E5%8A%A9.html#20241227142305-g5225eu"
target="_blank"
rel="noopener noreferrer"
style="position: fixed; right: 0; top: 50%; transform: translateY(-50%); background: #e6a700; color: #fff; padding: 12px 6px; font-size: 12px; writing-mode: vertical-rl; text-decoration: none; border-radius: 6px 0 0 6px; box-shadow: -2px 0 8px rgba(0,0,0,0.1); z-index: 999; transition: padding 0.2s"
onmouseover="this.style.paddingRight='10px'"
onmouseout="this.style.paddingRight='6px'"
>
赞助支持
</a>
</div>
</template>

View File

@ -1,107 +0,0 @@
import { createSignal, onMount, onCleanup, For } from "solid-js";
import type { FontInfo } from "./api";
const PREVIEW_TEXT = `天地无极乾坤借法:“”:"" 0123456789 ABCDEF`;
const s = {
section: {
"margin-bottom": "28px",
padding: "16px",
border: "2px dashed #e6a700",
"border-radius": "8px",
background: "#fffdf5",
} as const,
card: {
"margin-bottom": "12px",
padding: "8px 12px",
background: "#fff",
border: "1px solid #e8e8e8",
"border-radius": "6px",
} as const,
row: {
"margin-bottom": "4px",
display: "flex",
"align-items": "baseline",
gap: "8px",
} as const,
label: {
"font-size": "11px",
color: "#bbb",
"min-width": "40px",
flex: "none",
} as const,
text: {
"font-size": "22px",
"line-height": "1.5",
color: "#1a1a1a",
"min-height": "36px",
} as const,
};
export default function FontDebugPreview() {
const [fonts, set_fonts] = createSignal<FontInfo[]>([]);
const loaders = new Map<string, { update: (text: string) => void; dispose: () => void }>();
onMount(async () => {
const res = await fetch("/api/fonts");
const fontList: FontInfo[] = await res.json();
const usableFonts = fontList.filter((f) => /\.(ttf|otf)$/i.test(f.name));
set_fonts(usableFonts);
for (const font of usableFonts) {
const base = font.name.replace(/\.[^.]+$/, "");
for (const ot of ["woff2", "ttf"] as const) {
const family = `DevPreview_${base}_${ot}`;
const loader = (globalThis as any).WebFont?.loadText({
fontName: font.name,
text: PREVIEW_TEXT,
family,
outType: ot,
});
if (loader) loaders.set(`${font.name}|${ot}`, loader);
}
}
});
onCleanup(() => {
for (const loader of loaders.values()) loader.dispose();
loaders.clear();
});
return (
<section style={s.section}>
<div style={{ display: "flex", "align-items": "center", gap: "8px", "margin-bottom": "12px" }}>
<span style={{ "font-size": "13px", "font-weight": 600, color: "#e6a700" }}>DEV </span>
<span style={{ "font-size": "11px", color: "#aaa" }}> woff2 / ttf </span>
</div>
<For each={fonts()}>
{(font) => {
const base = font.name.replace(/\.[^.]+$/, "");
return (
<div style={s.card}>
<div style={{ "font-size": "11px", color: "#999", "margin-bottom": "6px", display: "flex", "justify-content": "space-between" }}>
<span style={{ "font-weight": 500, color: "#555" }}>{font.name}</span>
<span style={{ color: "#bbb" }}>{font.dir}</span>
</div>
<For each={["woff2", "ttf"] as const}>
{(ot) => (
<div style={s.row}>
<span style={s.label}>{ot}</span>
<div
style={{
...s.text,
"font-family": `"DevPreview_${base}_${ot}", "楷体", KaiTi, STKaiti, serif`,
}}
>
{PREVIEW_TEXT}
</div>
</div>
)}
</For>
</div>
);
}}
</For>
</section>
);
}

62
src/FontDebugPreview.vue Normal file
View File

@ -0,0 +1,62 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import type { FontInfo } from "./api";
const PREVIEW_TEXT = `天地无极乾坤借法:"":"" 0123456789 ABCDEF`;
const fonts = ref<FontInfo[]>([]);
const loaders = new Map<string, { update: (text: string) => void; dispose: () => void }>();
onMounted(async () => {
const res = await fetch("/api/fonts");
const fontList: FontInfo[] = await res.json();
const usableFonts = fontList.filter((f) => /\.(ttf|otf)$/i.test(f.name));
fonts.value = usableFonts;
for (const font of usableFonts) {
const base = font.name.replace(/\.[^.]+$/, "");
for (const ot of ["woff2", "ttf"] as const) {
const family = `DevPreview_${base}_${ot}`;
const loader = (globalThis as any).WebFont?.loadText({
fontName: font.name,
text: PREVIEW_TEXT,
family,
outType: ot,
});
if (loader) loaders.set(`${font.name}|${ot}`, loader);
}
}
});
onUnmounted(() => {
for (const loader of loaders.values()) loader.dispose();
loaders.clear();
});
function fontFamily(font: FontInfo, ot: string) {
const base = font.name.replace(/\.[^.]+$/, "");
return `"DevPreview_${base}_${ot}", "楷体", KaiTi, STKaiti, serif`;
}
</script>
<template>
<section style="margin-bottom: 28px; padding: 16px; border: 2px dashed #e6a700; border-radius: 8px; background: #fffdf5">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px">
<span style="font-size: 13px; font-weight: 600; color: #e6a700">DEV 字体调试预览</span>
<span style="font-size: 11px; color: #aaa">所有字体的 woff2 / ttf 渲染效果</span>
</div>
<div v-for="font in fonts" :key="font.name" style="margin-bottom: 12px; padding: 8px 12px; background: #fff; border: 1px solid #e8e8e8; border-radius: 6px">
<div style="font-size: 11px; color: #999; margin-bottom: 6px; display: flex; justify-content: space-between">
<span style="font-weight: 500; color: #555">{{ font.name }}</span>
<span style="color: #bbb">{{ font.dir }}</span>
</div>
<div v-for="ot in (['woff2', 'ttf'] as const)" :key="ot" style="margin-bottom: 4px; display: flex; align-items: baseline; gap: 8px">
<span style="font-size: 11px; color: #bbb; min-width: 40px; flex: none">{{ ot }}</span>
<div
:style="{ fontSize: '22px', lineHeight: '1.5', color: '#1a1a1a', minHeight: '36px', fontFamily: fontFamily(font, ot) }"
>
{{ PREVIEW_TEXT }}
</div>
</div>
</div>
</section>
</template>

View File

@ -1,118 +0,0 @@
import { For } from "solid-js";
interface FontSelectorProps {
fonts: Array<{ name: string; dir: string }>;
selectedFont: string;
onFontChange: (font: string) => void;
}
interface OutTypeSelectorProps {
supportedOutTypes: ("woff2" | "ttf")[];
outType: "woff2" | "ttf";
onOutTypeChange: (type: "woff2" | "ttf") => void;
}
const outTypeLabels: Record<string, string> = {
woff2: "WOFF2 体积更小",
ttf: "TTF 速度更快",
};
const outTypeDescs: Record<string, string> = {
woff2: "约压缩 50%,适合生产",
ttf: "无编码开销,适合开发",
};
const s = {
wrap: {
display: "flex",
gap: "12px",
} as const,
col: {
flex: 1,
} as const,
label: {
display: "block",
"font-size": "13px",
color: "#555",
"margin-bottom": "6px",
} as const,
select: {
width: "100%",
padding: "8px 12px",
"font-size": "14px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
outline: "none",
"box-sizing": "border-box",
cursor: "pointer",
appearance: "none",
"background-image": "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' fill='none' stroke='%23999' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E\")",
"background-repeat": "no-repeat",
"background-position": "right 10px center",
"padding-right": "28px",
} as const,
desc: {
"font-size": "11px",
color: "#bbb",
"margin-top": "4px",
} as const,
};
export function FontSelector(props: FontSelectorProps) {
return (
<div style={s.col}>
<label style={s.label}></label>
<select
style={s.select}
value={props.selectedFont}
onChange={(e) => props.onFontChange(e.target.value)}
>
<option value="">-- --</option>
<For each={props.fonts}>
{(f) => (
<option value={f.name}>
{f.name}
</option>
)}
</For>
</select>
</div>
);
}
export function OutTypeSelector(props: OutTypeSelectorProps) {
return (
<div style={{ width: "160px" }}>
<label style={s.label}></label>
<select
style={s.select}
value={props.outType}
onChange={(e) => props.onOutTypeChange(e.target.value as "woff2" | "ttf")}
>
<For each={props.supportedOutTypes}>
{(t) => (
<option value={t}>{outTypeLabels[t]}</option>
)}
</For>
</select>
<p style={s.desc}>{outTypeDescs[props.outType]}</p>
</div>
);
}
export function SelectorRow(props: FontSelectorProps & OutTypeSelectorProps) {
return (
<div style={s.wrap}>
<FontSelector
fonts={props.fonts}
selectedFont={props.selectedFont}
onFontChange={props.onFontChange}
/>
<OutTypeSelector
supportedOutTypes={props.supportedOutTypes}
outType={props.outType}
onOutTypeChange={props.onOutTypeChange}
/>
</div>
);
}

49
src/FontSelector.vue Normal file
View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import type { FontInfo } from "./api";
const outTypeLabels = {
woff2: "WOFF2 体积更小",
ttf: "TTF 速度更快",
};
const outTypeDescs = {
woff2: "约压缩 50%,适合生产",
ttf: "无编码开销,适合开发",
};
defineProps<{
fonts: FontInfo[];
selectedFont: string;
onFontChange: (font: string) => void;
supportedOutTypes: ("woff2" | "ttf")[];
outType: "woff2" | "ttf";
onOutTypeChange: (v: "woff2" | "ttf") => void;
}>();
</script>
<template>
<div style="display: flex; gap: 12px">
<div style="flex: 1">
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">选择字体</label>
<select
:value="selectedFont"
@change="onFontChange(($event.target).value)"
style="width: 100%; padding: 8px 12px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; outline: none; box-sizing: border-box; cursor: pointer; appearance: none; background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' fill='none' stroke='%23999' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 10px center; padding-right: 28px"
>
<option value="">-- 请选择 --</option>
<option v-for="f in fonts" :key="f.name" :value="f.name">{{ f.name }}</option>
</select>
</div>
<div style="width: 160px">
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">输出格式</label>
<select
:value="outType"
@change="onOutTypeChange(($event.target).value)"
style="width: 100%; padding: 8px 12px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; outline: none; box-sizing: border-box; cursor: pointer; appearance: none; background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' fill='none' stroke='%23999' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 10px center; padding-right: 28px"
>
<option v-for="t in supportedOutTypes" :key="t" :value="t">{{ outTypeLabels[t] }}</option>
</select>
<p style="font-size: 11px; color: #bbb; margin-top: 4px">{{ outTypeDescs[outType] }}</p>
</div>
</div>
</template>

View File

@ -1,72 +0,0 @@
import { createSignal, onMount, onCleanup } from "solid-js";
import { fetchStats, type ServerStats } from "./api";
/** 将秒数格式化为可读时长 */
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}${seconds % 60}`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h < 24) return `${h}${m}${s}`;
const d = Math.floor(h / 24);
return `${d}${h % 24}${m}`;
}
export default function StatsPanel() {
const [data, setData] = createSignal<ServerStats | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
const load = async () => {
const s = await fetchStats().catch(() => null);
if (s) setData(s);
};
const startPolling = () => {
if (timer) return;
load();
timer = setInterval(load, 10_000);
};
const stopPolling = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
startPolling();
} else {
stopPolling();
}
};
onMount(() => {
document.addEventListener("visibilitychange", onVisibilityChange);
startPolling();
});
onCleanup(() => {
stopPolling();
document.removeEventListener("visibilitychange", onVisibilityChange);
});
const s = data();
if (!s) return null;
const hitRate = s.subsetRequests > 0 ? ((s.subsetCacheHits / s.subsetRequests) * 100).toFixed(1) : "0.0";
return (
<section style={{ "font-size": "12px", color: "#999", "line-height": "1.8", "margin-top": "24px" }}>
<div style={{ display: "flex", gap: "16px", "flex-wrap": "wrap" }}>
<span> {formatUptime(s.uptime)}</span>
<span> {s.totalRequests} </span>
<span> {s.subsetRequests} </span>
<span> {s.totalChars} </span>
<span> {hitRate}%</span>
</div>
</section>
);
}

67
src/StatsPanel.vue Normal file
View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { fetchStats, type ServerStats } from "./api";
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}${seconds % 60}`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h < 24) return `${h}${m}${s}`;
const d = Math.floor(h / 24);
return `${d}${h % 24}${m}`;
}
const data = ref<ServerStats | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
async function load() {
const s = await fetchStats().catch(() => null);
if (s) data.value = s;
}
function startPolling() {
if (timer) return;
load();
timer = setInterval(load, 10_000);
}
function stopPolling() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function onVisibilityChange() {
if (document.visibilityState === "visible") {
startPolling();
} else {
stopPolling();
}
}
onMounted(() => {
document.addEventListener("visibilitychange", onVisibilityChange);
startPolling();
});
onUnmounted(() => {
stopPolling();
document.removeEventListener("visibilitychange", onVisibilityChange);
});
</script>
<template>
<section v-if="data" style="margin-top: 24px; margin-bottom: 28px; padding: 12px 16px; background: #f0f0f0; border-radius: 8px">
<div style="font-size: 13px; font-weight: 600; color: #333; margin-bottom: 4px">服务状态</div>
<div style="display: flex; gap: 20px; flex-wrap: wrap; font-size: 13px; color: #555; line-height: 2">
<span><b style="color: #333">运行</b> {{ formatUptime(data.uptime) }}</span>
<span><b style="color: #333">请求</b> {{ data.totalRequests }} </span>
<span><b style="color: #333">裁剪</b> {{ data.subsetRequests }} </span>
<span><b style="color: #333">文字</b> {{ data.totalChars }} </span>
<span><b style="color: #333">缓存命中</b> {{ data.subsetRequests > 0 ? ((data.subsetCacheHits / data.subsetRequests) * 100).toFixed(1) : '0.0' }}%</span>
</div>
</section>
</template>

View File

@ -1,224 +0,0 @@
import { createSignal, Show } from "solid-js";
import { uploadFont, type UploadResult, type ServerConfig } from "./api";
const ACCEPT = ".ttf,.otf,.woff,.woff2";
const UPLOAD_TIP = "支持 .ttf 和 .otf 格式,建议上传 .ttf 字体文件以获得最佳兼容性";
const btn = {
padding: "6px 20px",
"font-size": "14px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
cursor: "pointer",
background: "#fff",
color: "#333",
} as const;
const card = {
padding: "16px",
border: "1px solid #e8e8e8",
"border-radius": "8px",
"margin-bottom": "16px",
} as const;
const label = {
display: "block",
"font-size": "13px",
color: "#555",
"margin-bottom": "6px",
} as const;
const input = {
padding: "6px 12px",
"font-size": "14px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
outline: "none",
"box-sizing": "border-box",
} as const;
const section = {
"margin-bottom": "28px",
} as const;
/** 通用文件上传行:选择文件 + 文件名 + 上传按钮 */
function FileUploader(props: {
accept?: string;
disabled?: boolean;
uploading?: boolean;
onFileSelect: (file: File) => void;
fileName: string | undefined;
onUpload: () => void;
}) {
return (
<div style={{ display: "flex", "gap": "8px", "align-items": "center" }}>
<label style={{ ...btn, display: "inline-flex", "align-items": "center", cursor: "pointer" }}>
<input
type="file"
accept={props.accept ?? ACCEPT}
style={{ display: "none" }}
onChange={(e) => e.target.files?.[0] && props.onFileSelect(e.target.files[0])}
/>
</label>
<span style={{ "font-size": "13px", color: "#666" }}>
{props.fileName ?? "未选择文件"}
</span>
<button
style={{ ...btn, opacity: !props.disabled && !props.uploading ? 1 : 0.5, cursor: !props.disabled && !props.uploading ? "pointer" : "not-allowed" }}
disabled={props.disabled || props.uploading}
onClick={props.onUpload}
>
{props.uploading ? "..." : "上传"}
</button>
</div>
);
}
function useUpload(onSuccess: () => void) {
const [file, set_file] = createSignal<File | null>(null);
const [apiKey, set_apiKey] = createSignal("");
const [uploading, set_uploading] = createSignal(false);
const [msg, set_msg] = createSignal<{ ok: boolean; text: string } | null>(null);
function showMsg(ok: boolean, text: string) {
set_msg({ ok, text });
setTimeout(() => set_msg(null), 3000);
}
async function upload(mode: "temp" | "admin", key?: string) {
const f = file();
if (!f) return;
set_uploading(true);
const result: UploadResult = await uploadFont(f, mode, key);
set_uploading(false);
if (result.success) {
showMsg(true, "上传成功");
set_file(null);
onSuccess();
} else {
showMsg(false, result.error ?? "上传失败");
}
}
return { file, set_file, apiKey, set_apiKey, uploading, msg, showMsg, upload };
}
/** 游客上传区域 */
function TempUploadCard(props: {
uploading: boolean;
msg: { ok: boolean; text: string } | null;
onFileSelect: (file: File) => void;
fileName: string | undefined;
onUpload: () => void;
}) {
return (
<div style={card}>
<div style={{ "font-size": "14px", "font-weight": 500, "margin-bottom": "4px" }}></div>
<div style={{ "font-size": "12px", color: "#999", "margin-bottom": "12px" }}>
10 200MB
</div>
<FileUploader
disabled={!props.fileName}
uploading={props.uploading}
onFileSelect={props.onFileSelect}
fileName={props.fileName}
onUpload={props.onUpload}
/>
</div>
);
}
/** 管理员上传区域 */
function AdminUploadCard(props: {
uploading: boolean;
msg: { ok: boolean; text: string } | null;
apiKey: string;
onApiKeyInput: (value: string) => void;
onFileSelect: (file: File) => void;
fileName: string | undefined;
onUpload: () => void;
}) {
return (
<div style={card}>
<div style={{ "font-size": "14px", "font-weight": 500, "margin-bottom": "4px" }}></div>
<div style={{ "font-size": "12px", color: "#999", "margin-bottom": "12px" }}>
API Key
</div>
<input
type="text"
autocomplete="off"
style={{ ...input, width: "100%", "margin-bottom": "10px" }}
value={props.apiKey}
onInput={(e) => props.onApiKeyInput(e.target.value)}
placeholder="API Key"
/>
<FileUploader
disabled={!props.fileName || !props.apiKey}
uploading={props.uploading}
onFileSelect={props.onFileSelect}
fileName={props.fileName}
onUpload={props.onUpload}
/>
</div>
);
}
export default function UploadSection(props: { config: ServerConfig; onUploaded: () => void }) {
const temp = useUpload(props.onUploaded);
const admin = useUpload(props.onUploaded);
const canUpload = () => props.config.enableTempUpload || props.config.adminUploadEnabled;
return (
<Show when={canUpload()}>
<section style={section}>
<label style={{ ...label, "font-size": "14px", "font-weight": 500, "margin-bottom": "12px" }}></label>
<div style={{ "font-size": "12px", color: "#e6a700", "margin-bottom": "12px" }}>
{UPLOAD_TIP}
</div>
<Show when={temp.msg()}>
{(m) => (
<div
style={{
padding: "8px 12px",
"margin-bottom": "12px",
"border-radius": "6px",
"font-size": "13px",
background: m().ok ? "#f0faf0" : "#fef2f2",
color: m().ok ? "#166534" : "#b91c1c",
border: `1px solid ${m().ok ? "#bbf7d0" : "#fecaca"}`,
}}
>
{m().text}
</div>
)}
</Show>
<Show when={props.config.enableTempUpload}>
<TempUploadCard
uploading={temp.uploading()}
msg={temp.msg()}
onFileSelect={(f) => temp.set_file(f)}
fileName={temp.file()?.name}
onUpload={() => temp.upload("temp")}
/>
</Show>
<Show when={props.config.adminUploadEnabled}>
<AdminUploadCard
uploading={admin.uploading()}
msg={admin.msg()}
apiKey={admin.apiKey()}
onApiKeyInput={(v) => admin.set_apiKey(v)}
onFileSelect={(f) => admin.set_file(f)}
fileName={admin.file()?.name}
onUpload={() => admin.upload("admin", admin.apiKey())}
/>
</Show>
</section>
</Show>
);
}

117
src/UploadSection.vue Normal file
View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { uploadFont, type UploadResult, type ServerConfig } from "./api";
const ACCEPT = ".ttf,.otf,.woff,.woff2";
const UPLOAD_TIP = "支持 .ttf 和 .otf 格式,建议上传 .ttf 字体文件以获得最佳兼容性";
const props = defineProps<{
config: ServerConfig;
onUploaded: () => void;
}>();
function useUpload(onSuccess: () => void) {
const file = ref<File | null>(null);
const apiKey = ref("");
const uploading = ref(false);
const msg = ref<{ ok: boolean; text: string } | null>(null);
function showMsg(ok: boolean, text: string) {
msg.value = { ok, text };
setTimeout(() => { msg.value = null; }, 3000);
}
async function upload(mode: "temp" | "admin", key?: string) {
const f = file.value;
if (!f) return;
uploading.value = true;
const result: UploadResult = await uploadFont(f, mode, key);
uploading.value = false;
if (result.success) {
showMsg(true, "上传成功");
file.value = null;
onSuccess();
} else {
showMsg(false, result.error ?? "上传失败");
}
}
return { file, apiKey, uploading, msg, upload };
}
const temp = useUpload(() => props.onUploaded());
const admin = useUpload(() => props.onUploaded());
const canUpload = computed(() => props.config.enableTempUpload || props.config.adminUploadEnabled);
function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) target.file.value = f;
}
</script>
<template>
<section v-if="canUpload" style="margin-bottom: 28px">
<label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 12px">上传字体</label>
<div style="font-size: 12px; color: #e6a700; margin-bottom: 12px">{{ UPLOAD_TIP }}</div>
<div
v-if="temp.msg.value"
:style="{
padding: '8px 12px',
marginBottom: '12px',
borderRadius: '6px',
fontSize: '13px',
background: temp.msg.value.ok ? '#f0faf0' : '#fef2f2',
color: temp.msg.value.ok ? '#166534' : '#b91c1c',
border: `1px solid ${temp.msg.value.ok ? '#bbf7d0' : '#fecaca'}`,
}"
>
{{ temp.msg.value.text }}
</div>
<div v-if="config.enableTempUpload" style="padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 16px">
<div style="font-size: 14px; font-weight: 500; margin-bottom: 4px">游客上传</div>
<div style="font-size: 12px; color: #999; margin-bottom: 12px">临时文件最多保留 10 总大小限制 200MB超出后自动删除最早上传的</div>
<div style="display: flex; gap: 8px; align-items: center">
<label style="padding: 6px 20px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333; display: inline-flex; align-items: center">
选择文件
<input type="file" :accept="ACCEPT" style="display: none" @change="onFileSelect($event, temp)" />
</label>
<span style="font-size: 13px; color: #666">{{ temp.file.value?.name ?? '未选择文件' }}</span>
<button
:disabled="!temp.file.value || temp.uploading.value"
:style="{ padding: '6px 20px', fontSize: '14px', border: '1px solid #d9d9d9', borderRadius: '6px', cursor: temp.file.value && !temp.uploading.value ? 'pointer' : 'not-allowed', background: '#fff', color: '#333', opacity: temp.file.value && !temp.uploading.value ? 1 : 0.5 }"
@click="temp.upload('temp')"
>
{{ temp.uploading.value ? '...' : '上传' }}
</button>
</div>
</div>
<div v-if="config.adminUploadEnabled" style="padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 16px">
<div style="font-size: 14px; font-weight: 500; margin-bottom: 4px">管理员上传</div>
<div style="font-size: 12px; color: #999; margin-bottom: 12px">永久保存需要 API Key 认证</div>
<input
type="text"
autocomplete="off"
v-model="admin.apiKey.value"
placeholder="API Key"
style="padding: 6px 12px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; outline: none; box-sizing: border-box; width: 100%; margin-bottom: 10px"
/>
<div style="display: flex; gap: 8px; align-items: center">
<label style="padding: 6px 20px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333; display: inline-flex; align-items: center">
选择文件
<input type="file" :accept="ACCEPT" style="display: none" @change="onFileSelect($event, admin)" />
</label>
<span style="font-size: 13px; color: #666">{{ admin.file.value?.name ?? '未选择文件' }}</span>
<button
:disabled="!admin.file.value || !admin.apiKey.value || admin.uploading.value"
:style="{ padding: '6px 20px', fontSize: '14px', border: '1px solid #d9d9d9', borderRadius: '6px', cursor: admin.file.value && admin.apiKey.value && !admin.uploading.value ? 'pointer' : 'not-allowed', background: '#fff', color: '#333', opacity: admin.file.value && admin.apiKey.value && !admin.uploading.value ? 1 : 0.5 }"
@click="admin.upload('admin', admin.apiKey.value)"
>
{{ admin.uploading.value ? '...' : '上传' }}
</button>
</div>
</div>
</section>
</template>

View File

@ -1,8 +0,0 @@
/* @refresh reload */
import { render } from 'solid-js/web'
import App from './App'
const root = document.getElementById('root')
render(() => <App />, root!)

4
src/main.ts Normal file
View File

@ -0,0 +1,4 @@
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#root");

View File

@ -17,7 +17,6 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Linting */
"strict": true,
"noUnusedLocals": true,
@ -25,6 +24,8 @@
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue"
]
}
}

13
tsdown.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["backend/app.ts"],
format: ["cjs"],
clean: true,
sourcemap: true,
outDir: "dist_backend",
deps: {
/** 所有依赖都打进 bundleLLRT scratch 镜像无 node_modules */
alwaysBundle: [/.*/],
},
});

View File

@ -1,11 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["backend/app.ts", "基准测试_llrt.ts"],
splitting: false,
sourcemap: true,
clean: true,
bundle: true,
noExternal: [/.*/],
outDir: "dist_backend",
});

View File

@ -1,9 +1,9 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import vue from "@vitejs/plugin-vue";
import { pilot } from "vite-plugin-pilot";
export default defineConfig({
plugins: [solid(), pilot({ locale: "zh" })],
plugins: [vue(), pilot({ locale: "zh" })],
server: {
host: "0.0.0.0",
proxy: {