diff --git a/napcat.webui/src/components/input/file_input.tsx b/napcat.webui/src/components/input/file_input.tsx new file mode 100644 index 00000000..d1495ae3 --- /dev/null +++ b/napcat.webui/src/components/input/file_input.tsx @@ -0,0 +1,69 @@ +import { Button } from '@heroui/button' +import { Input } from '@heroui/input' +import { useRef, useState } from 'react' + +export interface FileInputProps { + onChange: (file: File) => Promise | void + onDelete?: () => Promise | void + label?: string + accept?: string +} + +const FileInput: React.FC = ({ + onChange, + onDelete, + label, + accept +}) => { + const inputRef = useRef(null) + const [isLoading, setIsLoading] = useState(false) + return ( +
+
+ { + try { + setIsLoading(true) + const file = e.target.files?.[0] + if (file) { + await onChange(file) + } + } catch (error) { + console.error(error) + } finally { + setIsLoading(false) + if (inputRef.current) inputRef.current.value = '' + } + }} + /> +
+ +
+ ) +} + +export default FileInput diff --git a/napcat.webui/src/controllers/file_manager.ts b/napcat.webui/src/controllers/file_manager.ts index 69637815..ad3d1d33 100644 --- a/napcat.webui/src/controllers/file_manager.ts +++ b/napcat.webui/src/controllers/file_manager.ts @@ -196,4 +196,26 @@ export default class FileManager { ) return data.data } + + public static async uploadWebUIFont(file: File) { + const formData = new FormData() + formData.append('file', file) + const { data } = await serverRequest.post>( + '/File/font/upload/webui', + formData, + { + headers: { + 'Content-Type': 'multipart/form-data' + } + } + ) + return data.data + } + + public static async deleteWebUIFont() { + const { data } = await serverRequest.post>( + '/File/font/delete/webui' + ) + return data.data + } } diff --git a/napcat.webui/src/pages/dashboard/config/webui.tsx b/napcat.webui/src/pages/dashboard/config/webui.tsx index c4459374..c57760cc 100644 --- a/napcat.webui/src/pages/dashboard/config/webui.tsx +++ b/napcat.webui/src/pages/dashboard/config/webui.tsx @@ -7,11 +7,13 @@ import toast from 'react-hot-toast' import key from '@/const/key' import SaveButtons from '@/components/button/save_buttons' +import FileInput from '@/components/input/file_input' import ImageInput from '@/components/input/image_input' import useMusic from '@/hooks/use-music' import { siteConfig } from '@/config/site' +import FileManager from '@/controllers/file_manager' const WebUIConfigCard = () => { const { @@ -59,17 +61,47 @@ const WebUIConfigCard = () => { return ( <> WebUI配置 - NapCat WebUI - ( - +
WebUI字体
+
+ 此项不需要手动保存,上传成功后需清空网页缓存并刷新 + { + try { + await FileManager.uploadWebUIFont(file) + toast.success('上传成功') + setTimeout(() => { + window.location.reload() + }, 1000) + } catch (error) { + toast.error('上传失败: ' + (error as Error).message) + } + }} + onDelete={async () => { + try { + await FileManager.deleteWebUIFont() + } catch (error) { + toast.error('删除失败: ' + (error as Error).message) + } + }} /> - )} - /> +
+ +
+
WebUI音乐播放器
+ ( + + )} + /> +
背景图
{ + const isFontExist = await WebUiConfigWrapper.CheckWebUIFontExist(); + console.log(isFontExist, 'isFontExist'); + if (isFontExist) { + res.sendFile(WebUiConfigWrapper.GetWebUIFontPath()); + } else { + next(); + } + }); + // ------------中间件结束------------ // ------------挂载路由------------ - // 挂载静态路由(前端),路径为 [/前缀]/webui + // 挂载静态路由(前端),路径为 /webui app.use('/webui', express.static(pathWrapper.staticPath)); // 初始化WebSocket服务器 server.on('upgrade', (request, socket, head) => { @@ -64,7 +77,19 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp app.all('/', (_req, res) => { sendSuccess(res, null, 'NapCat WebAPI is now running!'); }); - // ------------路由挂载结束------------ + + // 错误处理中间件,捕获multer的错误 + app.use((err: Error, _: express.Request, res: express.Response, next: express.NextFunction) => { + if (err instanceof multer.MulterError) { + return sendError(res, err.message, true); + } + next(err); + }); + + // 全局错误处理中间件(非multer错误) + app.use((_: Error, __: express.Request, res: express.Response, ___: express.NextFunction) => { + sendError(res, 'An unknown error occurred.', true); + }); // ------------启动服务------------ server.listen(config.port, config.host, async () => { diff --git a/src/webui/src/api/File.ts b/src/webui/src/api/File.ts index 68c60184..1bf5cf8c 100644 --- a/src/webui/src/api/File.ts +++ b/src/webui/src/api/File.ts @@ -1,4 +1,4 @@ -import type { RequestHandler, Request } from 'express'; +import type { RequestHandler } from 'express'; import { sendError, sendSuccess } from '../utils/response'; import fsProm from 'fs/promises'; import fs from 'fs'; @@ -7,7 +7,9 @@ import os from 'os'; import compressing from 'compressing'; import { PassThrough } from 'stream'; import multer from 'multer'; -import { randomUUID } from 'crypto'; +import { WebUiConfigWrapper } from '../helper/config'; +import webUIFontUploader from '../uploader/webui_font'; +import diskUploader from '../uploader/disk'; const isWindows = os.platform() === 'win32'; @@ -268,11 +270,11 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => { // 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存) export const DownloadHandler: RequestHandler = async (req, res) => { try { - const filePath = normalizePath( req.query[ 'path' ] as string ); + const filePath = normalizePath(req.query['path'] as string); if (!filePath) { - return sendError( res, '参数错误' ); + return sendError(res, '参数错误'); } - + const stat = await fsProm.stat(filePath); res.setHeader('Content-Type', 'application/octet-stream'); @@ -327,74 +329,71 @@ export const BatchDownloadHandler: RequestHandler = async (req, res) => { } }; -// 修改:使用 Buffer 转码文件名,解决文件上传时乱码问题 -const decodeFileName = (fileName: string): string => { +// 修改上传处理方法 +export const UploadHandler: RequestHandler = async (req, res) => { try { - return Buffer.from(fileName, 'binary').toString('utf8'); - } catch { - return fileName; + await diskUploader(req, res); + return sendSuccess(res, true, '文件上传成功', true); + } catch (error) { + let errorMessage = '文件上传失败'; + + if (error instanceof multer.MulterError) { + switch (error.code) { + case 'LIMIT_FILE_SIZE': + errorMessage = '文件大小超过限制(40MB)'; + break; + case 'LIMIT_UNEXPECTED_FILE': + errorMessage = '无效的文件上传字段'; + break; + default: + errorMessage = `上传错误: ${error.message}`; + } + } else if (error instanceof Error) { + errorMessage = error.message; + } + return sendError(res, errorMessage, true); } }; -// 修改上传处理方法 -export const UploadHandler: RequestHandler = (req, res) => { - const uploadPath = (req.query['path'] || '') as string; +// 上传WebUI字体文件处理方法 +export const UploadWebUIFontHandler: RequestHandler = async (req, res) => { + try { + await webUIFontUploader(req, res); + return sendSuccess(res, true, '字体文件上传成功', true); + } catch (error) { + let errorMessage = '字体文件上传失败'; - const storage = multer.diskStorage({ - destination: ( - _: Request, - file: Express.Multer.File, - cb: (error: Error | null, destination: string) => void - ) => { - try { - const decodedName = decodeFileName(file.originalname); - - if (!uploadPath) { - return cb(new Error('上传路径不能为空'), ''); - } - - if (isWindows && uploadPath === '\\') { - return cb(new Error('根目录不允许上传文件'), ''); - } - - // 处理文件夹上传的情况 - if (decodedName.includes('/') || decodedName.includes('\\')) { - const fullPath = path.join(uploadPath, path.dirname(decodedName)); - fs.mkdirSync(fullPath, { recursive: true }); - cb(null, fullPath); - } else { - cb(null, uploadPath); - } - } catch (error) { - cb(error as Error, ''); + if (error instanceof multer.MulterError) { + switch (error.code) { + case 'LIMIT_FILE_SIZE': + errorMessage = '字体文件大小超过限制(40MB)'; + break; + case 'LIMIT_UNEXPECTED_FILE': + errorMessage = '无效的文件上传字段'; + break; + default: + errorMessage = `上传错误: ${error.message}`; } - }, - filename: (_: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => { - try { - const decodedName = decodeFileName(file.originalname); - const fileName = path.basename(decodedName); - - // 检查文件是否存在 - const fullPath = path.join(uploadPath, decodedName); - if (fs.existsSync(fullPath)) { - const ext = path.extname(fileName); - const name = path.basename(fileName, ext); - cb(null, `${name}-${randomUUID()}${ext}`); - } else { - cb(null, fileName); - } - } catch (error) { - cb(error as Error, ''); - } - }, - }); - - const upload = multer({ storage }).array('files'); - - upload(req, res, (err: any) => { - if (err) { - return sendError(res, err.message || '文件上传失败'); + } else if (error instanceof Error) { + errorMessage = error.message; } - return sendSuccess(res, true); - }); + return sendError(res, errorMessage, true); + } +}; + +// 删除WebUI字体文件处理方法 +export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => { + try { + const fontPath = WebUiConfigWrapper.GetWebUIFontPath(); + const exists = await WebUiConfigWrapper.CheckWebUIFontExist(); + + if (!exists) { + return sendSuccess(res, true); + } + + await fsProm.unlink(fontPath); + return sendSuccess(res, true); + } catch (error) { + return sendError(res, '删除字体文件失败'); + } }; diff --git a/src/webui/src/helper/config.ts b/src/webui/src/helper/config.ts index 9f1f08b1..46e94589 100644 --- a/src/webui/src/helper/config.ts +++ b/src/webui/src/helper/config.ts @@ -203,4 +203,32 @@ export class WebUiConfigWrapper { } return ''; } + + // 获取字体文件夹内的字体列表 + public static async GetFontList(): Promise { + const fontsPath = resolve(webUiPathWrapper.configPath, './fonts'); + if ( + await fs + .access(fontsPath, constants.F_OK) + .then(() => true) + .catch(() => false) + ) { + return (await fs.readdir(fontsPath)).filter((file) => file.endsWith('.ttf')); + } + return []; + } + + // 判断字体是否存在(webui.woff) + public static async CheckWebUIFontExist(): Promise { + const fontsPath = resolve(webUiPathWrapper.configPath, './fonts'); + return await fs + .access(resolve(fontsPath, './webui.woff'), constants.F_OK) + .then(() => true) + .catch(() => false); + } + + // 获取webui字体文件路径 + public static GetWebUIFontPath(): string { + return resolve(webUiPathWrapper.configPath, './fonts/webui.woff'); + } } diff --git a/src/webui/src/router/File.ts b/src/webui/src/router/File.ts index d59d3652..358f228c 100644 --- a/src/webui/src/router/File.ts +++ b/src/webui/src/router/File.ts @@ -13,7 +13,9 @@ import { BatchMoveHandler, DownloadHandler, BatchDownloadHandler, // 新增下载处理方法 - UploadHandler, // 添加上传处理器 + UploadHandler, + UploadWebUIFontHandler, + DeleteWebUIFontHandler, // 添加上传处理器 } from '../api/File'; const router = Router(); @@ -37,5 +39,8 @@ router.post('/move', MoveHandler); router.post('/batchMove', BatchMoveHandler); router.post('/download', DownloadHandler); router.post('/batchDownload', BatchDownloadHandler); -router.post('/upload', UploadHandler); // 添加上传处理路由 +router.post('/upload', UploadHandler); + +router.post('/font/upload/webui', UploadWebUIFontHandler); +router.post('/font/delete/webui', DeleteWebUIFontHandler); export { router as FileRouter }; diff --git a/src/webui/src/uploader/disk.ts b/src/webui/src/uploader/disk.ts new file mode 100644 index 00000000..d877ce9e --- /dev/null +++ b/src/webui/src/uploader/disk.ts @@ -0,0 +1,85 @@ +import multer from 'multer'; +import { Request, Response } from 'express'; +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; +const isWindows = process.platform === 'win32'; + +// 修改:使用 Buffer 转码文件名,解决文件上传时乱码问题 +const decodeFileName = (fileName: string): string => { + try { + return Buffer.from(fileName, 'binary').toString('utf8'); + } catch { + return fileName; + } +}; + +export const createDiskStorage = (uploadPath: string) => { + return multer.diskStorage({ + destination: ( + _: Request, + file: Express.Multer.File, + cb: (error: Error | null, destination: string) => void + ) => { + try { + const decodedName = decodeFileName(file.originalname); + + if (!uploadPath) { + return cb(new Error('上传路径不能为空'), ''); + } + + if (isWindows && uploadPath === '\\') { + return cb(new Error('根目录不允许上传文件'), ''); + } + + // 处理文件夹上传的情况 + if (decodedName.includes('/') || decodedName.includes('\\')) { + const fullPath = path.join(uploadPath, path.dirname(decodedName)); + fs.mkdirSync(fullPath, { recursive: true }); + cb(null, fullPath); + } else { + cb(null, uploadPath); + } + } catch (error) { + cb(error as Error, ''); + } + }, + filename: (_: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => { + try { + const decodedName = decodeFileName(file.originalname); + const fileName = path.basename(decodedName); + + // 检查文件是否存在 + const fullPath = path.join(uploadPath, decodedName); + if (fs.existsSync(fullPath)) { + const ext = path.extname(fileName); + const name = path.basename(fileName, ext); + cb(null, `${name}-${randomUUID()}${ext}`); + } else { + cb(null, fileName); + } + } catch (error) { + cb(error as Error, ''); + } + }, + }); +}; + +export const createDiskUpload = (uploadPath: string) => { + const upload = multer({ storage: createDiskStorage(uploadPath) }).array('files'); + return upload; +}; + +const diskUploader = (req: Request, res: Response) => { + const uploadPath = (req.query['path'] || '') as string; + return new Promise((resolve, reject) => { + createDiskUpload(uploadPath)(req, res, (error) => { + if (error) { + // 错误处理 + return reject(error); + } + return resolve(true); + }); + }); +}; +export default diskUploader; diff --git a/src/webui/src/uploader/webui_font.ts b/src/webui/src/uploader/webui_font.ts new file mode 100644 index 00000000..35136580 --- /dev/null +++ b/src/webui/src/uploader/webui_font.ts @@ -0,0 +1,52 @@ +import multer from 'multer'; +import { WebUiConfigWrapper } from '../helper/config'; +import path from 'path'; +import fs from 'fs'; +import type { Request, Response } from 'express'; + +export const webUIFontStorage = multer.diskStorage({ + destination: (_, __, cb) => { + try { + const fontsPath = path.dirname(WebUiConfigWrapper.GetWebUIFontPath()); + // 确保字体目录存在 + fs.mkdirSync(fontsPath, { recursive: true }); + cb(null, fontsPath); + } catch (error) { + // 确保错误信息被正确传递 + cb(new Error(`创建字体目录失败:${(error as Error).message}`), ''); + } + }, + filename: (_, __, cb) => { + // 统一保存为webui.woff + cb(null, 'webui.woff'); + }, +}); + +export const webUIFontUpload = multer({ + storage: webUIFontStorage, + fileFilter: (_, file, cb) => { + // 再次验证文件类型 + if (!file.originalname.toLowerCase().endsWith('.woff')) { + cb(new Error('只支持WOFF格式的字体文件')); + return; + } + cb(null, true); + }, + limits: { + fileSize: 40 * 1024 * 1024, // 限制40MB + }, +}).single('file'); + +const webUIFontUploader = (req: Request, res: Response) => { + return new Promise((resolve, reject) => { + webUIFontUpload(req, res, (error) => { + if (error) { + // 错误处理 + // sendError(res, error.message, true); + return reject(error); + } + return resolve(true); + }); + }); +}; +export default webUIFontUploader; diff --git a/src/webui/src/utils/response.ts b/src/webui/src/utils/response.ts index eca6ee72..81417809 100644 --- a/src/webui/src/utils/response.ts +++ b/src/webui/src/utils/response.ts @@ -2,25 +2,46 @@ import type { Response } from 'express'; import { ResponseCode, HttpStatusCode } from '@webapi/const/status'; -export const sendResponse = (res: Response, data?: T, code: ResponseCode = 0, message = 'success') => { - res.status(HttpStatusCode.OK).json({ +export const sendResponse = ( + res: Response, + data?: T, + code: ResponseCode = 0, + message = 'success', + useSend: boolean = false +) => { + const result = { code, message, data, - }); + }; + if (useSend) { + res.status(HttpStatusCode.OK).send(JSON.stringify(result)); + return; + } + res.status(HttpStatusCode.OK).json(result); }; -export const sendError = (res: Response, message = 'error') => { - res.status(HttpStatusCode.OK).json({ +export const sendError = (res: Response, message = 'error', useSend: boolean = false) => { + const result = { code: ResponseCode.Error, message, - }); + }; + if (useSend) { + res.status(HttpStatusCode.OK).send(JSON.stringify(result)); + return; + } + res.status(HttpStatusCode.OK).json(result); }; -export const sendSuccess = (res: Response, data?: T, message = 'success') => { - res.status(HttpStatusCode.OK).json({ +export const sendSuccess = (res: Response, data?: T, message = 'success', useSend: boolean = false) => { + const result = { code: ResponseCode.Success, data, message, - }); + }; + if (useSend) { + res.status(HttpStatusCode.OK).send(JSON.stringify(result)); + return; + } + res.status(HttpStatusCode.OK).json(result); };