feat: 添加自定义终端html背景的功能
This commit is contained in:
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AppearanceSettings, '_id'> => {
|
||||
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<keyof UpdateAppearanceDto>) {
|
||||
const value = settingsDto[key];
|
||||
for (const dtoKey of Object.keys(settingsDto) as Array<keyof UpdateAppearanceDto>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> => {
|
||||
};
|
||||
|
||||
|
||||
// --- 自定义 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<Array<{ name: string, type: 'preset' }>> 主题对象列表
|
||||
*/
|
||||
export const listPresetHtmlThemes = async (): Promise<Array<{ name: string, type: 'preset' }>> => {
|
||||
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<string> 主题的 HTML 内容
|
||||
*/
|
||||
export const getPresetHtmlThemeContent = async (themeName: string): Promise<string> => { // 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<Array<{ name: string, type: 'custom' }>> 主题对象列表
|
||||
*/
|
||||
export const listUserCustomHtmlThemes = async (): Promise<Array<{ name: string, type: 'custom' }>> => {
|
||||
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<string> 主题的 HTML 内容
|
||||
*/
|
||||
export const getUserCustomHtmlThemeContent = async (themeName: string): Promise<string> => {
|
||||
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<void>
|
||||
*/
|
||||
export const createUserCustomHtmlTheme = async (themeName: string, content: string): Promise<void> => {
|
||||
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<void>
|
||||
*/
|
||||
export const updateUserCustomHtmlTheme = async (themeName: string, content: string): Promise<void> => {
|
||||
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<void>
|
||||
*/
|
||||
export const deleteUserCustomHtmlTheme = async (themeName: string): Promise<void> => {
|
||||
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<Array<{ name: string, type: 'preset' | 'custom' }>>
|
||||
*/
|
||||
export const listAllHtmlThemes = async (): Promise<Array<{ name: string, type: 'preset' | 'custom' }>> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
console.warn("[AppearanceService] deleteLocalHtmlPreset is deprecated and now operates on user custom themes. Consider using deleteUserCustomHtmlTheme.");
|
||||
return deleteUserCustomHtmlTheme(themeName);
|
||||
};
|
||||
|
||||
|
||||
// -- 远程 GitHub HTML 主题管理 --
|
||||
|
||||
/**
|
||||
* 获取当前存储的远程仓库链接
|
||||
* @returns Promise<string | null> 远程仓库 URL 或 null
|
||||
*/
|
||||
export const getRemoteHtmlPresetsRepositoryUrl = async (): Promise<string | null> => {
|
||||
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<void>
|
||||
*/
|
||||
export const updateRemoteHtmlPresetsRepositoryUrl = async (url: string | null): Promise<void> => {
|
||||
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<Array<{ name: string, downloadUrl: string | null }>> 主题对象列表
|
||||
*/
|
||||
export const listRemoteHtmlPresets = async (repoUrl?: string): Promise<Array<{ name: string, downloadUrl: string | null }>> => {
|
||||
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<string> 主题的 HTML 内容
|
||||
*/
|
||||
export const getRemoteHtmlPresetContent = async (fileUrl: string): Promise<string> => {
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user