mirror of
https://github.com/2234839/web-font.git
synced 2026-05-09 05:58:11 +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 { 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 });
|
||||
|
||||
@ -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}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
24
package.json
24
package.json
@ -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
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.`);
|
||||
}
|
||||
|
||||
/** 运行 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();
|
||||
}
|
||||
|
||||
|
||||
@ -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"], {
|
||||
|
||||
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",
|
||||
"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
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 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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user