From 81b26cd6962cca2d992be14820708797fb714fb1 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Tue, 27 May 2025 15:32:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E7=BB=88=E7=AB=AFhtml=E8=83=8C=E6=99=AF=E7=9A=84?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 27 +- packages/backend/package.json | 1 + .../src/appearance/appearance.controller.ts | 163 ++++++ .../src/appearance/appearance.routes.ts | 18 + .../src/repositories/appearance.repository.ts | 40 +- .../src/services/appearance.service.ts | 471 ++++++++++++++++ .../backend/src/types/appearance.types.ts | 1 + .../src/components/StyleCustomizer.vue | 2 +- .../StyleCustomizerBackgroundTab.vue | 517 ++++++++++++++++-- packages/frontend/src/locales/en-US.json | 51 +- packages/frontend/src/locales/ja-JP.json | 52 +- packages/frontend/src/locales/zh-CN.json | 53 +- .../frontend/src/stores/appearance.store.ts | 173 +++++- .../frontend/src/types/appearance.types.ts | 1 + 14 files changed, 1503 insertions(+), 67 deletions(-) diff --git a/package-lock.json b/package-lock.json index d8a8488..992f842 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7197,6 +7197,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", @@ -8078,6 +8087,15 @@ "tree-kill": "cli.js" } }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -8471,6 +8489,12 @@ "untyped": "dist/cli.mjs" } }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "license": "(WTFPL OR MIT)" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9017,6 +9041,7 @@ "multer": ">=2.0.0", "nodemailer": "^6.10.1", "qrcode": "^1.5.4", + "sanitize-filename": "^1.6.3", "session-file-store": "^1.5.0", "socks": "^2.8.4", "speakeasy": "^2.0.0", @@ -9119,7 +9144,7 @@ }, "packages/frontend": { "name": "@nexus-terminal/frontend", - "version": "0.6.2", + "version": "0.7.5", "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index 299d922..a07711c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -33,6 +33,7 @@ "multer": ">=2.0.0", "nodemailer": "^6.10.1", "qrcode": "^1.5.4", + "sanitize-filename": "^1.6.3", "session-file-store": "^1.5.0", "socks": "^2.8.4", "speakeasy": "^2.0.0", diff --git a/packages/backend/src/appearance/appearance.controller.ts b/packages/backend/src/appearance/appearance.controller.ts index 58d4897..1833c2e 100644 --- a/packages/backend/src/appearance/appearance.controller.ts +++ b/packages/backend/src/appearance/appearance.controller.ts @@ -189,3 +189,166 @@ export const removeTerminalBackgroundController = async (req: Request, res: Resp res.status(500).json({ message: '移除终端背景失败', error: error.message }); } }; + +// --- HTML 预设主题控制器方法 --- + +// GET /api/v1/appearance/html-presets/local +export const listLocalHtmlPresetsController = async (req: Request, res: Response): Promise => { + try { + // 现在获取所有主题,包括预设和自定义,它们将带有 type 属性 + const allThemes = await appearanceService.listAllHtmlThemes(); + res.status(200).json(allThemes); // 直接返回带有 type 的列表 + } catch (error: any) { + res.status(500).json({ message: '获取 HTML 主题列表失败', error: error.message }); + } +}; + +// GET /api/v1/appearance/html-presets/local/:themeName +export const getLocalHtmlPresetContentController = async (req: Request, res: Response): Promise => { + try { + const themeName = req.params.themeName; + let content: string | null = null; + let found = false; + + // 1. 尝试作为用户自定义主题获取 + try { + content = await appearanceService.getUserCustomHtmlThemeContent(themeName); + found = true; + } catch (customError: any) { + if (!customError.message.includes('未找到')) { + // 如果不是 "未找到" 错误,则直接抛出 + throw customError; + } + // 如果是 "未找到",则继续尝试预设主题 + } + + // 2. 如果用户自定义主题未找到,尝试作为预设主题获取 + if (!found) { + try { + content = await appearanceService.getPresetHtmlThemeContent(themeName); + found = true; + } catch (presetError: any) { + if (!presetError.message.includes('未找到')) { + throw presetError; + } + // 如果预设也未找到,此时才真正是 404 + } + } + + if (found && content !== null) { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.status(200).send(content); + } else { + res.status(404).json({ message: `主题 '${themeName}' 未找到` }); + } + } catch (error: any) { + // 通用错误处理 + res.status(500).json({ message: `获取主题 '${req.params.themeName}' 内容失败`, error: error.message }); + } +}; + +// POST /api/v1/appearance/html-presets/local +export const createLocalHtmlPresetController = async (req: Request, res: Response): Promise => { + try { + const { name, content } = req.body; + if (!name || !content) { + res.status(400).json({ message: '主题名称和内容不能为空' }); + return; + } + // "本地创建" 现在总是创建用户自定义主题 + await appearanceService.createUserCustomHtmlTheme(name, content); + res.status(201).json({ message: '用户自定义 HTML 主题创建成功' }); + } catch (error: any) { + res.status(500).json({ message: '创建用户自定义 HTML 主题失败', error: error.message }); + } +}; + +// PUT /api/v1/appearance/html-presets/local/:themeName +export const updateLocalHtmlPresetController = async (req: Request, res: Response): Promise => { + try { + const themeName = req.params.themeName; + const { content } = req.body; + if (content === undefined) { + res.status(400).json({ message: '主题内容不能为空' }); + return; + } + // "本地更新" 现在总是更新用户自定义主题 + await appearanceService.updateUserCustomHtmlTheme(themeName, content); + res.status(200).json({ message: '用户自定义 HTML 主题更新成功' }); + } catch (error: any) { + if (error.message.includes('未找到')) { + res.status(404).json({ message: error.message }); + } else { + res.status(500).json({ message: '更新用户自定义 HTML 主题失败', error: error.message }); + } + } +}; + +// DELETE /api/v1/appearance/html-presets/local/:themeName +export const deleteLocalHtmlPresetController = async (req: Request, res: Response): Promise => { + try { + const themeName = req.params.themeName; + // "本地删除" 现在总是删除用户自定义主题 + await appearanceService.deleteUserCustomHtmlTheme(themeName); + res.status(200).json({ message: '用户自定义 HTML 主题删除成功' }); + } catch (error: any) { + if (error.message.includes('未找到')) { + res.status(404).json({ message: error.message }); + } else { + res.status(500).json({ message: '删除用户自定义 HTML 主题失败', error: error.message }); + } + } +}; + +// GET /api/v1/appearance/html-presets/remote/repository-url +export const getRemoteHtmlPresetsRepositoryUrlController = async (req: Request, res: Response): Promise => { + try { + const url = await appearanceService.getRemoteHtmlPresetsRepositoryUrl(); + res.status(200).json({ url }); + } catch (error: any) { + res.status(500).json({ message: '获取远程 HTML 主题仓库链接失败', error: error.message }); + } +}; + +// PUT /api/v1/appearance/html-presets/remote/repository-url +export const updateRemoteHtmlPresetsRepositoryUrlController = async (req: Request, res: Response): Promise => { + try { + const { url } = req.body; + // 注意:允许 url 为 null 或空字符串以清除设置 + if (url === undefined) { + res.status(400).json({ message: 'URL 不能为空或 undefined' }); + return; + } + await appearanceService.updateRemoteHtmlPresetsRepositoryUrl(url); + res.status(200).json({ message: '远程 HTML 主题仓库链接更新成功' }); + } catch (error: any) { + res.status(500).json({ message: '更新远程 HTML 主题仓库链接失败', error: error.message }); + } +}; + +// GET /api/v1/appearance/html-presets/remote/list +export const listRemoteHtmlPresetsController = async (req: Request, res: Response): Promise => { + try { + const repoUrl = req.query.repoUrl as string | undefined; + const presets = await appearanceService.listRemoteHtmlPresets(repoUrl); + res.status(200).json(presets); + } catch (error: any) { + res.status(500).json({ message: '获取远程 HTML 主题列表失败', error: error.message }); + } +}; + +// GET /api/v1/appearance/html-presets/remote/content +export const getRemoteHtmlPresetContentController = async (req: Request, res: Response): Promise => { + try { + const fileUrl = req.query.fileUrl as string; + if (!fileUrl) { + res.status(400).json({ message: 'fileUrl 查询参数不能为空' }); + return; + } + const content = await appearanceService.getRemoteHtmlPresetContent(fileUrl); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.status(200).send(content); + } catch (error: any) { + res.status(500).json({ message: '获取远程 HTML 主题内容失败', error: error.message }); + } +}; diff --git a/packages/backend/src/appearance/appearance.routes.ts b/packages/backend/src/appearance/appearance.routes.ts index ba218f0..5534424 100644 --- a/packages/backend/src/appearance/appearance.routes.ts +++ b/packages/backend/src/appearance/appearance.routes.ts @@ -36,4 +36,22 @@ router.delete('/background/page', appearanceController.removePageBackgroundContr // DELETE /api/v1/appearance/background/terminal - 删除终端背景图片 router.delete('/background/terminal', appearanceController.removeTerminalBackgroundController); +// HTML 预设主题相关路由 /api/v1/appearance/html-presets +const htmlPresetsRouter = express.Router(); + +// 本地 HTML 主题接口 /api/v1/appearance/html-presets/local +htmlPresetsRouter.get('/local', appearanceController.listLocalHtmlPresetsController); +htmlPresetsRouter.get('/local/:themeName', appearanceController.getLocalHtmlPresetContentController); +htmlPresetsRouter.post('/local', appearanceController.createLocalHtmlPresetController); +htmlPresetsRouter.put('/local/:themeName', appearanceController.updateLocalHtmlPresetController); +htmlPresetsRouter.delete('/local/:themeName', appearanceController.deleteLocalHtmlPresetController); + +// 远程 GitHub HTML 主题接口 /api/v1/appearance/html-presets/remote +htmlPresetsRouter.get('/remote/repository-url', appearanceController.getRemoteHtmlPresetsRepositoryUrlController); +htmlPresetsRouter.put('/remote/repository-url', appearanceController.updateRemoteHtmlPresetsRepositoryUrlController); +htmlPresetsRouter.get('/remote/list', appearanceController.listRemoteHtmlPresetsController); +htmlPresetsRouter.get('/remote/content', appearanceController.getRemoteHtmlPresetContentController); + +router.use('/html-presets', htmlPresetsRouter); + export default router; diff --git a/packages/backend/src/repositories/appearance.repository.ts b/packages/backend/src/repositories/appearance.repository.ts index 0a17803..ec65cb4 100644 --- a/packages/backend/src/repositories/appearance.repository.ts +++ b/packages/backend/src/repositories/appearance.repository.ts @@ -64,6 +64,9 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc case 'terminal_custom_html': settings.terminal_custom_html = row.value; break; + case 'remote_html_presets_url': + settings.remoteHtmlPresetsUrl = row.value || null; // 如果为空字符串,则视为 null + break; } } @@ -86,8 +89,9 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc ? settings.terminalBackgroundOverlayOpacity // 使用数据库找到的值 : defaults.terminalBackgroundOverlayOpacity, // 否则使用默认值 terminal_custom_html: settings.terminal_custom_html ?? defaults.terminal_custom_html, + remoteHtmlPresetsUrl: settings.remoteHtmlPresetsUrl ?? defaults.remoteHtmlPresetsUrl, updatedAt: latestUpdatedAt || defaults.updatedAt, // 使用最新的更新时间,否则使用默认时间戳 - }; + }; }; @@ -105,8 +109,9 @@ const getDefaultAppearanceSettings = (): Omit => { terminalBackgroundEnabled: true, // 默认启用 terminalBackgroundOverlayOpacity: 0.5, // 默认蒙版透明度 terminal_custom_html: '', // 默认自定义 HTML 为空字符串 + remoteHtmlPresetsUrl: null, // 默认远程 HTML 预设 URL 为 null updatedAt: Date.now(), // 提供默认时间戳 - }; + }; }; @@ -133,6 +138,7 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise< { key: 'terminalBackgroundEnabled', value: defaults.terminalBackgroundEnabled }, { key: 'terminalBackgroundOverlayOpacity', value: defaults.terminalBackgroundOverlayOpacity }, { key: 'terminal_custom_html', value: defaults.terminal_custom_html }, + { key: 'remoteHtmlPresetsUrl', value: defaults.remoteHtmlPresetsUrl }, ]; try { @@ -262,13 +268,21 @@ const updateAppearanceSettingsInternal = async (db: sqlite3.Database, settingsDt let changesMade = false; try { - for (const key of Object.keys(settingsDto) as Array) { - const value = settingsDto[key]; + for (const dtoKey of Object.keys(settingsDto) as Array) { + const value = settingsDto[dtoKey]; let dbValue: string; + let dbKey = dtoKey as string; // Default to DTO key + + // 将 DTO 键名映射到数据库键名 + if (dtoKey === 'remoteHtmlPresetsUrl') { + dbKey = 'remote_html_presets_url'; + } + // 如果将来还有其他 DTO 键名与数据库键名不一致的情况,在此添加映射 + // 例如: else if (dtoKey === 'someOtherDtoKey') { dbKey = 'some_other_db_key'; } // 将值转换为字符串以存储到数据库,处理 null/undefined if (value === null || value === undefined) { - dbValue = key === 'activeTerminalThemeId' ? 'null' : ''; // 主题 ID 特殊存储为 'null' + dbValue = dtoKey === 'activeTerminalThemeId' ? 'null' : ''; // 主题 ID 特殊存储为 'null' } else if (typeof value === 'object') { dbValue = JSON.stringify(value); } else if (typeof value === 'boolean') { // 处理布尔值 @@ -277,23 +291,23 @@ const updateAppearanceSettingsInternal = async (db: sqlite3.Database, settingsDt dbValue = String(value); } - // 对 activeTerminalThemeId 的特殊处理:存储 'null' 字符串或数字字符串 - if (key === 'activeTerminalThemeId') { + // 对 activeTerminalThemeId 的特殊处理:存储 'null' 字符串或数字字符串 (基于 dtoKey 判断) + if (dtoKey === 'activeTerminalThemeId') { dbValue = value === null ? 'null' : String(value); } - // 保存前验证 active_terminal_theme_id 类型 - if (key === 'activeTerminalThemeId' && value !== null && typeof value !== 'number') { + // 保存前验证 active_terminal_theme_id 类型 (基于 dtoKey 判断) + if (dtoKey === 'activeTerminalThemeId' && value !== null && typeof value !== 'number') { console.error(`[AppearanceRepo] 更新 activeTerminalThemeId 时收到无效类型值: ${value} (类型: ${typeof value}),应为数字或 null。跳过此字段。`); continue; // 跳过此键 } - // 对每个键值对执行 INSERT OR REPLACE - console.log(`[AppearanceRepo LOG] 准备更新/插入键: '${key}', 值: '${dbValue}'`); // 添加保存日志 - const result = await runDb(db, sqlReplace, [key, dbValue, nowSeconds]); + // 对每个键值对执行 INSERT OR REPLACE,使用映射后的 dbKey + console.log(`[AppearanceRepo LOG] 准备更新/插入数据库键: '${dbKey}', 值: '${dbValue}' (来自 DTO 键: '${dtoKey}')`); + const result = await runDb(db, sqlReplace, [dbKey, dbValue, nowSeconds]); if (result.changes > 0) { - console.log(`[AppearanceRepo LOG] 键 '${key}' 更新成功。`); // 添加成功日志 + console.log(`[AppearanceRepo LOG] 数据库键 '${dbKey}' 更新成功。`); // 添加成功日志 changesMade = true; } } diff --git a/packages/backend/src/services/appearance.service.ts b/packages/backend/src/services/appearance.service.ts index 4efaee0..a855ea7 100644 --- a/packages/backend/src/services/appearance.service.ts +++ b/packages/backend/src/services/appearance.service.ts @@ -3,6 +3,41 @@ import path from 'path'; import * as appearanceRepository from '../repositories/appearance.repository'; import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types'; import * as terminalThemeRepository from '../repositories/terminal-theme.repository'; +import axios from 'axios'; +import sanitize from 'sanitize-filename'; // 用于清理文件名 + +// 预设 HTML 主题的存储路径 (作为只读预设) +const PRESET_HTML_THEMES_DIR = path.join(__dirname, '../../html-presets/'); // 原 HTML_PRESETS_DIR + +const USER_CUSTOM_HTML_THEMES_DIR = path.join(__dirname, '../../data/custom_html_theme/'); + + +// 确保预设 html-themes 目录存在 +const ensurePresetHtmlThemesDirExists = async () => { // Renamed + try { + await fs.access(PRESET_HTML_THEMES_DIR); + } catch (error) { + // 目录不存在,创建它 + await fs.mkdir(PRESET_HTML_THEMES_DIR, { recursive: true }); + console.log(`[AppearanceService] Created preset html-themes directory at ${PRESET_HTML_THEMES_DIR}`); + } +}; +// 在服务初始化时确保目录存在 +ensurePresetHtmlThemesDirExists(); + +// 确保用户自定义 custom_html_theme 目录存在 +const ensureUserCustomHtmlThemesDirExists = async () => { + try { + await fs.access(USER_CUSTOM_HTML_THEMES_DIR); + } catch (error) { + // 目录不存在,创建它 + await fs.mkdir(USER_CUSTOM_HTML_THEMES_DIR, { recursive: true }); + console.log(`[AppearanceService] Created user custom_html_theme directory at ${USER_CUSTOM_HTML_THEMES_DIR}`); + } +}; +// 在服务初始化时确保目录存在 +ensureUserCustomHtmlThemesDirExists(); + /** * 获取外观设置 @@ -114,6 +149,30 @@ export const updateSettings = async (settingsDto: UpdateAppearanceDto): Promise< } } + // 验证 remoteHtmlPresetsUrl (如果提供了) + if (settingsDto.hasOwnProperty('remoteHtmlPresetsUrl')) { + const url = settingsDto.remoteHtmlPresetsUrl; + if (url === null || url === undefined) { + // 允许设置为 null 或 undefined (将被视为空) + settingsDto.remoteHtmlPresetsUrl = null; // 统一为 null + } else if (typeof url === 'string') { + if (url.trim() === '') { + settingsDto.remoteHtmlPresetsUrl = null; // 空字符串也视为空 + } else { + // 可选:添加更严格的 URL 格式验证 + if (!url.startsWith('http://') && !url.startsWith('https://')) { + // 暂时只做简单检查,允许非 GitHub URL,因为前端可能有其他用途 + // throw new Error('无效的远程 HTML 主题仓库链接格式,应以 http:// 或 https:// 开头。'); + } + if (url.length > 1024) { // 限制 URL 长度 + throw new Error('远程 HTML 主题仓库链接过长,最多允许 1024 个字符。'); + } + } + } else { + throw new Error('无效的远程 HTML 主题仓库链接类型,应为字符串或 null。'); + } + } + return appearanceRepository.updateAppearanceSettings(settingsDto); }; /** @@ -186,3 +245,415 @@ export const removeTerminalBackground = async (): Promise => { }; +// --- 自定义 HTML 背景主题服务方法 --- + +// -- 本地 HTML 主题管理 -- + +/** + * 验证并清理主题文件名 (通用函数) + * @param themeName 原始主题文件名 + * @returns 清理后的安全文件名 + * @throws Error 如果文件名无效或包含路径遍历字符 + */ +const sanitizeThemeNameInternal = (themeName: string): string => { // Renamed for clarity + if (!themeName || typeof themeName !== 'string') { + throw new Error('主题文件名不能为空且必须是字符串。'); + } + // 进一步清理,确保文件名安全 + const safeName = sanitize(themeName); + if (safeName !== themeName || themeName.includes('/') || themeName.includes('\\') || themeName.includes('..')) { + // Sanitize 会移除或替换非法字符,如果清理后的名字和原名不同,或原名包含路径字符,则认为非法。 + // 额外检查 '..' 防止即使 sanitize 未移除(不太可能)的情况。 + console.warn(`[AppearanceService] 检测到潜在不安全的主题文件名: ${themeName}, 清理后: ${safeName}`); + throw new Error(`主题文件名 "${themeName}" 包含非法字符或路径。`); + } + if (!safeName.endsWith('.html')) { + throw new Error('主题文件名必须以 .html 结尾。'); + } + if (safeName.length > 255) { // 合理的文件名长度限制 + throw new Error('主题文件名过长。'); + } + return safeName; +}; + + +/** + * 获取所有预设 HTML 主题的名称列表 + * @returns Promise> 主题对象列表 + */ +export const listPresetHtmlThemes = async (): Promise> => { + try { + await ensurePresetHtmlThemesDirExists(); // 确保目录存在 + const files = await fs.readdir(PRESET_HTML_THEMES_DIR); + return files + .filter(file => file.endsWith('.html')) + .map(name => ({ name, type: 'preset' as const })); // Add type + } catch (error: any) { + console.error('[AppearanceService] 列出预设 HTML 主题失败:', error); + if (error.code === 'ENOENT') { + // 目录不存在 + console.warn(`[AppearanceService] 预设 HTML 主题目录 (${PRESET_HTML_THEMES_DIR}) 未找到。`); + return []; + } + throw new Error('无法列出预设 HTML 主题。'); + } +}; + +/** + * 获取指定预设 HTML 主题的内容 + * @param themeName 主题文件名 (例如: my-theme.html) + * @returns Promise 主题的 HTML 内容 + */ +export const getPresetHtmlThemeContent = async (themeName: string): Promise => { // Renamed + const safeThemeName = sanitizeThemeNameInternal(themeName); // Use internal sanitizer + const filePath = path.join(PRESET_HTML_THEMES_DIR, safeThemeName); + try { + await ensurePresetHtmlThemesDirExists(); // 确保目录存在 + return await fs.readFile(filePath, 'utf-8'); + } catch (error: any) { + console.error(`[AppearanceService] 获取预设 HTML 主题 "${safeThemeName}" 内容失败:`, error); + if (error.code === 'ENOENT') { + throw new Error(`预设 HTML 主题 "${safeThemeName}" 未找到。`); + } + throw new Error(`无法获取预设 HTML 主题 "${safeThemeName}" 的内容。`); + } +}; + +// -- 用户自定义 HTML 主题管理 -- + +/** + * 获取所有用户自定义 HTML 主题的名称列表 + * @returns Promise> 主题对象列表 + */ +export const listUserCustomHtmlThemes = async (): Promise> => { + try { + await ensureUserCustomHtmlThemesDirExists(); + const files = await fs.readdir(USER_CUSTOM_HTML_THEMES_DIR); + return files + .filter(file => file.endsWith('.html')) + .map(name => ({ name, type: 'custom' as const })); // Add type + } catch (error: any) { + console.error('[AppearanceService] 列出用户自定义 HTML 主题失败:', error); + if (error.code === 'ENOENT') { + console.warn(`[AppearanceService] 用户自定义 HTML 主题目录 (${USER_CUSTOM_HTML_THEMES_DIR}) 未找到。`); + return []; + } + throw new Error('无法列出用户自定义 HTML 主题。'); + } +}; + +/** + * 获取指定用户自定义 HTML 主题的内容 + * @param themeName 主题文件名 (例如: my-custom-theme.html) + * @returns Promise 主题的 HTML 内容 + */ +export const getUserCustomHtmlThemeContent = async (themeName: string): Promise => { + const safeThemeName = sanitizeThemeNameInternal(themeName); + const filePath = path.join(USER_CUSTOM_HTML_THEMES_DIR, safeThemeName); + try { + await ensureUserCustomHtmlThemesDirExists(); + return await fs.readFile(filePath, 'utf-8'); + } catch (error: any) { + console.error(`[AppearanceService] 获取用户自定义 HTML 主题 "${safeThemeName}" 内容失败:`, error); + if (error.code === 'ENOENT') { + throw new Error(`用户自定义 HTML 主题 "${safeThemeName}" 未找到。`); + } + throw new Error(`无法获取用户自定义 HTML 主题 "${safeThemeName}" 的内容。`); + } +}; + +/** + * 创建新的用户自定义 HTML 主题 + * @param themeName 主题文件名 (例如: my-custom-theme.html) + * @param content HTML 内容 + * @returns Promise + */ +export const createUserCustomHtmlTheme = async (themeName: string, content: string): Promise => { + const safeThemeName = sanitizeThemeNameInternal(themeName); + const filePath = path.join(USER_CUSTOM_HTML_THEMES_DIR, safeThemeName); + try { + await ensureUserCustomHtmlThemesDirExists(); // 确保目录存在 + // 检查文件是否已存在 + try { + await fs.access(filePath); + // 文件已存在 + throw new Error(`用户自定义 HTML 主题 "${safeThemeName}" 已存在。`); + } catch (accessError: any) { + // 文件不存在,可以创建 + if (accessError.code !== 'ENOENT') { + throw accessError; // 其他 access 错误 + } + } + await fs.writeFile(filePath, content, 'utf-8'); + console.log(`[AppearanceService] 用户自定义 HTML 主题 "${safeThemeName}" 创建成功。`); + } catch (error: any) { + console.error(`[AppearanceService] 创建用户自定义 HTML 主题 "${safeThemeName}" 失败:`, error); + throw error; // 重新抛出原始错误或包装后的错误 + } +}; + +/** + * 更新指定用户自定义 HTML 主题的内容 + * @param themeName 主题文件名 (例如: my-custom-theme.html) + * @param content 新的 HTML 内容 + * @returns Promise + */ +export const updateUserCustomHtmlTheme = async (themeName: string, content: string): Promise => { + const safeThemeName = sanitizeThemeNameInternal(themeName); + const filePath = path.join(USER_CUSTOM_HTML_THEMES_DIR, safeThemeName); + try { + await ensureUserCustomHtmlThemesDirExists(); // 确保目录存在 + // 确保文件存在才能更新 + try { + await fs.access(filePath); + } catch (accessError: any) { + if (accessError.code === 'ENOENT') { + throw new Error(`用户自定义 HTML 主题 "${safeThemeName}" 未找到,无法更新。`); + } + throw accessError; + } + await fs.writeFile(filePath, content, 'utf-8'); + console.log(`[AppearanceService] 用户自定义 HTML 主题 "${safeThemeName}" 更新成功。`); + } catch (error: any) { + console.error(`[AppearanceService] 更新用户自定义 HTML 主题 "${safeThemeName}" 失败:`, error); + throw error; + } +}; + +/** + * 删除指定的用户自定义 HTML 主题文件 + * @param themeName 主题文件名 (例如: my-custom-theme.html) + * @returns Promise + */ +export const deleteUserCustomHtmlTheme = async (themeName: string): Promise => { + const safeThemeName = sanitizeThemeNameInternal(themeName); + const filePath = path.join(USER_CUSTOM_HTML_THEMES_DIR, safeThemeName); + try { + await ensureUserCustomHtmlThemesDirExists(); // 确保目录存在 + await fs.unlink(filePath); + console.log(`[AppearanceService] 用户自定义 HTML 主题 "${safeThemeName}" 删除成功。`); + } catch (error: any) { + console.error(`[AppearanceService] 删除用户自定义 HTML 主题 "${safeThemeName}" 失败:`, error); + if (error.code === 'ENOENT') { + throw new Error(`用户自定义 HTML 主题 "${safeThemeName}" 未找到,无法删除。`); + } + throw new Error(`无法删除用户自定义 HTML 主题 "${safeThemeName}"。`); + } +}; + +// -- 合并主题列表 -- + +/** + * 获取所有 HTML 主题 (预设和用户自定义) + * @returns Promise> + */ +export const listAllHtmlThemes = async (): Promise> => { + try { + const presetThemes = await listPresetHtmlThemes(); + const customThemes = await listUserCustomHtmlThemes(); + return [...presetThemes, ...customThemes]; + } catch (error) { + console.error('[AppearanceService] 列出所有 HTML 主题失败:', error); + throw new Error('无法列出所有 HTML 主题。'); + } +}; + + +// --- 现有本地 HTML 主题函数调整/重命名 --- +// 为了兼容现有的 appearance.store.ts 调用,暂时保留这些导出名,但内部调用新的对应函数。 +// 建议后续步骤修改 appearance.store.ts 去调用新的、更明确的函数名 (e.g., listPresetHtmlThemes, createUserCustomHtmlTheme). + +/** + * @deprecated Use createUserCustomHtmlTheme instead. This function now creates a USER CUSTOM theme. + * The 'local' in its name is misleading under the new system. + */ +export const createLocalHtmlPreset = async (themeName: string, content: string): Promise => { + console.warn("[AppearanceService] createLocalHtmlPreset is deprecated and now operates on user custom themes. Consider using createUserCustomHtmlTheme."); + return createUserCustomHtmlTheme(themeName, content); +}; + +/** + * @deprecated Use updateUserCustomHtmlTheme instead. This function now updates a USER CUSTOM theme. + * The 'local' in its name is misleading under the new system. + */ +export const updateLocalHtmlPreset = async (themeName: string, content: string): Promise => { + console.warn("[AppearanceService] updateLocalHtmlPreset is deprecated and now operates on user custom themes. Consider using updateUserCustomHtmlTheme."); + return updateUserCustomHtmlTheme(themeName, content); +}; + +/** + * @deprecated Use deleteUserCustomHtmlTheme instead. This function now deletes a USER CUSTOM theme. + * The 'local' in its name is misleading under the new system. + */ +export const deleteLocalHtmlPreset = async (themeName: string): Promise => { + console.warn("[AppearanceService] deleteLocalHtmlPreset is deprecated and now operates on user custom themes. Consider using deleteUserCustomHtmlTheme."); + return deleteUserCustomHtmlTheme(themeName); +}; + + +// -- 远程 GitHub HTML 主题管理 -- + +/** + * 获取当前存储的远程仓库链接 + * @returns Promise 远程仓库 URL 或 null + */ +export const getRemoteHtmlPresetsRepositoryUrl = async (): Promise => { + try { + const settings = await getSettings(); + return settings.remoteHtmlPresetsUrl !== undefined ? settings.remoteHtmlPresetsUrl : null; + } catch (error: any) { + console.error('[AppearanceService] 获取远程 HTML 主题仓库链接失败:', error); + throw new Error('无法获取远程 HTML 主题仓库链接。'); + } +}; + +/** + * 更新远程仓库链接 + * @param url 新的远程仓库 URL (可以是 null 或空字符串来清除) + * @returns Promise + */ +export const updateRemoteHtmlPresetsRepositoryUrl = async (url: string | null): Promise => { + try { + // 验证 URL 格式 (可选, 但推荐) + if (url && typeof url === 'string' && url.trim() !== '') { + // 简单的 URL 验证,可以根据需要增强 + if (!url.startsWith('https://github.com/') && !url.startsWith('http://github.com/')) { + // 允许其他 git 仓库源?目前按计划仅 GitHub + // throw new Error('无效的 GitHub 仓库链接格式。应形如 https://github.com/user/repo/tree/branch/path'); + } + } else if (url === '') { + // 如果是空字符串,则视为 null,表示清除 + url = null; + } else if (url !== null) { + throw new Error('无效的 URL 值。'); + } + + await updateSettings({ remoteHtmlPresetsUrl: url }); + console.log(`[AppearanceService] 远程 HTML 主题仓库链接更新为: ${url}`); + } catch (error: any) { + console.error('[AppearanceService] 更新远程 HTML 主题仓库链接失败:', error); + throw error; // 重新抛出,让控制器处理 + } +}; + + +/** + * 解析 GitHub 仓库 URL,提取 user, repo, path 和 ref (分支/tag/commit) + * @param repoUrl 例如: https://github.com/user/repo/tree/main/path/to/themes + * @returns { user: string, repo: string, path: string, ref: string } 或 null + */ +const parseGitHubRepoUrl = (repoUrl: string): { user: string; repo: string; repoPath: string; ref: string } | null => { + // 改进的正则表达式以更好地处理不同的 GitHub URL 格式 + const githubUrlRegex = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/tree\/([^/]+)\/?(.*?)|\/?(.*))?$/; + const match = repoUrl.match(githubUrlRegex); + + if (match) { + const user = match[1]; + const repo = match[2]; + let ref = match[3]; // 分支/tag 从 /tree/部分提取 + let repoPath = match[4]; // 路径在 /tree/之后 + + if (ref === undefined && repoPath === undefined) { + // 处理 https://github.com/user/repo 这种形式, ref 和 path 从第五个捕获组获取 + ref = 'HEAD'; // 默认为 HEAD (通常是默认分支) + repoPath = match[5] || ''; // 如果路径为空,则为空字符串 + } else { + // 如果 /tree/ 部分存在 + ref = ref || 'HEAD'; // 如果 ref 未定义(例如 URL 以 /tree/ 结尾),默认为 HEAD + repoPath = repoPath || ''; // 如果路径为空,则为空字符串 + } + // 移除路径末尾的斜杠 + repoPath = repoPath.replace(/\/$/, ''); + + return { user, repo, ref, repoPath }; + } + return null; +}; + + +/** + * 获取远程仓库的主题列表 (文件名) + * @param repoUrl 可选的仓库 URL。如果不提供,则使用已保存的链接。 + * @returns Promise> 主题对象列表 + */ +export const listRemoteHtmlPresets = async (repoUrl?: string): Promise> => { + let urlToFetch = repoUrl; + if (!urlToFetch) { + const savedUrl = await getRemoteHtmlPresetsRepositoryUrl(); + if (!savedUrl) { + throw new Error('未提供远程仓库链接,且未找到已保存的链接。'); + } + urlToFetch = savedUrl; + } + + const parsed = parseGitHubRepoUrl(urlToFetch); + if (!parsed) { + throw new Error(`无效的 GitHub 仓库链接格式: ${urlToFetch}`); + } + + const { user, repo, ref, repoPath } = parsed; + // GitHub API 端点获取目录内容 + const apiUrl = `https://api.github.com/repos/${user}/${repo}/contents/${repoPath}?ref=${ref}`; + + try { + console.log(`[AppearanceService] 正在从 GitHub API 获取远程主题列表: ${apiUrl}`); + const response = await axios.get(apiUrl, { + headers: { 'Accept': 'application/vnd.github.v3+json' } + // 对于公共仓库,通常不需要 token + }); + + if (response.status === 200 && Array.isArray(response.data)) { + const htmlFiles = response.data + .filter(item => item.type === 'file' && item.name.endsWith('.html')) + .map(item => ({ + name: item.name, + downloadUrl: item.download_url // GitHub API 通常会提供 download_url + })); + console.log(`[AppearanceService] 成功获取 ${htmlFiles.length} 个远程 HTML 主题。`); + return htmlFiles; + } else { + console.error(`[AppearanceService] 从 GitHub API 获取主题列表失败: 状态 ${response.status}`, response.data); + throw new Error(`无法从 GitHub (${urlToFetch}) 获取主题列表。状态: ${response.status}`); + } + } catch (error: any) { + console.error(`[AppearanceService] 请求 GitHub API (${apiUrl}) 时出错:`, error.response?.data || error.message); + if (axios.isAxiosError(error) && error.response?.status === 404) { + throw new Error(`远程仓库路径未找到: ${urlToFetch} (API: ${apiUrl})`); + } + throw new Error(`请求 GitHub API 获取主题列表时出错: ${error.message}`); + } +}; + +/** + * 获取远程仓库中指定主题的 HTML 内容 + * @param fileUrl GitHub API 返回的 download_url 或可构造的 raw 文件链接 + * @returns Promise 主题的 HTML 内容 + */ +export const getRemoteHtmlPresetContent = async (fileUrl: string): Promise => { + if (!fileUrl || typeof fileUrl !== 'string') { + throw new Error('无效的远程文件 URL。'); + } + // 基本的 URL 校验,确保它看起来像一个可下载的链接 + if (!fileUrl.startsWith('http://') && !fileUrl.startsWith('https://')) { + throw new Error('文件 URL 必须是有效的 HTTP/HTTPS 链接。'); + } + + try { + console.log(`[AppearanceService] 正在从远程 URL 获取主题内容: ${fileUrl}`); + const response = await axios.get(fileUrl, { + responseType: 'text', // 确保获取的是文本内容 + }); + + if (response.status === 200 && typeof response.data === 'string') { + console.log(`[AppearanceService] 成功从 ${fileUrl} 获取主题内容。`); + return response.data; + } else { + console.error(`[AppearanceService] 从 ${fileUrl} 获取内容失败: 状态 ${response.status}`, response.data); + throw new Error(`无法从远程 URL (${fileUrl}) 获取内容。状态: ${response.status}`); + } + } catch (error: any) { + console.error(`[AppearanceService] 请求远程文件内容 (${fileUrl}) 时出错:`, error.response?.data || error.message); + throw new Error(`请求远程文件内容时出错: ${error.message}`); + } +}; + diff --git a/packages/backend/src/types/appearance.types.ts b/packages/backend/src/types/appearance.types.ts index 33fa71f..c894a7b 100644 --- a/packages/backend/src/types/appearance.types.ts +++ b/packages/backend/src/types/appearance.types.ts @@ -20,6 +20,7 @@ export interface AppearanceSettings { terminalBackgroundEnabled?: boolean; // 终端背景是否启用 terminalBackgroundOverlayOpacity?: number; // 终端背景蒙版透明度 (0-1) terminal_custom_html?: string; // 用户自定义终端背景 HTML + remoteHtmlPresetsUrl?: string | null; // 远程 HTML 预设仓库 URL updatedAt?: number; } diff --git a/packages/frontend/src/components/StyleCustomizer.vue b/packages/frontend/src/components/StyleCustomizer.vue index e22cc14..ae4878b 100644 --- a/packages/frontend/src/components/StyleCustomizer.vue +++ b/packages/frontend/src/components/StyleCustomizer.vue @@ -136,7 +136,7 @@ onMounted(() => {