mirror of
https://github.com/2234839/web-font.git
synced 2026-05-31 22:28:31 +08:00
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:
parent
68df9db2da
commit
53d170dc90
@ -9,18 +9,9 @@ import { handleGetConfig } from "./routes/config";
|
|||||||
import { handleStats } from "./routes/stats";
|
import { handleStats } from "./routes/stats";
|
||||||
import { handleUpload } from "./routes/upload";
|
import { handleUpload } from "./routes/upload";
|
||||||
import { handleFontSubset } from "./routes/subset";
|
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";
|
const ROOT_DIR = "dist";
|
||||||
|
|
||||||
/** 启动时确保必要目录存在 */
|
/** 启动时确保必要目录存在 */
|
||||||
@ -158,7 +149,6 @@ const uploadSizeMiddleware: cMiddleware = async (req, res, next) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await runtimeReady;
|
|
||||||
await ensureDirectories();
|
await ensureDirectories();
|
||||||
|
|
||||||
const server = new SimpleHttpServer({ port: 8087 });
|
const server = new SimpleHttpServer({ port: 8087 });
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { cMiddleware, cRequest, cResponse, type cNext } from "./req_res";
|
import { cMiddleware, cRequest, cResponse, type cNext } from "./req_res";
|
||||||
|
import { createTcpServer } from "./tcp_server";
|
||||||
// 配置
|
// 配置
|
||||||
// 路由器类
|
// 路由器类
|
||||||
export class cRouter {
|
export class cRouter {
|
||||||
@ -30,13 +31,11 @@ export class SimpleHttpServer {
|
|||||||
const release_name = globalThis?.process?.release?.name;
|
const release_name = globalThis?.process?.release?.name;
|
||||||
console.log("[release.name]", release_name);
|
console.log("[release.name]", release_name);
|
||||||
if (release_name === "llrt" || release_name === "node") {
|
if (release_name === "llrt" || release_name === "node") {
|
||||||
import("./tcp_server").then((m) => {
|
const server = createTcpServer((socket) => {
|
||||||
const server = m.createTcpServer((socket) => {
|
connectionHandle(socket, (req, res) => this.router.handle(req, res));
|
||||||
connectionHandle(socket, (req, res) => this.router.handle(req, res));
|
});
|
||||||
});
|
server.listen(options.port, options.hostname, () => {
|
||||||
server.listen(options.port, options.hostname, () => {
|
console.log(`Server is listening on port ${options.port}`);
|
||||||
console.log(`Server is listening on port ${options.port}`);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
import { enableTempUpload, adminApiKey, tempMaxFiles, tempMaxTotalSize } from "./config";
|
||||||
|
|
||||||
/** 允许的字体文件扩展名 */
|
/** 允许的字体文件扩展名 */
|
||||||
@ -22,7 +22,6 @@ function sanitizeFilename(filename: string) {
|
|||||||
|
|
||||||
/** 确保目录存在,不存在则创建 */
|
/** 确保目录存在,不存在则创建 */
|
||||||
async function ensureDir(dir: string) {
|
async function ensureDir(dir: string) {
|
||||||
const { stat, mkdir } = await import("./interface");
|
|
||||||
try {
|
try {
|
||||||
await stat(dir);
|
await stat(dir);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="在线字体裁剪工具 — 服务端按需裁剪字体子集,大小无限制,免费开源。支持自定义裁剪、增量加载 SDK,轻松嵌入任何网站。" />
|
<meta name="description" content="在线字体裁剪工具 — 服务端按需裁剪字体子集,大小无限制,免费开源。支持自定义裁剪、增量加载 SDK,轻松嵌入任何网站。" />
|
||||||
<title>WebFont — 在线字体裁剪 | 按需加载 | 免费开源</title>
|
<title>WebFont — 在线字体裁剪 | 按需加载 | 免费开源</title>
|
||||||
@ -10,6 +10,6 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script src="/webfont-sdk.js"></script>
|
<script src="/webfont-sdk.js"></script>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
24
package.json
24
package.json
@ -15,21 +15,21 @@
|
|||||||
"release": "pnpm build && pnpm build_backend && pnpm docker_build && pnpm docker_push"
|
"release": "pnpm build && pnpm build_backend && pnpm docker_build && pnpm docker_push"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"solid-js": "^1.9.12",
|
"vue": "3.6.0-beta.10",
|
||||||
"web-streams-polyfill": "^4.2.0"
|
"web-streams-polyfill": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.6.0",
|
||||||
"@xmldom/xmldom": "^0.9.9",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"jsdom": "^29.0.2",
|
"@xmldom/xmldom": "^0.9.10",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"puppeteer": "^24.40.0",
|
"puppeteer": "^24.42.0",
|
||||||
"tsup": "^8.5.1",
|
"tsdown": "^0.21.10",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"undici": "^8.0.2",
|
"undici": "^8.1.0",
|
||||||
"vite": "^8.0.7",
|
"vite": "^8.0.10",
|
||||||
"vite-plugin-pilot": "^1.0.19",
|
"vite-plugin-pilot": "^1.0.24",
|
||||||
"vite-plugin-solid": "^2.11.12",
|
"vitest": "^4.1.5"
|
||||||
"vitest": "^4.1.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1906
pnpm-lock.yaml
generated
1906
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
22
public/favicon.svg
Normal file
22
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
@ -94,10 +94,10 @@ async function ensureLlrt() {
|
|||||||
console.log(`LLRT ${version} installed successfully.`);
|
console.log(`LLRT ${version} installed successfully.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 运行 tsup 编译 */
|
/** 运行 tsdown 编译 */
|
||||||
function runTsup() {
|
function runTsdown() {
|
||||||
console.log("\n--- Running tsup build ---");
|
console.log("\n--- Running tsdown build ---");
|
||||||
execSync("pnpm tsup", { stdio: "inherit", cwd: ROOT_DIR });
|
execSync("pnpm tsdown", { stdio: "inherit", cwd: ROOT_DIR });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** woff2 已使用纯 JS 实现(vendor/fonteditor-core/woff2/index.js),无需复制 wasm */
|
/** woff2 已使用纯 JS 实现(vendor/fonteditor-core/woff2/index.js),无需复制 wasm */
|
||||||
@ -119,7 +119,7 @@ function runLlrtCompile() {
|
|||||||
/** 主流程 */
|
/** 主流程 */
|
||||||
async function main() {
|
async function main() {
|
||||||
await ensureLlrt();
|
await ensureLlrt();
|
||||||
runTsup();
|
runTsdown();
|
||||||
runLlrtCompile();
|
runLlrtCompile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* 同时启动前端 (vite) 和后端 (tsx backend/app.ts) 的开发服务器
|
* 同时启动前端 (vite) 和后端 (tsx backend/app.ts) 的开发服务器
|
||||||
* Ctrl+C 会同时终止两个进程
|
* Ctrl+C 会同时终止两个进程
|
||||||
*/
|
*/
|
||||||
import { spawn } from "node:child_process";
|
import { execSync, spawn } from "node:child_process";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
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", () => {
|
process.on("SIGINT", () => {
|
||||||
cleanup();
|
cleanup();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@ -28,6 +37,7 @@ process.on("SIGTERM", () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
killPort(8087);
|
||||||
console.log("Starting frontend and backend dev servers...\n");
|
console.log("Starting frontend and backend dev servers...\n");
|
||||||
|
|
||||||
const backend = spawn("pnpx", ["tsx", "watch", "backend/app.ts"], {
|
const backend = spawn("pnpx", ["tsx", "watch", "backend/app.ts"], {
|
||||||
|
|||||||
320
src/App.tsx
320
src/App.tsx
@ -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
221
src/App.vue
Normal 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">{{ `<style>\n@font-face {\n font-family: "MyFont";\n src: url("${origin}/api?font=字体名&text=你的文字") format("woff2");\n}\n.title { font-family: "MyFont"; }\n</style>\n<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="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">{{ `<script src="${origin}/webfont-sdk.js"></script>\n<script>\n WebFont.loadFont({\n fontName: "字体文件名.ttf",\n selector: ".my-element",\n family: "MyFont",\n interval: 1000,\n });\n</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>
|
||||||
|
<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>
|
||||||
@ -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
62
src/FontDebugPreview.vue
Normal 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>
|
||||||
@ -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
49
src/FontSelector.vue
Normal 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("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"
|
||||||
|
>
|
||||||
|
<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("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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
@ -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
67
src/StatsPanel.vue
Normal 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>
|
||||||
@ -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
117
src/UploadSection.vue
Normal 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>
|
||||||
@ -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
4
src/main.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
|
||||||
|
createApp(App).mount("#root");
|
||||||
@ -17,7 +17,6 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"jsxImportSource": "solid-js",
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
@ -25,6 +24,8 @@
|
|||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.vue"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
13
tsdown.config.ts
Normal file
13
tsdown.config.ts
Normal 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: {
|
||||||
|
/** 所有依赖都打进 bundle(LLRT scratch 镜像无 node_modules) */
|
||||||
|
alwaysBundle: [/.*/],
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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",
|
|
||||||
});
|
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import solid from "vite-plugin-solid";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { pilot } from "vite-plugin-pilot";
|
import { pilot } from "vite-plugin-pilot";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [solid(), pilot({ locale: "zh" })],
|
plugins: [vue(), pilot({ locale: "zh" })],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user