mirror of
https://github.com/2234839/web-font.git
synced 2026-06-06 17:58:17 +08:00
重构前端UI,新增字体上传/下载/模糊匹配功能
- 前端UI重构:字体选择器、文本预览、CSS输出+复制、下载字体、上传区域 - 后端新增API:/api/fonts(列出字体)、/api/config(公开配置)、/api/upload(上传字体) - 字体查找支持模糊匹配(精确 > 前缀 > 包含) - 上传功能分两种模式:临时上传(ENV开关,FIFO上限10个)和管理员上传(API Key认证) - 新增multipart解析器、配置模块、文件系统抽象(writeFile/readdir/mkdir/unlink) - 后端启动时等待运行时初始化完成,确保接口可用 - dev-all.ts后端启用tsx watch模式自动重载 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1a611e7e28
commit
7fceccfe4d
247
backend/app.ts
247
backend/app.ts
@ -2,25 +2,101 @@ import { fontSubset } from "./font_util/font";
|
|||||||
import { mimeTypes } from "./server/mime_type";
|
import { mimeTypes } from "./server/mime_type";
|
||||||
import type { cMiddleware } from "./server/req_res";
|
import type { cMiddleware } from "./server/req_res";
|
||||||
import { SimpleHttpServer } from "./server/server";
|
import { SimpleHttpServer } from "./server/server";
|
||||||
import { path_join, readFile, stat } from "./interface";
|
import { path_join, readFile, stat, readdir, mkdir } from "./interface";
|
||||||
|
import { enableTempUpload, adminApiKey, fontDirs } from "./config";
|
||||||
|
import { parseMultipart } from "./multipart";
|
||||||
|
import { handleTempUpload, handleAdminUpload } from "./upload";
|
||||||
|
|
||||||
let release_name = global.tjs ? "tjs" : globalThis?.process?.release?.name;
|
let release_name = global.tjs ? "tjs" : globalThis?.process?.release?.name;
|
||||||
|
|
||||||
|
let runtimeReady: Promise<void>;
|
||||||
if (release_name === "tjs") {
|
if (release_name === "tjs") {
|
||||||
import("./server/tjs");
|
runtimeReady = import("./server/tjs").then(() => {});
|
||||||
} else if (release_name === "node" || release_name === "llrt") {
|
} else if (release_name === "node" || release_name === "llrt") {
|
||||||
import("./server/node");
|
runtimeReady = import("./server/node").then(() => {});
|
||||||
|
} else {
|
||||||
|
runtimeReady = Promise.resolve();
|
||||||
}
|
}
|
||||||
if (release_name === "llrt") {
|
if (release_name === "llrt") {
|
||||||
import("./server/llrt");
|
runtimeReady = runtimeReady.then(() => import("./server/llrt").then(() => {}));
|
||||||
|
}
|
||||||
|
const ROOT_DIR = "dist";
|
||||||
|
|
||||||
|
/** 启动时确保必要目录存在 */
|
||||||
|
async function ensureDirectories() {
|
||||||
|
for (const dir of ["font/temp", "font/admin"]) {
|
||||||
|
try {
|
||||||
|
await stat(dir);
|
||||||
|
} catch {
|
||||||
|
await mkdir(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在所有字体目录中查找字体文件
|
||||||
|
* 匹配优先级:精确匹配 > 前缀匹配 > 包含匹配
|
||||||
|
* @returns 找到的字体完整路径,未找到则返回 null
|
||||||
|
*/
|
||||||
|
async function findFontPath(filename: string): Promise<string | null> {
|
||||||
|
// 先尝试精确匹配
|
||||||
|
for (const dir of fontDirs) {
|
||||||
|
const filePath = path_join(dir, filename);
|
||||||
|
try {
|
||||||
|
const s = await stat(filePath);
|
||||||
|
if (s.isFile()) return filePath;
|
||||||
|
} catch {
|
||||||
|
// 继续搜索
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集所有字体文件名(不含扩展名)和完整路径
|
||||||
|
const allFonts: Array<{ basename: string; path: string }> = [];
|
||||||
|
for (const dir of fontDirs) {
|
||||||
|
try {
|
||||||
|
const entries = await readdir(dir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
|
||||||
|
allFonts.push({
|
||||||
|
basename: entry.name.replace(/\.[^.]+$/, ""),
|
||||||
|
path: path_join(dir, entry.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 目录不存在,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = filename.replace(/\.[^.]+$/, "").toLowerCase();
|
||||||
|
|
||||||
|
// 前缀匹配
|
||||||
|
for (const f of allFonts) {
|
||||||
|
if (f.basename.toLowerCase().startsWith(query)) return f.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 包含匹配
|
||||||
|
for (const f of allFonts) {
|
||||||
|
if (f.basename.toLowerCase().includes(query)) return f.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 响应工具 */
|
||||||
|
function jsonResponse(data: unknown, status = 200) {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const ROOT_DIR = "dist"; // 静态文件目录
|
|
||||||
|
|
||||||
const logMiddleware: cMiddleware = async (req, res, next) => {
|
const logMiddleware: cMiddleware = async (req, res, next) => {
|
||||||
const t1 = Date.now();
|
const t1 = Date.now();
|
||||||
const r = await next(req, res);
|
const r = await next(req, res);
|
||||||
const t2 = Date.now();
|
const t2 = Date.now();
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
console.log(`[${t2 - t1}ms] ${url.pathname}`);
|
console.log(`[${t2 - t1}ms] ${req.method} ${url.pathname}`);
|
||||||
return r;
|
return r;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -43,7 +119,6 @@ const staticFileMiddleware: cMiddleware = async function (req, res, next) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 文件不存在
|
|
||||||
newRes = new Response("404 Not Found", {
|
newRes = new Response("404 Not Found", {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: {
|
headers: {
|
||||||
@ -72,9 +147,7 @@ const staticFileMiddleware: cMiddleware = async function (req, res, next) {
|
|||||||
return next(req, newRes);
|
return next(req, newRes);
|
||||||
};
|
};
|
||||||
const corsMiddleware: cMiddleware = async (req, res, next) => {
|
const corsMiddleware: cMiddleware = async (req, res, next) => {
|
||||||
// 如果是 OPTIONS 请求(预检请求),直接返回成功响应
|
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
// 直接结束请求,不继续传递到下一个中间件
|
|
||||||
return {
|
return {
|
||||||
req,
|
req,
|
||||||
res: new Response("", {
|
res: new Response("", {
|
||||||
@ -86,33 +159,94 @@ const corsMiddleware: cMiddleware = async (req, res, next) => {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const newRes = await next(req, res);
|
const newRes = await next(req, res);
|
||||||
// 允许所有域跨域请求
|
|
||||||
newRes.res.headers.append("Access-Control-Allow-Origin", "*");
|
newRes.res.headers.append("Access-Control-Allow-Origin", "*");
|
||||||
// 如果你只想允许特定域名:
|
|
||||||
// res.headers["Access-Control-Allow-Origin"] = "https://example.com";
|
|
||||||
// 允许常见的 HTTP 方法
|
|
||||||
newRes.res.headers.append("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
newRes.res.headers.append("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||||
// 允许的请求头
|
|
||||||
newRes.res.headers.append("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
newRes.res.headers.append("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
return newRes;
|
return newRes;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const fontApiMiddleware: cMiddleware = async (req, res, next) => {
|
|
||||||
// 创建一个新的 URL 对象(需要一个完整的 URL,必须包含协议和主机)
|
/** GET /api/fonts — 列出所有可用字体 */
|
||||||
|
async function handleListFonts(req: Request, res: Response) {
|
||||||
|
const allFonts: Array<{ name: string; dir: string }> = [];
|
||||||
|
|
||||||
|
for (const dir of fontDirs) {
|
||||||
|
try {
|
||||||
|
const entries = await readdir(dir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
|
||||||
|
allFonts.push({ name: entry.name, dir });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 目录不存在,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { req, res: jsonResponse(allFonts) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/config — 返回公开配置 */
|
||||||
|
async function handleGetConfig(req: Request, res: Response) {
|
||||||
|
return {
|
||||||
|
req,
|
||||||
|
res: jsonResponse({
|
||||||
|
enableTempUpload,
|
||||||
|
adminUploadEnabled: !!adminApiKey,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/upload?mode=temp|admin — 上传字体 */
|
||||||
|
async function handleUpload(req: Request, res: Response) {
|
||||||
|
const url = new URL(req.url, "https://webfont.shenzilong.com");
|
||||||
|
const mode = url.searchParams.get("mode") ?? "temp";
|
||||||
|
|
||||||
|
const contentType = req.headers.get("Content-Type") ?? "";
|
||||||
|
let body: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
body = await req.arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
return { req, res: jsonResponse({ success: false, error: "读取请求体失败" }, 400) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = parseMultipart(contentType, body);
|
||||||
|
} catch {
|
||||||
|
return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.files || parsed.files.length === 0) {
|
||||||
|
return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = parsed.files[0];
|
||||||
|
|
||||||
|
if (mode === "admin") {
|
||||||
|
const authHeader = req.headers.get("Authorization") ?? "";
|
||||||
|
const apiKey = authHeader.replace("Bearer ", "");
|
||||||
|
const result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey);
|
||||||
|
return { req, res: jsonResponse(result, result.success ? 200 : 403) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认:临时上传
|
||||||
|
const result = await handleTempUpload({ data: file.data, filename: file.filename });
|
||||||
|
return { req, res: jsonResponse(result, result.success ? 200 : 400) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api?font=...&text=... — 字体裁剪 */
|
||||||
|
async function handleFontSubset(req: Request, res: Response) {
|
||||||
const url = new URL(req.url, "https://webfont.shenzilong.com");
|
const url = new URL(req.url, "https://webfont.shenzilong.com");
|
||||||
if (!url.pathname.startsWith("/api")) return next(req, res);
|
|
||||||
const params = new URLSearchParams(url.search);
|
const params = new URLSearchParams(url.search);
|
||||||
const font = params.get("font") || "";
|
const font = params.get("font") || "";
|
||||||
const text = params.get("text") || "";
|
const text = params.get("text") || "";
|
||||||
if (text.length === 0) {
|
if (text.length === 0) {
|
||||||
return { req, res };
|
return { req, res };
|
||||||
}
|
}
|
||||||
const path = `font/${font}`;
|
|
||||||
const fontType = path.split(".").pop() as "ttf";
|
const fontPath = await findFontPath(font);
|
||||||
let oldFontBuffer: ArrayBuffer;
|
if (!fontPath) {
|
||||||
try {
|
|
||||||
oldFontBuffer = new Uint8Array(await readFile(path)).buffer;
|
|
||||||
} catch {
|
|
||||||
return {
|
return {
|
||||||
req,
|
req,
|
||||||
res: new Response(`Font not found: ${font}`, {
|
res: new Response(`Font not found: ${font}`, {
|
||||||
@ -122,6 +256,20 @@ const fontApiMiddleware: cMiddleware = async (req, res, next) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fontType = fontPath.split(".").pop() as "ttf";
|
||||||
|
let oldFontBuffer: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
oldFontBuffer = new Uint8Array(await readFile(fontPath)).buffer;
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
req,
|
||||||
|
res: new Response(`Font read error: ${font}`, {
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const outType = "ttf";
|
const outType = "ttf";
|
||||||
const newFont = await fontSubset(oldFontBuffer, text, {
|
const newFont = await fontSubset(oldFontBuffer, text, {
|
||||||
outType: outType,
|
outType: outType,
|
||||||
@ -137,12 +285,59 @@ const fontApiMiddleware: cMiddleware = async (req, res, next) => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一的 API 路由中间件 */
|
||||||
|
const fontApiMiddleware: cMiddleware = async (req, res, next) => {
|
||||||
|
const url = new URL(req.url, "https://webfont.shenzilong.com");
|
||||||
|
if (!url.pathname.startsWith("/api")) return next(req, res);
|
||||||
|
|
||||||
|
if (url.pathname === "/api/fonts" && req.method === "GET") {
|
||||||
|
return handleListFonts(req, res);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/config" && req.method === "GET") {
|
||||||
|
return handleGetConfig(req, res);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/upload" && req.method === "POST") {
|
||||||
|
return handleUpload(req, res);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api" && req.method === "GET") {
|
||||||
|
return handleFontSubset(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(req, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = new SimpleHttpServer({ port: 8087 });
|
/** 上传文件大小限制 50MB */
|
||||||
server.use(
|
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
const uploadSizeMiddleware: cMiddleware = async (req, res, next) => {
|
||||||
|
if (req.method === "POST" && new URL(req.url).pathname === "/api/upload") {
|
||||||
|
const contentLength = parseInt(req.headers.get("Content-Length") ?? "0", 10);
|
||||||
|
if (contentLength > MAX_UPLOAD_SIZE) {
|
||||||
|
return {
|
||||||
|
req,
|
||||||
|
res: jsonResponse({ success: false, error: "文件过大,最大 50MB" }, 413),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next(req, res);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await runtimeReady;
|
||||||
|
await ensureDirectories();
|
||||||
|
|
||||||
|
const server = new SimpleHttpServer({ port: 8087 });
|
||||||
|
server.use(
|
||||||
logMiddleware,
|
logMiddleware,
|
||||||
corsMiddleware,
|
corsMiddleware,
|
||||||
|
uploadSizeMiddleware,
|
||||||
fontApiMiddleware,
|
fontApiMiddleware,
|
||||||
staticFileMiddleware,
|
staticFileMiddleware,
|
||||||
);
|
);
|
||||||
|
console.log("[config] temp upload:", enableTempUpload);
|
||||||
|
console.log("[config] admin upload:", !!adminApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|||||||
16
backend/config.ts
Normal file
16
backend/config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 从环境变量读取服务配置,启动时一次性加载
|
||||||
|
*/
|
||||||
|
const env = globalThis.process?.env ?? {};
|
||||||
|
|
||||||
|
/** 临时上传开关 */
|
||||||
|
export const enableTempUpload = env.ENABLE_TEMP_UPLOAD === "true";
|
||||||
|
|
||||||
|
/** 管理员 API Key,为空则管理员上传不可用 */
|
||||||
|
export const adminApiKey: string = env.ADMIN_API_KEY ?? "";
|
||||||
|
|
||||||
|
/** 临时上传目录最大文件数 */
|
||||||
|
export const tempMaxFiles = 10;
|
||||||
|
|
||||||
|
/** 字体搜索目录(按优先级排序) */
|
||||||
|
export const fontDirs = ["font", "font/temp", "font/admin"] as const;
|
||||||
@ -5,9 +5,31 @@ export let stat: (path: string) => Promise<{
|
|||||||
|
|
||||||
export let readFile: (path: string) => Promise<Uint8Array>;
|
export let readFile: (path: string) => Promise<Uint8Array>;
|
||||||
|
|
||||||
export const implInterface = (options: { stat: typeof stat; readFile: typeof readFile }) => {
|
export let writeFile: (path: string, data: Uint8Array) => Promise<void>;
|
||||||
|
|
||||||
|
export let readdir: (path: string) => Promise<{
|
||||||
|
isFile: () => boolean;
|
||||||
|
name: string;
|
||||||
|
}[]>;
|
||||||
|
|
||||||
|
export let mkdir: (path: string) => Promise<void>;
|
||||||
|
|
||||||
|
export let unlink: (path: string) => Promise<void>;
|
||||||
|
|
||||||
|
export const implInterface = (options: {
|
||||||
|
stat: typeof stat;
|
||||||
|
readFile: typeof readFile;
|
||||||
|
writeFile: typeof writeFile;
|
||||||
|
readdir: typeof readdir;
|
||||||
|
mkdir: typeof mkdir;
|
||||||
|
unlink: typeof unlink;
|
||||||
|
}) => {
|
||||||
stat = options.stat;
|
stat = options.stat;
|
||||||
readFile = options.readFile;
|
readFile = options.readFile;
|
||||||
|
writeFile = options.writeFile;
|
||||||
|
readdir = options.readdir;
|
||||||
|
mkdir = options.mkdir;
|
||||||
|
unlink = options.unlink;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function path_join(...paths: string[]) {
|
export function path_join(...paths: string[]) {
|
||||||
|
|||||||
89
backend/multipart.ts
Normal file
89
backend/multipart.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 轻量 multipart/form-data 解析器,不依赖外部库
|
||||||
|
*/
|
||||||
|
export interface MultipartFile {
|
||||||
|
/** 表单字段名 */
|
||||||
|
name: string;
|
||||||
|
/** 原始文件名 */
|
||||||
|
filename: string;
|
||||||
|
/** 文件二进制数据 */
|
||||||
|
data: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMultipart(contentType: string, body: ArrayBuffer): MultipartFile {
|
||||||
|
const boundaryMatch = contentType.match(/boundary=([^\s;]+)/);
|
||||||
|
if (!boundaryMatch) throw new Error("No boundary found");
|
||||||
|
const boundary = boundaryMatch[1].replace(/^"(.*)"$/, "$1");
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
const bodyBytes = new Uint8Array(body);
|
||||||
|
const delimiter = encoder.encode("\r\n--" + boundary);
|
||||||
|
const endDelimiter = encoder.encode("--" + boundary + "--");
|
||||||
|
|
||||||
|
/** 在字节数组中查找子串位置 */
|
||||||
|
function findBytes(haystack: Uint8Array, needle: Uint8Array, offset: number): number {
|
||||||
|
for (let i = offset; i <= haystack.length - needle.length; i++) {
|
||||||
|
let match = true;
|
||||||
|
for (let j = 0; j < needle.length; j++) {
|
||||||
|
if (haystack[i + j] !== needle[j]) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: MultipartFile[] = [];
|
||||||
|
|
||||||
|
// 跳过起始边界 "--boundary\r\n"
|
||||||
|
const startBoundary = encoder.encode("--" + boundary + "\r\n");
|
||||||
|
let pos = 0;
|
||||||
|
const sbPos = findBytes(bodyBytes, startBoundary, pos);
|
||||||
|
if (sbPos === -1) throw new Error("Invalid multipart: no start boundary");
|
||||||
|
pos = sbPos + startBoundary.length;
|
||||||
|
|
||||||
|
while (pos < bodyBytes.length) {
|
||||||
|
// 检查是否到达结束边界
|
||||||
|
if (findBytes(bodyBytes, endDelimiter, pos - 2) !== -1) break;
|
||||||
|
|
||||||
|
// 查找 headers 和 body 的分界 "\r\n\r\n"
|
||||||
|
const headerEnd = findBytes(bodyBytes, encoder.encode("\r\n\r\n"), pos);
|
||||||
|
if (headerEnd === -1) break;
|
||||||
|
|
||||||
|
const headerText = decoder.decode(bodyBytes.slice(pos, headerEnd));
|
||||||
|
const bodyStart = headerEnd + 4;
|
||||||
|
|
||||||
|
// 查找下一个 boundary
|
||||||
|
const nextBoundary = findBytes(bodyBytes, delimiter, bodyStart);
|
||||||
|
if (nextBoundary === -1) break;
|
||||||
|
|
||||||
|
// part body 去掉末尾的 "\r\n"
|
||||||
|
const partBody = bodyBytes.slice(bodyStart, nextBoundary);
|
||||||
|
const actualBody = partBody.length >= 2 && partBody[partBody.length - 1] === 10 && partBody[partBody.length - 2] === 13
|
||||||
|
? partBody.slice(0, partBody.length - 2)
|
||||||
|
: partBody;
|
||||||
|
|
||||||
|
// 从 Content-Disposition 中解析字段名和文件名
|
||||||
|
const nameMatch = headerText.match(/name="([^"]*)"/);
|
||||||
|
const filenameMatch = headerText.match(/filename="([^"]*)"/);
|
||||||
|
|
||||||
|
if (nameMatch) {
|
||||||
|
files.push({
|
||||||
|
name: nameMatch[1],
|
||||||
|
filename: filenameMatch?.[1] ?? "",
|
||||||
|
data: actualBody,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = nextBoundary + delimiter.length;
|
||||||
|
// 跳过 boundary 后的 "\r\n"
|
||||||
|
if (pos < bodyBytes.length && bodyBytes[pos] === 13 && bodyBytes[pos + 1] === 10) {
|
||||||
|
pos += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...files[0], files } as MultipartFile & { files: MultipartFile[] };
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { implInterface } from "../interface";
|
import { implInterface } from "../interface";
|
||||||
import { stat, readFile } from "fs/promises";
|
import { stat, readFile, writeFile, readdir, mkdir, unlink } from "fs/promises";
|
||||||
implInterface({
|
implInterface({
|
||||||
async stat(path) {
|
async stat(path) {
|
||||||
const r = await stat(path);
|
const r = await stat(path);
|
||||||
@ -8,4 +8,20 @@ implInterface({
|
|||||||
readFile(path) {
|
readFile(path) {
|
||||||
return readFile(path);
|
return readFile(path);
|
||||||
},
|
},
|
||||||
|
writeFile(path, data) {
|
||||||
|
return writeFile(path, data);
|
||||||
|
},
|
||||||
|
async readdir(path) {
|
||||||
|
const entries = await readdir(path, { withFileTypes: true });
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
isFile: () => entry.isFile(),
|
||||||
|
name: entry.name,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async mkdir(path) {
|
||||||
|
await mkdir(path, { recursive: true });
|
||||||
|
},
|
||||||
|
unlink(path) {
|
||||||
|
return unlink(path);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -99,7 +99,6 @@ async function connectionHandle(
|
|||||||
// node 运行时
|
// node 运行时
|
||||||
// 释放写入器的锁定
|
// 释放写入器的锁定
|
||||||
resWriter.releaseLock();
|
resWriter.releaseLock();
|
||||||
console.log("[connection.writable.locked]", connection.writable.locked);
|
|
||||||
// https://github.com/saghul/txiki.js/issues/646
|
// https://github.com/saghul/txiki.js/issues/646
|
||||||
await res.body?.pipeTo(connection.writable);
|
await res.body?.pipeTo(connection.writable);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -2,13 +2,36 @@ import { implInterface } from "../interface";
|
|||||||
|
|
||||||
implInterface({
|
implInterface({
|
||||||
async stat(path) {
|
async stat(path) {
|
||||||
const r = await global.tjs.stat(path);
|
const r = await tjs.stat(path);
|
||||||
return {
|
return {
|
||||||
isFile: () => r.isFile,
|
isFile: () => r.isFile,
|
||||||
size: r.size,
|
size: r.size,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
readFile(path) {
|
readFile(path) {
|
||||||
return global.tjs.readFile(path);
|
return tjs.readFile(path);
|
||||||
|
},
|
||||||
|
writeFile(path, data) {
|
||||||
|
return tjs.writeFile(path, data);
|
||||||
|
},
|
||||||
|
async readdir(path) {
|
||||||
|
const entries: { isFile: () => boolean; name: string }[] = [];
|
||||||
|
const dir = await tjs.readDir(path);
|
||||||
|
for await (const entry of dir) {
|
||||||
|
entries.push({
|
||||||
|
isFile: () => entry.isFile,
|
||||||
|
name: entry.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await dir.close();
|
||||||
|
return entries;
|
||||||
|
},
|
||||||
|
/** TJS 没有 mkdir,通过写入占位文件来确保目录存在 */
|
||||||
|
async mkdir(path) {
|
||||||
|
const placeholder = path + "/.keep";
|
||||||
|
await tjs.writeFile(placeholder, new Uint8Array(0));
|
||||||
|
},
|
||||||
|
unlink(path) {
|
||||||
|
return tjs.remove(path);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
98
backend/upload.ts
Normal file
98
backend/upload.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { writeFile, unlink, readdir, path_join } from "./interface";
|
||||||
|
import { enableTempUpload, adminApiKey, tempMaxFiles } from "./config";
|
||||||
|
|
||||||
|
/** 允许的字体文件扩展名 */
|
||||||
|
const ALLOWED_EXTENSIONS = [".ttf", ".otf", ".woff", ".woff2"];
|
||||||
|
|
||||||
|
function isAllowedFontFile(filename: string): boolean {
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理文件名,移除路径分隔符和危险字符 */
|
||||||
|
function sanitizeFilename(filename: string): string {
|
||||||
|
return filename.replace(/[/\\]/g, "").replace(/[\x00-\x1f]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成带时间戳前缀的文件名,用于 FIFO 排序 */
|
||||||
|
function generateTempFilename(originalName: string): string {
|
||||||
|
const ts = Date.now().toString(36);
|
||||||
|
const ext = originalName.includes(".") ? "." + originalName.split(".").pop() : ".ttf";
|
||||||
|
const base = originalName.replace(/\.[^.]+$/, "");
|
||||||
|
return `${ts}_${base}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取临时目录中的字体文件列表 */
|
||||||
|
async function getTempFiles(): Promise<string[]> {
|
||||||
|
const entries = await readdir("font/temp");
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.isFile() && isAllowedFontFile(e.name))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确保目录存在,不存在则创建 */
|
||||||
|
async function ensureDir(dir: string) {
|
||||||
|
const { stat, mkdir } = await import("./interface");
|
||||||
|
try {
|
||||||
|
await stat(dir);
|
||||||
|
} catch {
|
||||||
|
await mkdir(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleTempUpload(fileData: { data: Uint8Array; filename: string }): Promise<UploadResult> {
|
||||||
|
if (!enableTempUpload) {
|
||||||
|
return { success: false, error: "临时上传功能未启用" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedFontFile(fileData.filename)) {
|
||||||
|
return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureDir("font/temp");
|
||||||
|
|
||||||
|
const existingFiles = await getTempFiles();
|
||||||
|
|
||||||
|
// FIFO: 超出上限时删除最早的文件
|
||||||
|
if (existingFiles.length >= tempMaxFiles) {
|
||||||
|
const toDelete = existingFiles[0];
|
||||||
|
try {
|
||||||
|
await unlink(path_join("font/temp", toDelete));
|
||||||
|
} catch {
|
||||||
|
// 删除失败不影响上传
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = generateTempFilename(sanitizeFilename(fileData.filename));
|
||||||
|
await writeFile(path_join("font/temp", filename), fileData.data);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAdminUpload(
|
||||||
|
fileData: { data: Uint8Array; filename: string },
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<UploadResult> {
|
||||||
|
if (!adminApiKey) {
|
||||||
|
return { success: false, error: "管理员上传功能未启用" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey !== adminApiKey) {
|
||||||
|
return { success: false, error: "API Key 无效" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedFontFile(fileData.filename)) {
|
||||||
|
return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureDir("font/admin");
|
||||||
|
|
||||||
|
const filename = sanitizeFilename(fileData.filename);
|
||||||
|
await writeFile(path_join("font/admin", filename), fileData.data);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@ -1,10 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<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="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + Solid + TS</title>
|
<title>Web Font</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ process.on("SIGTERM", () => {
|
|||||||
|
|
||||||
console.log("Starting frontend and backend dev servers...\n");
|
console.log("Starting frontend and backend dev servers...\n");
|
||||||
|
|
||||||
const backend = spawn("pnpx", ["tsx", "backend/app.ts"], {
|
const backend = spawn("pnpx", ["tsx", "watch", "backend/app.ts"], {
|
||||||
cwd: ROOT_DIR,
|
cwd: ROOT_DIR,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: true,
|
shell: true,
|
||||||
|
|||||||
368
src/App.tsx
368
src/App.tsx
@ -1,52 +1,366 @@
|
|||||||
import { createMemo, createSignal, type Accessor } from "solid-js";
|
import { createMemo, createSignal, onMount, Show, For, type Accessor } from "solid-js";
|
||||||
|
import { fetchFonts, fetchConfig, uploadFont, type FontInfo, type ServerConfig } from "./api";
|
||||||
|
|
||||||
|
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": "14px",
|
||||||
|
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%",
|
||||||
|
height: "72px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
"font-size": "18px",
|
||||||
|
border: "1px solid #d9d9d9",
|
||||||
|
"border-radius": "6px",
|
||||||
|
resize: "vertical",
|
||||||
|
"box-sizing": "border-box",
|
||||||
|
outline: "none",
|
||||||
|
color: "#e74c3c",
|
||||||
|
} 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() {
|
function App() {
|
||||||
const [text, set_text] = createSignal("天地无极,乾坤借法");
|
const [text, set_text] = createSignal("天地无极,乾坤借法");
|
||||||
|
const [fonts, set_fonts] = createSignal<FontInfo[]>([]);
|
||||||
|
const [selectedFont, set_selectedFont] = createSignal("");
|
||||||
|
const [serverConfig, set_serverConfig] = createSignal<ServerConfig>({
|
||||||
|
enableTempUpload: false,
|
||||||
|
adminUploadEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
// const serverPath = import.meta.env.DEV ? "/" : "https://webfont.shenzilong.cn/";
|
onMount(async () => {
|
||||||
const serverPath = "/";
|
const [fontList, config] = await Promise.all([fetchFonts().catch(() => []), fetchConfig().catch(() => ({ enableTempUpload: false, adminUploadEnabled: false }))]);
|
||||||
const style = createMemo(
|
set_fonts(fontList);
|
||||||
() => `
|
set_serverConfig(config);
|
||||||
@font-face {
|
if (fontList.length > 0) {
|
||||||
font-family: "CustomFont";
|
set_selectedFont(fontList[0].name);
|
||||||
src: url("${serverPath}api?font=令东齐伋复刻体.ttf&text=${text()}") format("truetype");
|
|
||||||
}
|
}
|
||||||
input {
|
});
|
||||||
|
|
||||||
|
const cssStyle = createMemo(() => {
|
||||||
|
const font = selectedFont();
|
||||||
|
if (!font) return "";
|
||||||
|
return `@font-face {
|
||||||
|
font-family: "CustomFont";
|
||||||
|
src: url("/api?font=${font}&text=${encodeURIComponent(text())}") format("truetype");
|
||||||
|
}
|
||||||
|
.custom-font {
|
||||||
color: red;
|
color: red;
|
||||||
font-family: "CustomFont";
|
font-family: "CustomFont";
|
||||||
|
}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const throttledCss = useThrottledMemo(() => cssStyle(), 1000, text);
|
||||||
|
|
||||||
|
async function refreshFonts() {
|
||||||
|
const fontList = await fetchFonts();
|
||||||
|
set_fonts(fontList);
|
||||||
|
if (fontList.length > 0 && !selectedFont()) {
|
||||||
|
set_selectedFont(fontList[0].name);
|
||||||
}
|
}
|
||||||
`,
|
}
|
||||||
);
|
|
||||||
const throttledSetMemo = useThrottledMemo(() => style(), 1000);
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={s.wrap}>
|
||||||
<h1>
|
<div style={{ display: "flex", "align-items": "center", "justify-content": "space-between" }}>
|
||||||
<a href="https://github.com/2234839/web-font">web font</a>{" "}
|
<h1 style={s.h1}>Web Font</h1>
|
||||||
</h1>
|
<a
|
||||||
<div>
|
href="https://github.com/2234839/web-font"
|
||||||
<div>在下面输入文本查看效果</div>
|
target="_blank"
|
||||||
<input
|
rel="noopener noreferrer"
|
||||||
style={{ "font-size": "46px", "margin-top": "3px" }}
|
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 style={s.desc}>输入文本,获取仅包含所需字符的子集字体 CSS</p>
|
||||||
|
|
||||||
|
<section style={s.section}>
|
||||||
|
<label style={s.label}>选择字体</label>
|
||||||
|
<select
|
||||||
|
style={s.select}
|
||||||
|
value={selectedFont()}
|
||||||
|
onChange={(e) => set_selectedFont(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">-- 请选择 --</option>
|
||||||
|
<For each={fonts()}>
|
||||||
|
{(f) => (
|
||||||
|
<option value={f.name}>
|
||||||
|
{f.name} ({f.dir})
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={s.section}>
|
||||||
|
<label style={s.label}>输入文本预览效果</label>
|
||||||
|
<textarea
|
||||||
|
style={{
|
||||||
|
...s.textarea,
|
||||||
|
"font-family": selectedFont() ? '"CustomFont", sans-serif' : "inherit",
|
||||||
|
}}
|
||||||
value={text()}
|
value={text()}
|
||||||
onInput={(e) => set_text(e.target.value)}
|
onInput={(e) => set_text(e.target.value)}
|
||||||
|
placeholder="在此输入文本..."
|
||||||
/>
|
/>
|
||||||
<div>{text()}</div>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<pre>{"<style>" + style() + "</style>"}</pre>
|
<Show when={selectedFont() && text()}>
|
||||||
<style>{throttledSetMemo()}</style>
|
<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 a = document.createElement("a");
|
||||||
|
a.href = `/api?font=${selectedFont()}&text=${encodeURIComponent(text())}`;
|
||||||
|
a.download = selectedFont().replace(/\.[^.]+$/, "") + "_subset.ttf";
|
||||||
|
a.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下载字体
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{ ...s.btn, padding: "3px 12px", "font-size": "12px" }}
|
||||||
|
onClick={() => navigator.clipboard.writeText(cssStyle())}
|
||||||
|
>
|
||||||
|
复制 CSS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre style={s.pre}>{cssStyle()}</pre>
|
||||||
|
</section>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<UploadSection config={serverConfig()} onUploaded={refreshFonts} />
|
||||||
|
<style>{throttledCss()}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
function UploadSection(props: { config: ServerConfig; onUploaded: () => void }) {
|
||||||
|
const [tempFile, set_tempFile] = createSignal<File | null>(null);
|
||||||
|
const [adminFile, set_adminFile] = createSignal<File | null>(null);
|
||||||
|
const [adminApiKey, set_adminApiKey] = createSignal("");
|
||||||
|
const [uploading, set_uploading] = createSignal(false);
|
||||||
|
const [msg, set_msg] = createSignal<{ ok: boolean; text: string } | null>(null);
|
||||||
|
|
||||||
function useThrottledMemo<T>(fn: () => T, delay: number): Accessor<T> {
|
function showMsg(ok: boolean, text: string) {
|
||||||
|
set_msg({ ok, text });
|
||||||
|
setTimeout(() => set_msg(null), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTempUpload() {
|
||||||
|
const file = tempFile();
|
||||||
|
if (!file) return;
|
||||||
|
set_uploading(true);
|
||||||
|
const result = await uploadFont(file, "temp");
|
||||||
|
set_uploading(false);
|
||||||
|
if (result.success) {
|
||||||
|
showMsg(true, "上传成功");
|
||||||
|
set_tempFile(null);
|
||||||
|
props.onUploaded();
|
||||||
|
} else {
|
||||||
|
showMsg(false, result.error ?? "上传失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdminUpload() {
|
||||||
|
const file = adminFile();
|
||||||
|
if (!file) return;
|
||||||
|
set_uploading(true);
|
||||||
|
const result = await uploadFont(file, "admin", adminApiKey());
|
||||||
|
set_uploading(false);
|
||||||
|
if (result.success) {
|
||||||
|
showMsg(true, "上传成功");
|
||||||
|
set_adminFile(null);
|
||||||
|
props.onUploaded();
|
||||||
|
} else {
|
||||||
|
showMsg(false, result.error ?? "上传失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canUpload = () => props.config.enableTempUpload || props.config.adminUploadEnabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={canUpload()}>
|
||||||
|
<section style={s.section}>
|
||||||
|
<label style={{ ...s.label, "font-size": "14px", "font-weight": 500, "margin-bottom": "12px" }}>上传字体</label>
|
||||||
|
|
||||||
|
<Show when={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}>
|
||||||
|
<div style={s.card}>
|
||||||
|
<div style={{ "font-size": "14px", "font-weight": 500, "margin-bottom": "4px" }}>临时上传</div>
|
||||||
|
<div style={{ "font-size": "12px", color: "#999", "margin-bottom": "12px" }}>
|
||||||
|
最多保留 10 个文件,超出后自动删除最早上传的
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", "gap": "8px", "align-items": "center" }}>
|
||||||
|
<label style={{ ...s.btn, display: "inline-flex", "align-items": "center", cursor: "pointer" }}>
|
||||||
|
选择文件
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".ttf,.otf,.woff,.woff2"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => set_tempFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span style={{ "font-size": "13px", color: "#666" }}>
|
||||||
|
{tempFile()?.name ?? "未选择文件"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
style={{ ...s.btn, opacity: tempFile() && !uploading() ? 1 : 0.5, cursor: tempFile() && !uploading() ? "pointer" : "not-allowed" }}
|
||||||
|
disabled={!tempFile() || uploading()}
|
||||||
|
onClick={handleTempUpload}
|
||||||
|
>
|
||||||
|
{uploading() ? "..." : "上传"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.config.adminUploadEnabled}>
|
||||||
|
<div style={s.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="password"
|
||||||
|
style={{ ...s.input, width: "100%", "margin-bottom": "10px" }}
|
||||||
|
value={adminApiKey()}
|
||||||
|
onInput={(e) => set_adminApiKey(e.target.value)}
|
||||||
|
placeholder="API Key"
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", "gap": "8px", "align-items": "center" }}>
|
||||||
|
<label style={{ ...s.btn, display: "inline-flex", "align-items": "center", cursor: "pointer" }}>
|
||||||
|
选择文件
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".ttf,.otf,.woff,.woff2"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => set_adminFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span style={{ "font-size": "13px", color: "#666" }}>
|
||||||
|
{adminFile()?.name ?? "未选择文件"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
style={{ ...s.btn, opacity: adminFile() && adminApiKey() && !uploading() ? 1 : 0.5, cursor: adminFile() && adminApiKey() && !uploading() ? "pointer" : "not-allowed" }}
|
||||||
|
disabled={!adminFile() || !adminApiKey() || uploading()}
|
||||||
|
onClick={handleAdminUpload}
|
||||||
|
>
|
||||||
|
{uploading() ? "..." : "上传"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</section>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useThrottledMemo<T>(fn: () => T, delay: number, trigger?: Accessor<unknown>): Accessor<T> {
|
||||||
const [throttledValue, setThrottledValue] = createSignal<T>(fn());
|
const [throttledValue, setThrottledValue] = createSignal<T>(fn());
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let isFirst = true;
|
||||||
|
|
||||||
createMemo(() => {
|
createMemo(() => {
|
||||||
|
if (trigger) trigger();
|
||||||
const value = fn();
|
const value = fn();
|
||||||
|
if (isFirst) {
|
||||||
|
isFirst = false;
|
||||||
|
// @ts-expect-error
|
||||||
|
setThrottledValue(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (timeoutId === null) {
|
if (timeoutId === null) {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
setThrottledValue(value);
|
setThrottledValue(value);
|
||||||
@ -60,3 +374,5 @@ function useThrottledMemo<T>(fn: () => T, delay: number): Accessor<T> {
|
|||||||
|
|
||||||
return throttledValue;
|
return throttledValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
45
src/api.ts
Normal file
45
src/api.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
export interface FontInfo {
|
||||||
|
name: string;
|
||||||
|
dir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerConfig {
|
||||||
|
enableTempUpload: boolean;
|
||||||
|
adminUploadEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFonts(): Promise<FontInfo[]> {
|
||||||
|
const res = await fetch("/api/fonts");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConfig(): Promise<ServerConfig> {
|
||||||
|
const res = await fetch("/api/config");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFont(
|
||||||
|
file: File,
|
||||||
|
mode: "temp" | "admin",
|
||||||
|
apiKey?: string,
|
||||||
|
): Promise<UploadResult> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("font", file);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (apiKey) {
|
||||||
|
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/upload?mode=${mode}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user