Merge pull request #51 from Heavrnl/feature/customHtml
Feature/custom html
This commit is contained in:
Generated
+26
-1
@@ -7197,6 +7197,15 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/scule": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||||
@@ -8078,6 +8087,15 @@
|
|||||||
"tree-kill": "cli.js"
|
"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": {
|
"node_modules/ts-node": {
|
||||||
"version": "10.9.2",
|
"version": "10.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||||
@@ -8471,6 +8489,12 @@
|
|||||||
"untyped": "dist/cli.mjs"
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -9017,6 +9041,7 @@
|
|||||||
"multer": ">=2.0.0",
|
"multer": ">=2.0.0",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"sanitize-filename": "^1.6.3",
|
||||||
"session-file-store": "^1.5.0",
|
"session-file-store": "^1.5.0",
|
||||||
"socks": "^2.8.4",
|
"socks": "^2.8.4",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
@@ -9119,7 +9144,7 @@
|
|||||||
},
|
},
|
||||||
"packages/frontend": {
|
"packages/frontend": {
|
||||||
"name": "@nexus-terminal/frontend",
|
"name": "@nexus-terminal/frontend",
|
||||||
"version": "0.6.2",
|
"version": "0.7.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"multer": ">=2.0.0",
|
"multer": ">=2.0.0",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"sanitize-filename": "^1.6.3",
|
||||||
"session-file-store": "^1.5.0",
|
"session-file-store": "^1.5.0",
|
||||||
"socks": "^2.8.4",
|
"socks": "^2.8.4",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
|
|||||||
@@ -189,3 +189,166 @@ export const removeTerminalBackgroundController = async (req: Request, res: Resp
|
|||||||
res.status(500).json({ message: '移除终端背景失败', error: error.message });
|
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 - 删除终端背景图片
|
// DELETE /api/v1/appearance/background/terminal - 删除终端背景图片
|
||||||
router.delete('/background/terminal', appearanceController.removeTerminalBackgroundController);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc
|
|||||||
case 'editorFontFamily':
|
case 'editorFontFamily':
|
||||||
settings.editorFontFamily = row.value || null; // 如果为空字符串,则视为 null
|
settings.editorFontFamily = row.value || null; // 如果为空字符串,则视为 null
|
||||||
break;
|
break;
|
||||||
|
case 'terminal_custom_html':
|
||||||
|
settings.terminal_custom_html = row.value;
|
||||||
|
break;
|
||||||
|
case 'remote_html_presets_url':
|
||||||
|
settings.remoteHtmlPresetsUrl = row.value || null; // 如果为空字符串,则视为 null
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,15 +81,17 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc
|
|||||||
editorFontFamily: settings.editorFontFamily ?? defaults.editorFontFamily,
|
editorFontFamily: settings.editorFontFamily ?? defaults.editorFontFamily,
|
||||||
terminalBackgroundImage: settings.terminalBackgroundImage ?? defaults.terminalBackgroundImage,
|
terminalBackgroundImage: settings.terminalBackgroundImage ?? defaults.terminalBackgroundImage,
|
||||||
pageBackgroundImage: settings.pageBackgroundImage ?? defaults.pageBackgroundImage,
|
pageBackgroundImage: settings.pageBackgroundImage ?? defaults.pageBackgroundImage,
|
||||||
// 修改:只有当数据库中未找到记录时才使用默认值
|
// 只有当数据库中未找到记录时才使用默认值
|
||||||
terminalBackgroundEnabled: terminalBackgroundEnabledFound
|
terminalBackgroundEnabled: terminalBackgroundEnabledFound
|
||||||
? settings.terminalBackgroundEnabled // 使用数据库找到的值 (true 或 false)
|
? settings.terminalBackgroundEnabled // 使用数据库找到的值 (true 或 false)
|
||||||
: defaults.terminalBackgroundEnabled, // 否则使用默认值 (true)
|
: defaults.terminalBackgroundEnabled, // 否则使用默认值 (true)
|
||||||
terminalBackgroundOverlayOpacity: terminalBackgroundOverlayOpacityFound
|
terminalBackgroundOverlayOpacity: terminalBackgroundOverlayOpacityFound
|
||||||
? settings.terminalBackgroundOverlayOpacity // 使用数据库找到的值
|
? settings.terminalBackgroundOverlayOpacity // 使用数据库找到的值
|
||||||
: defaults.terminalBackgroundOverlayOpacity, // 否则使用默认值
|
: defaults.terminalBackgroundOverlayOpacity, // 否则使用默认值
|
||||||
updatedAt: latestUpdatedAt || defaults.updatedAt, // 使用最新的更新时间,否则使用默认时间戳
|
terminal_custom_html: settings.terminal_custom_html ?? defaults.terminal_custom_html,
|
||||||
};
|
remoteHtmlPresetsUrl: settings.remoteHtmlPresetsUrl ?? defaults.remoteHtmlPresetsUrl,
|
||||||
|
updatedAt: latestUpdatedAt || defaults.updatedAt, // 使用最新的更新时间,否则使用默认时间戳
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -100,8 +108,10 @@ const getDefaultAppearanceSettings = (): Omit<AppearanceSettings, '_id'> => {
|
|||||||
pageBackgroundImage: undefined,
|
pageBackgroundImage: undefined,
|
||||||
terminalBackgroundEnabled: true, // 默认启用
|
terminalBackgroundEnabled: true, // 默认启用
|
||||||
terminalBackgroundOverlayOpacity: 0.5, // 默认蒙版透明度
|
terminalBackgroundOverlayOpacity: 0.5, // 默认蒙版透明度
|
||||||
updatedAt: Date.now(), // 提供默认时间戳
|
terminal_custom_html: '', // 默认自定义 HTML 为空字符串
|
||||||
};
|
remoteHtmlPresetsUrl: null, // 默认远程 HTML 预设 URL 为 null
|
||||||
|
updatedAt: Date.now(), // 提供默认时间戳
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +136,9 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<
|
|||||||
{ key: 'terminalBackgroundImage', value: defaults.terminalBackgroundImage ?? '' }, // 数据库中使用空字符串
|
{ key: 'terminalBackgroundImage', value: defaults.terminalBackgroundImage ?? '' }, // 数据库中使用空字符串
|
||||||
{ key: 'pageBackgroundImage', value: defaults.pageBackgroundImage ?? '' }, // 数据库中使用空字符串
|
{ key: 'pageBackgroundImage', value: defaults.pageBackgroundImage ?? '' }, // 数据库中使用空字符串
|
||||||
{ key: 'terminalBackgroundEnabled', value: defaults.terminalBackgroundEnabled },
|
{ key: 'terminalBackgroundEnabled', value: defaults.terminalBackgroundEnabled },
|
||||||
{ key: 'terminalBackgroundOverlayOpacity', value: defaults.terminalBackgroundOverlayOpacity },
|
{ key: 'terminalBackgroundOverlayOpacity', value: defaults.terminalBackgroundOverlayOpacity },
|
||||||
|
{ key: 'terminal_custom_html', value: defaults.terminal_custom_html },
|
||||||
|
{ key: 'remoteHtmlPresetsUrl', value: defaults.remoteHtmlPresetsUrl },
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -256,13 +268,21 @@ const updateAppearanceSettingsInternal = async (db: sqlite3.Database, settingsDt
|
|||||||
let changesMade = false;
|
let changesMade = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const key of Object.keys(settingsDto) as Array<keyof UpdateAppearanceDto>) {
|
for (const dtoKey of Object.keys(settingsDto) as Array<keyof UpdateAppearanceDto>) {
|
||||||
const value = settingsDto[key];
|
const value = settingsDto[dtoKey];
|
||||||
let dbValue: string;
|
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
|
// 将值转换为字符串以存储到数据库,处理 null/undefined
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
dbValue = key === 'activeTerminalThemeId' ? 'null' : ''; // 主题 ID 特殊存储为 'null'
|
dbValue = dtoKey === 'activeTerminalThemeId' ? 'null' : ''; // 主题 ID 特殊存储为 'null'
|
||||||
} else if (typeof value === 'object') {
|
} else if (typeof value === 'object') {
|
||||||
dbValue = JSON.stringify(value);
|
dbValue = JSON.stringify(value);
|
||||||
} else if (typeof value === 'boolean') { // 处理布尔值
|
} else if (typeof value === 'boolean') { // 处理布尔值
|
||||||
@@ -271,23 +291,23 @@ const updateAppearanceSettingsInternal = async (db: sqlite3.Database, settingsDt
|
|||||||
dbValue = String(value);
|
dbValue = String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对 activeTerminalThemeId 的特殊处理:存储 'null' 字符串或数字字符串
|
// 对 activeTerminalThemeId 的特殊处理:存储 'null' 字符串或数字字符串 (基于 dtoKey 判断)
|
||||||
if (key === 'activeTerminalThemeId') {
|
if (dtoKey === 'activeTerminalThemeId') {
|
||||||
dbValue = value === null ? 'null' : String(value);
|
dbValue = value === null ? 'null' : String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 保存前验证 active_terminal_theme_id 类型
|
// 保存前验证 active_terminal_theme_id 类型 (基于 dtoKey 判断)
|
||||||
if (key === 'activeTerminalThemeId' && value !== null && typeof value !== 'number') {
|
if (dtoKey === 'activeTerminalThemeId' && value !== null && typeof value !== 'number') {
|
||||||
console.error(`[AppearanceRepo] 更新 activeTerminalThemeId 时收到无效类型值: ${value} (类型: ${typeof value}),应为数字或 null。跳过此字段。`);
|
console.error(`[AppearanceRepo] 更新 activeTerminalThemeId 时收到无效类型值: ${value} (类型: ${typeof value}),应为数字或 null。跳过此字段。`);
|
||||||
continue; // 跳过此键
|
continue; // 跳过此键
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对每个键值对执行 INSERT OR REPLACE
|
// 对每个键值对执行 INSERT OR REPLACE,使用映射后的 dbKey
|
||||||
console.log(`[AppearanceRepo LOG] 准备更新/插入键: '${key}', 值: '${dbValue}'`); // 添加保存日志
|
console.log(`[AppearanceRepo LOG] 准备更新/插入数据库键: '${dbKey}', 值: '${dbValue}' (来自 DTO 键: '${dtoKey}')`);
|
||||||
const result = await runDb(db, sqlReplace, [key, dbValue, nowSeconds]);
|
const result = await runDb(db, sqlReplace, [dbKey, dbValue, nowSeconds]);
|
||||||
if (result.changes > 0) {
|
if (result.changes > 0) {
|
||||||
console.log(`[AppearanceRepo LOG] 键 '${key}' 更新成功。`); // 添加成功日志
|
console.log(`[AppearanceRepo LOG] 数据库键 '${dbKey}' 更新成功。`); // 添加成功日志
|
||||||
changesMade = true;
|
changesMade = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,41 @@ import path from 'path';
|
|||||||
import * as appearanceRepository from '../repositories/appearance.repository';
|
import * as appearanceRepository from '../repositories/appearance.repository';
|
||||||
import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types';
|
import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types';
|
||||||
import * as terminalThemeRepository from '../repositories/terminal-theme.repository';
|
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();
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取外观设置
|
* 获取外观设置
|
||||||
@@ -100,7 +135,43 @@ export const updateSettings = async (settingsDto: UpdateAppearanceDto): Promise<
|
|||||||
settingsDto.terminalBackgroundOverlayOpacity = opacity; // 确保类型正确
|
settingsDto.terminalBackgroundOverlayOpacity = opacity; // 确保类型正确
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 如果实现了背景图片上传,这里需要处理文件路径或 URL 的验证/保存逻辑
|
|
||||||
|
|
||||||
|
// 验证 terminal_custom_html (如果提供了)
|
||||||
|
if (settingsDto.hasOwnProperty('terminal_custom_html')) {
|
||||||
|
if (settingsDto.terminal_custom_html === null || settingsDto.terminal_custom_html === undefined || typeof settingsDto.terminal_custom_html === 'string') {
|
||||||
|
// 允许为空字符串、null 或 undefined (将被视为空)
|
||||||
|
if (typeof settingsDto.terminal_custom_html === 'string' && settingsDto.terminal_custom_html.length > 10240) { // 10KB 限制
|
||||||
|
throw new Error('自定义终端 HTML 过长,最多允许 10240 个字符。');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('无效的自定义终端 HTML 类型,应为字符串。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 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);
|
return appearanceRepository.updateAppearanceSettings(settingsDto);
|
||||||
};
|
};
|
||||||
@@ -174,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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface AppearanceSettings {
|
|||||||
editorFontFamily?: string | null; // Monaco Editor 字体偏好
|
editorFontFamily?: string | null; // Monaco Editor 字体偏好
|
||||||
terminalBackgroundEnabled?: boolean; // 终端背景是否启用
|
terminalBackgroundEnabled?: boolean; // 终端背景是否启用
|
||||||
terminalBackgroundOverlayOpacity?: number; // 终端背景蒙版透明度 (0-1)
|
terminalBackgroundOverlayOpacity?: number; // 终端背景蒙版透明度 (0-1)
|
||||||
|
terminal_custom_html?: string; // 用户自定义终端背景 HTML
|
||||||
|
remoteHtmlPresetsUrl?: string | null; // 远程 HTML 预设仓库 URL
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { TerminalTheme } from '../types/terminal-theme.types';
|
import type { TerminalTheme } from '../types/terminal-theme.types';
|
||||||
import StyleCustomizerUiTab from './style-customizer/StyleCustomizerUiTab.vue';
|
import StyleCustomizerUiTab from './style-customizer/StyleCustomizerUiTab.vue';
|
||||||
@@ -42,15 +42,102 @@ const handleResetUiTheme = async () => {
|
|||||||
|
|
||||||
|
|
||||||
const modalRootRef = ref<HTMLDivElement | null>(null);
|
const modalRootRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const headerRef = ref<HTMLElement | null>(null);
|
||||||
|
const dialogContentRef = ref<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const draggableState = reactive({
|
||||||
|
isDragging: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
initialLeft: 0,
|
||||||
|
initialTop: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const headerElement = headerRef.value;
|
||||||
|
const dialogEl = dialogContentRef.value;
|
||||||
|
const rootEl = modalRootRef.value;
|
||||||
|
|
||||||
|
if (!headerElement || !dialogEl || !rootEl) {
|
||||||
|
// console.warn("Draggable elements not found for StyleCustomizer modal."); // 用于调试的可选日志
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
//确保对话框已渲染且其尺寸可用
|
||||||
|
if (dialogEl && rootEl) {
|
||||||
|
dialogEl.style.position = 'absolute'; // 使对话框在rootEl内绝对定位
|
||||||
|
const rootWidth = rootEl.clientWidth;
|
||||||
|
const rootHeight = rootEl.clientHeight;
|
||||||
|
const dialogWidth = dialogEl.offsetWidth;
|
||||||
|
const dialogHeight = dialogEl.offsetHeight;
|
||||||
|
|
||||||
|
// 使对话框居中
|
||||||
|
dialogEl.style.left = `${Math.max(0, (rootWidth - dialogWidth) / 2)}px`;
|
||||||
|
dialogEl.style.top = `${Math.max(0, (rootHeight - dialogHeight) / 2)}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMouseDown = (event: MouseEvent) => {
|
||||||
|
if (!dialogEl) return;
|
||||||
|
event.preventDefault(); // 防止文本选择等默认行为
|
||||||
|
|
||||||
|
draggableState.isDragging = true;
|
||||||
|
draggableState.startX = event.clientX;
|
||||||
|
draggableState.startY = event.clientY;
|
||||||
|
draggableState.initialLeft = dialogEl.offsetLeft;
|
||||||
|
draggableState.initialTop = dialogEl.offsetTop;
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
document.addEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseMove = (event: MouseEvent) => {
|
||||||
|
if (!draggableState.isDragging || !dialogEl || !rootEl) return;
|
||||||
|
|
||||||
|
const dx = event.clientX - draggableState.startX;
|
||||||
|
const dy = event.clientY - draggableState.startY;
|
||||||
|
|
||||||
|
let newLeft = draggableState.initialLeft + dx;
|
||||||
|
let newTop = draggableState.initialTop + dy;
|
||||||
|
|
||||||
|
// 边界检查,使对话框保持在 modalRootRef (视口) 内
|
||||||
|
newLeft = Math.max(0, Math.min(newLeft, rootEl.clientWidth - dialogEl.offsetWidth));
|
||||||
|
newTop = Math.max(0, Math.min(newTop, rootEl.clientHeight - dialogEl.offsetHeight));
|
||||||
|
|
||||||
|
dialogEl.style.left = `${newLeft}px`;
|
||||||
|
dialogEl.style.top = `${newTop}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
if (!draggableState.isDragging) return;
|
||||||
|
draggableState.isDragging = false;
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
headerElement.addEventListener('mousedown', onMouseDown);
|
||||||
|
headerElement.style.cursor = 'move'; // 设置页眉鼠标样式为可拖动
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (headerElement) {
|
||||||
|
headerElement.removeEventListener('mousedown', onMouseDown);
|
||||||
|
headerElement.style.cursor = ''; // 重置鼠标样式
|
||||||
|
}
|
||||||
|
// 清理全局监听器
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="modalRootRef" class="fixed inset-0 bg-black/60 flex justify-center items-center z-[1000] p-2 md:p-4" @click.self="closeCustomizer">
|
<div ref="modalRootRef" class="fixed inset-0 z-[1000]" @click.self="closeCustomizer">
|
||||||
<div class="bg-background text-foreground rounded-lg shadow-lg w-full h-full md:w-[90%] md:max-w-[800px] md:h-[85vh] md:max-h-[700px] flex flex-col overflow-hidden">
|
<div ref="dialogContentRef" class="bg-background text-foreground rounded-lg shadow-[0px_0px_15px_rgb(0_0_0_/_0.15)] w-full h-full md:w-[90%] md:max-w-[800px] md:h-[85vh] md:max-h-[700px] flex flex-col overflow-hidden">
|
||||||
<header class="flex justify-between items-center px-4 py-3 border-b border-border bg-header flex-shrink-0">
|
<header ref="headerRef" class="flex justify-between items-center px-4 py-3 border-b border-border bg-header flex-shrink-0">
|
||||||
<h2 class="m-0 text-lg md:text-xl text-foreground">{{ t('styleCustomizer.title') }}</h2>
|
<h2 class="m-0 text-lg md:text-xl text-foreground">{{ t('styleCustomizer.title') }}</h2>
|
||||||
<button @click="closeCustomizer" class="bg-transparent border-none text-2xl md:text-3xl leading-none cursor-pointer text-text-secondary px-2 py-1 rounded hover:text-foreground hover:bg-black/10">×</button>
|
<button @click="closeCustomizer" class="bg-transparent border-none text-2xl md:text-3xl leading-none cursor-pointer text-text-secondary px-2 py-1 rounded hover:text-foreground hover:bg-black/10">×</button>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const terminalOuterWrapperRef = ref<HTMLElement | null>(null); // 最外层容
|
|||||||
let terminal: Terminal | null = null;
|
let terminal: Terminal | null = null;
|
||||||
let fitAddon: FitAddon | null = null;
|
let fitAddon: FitAddon | null = null;
|
||||||
let searchAddon: SearchAddon | null = null; // *** 添加 searchAddon 变量 ***
|
let searchAddon: SearchAddon | null = null; // *** 添加 searchAddon 变量 ***
|
||||||
|
const customHtmlLayerRef = ref<HTMLElement | null>(null); // Ref for the custom HTML layer
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let observedElement: HTMLElement | null = null; // +++ Store the observed element +++
|
let observedElement: HTMLElement | null = null; // +++ Store the observed element +++
|
||||||
let debounceTimer: number | null = null; // 用于防抖的计时器 ID
|
let debounceTimer: number | null = null; // 用于防抖的计时器 ID
|
||||||
@@ -49,6 +50,7 @@ const {
|
|||||||
currentTerminalFontSize,
|
currentTerminalFontSize,
|
||||||
isTerminalBackgroundEnabled,
|
isTerminalBackgroundEnabled,
|
||||||
currentTerminalBackgroundOverlayOpacity, // 获取蒙版透明度
|
currentTerminalBackgroundOverlayOpacity, // 获取蒙版透明度
|
||||||
|
terminalCustomHTML, // 用于自定义终端背景 HTML
|
||||||
} = storeToRefs(appearanceStore);
|
} = storeToRefs(appearanceStore);
|
||||||
|
|
||||||
// --- Settings Store ---
|
// --- Settings Store ---
|
||||||
@@ -614,7 +616,28 @@ defineExpose({ write, findNext, findPrevious, clearSearch, clear }); // 暴露 c
|
|||||||
const applyTerminalBackground = () => {
|
const applyTerminalBackground = () => {
|
||||||
// 背景应用到 terminalOuterWrapperRef
|
// 背景应用到 terminalOuterWrapperRef
|
||||||
if (terminalOuterWrapperRef.value) {
|
if (terminalOuterWrapperRef.value) {
|
||||||
if (!isTerminalBackgroundEnabled.value) {
|
if (isTerminalBackgroundEnabled.value) {
|
||||||
|
// 只要启用了背景功能,就应该让 xterm 透明以显示下方内容
|
||||||
|
nextTick(() => {
|
||||||
|
if (terminalOuterWrapperRef.value) {
|
||||||
|
terminalOuterWrapperRef.value.classList.add('has-terminal-background');
|
||||||
|
if (terminalBackgroundImage.value) {
|
||||||
|
const backendUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||||
|
const imagePath = terminalBackgroundImage.value;
|
||||||
|
const fullImageUrl = `${backendUrl}${imagePath}`;
|
||||||
|
terminalOuterWrapperRef.value.style.backgroundImage = `url(${fullImageUrl})`;
|
||||||
|
terminalOuterWrapperRef.value.style.backgroundSize = 'cover';
|
||||||
|
terminalOuterWrapperRef.value.style.backgroundPosition = 'center';
|
||||||
|
terminalOuterWrapperRef.value.style.backgroundRepeat = 'no-repeat';
|
||||||
|
console.log(`[Terminal ${props.sessionId}] 应用终端背景图片: ${terminalBackgroundImage.value}`);
|
||||||
|
} else {
|
||||||
|
terminalOuterWrapperRef.value.style.backgroundImage = 'none';
|
||||||
|
console.log(`[Terminal ${props.sessionId}] 终端背景功能已启用,但无背景图片,xterm 应透明。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 背景功能禁用
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (terminalOuterWrapperRef.value) {
|
if (terminalOuterWrapperRef.value) {
|
||||||
terminalOuterWrapperRef.value.style.backgroundImage = 'none';
|
terminalOuterWrapperRef.value.style.backgroundImage = 'none';
|
||||||
@@ -622,45 +645,82 @@ const applyTerminalBackground = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(`[Terminal ${props.sessionId}] 终端背景已禁用,移除背景。`);
|
console.log(`[Terminal ${props.sessionId}] 终端背景已禁用,移除背景。`);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (terminalBackgroundImage.value) {
|
|
||||||
const backendUrl = import.meta.env.VITE_API_BASE_URL || '';
|
|
||||||
const imagePath = terminalBackgroundImage.value;
|
|
||||||
const fullImageUrl = `${backendUrl}${imagePath}`;
|
|
||||||
nextTick(() => {
|
|
||||||
if (terminalOuterWrapperRef.value) {
|
|
||||||
terminalOuterWrapperRef.value.style.backgroundImage = `url(${fullImageUrl})`;
|
|
||||||
terminalOuterWrapperRef.value.style.backgroundSize = 'cover';
|
|
||||||
terminalOuterWrapperRef.value.style.backgroundPosition = 'center';
|
|
||||||
terminalOuterWrapperRef.value.style.backgroundRepeat = 'no-repeat';
|
|
||||||
terminalOuterWrapperRef.value.classList.add('has-terminal-background');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(`[Terminal ${props.sessionId}] 应用终端背景图片: ${terminalBackgroundImage.value}`);
|
|
||||||
} else {
|
|
||||||
nextTick(() => {
|
|
||||||
if (terminalOuterWrapperRef.value) {
|
|
||||||
terminalOuterWrapperRef.value.style.backgroundImage = 'none';
|
|
||||||
terminalOuterWrapperRef.value.classList.remove('has-terminal-background');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(`[Terminal ${props.sessionId}] 移除终端背景图片。`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to execute scripts within an element
|
||||||
|
const executeScriptsInElement = (container: HTMLElement) => {
|
||||||
|
if (!container) return;
|
||||||
|
console.log('[Terminal] Attempting to execute scripts in custom HTML container:', container);
|
||||||
|
|
||||||
|
const scripts = Array.from(container.getElementsByTagName('script'));
|
||||||
|
console.log(`[Terminal] Found ${scripts.length} script(s) in custom HTML.`);
|
||||||
|
|
||||||
|
scripts.forEach((oldScript, index) => {
|
||||||
|
console.log(`[Terminal] Processing script #${index + 1}:`, oldScript.outerHTML.substring(0, 100) + '...');
|
||||||
|
const newScript = document.createElement('script');
|
||||||
|
|
||||||
|
// Copy attributes (type, src, async, defer, etc.)
|
||||||
|
Array.from(oldScript.attributes).forEach(attr => {
|
||||||
|
newScript.setAttribute(attr.name, attr.value);
|
||||||
|
console.log(`[Terminal] Script #${index + 1}: Copied attribute ${attr.name}="${attr.value}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy content for inline scripts
|
||||||
|
if (oldScript.textContent) {
|
||||||
|
newScript.textContent = oldScript.textContent;
|
||||||
|
console.log(`[Terminal] Script #${index + 1}: Copied inline content.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldScript.parentNode) {
|
||||||
|
oldScript.parentNode.insertBefore(newScript, oldScript.nextSibling); // Insert new after old
|
||||||
|
oldScript.parentNode.removeChild(oldScript); // Then remove old
|
||||||
|
console.log('[Terminal] Script #${index + 1} re-inserted and old one removed.');
|
||||||
|
} else {
|
||||||
|
container.appendChild(newScript);
|
||||||
|
console.warn('[Terminal] Script #${index + 1} had no parent, appended to container directly.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('[Terminal] Finished processing scripts in custom HTML.');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for changes in terminalCustomHTML and execute scripts
|
||||||
|
watch(terminalCustomHTML, (newHtmlContent) => {
|
||||||
|
// Always operate within nextTick to ensure v-html has updated the DOM
|
||||||
|
nextTick(() => {
|
||||||
|
const container = customHtmlLayerRef.value;
|
||||||
|
if (container) {
|
||||||
|
if (newHtmlContent) {
|
||||||
|
console.log('[Terminal] terminalCustomHTML changed, processing new HTML content.');
|
||||||
|
executeScriptsInElement(container);
|
||||||
|
} else {
|
||||||
|
console.log('[Terminal] terminalCustomHTML cleared.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="terminalOuterWrapperRef" class="terminal-outer-wrapper">
|
<div ref="terminalOuterWrapperRef" class="terminal-outer-wrapper">
|
||||||
<!-- 蒙版层 -->
|
<!-- 蒙版层 -->
|
||||||
<div
|
<div
|
||||||
v-if="isTerminalBackgroundEnabled && terminalBackgroundImage"
|
v-if="isTerminalBackgroundEnabled"
|
||||||
class="terminal-background-overlay"
|
class="terminal-background-overlay"
|
||||||
:style="{ backgroundColor: `rgba(0, 0, 0, ${currentTerminalBackgroundOverlayOpacity})` }"
|
:style="{ backgroundColor: `rgba(0, 0, 0, ${currentTerminalBackgroundOverlayOpacity})` }"
|
||||||
></div>
|
></div>
|
||||||
|
<div
|
||||||
|
ref="customHtmlLayerRef"
|
||||||
|
v-if="isTerminalBackgroundEnabled && terminalCustomHTML"
|
||||||
|
class="terminal-custom-html-layer"
|
||||||
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none;"
|
||||||
|
v-html="terminalCustomHTML"
|
||||||
|
></div>
|
||||||
<!-- xterm 实际挂载点 -->
|
<!-- xterm 实际挂载点 -->
|
||||||
<div ref="terminalRef" class="terminal-inner-container"></div>
|
<div ref="terminalRef" class="terminal-inner-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+479
-15
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from 'vue';
|
import { ref, onMounted, watch, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAppearanceStore } from '../../stores/appearance.store';
|
import { useAppearanceStore } from '../../stores/appearance.store';
|
||||||
import { useUiNotificationsStore } from '../../stores/uiNotifications.store';
|
import { useUiNotificationsStore } from '../../stores/uiNotifications.store';
|
||||||
@@ -8,25 +8,81 @@ import { storeToRefs } from 'pinia';
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const appearanceStore = useAppearanceStore();
|
const appearanceStore = useAppearanceStore();
|
||||||
const notificationsStore = useUiNotificationsStore();
|
const notificationsStore = useUiNotificationsStore();
|
||||||
|
|
||||||
|
// Existing state for background image and overlay
|
||||||
const {
|
const {
|
||||||
terminalBackgroundImage,
|
terminalBackgroundImage,
|
||||||
isTerminalBackgroundEnabled,
|
isTerminalBackgroundEnabled,
|
||||||
currentTerminalBackgroundOverlayOpacity,
|
currentTerminalBackgroundOverlayOpacity,
|
||||||
|
// terminalCustomHTML, // This will be replaced by preset logic
|
||||||
|
// HTML Preset related state from store
|
||||||
|
localHtmlPresets,
|
||||||
|
remoteHtmlPresets,
|
||||||
|
remoteHtmlPresetsRepositoryUrl,
|
||||||
|
activeHtmlPresetTab, // This is the ref from the store
|
||||||
|
isLoadingHtmlPresets,
|
||||||
|
htmlPresetError,
|
||||||
} = storeToRefs(appearanceStore);
|
} = storeToRefs(appearanceStore);
|
||||||
|
|
||||||
|
// Actions from store
|
||||||
|
const {
|
||||||
|
fetchLocalHtmlPresets,
|
||||||
|
getLocalHtmlPresetContent,
|
||||||
|
createLocalHtmlPreset,
|
||||||
|
updateLocalHtmlPreset,
|
||||||
|
deleteLocalHtmlPreset,
|
||||||
|
fetchRemoteHtmlPresetsRepositoryUrl,
|
||||||
|
updateRemoteHtmlPresetsRepositoryUrl,
|
||||||
|
fetchRemoteHtmlPresets,
|
||||||
|
getRemoteHtmlPresetContent,
|
||||||
|
applyHtmlPreset,
|
||||||
|
} = appearanceStore;
|
||||||
|
|
||||||
|
|
||||||
const localTerminalBackgroundEnabled = ref(true);
|
const localTerminalBackgroundEnabled = ref(true);
|
||||||
const editableTerminalBackgroundOverlayOpacity = ref(0.5);
|
const editableTerminalBackgroundOverlayOpacity = ref(0.5);
|
||||||
|
// const localTerminalCustomHTML = ref(''); // Replaced by preset editing
|
||||||
|
|
||||||
const terminalBgFileInput = ref<HTMLInputElement | null>(null);
|
const terminalBgFileInput = ref<HTMLInputElement | null>(null);
|
||||||
const uploadError = ref<string | null>(null);
|
const uploadError = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Component's internal active tab, synced with store's activeHtmlPresetTab
|
||||||
|
const currentActiveTab = ref<'local' | 'remote'>('local');
|
||||||
|
|
||||||
|
// State for local preset editing/creating
|
||||||
|
const showPresetEditor = ref(false);
|
||||||
|
const editingPreset = ref<{ name: string, content: string } | null>(null); // For editing existing
|
||||||
|
const newPresetName = ref('');
|
||||||
|
const newPresetContent = ref('');
|
||||||
|
|
||||||
|
// State for remote presets
|
||||||
|
const localRemoteHtmlPresetsRepositoryUrl = ref('');
|
||||||
|
const localHtmlSearchTerm = ref('');
|
||||||
|
const remoteHtmlSearchTerm = ref('');
|
||||||
|
|
||||||
|
|
||||||
const initializeEditableState = () => {
|
const initializeEditableState = () => {
|
||||||
localTerminalBackgroundEnabled.value = isTerminalBackgroundEnabled.value;
|
localTerminalBackgroundEnabled.value = isTerminalBackgroundEnabled.value;
|
||||||
editableTerminalBackgroundOverlayOpacity.value = currentTerminalBackgroundOverlayOpacity.value;
|
editableTerminalBackgroundOverlayOpacity.value = currentTerminalBackgroundOverlayOpacity.value;
|
||||||
|
// localTerminalCustomHTML.value = terminalCustomHTML.value || ''; // Replaced
|
||||||
uploadError.value = null;
|
uploadError.value = null;
|
||||||
|
currentActiveTab.value = activeHtmlPresetTab.value; // Sync with store state
|
||||||
|
localRemoteHtmlPresetsRepositoryUrl.value = remoteHtmlPresetsRepositoryUrl.value || 'https://github.com/Heavrnl/nexus-terminal/tree/main/doc/custom_html_theme';
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(initializeEditableState);
|
onMounted(async () => {
|
||||||
|
initializeEditableState();
|
||||||
|
await fetchLocalHtmlPresets();
|
||||||
|
// Fetch remote URL if not already set, or always? Per plan, store initializes it.
|
||||||
|
// If store's remoteHtmlPresetsRepositoryUrl is null, then fetch it.
|
||||||
|
if (!remoteHtmlPresetsRepositoryUrl.value) {
|
||||||
|
await fetchRemoteHtmlPresetsRepositoryUrl();
|
||||||
|
}
|
||||||
|
// If a URL exists, fetch remote presets
|
||||||
|
if (remoteHtmlPresetsRepositoryUrl.value) {
|
||||||
|
await fetchRemoteHtmlPresets();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
watch(isTerminalBackgroundEnabled, (newValue) => {
|
watch(isTerminalBackgroundEnabled, (newValue) => {
|
||||||
if (localTerminalBackgroundEnabled.value !== newValue) {
|
if (localTerminalBackgroundEnabled.value !== newValue) {
|
||||||
@@ -40,12 +96,20 @@ watch(currentTerminalBackgroundOverlayOpacity, (newValue) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Watch store's activeHtmlPresetTab and update local component state
|
||||||
|
watch(activeHtmlPresetTab, (newTab) => {
|
||||||
|
currentActiveTab.value = newTab;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(remoteHtmlPresetsRepositoryUrl, (newUrl) => {
|
||||||
|
localRemoteHtmlPresetsRepositoryUrl.value = newUrl || '';
|
||||||
|
});
|
||||||
|
|
||||||
const handleTriggerTerminalBgUpload = () => {
|
const handleTriggerTerminalBgUpload = () => {
|
||||||
uploadError.value = null;
|
uploadError.value = null;
|
||||||
terminalBgFileInput.value?.click();
|
terminalBgFileInput.value?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleTerminalBgUpload = async (event: Event) => {
|
const handleTerminalBgUpload = async (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
if (input.files && input.files[0]) {
|
if (input.files && input.files[0]) {
|
||||||
@@ -57,7 +121,7 @@ const handleTerminalBgUpload = async (event: Event) => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const determinedErrorMessage = error.message || t('styleCustomizer.uploadFailed');
|
const determinedErrorMessage = error.message || t('styleCustomizer.uploadFailed');
|
||||||
uploadError.value = determinedErrorMessage;
|
uploadError.value = determinedErrorMessage;
|
||||||
notificationsStore.addNotification({ type: 'error', message: determinedErrorMessage }); // 显示错误通知
|
notificationsStore.addNotification({ type: 'error', message: determinedErrorMessage });
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,22 +137,18 @@ const handleRemoveTerminalBg = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理终端背景启用/禁用切换
|
|
||||||
const handleToggleTerminalBackground = async () => {
|
const handleToggleTerminalBackground = async () => {
|
||||||
const newValue = !localTerminalBackgroundEnabled.value; // 先计算新值
|
const newValue = !localTerminalBackgroundEnabled.value;
|
||||||
localTerminalBackgroundEnabled.value = newValue; // 立即更新本地 UI
|
localTerminalBackgroundEnabled.value = newValue;
|
||||||
try {
|
try {
|
||||||
await appearanceStore.setTerminalBackgroundEnabled(newValue);
|
await appearanceStore.setTerminalBackgroundEnabled(newValue);
|
||||||
// 成功后不需要提示,UI 已更新
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("更新终端背景启用状态失败:", error);
|
console.error("更新终端背景启用状态失败:", error);
|
||||||
// 失败时回滚本地状态
|
|
||||||
localTerminalBackgroundEnabled.value = !newValue;
|
localTerminalBackgroundEnabled.value = !newValue;
|
||||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorToggleTerminalBg', { message: error.message }) });
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorToggleTerminalBg', { message: error.message }) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存终端背景蒙版透明度
|
|
||||||
const handleSaveTerminalBackgroundOverlayOpacity = async () => {
|
const handleSaveTerminalBackgroundOverlayOpacity = async () => {
|
||||||
try {
|
try {
|
||||||
const opacity = Number(editableTerminalBackgroundOverlayOpacity.value);
|
const opacity = Number(editableTerminalBackgroundOverlayOpacity.value);
|
||||||
@@ -104,19 +164,223 @@ const handleSaveTerminalBackgroundOverlayOpacity = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- HTML Preset Functions ---
|
||||||
|
const switchTab = (tab: 'local' | 'remote') => {
|
||||||
|
appearanceStore.activeHtmlPresetTab = tab; // Update store, which will update currentActiveTab via watcher
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyPreset = async (htmlContent: string) => {
|
||||||
|
try {
|
||||||
|
await applyHtmlPreset(htmlContent);
|
||||||
|
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.htmlPresetApplied') });
|
||||||
|
} catch (error: any) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.htmlPresetApplyFailed', { message: error.message }) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetCustomHtml = async () => {
|
||||||
|
try {
|
||||||
|
await applyHtmlPreset(''); // Apply empty HTML to reset
|
||||||
|
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.customHtmlResetSuccess', '自定义 HTML 已重置。') });
|
||||||
|
} catch (error: any) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.customHtmlResetFailed', { message: error.message }) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local preset functions
|
||||||
|
const openNewPresetEditor = () => {
|
||||||
|
editingPreset.value = null;
|
||||||
|
newPresetName.value = '';
|
||||||
|
newPresetContent.value = '';
|
||||||
|
showPresetEditor.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditPresetEditor = (preset: { name: string, content: string }) => { // This is for editing CUSTOM themes
|
||||||
|
editingPreset.value = { ...preset };
|
||||||
|
newPresetName.value = preset.name.replace(/\.html$/, ''); // Remove .html for editing
|
||||||
|
newPresetContent.value = preset.content;
|
||||||
|
showPresetEditor.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// New function to handle "Edit" for a preset theme, which means creating a new custom theme based on it
|
||||||
|
const handleEditPresetAsNew = async (preset: { name: string, type: 'preset' | 'custom' }) => {
|
||||||
|
if (preset.type !== 'preset') {
|
||||||
|
console.warn("handleEditPresetAsNew called with a non-preset theme. This should not happen.");
|
||||||
|
// Fallback to regular edit if it's somehow a custom theme
|
||||||
|
const content = await getLocalHtmlPresetContent(preset.name);
|
||||||
|
openEditPresetEditor({ name: preset.name, content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await getLocalHtmlPresetContent(preset.name); // Get content of the preset
|
||||||
|
editingPreset.value = null; // Important: we are creating a NEW theme
|
||||||
|
newPresetName.value = `${preset.name.replace(/\.html$/, '')}(1)`; // Default new name
|
||||||
|
newPresetContent.value = content;
|
||||||
|
showPresetEditor.value = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorFetchingPresetContentForCopy', { message: e.message, name: preset.name }) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleSaveLocalPreset = async () => {
|
||||||
|
const desiredBaseName = newPresetName.value.trim(); // Name without .html, from input
|
||||||
|
const content = newPresetContent.value.trim();
|
||||||
|
|
||||||
|
if (!desiredBaseName) {
|
||||||
|
// It's recommended to add this key to your i18n files, e.g., "Preset name cannot be empty."
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorPresetNameRequired', '预设名称不能为空。') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!content) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorPresetContentRequired') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalNewFullName = desiredBaseName.endsWith('.html') ? desiredBaseName : `${desiredBaseName}.html`;
|
||||||
|
|
||||||
|
if (editingPreset.value) { // Editing existing
|
||||||
|
const originalFullName = editingPreset.value.name; // Original name with .html
|
||||||
|
|
||||||
|
if (finalNewFullName === originalFullName) { // Name hasn't changed, only content might have
|
||||||
|
try {
|
||||||
|
await updateLocalHtmlPreset(originalFullName, content);
|
||||||
|
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.localPresetUpdated') });
|
||||||
|
showPresetEditor.value = false;
|
||||||
|
} catch (error: any) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.localPresetUpdateFailed', { message: error.message }) });
|
||||||
|
}
|
||||||
|
} else { // Name has changed: "Rename" by creating new and deleting old
|
||||||
|
try {
|
||||||
|
// Attempt to create the new preset first. If this name already exists, createLocalHtmlPreset should throw an error.
|
||||||
|
await createLocalHtmlPreset(finalNewFullName, content);
|
||||||
|
// If creation was successful, delete the old preset
|
||||||
|
await deleteLocalHtmlPreset(originalFullName);
|
||||||
|
// It's recommended to add this key to your i18n files, e.g., "Local preset '{oldName}' has been renamed to '{newName}'."
|
||||||
|
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.localPresetRenamed', { oldName: originalFullName.replace(/\.html$/, ''), newName: desiredBaseName }) });
|
||||||
|
showPresetEditor.value = false;
|
||||||
|
} catch (error: any) {
|
||||||
|
// It's recommended to add this key to your i18n files, e.g., "Failed to rename local preset: {message}"
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.localPresetRenameFailed', { message: error.message }) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // Creating new
|
||||||
|
// Validation for new name and content already happened above
|
||||||
|
try {
|
||||||
|
await createLocalHtmlPreset(finalNewFullName, content);
|
||||||
|
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.localPresetCreated') });
|
||||||
|
showPresetEditor.value = false;
|
||||||
|
newPresetName.value = ''; // Clear fields for next new preset
|
||||||
|
newPresetContent.value = '';
|
||||||
|
} catch (error: any) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.localPresetCreateFailed', { message: error.message }) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLocalPreset = async (name: string) => {
|
||||||
|
// name here is the full filename with .html
|
||||||
|
const displayName = name.replace(/\.html$/, '');
|
||||||
|
if (confirm(t('styleCustomizer.confirmDeletePreset', { name: displayName }))) {
|
||||||
|
try {
|
||||||
|
await deleteLocalHtmlPreset(name);
|
||||||
|
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.localPresetDeleted') });
|
||||||
|
} catch (error: any) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.localPresetDeleteFailed', { message: error.message }) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remote preset functions
|
||||||
|
const handleSaveRemoteRepositoryUrl = async () => {
|
||||||
|
// Allow saving an empty URL to disable remote presets
|
||||||
|
try {
|
||||||
|
await updateRemoteHtmlPresetsRepositoryUrl(localRemoteHtmlPresetsRepositoryUrl.value);
|
||||||
|
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.remoteUrlSaved') });
|
||||||
|
// Optionally fetch presets immediately after saving new URL
|
||||||
|
await fetchRemoteHtmlPresets(localRemoteHtmlPresetsRepositoryUrl.value);
|
||||||
|
} catch (error: any) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.remoteUrlSaveFailed', { message: error.message }) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadRemotePresets = async () => {
|
||||||
|
if (!remoteHtmlPresetsRepositoryUrl.value) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorSetRemoteUrlFirst') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fetchRemoteHtmlPresets();
|
||||||
|
if (!htmlPresetError.value) {
|
||||||
|
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.remotePresetsLoaded') });
|
||||||
|
}
|
||||||
|
} catch (error: any) { // This catch might not be needed if store handles errors
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.remotePresetsLoadFailed', { message: error.message }) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Placeholder for applying a local theme (needs to fetch content first)
|
||||||
|
const applyLocalPreset = async (presetName: string) => {
|
||||||
|
try {
|
||||||
|
const content = await getLocalHtmlPresetContent(presetName);
|
||||||
|
await handleApplyPreset(content);
|
||||||
|
} catch (error: any) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.localPresetApplyFailed', { message: error.message }) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Placeholder for applying a remote theme (needs to fetch content first)
|
||||||
|
const applyRemotePreset = async (downloadUrl?: string) => {
|
||||||
|
if (!downloadUrl) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorMissingDownloadUrl') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const content = await getRemoteHtmlPresetContent(downloadUrl);
|
||||||
|
await handleApplyPreset(content);
|
||||||
|
} catch (error: any) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.remotePresetApplyFailed', { message: error.message }) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredLocalHtmlPresets = computed(() => {
|
||||||
|
const searchTerm = localHtmlSearchTerm.value.toLowerCase().trim();
|
||||||
|
let presets = [...localHtmlPresets.value]; // Make a copy to sort
|
||||||
|
if (searchTerm) {
|
||||||
|
presets = presets.filter(preset => preset.name.replace(/\.html$/, '').toLowerCase().includes(searchTerm));
|
||||||
|
}
|
||||||
|
// Sort by name
|
||||||
|
presets.sort((a, b) => a.name.replace(/\.html$/, '').localeCompare(b.name.replace(/\.html$/, '')));
|
||||||
|
return presets;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredRemoteHtmlPresets = computed(() => {
|
||||||
|
const searchTerm = remoteHtmlSearchTerm.value.toLowerCase().trim();
|
||||||
|
let presets = [...remoteHtmlPresets.value]; // Make a copy to sort
|
||||||
|
if (searchTerm) {
|
||||||
|
presets = presets.filter(preset => preset.name.replace(/\.html$/, '').toLowerCase().includes(searchTerm));
|
||||||
|
}
|
||||||
|
presets.sort((a, b) => a.name.replace(/\.html$/, '').localeCompare(b.name.replace(/\.html$/, '')));
|
||||||
|
return presets;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h3 class="mt-0 border-b border-border pb-2 mb-4 text-lg font-semibold text-foreground">{{ t('styleCustomizer.backgroundSettings') }}</h3>
|
<h3 class="mt-0 border-b border-border pb-2 mb-4 text-lg font-semibold text-foreground">{{ t('styleCustomizer.backgroundSettings') }}</h3>
|
||||||
|
|
||||||
|
<!-- Tab Switcher -->
|
||||||
|
|
||||||
|
|
||||||
<hr class="my-4 md:my-8 border-border">
|
<hr class="my-4 md:my-8 border-border">
|
||||||
|
|
||||||
<!-- 终端背景 -->
|
<!-- Existing Terminal Background Image Settings -->
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h4 class="m-0 text-base font-semibold text-foreground">{{ t('styleCustomizer.terminalBackground') }}</h4>
|
<h4 class="m-0 text-base font-semibold text-foreground">{{ t('styleCustomizer.terminalBackground') }}</h4>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="handleToggleTerminalBackground"
|
@click="handleToggleTerminalBackground"
|
||||||
@@ -137,10 +401,8 @@ const handleSaveTerminalBackgroundOverlayOpacity = async () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div v-if="localTerminalBackgroundEnabled">
|
<div v-if="localTerminalBackgroundEnabled">
|
||||||
<div class="w-full h-[100px] md:h-[150px] border border-dashed border-border mb-2 flex justify-center items-center text-text-secondary bg-cover bg-center bg-no-repeat rounded bg-header relative overflow-hidden" :style="{ backgroundImage: terminalBackgroundImage ? `url(${terminalBackgroundImage})` : 'none' }">
|
<div class="w-full h-[100px] md:h-[150px] border border-dashed border-border mb-2 flex justify-center items-center text-text-secondary bg-cover bg-center bg-no-repeat rounded bg-header relative overflow-hidden" :style="{ backgroundImage: terminalBackgroundImage ? `url(${terminalBackgroundImage})` : 'none' }">
|
||||||
<!-- 实时预览蒙版 -->
|
|
||||||
<div
|
<div
|
||||||
v-if="terminalBackgroundImage"
|
v-if="terminalBackgroundImage"
|
||||||
class="absolute inset-0"
|
class="absolute inset-0"
|
||||||
@@ -154,7 +416,6 @@ const handleSaveTerminalBackgroundOverlayOpacity = async () => {
|
|||||||
<input type="file" ref="terminalBgFileInput" @change="handleTerminalBgUpload" accept="image/*" class="hidden" />
|
<input type="file" ref="terminalBgFileInput" @change="handleTerminalBgUpload" accept="image/*" class="hidden" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 终端背景蒙版透明度控制 -->
|
|
||||||
<div class="mt-4 pt-4 border-t border-border/50">
|
<div class="mt-4 pt-4 border-t border-border/50">
|
||||||
<label for="terminalBgOverlayOpacity" class="block text-sm font-medium text-foreground mb-1">{{ t('styleCustomizer.terminalBgOverlayOpacity', '终端背景蒙版透明度:') }}</label>
|
<label for="terminalBgOverlayOpacity" class="block text-sm font-medium text-foreground mb-1">{{ t('styleCustomizer.terminalBgOverlayOpacity', '终端背景蒙版透明度:') }}</label>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -171,9 +432,212 @@ const handleSaveTerminalBackgroundOverlayOpacity = async () => {
|
|||||||
<button @click="handleSaveTerminalBackgroundOverlayOpacity" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap">{{ t('common.save') }}</button>
|
<button @click="handleSaveTerminalBackgroundOverlayOpacity" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap">{{ t('common.save') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Old custom HTML textarea is removed from here -->
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="p-4 text-center text-text-secondary italic border border-dashed border-border/50 rounded-md">
|
<div v-else class="p-4 text-center text-text-secondary italic border border-dashed border-border/50 rounded-md">
|
||||||
{{ t('styleCustomizer.terminalBgDisabled', '终端背景功能已禁用。') }}
|
{{ t('styleCustomizer.terminalBgDisabled', '终端背景功能已禁用。') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-6 border-border">
|
||||||
|
|
||||||
|
<!-- Tab Switcher for HTML Background Themes -->
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<h4 class="mt-0 text-base font-semibold text-foreground">{{ t('styleCustomizer.htmlBackgroundThemes') }}</h4>
|
||||||
|
<button
|
||||||
|
@click="handleResetCustomHtml"
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 text-xs rounded text-foreground hover:bg-border transition-colors duration-150 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-rotate-left"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 flex border-b border-border">
|
||||||
|
<button
|
||||||
|
@click="switchTab('local')"
|
||||||
|
:class="['px-4 py-2 -mb-px border-b-2 transition-colors duration-150', currentActiveTab === 'local' ? 'border-primary text-primary font-semibold' : 'border-transparent hover:border-gray-400 dark:hover:border-gray-500 text-text-secondary hover:text-foreground']"
|
||||||
|
>
|
||||||
|
{{ t('styleCustomizer.localThemes') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="switchTab('remote')"
|
||||||
|
:class="['px-4 py-2 -mb-px border-b-2 transition-colors duration-150', currentActiveTab === 'remote' ? 'border-primary text-primary font-semibold' : 'border-transparent hover:border-gray-400 dark:hover:border-gray-500 text-text-secondary hover:text-foreground']"
|
||||||
|
>
|
||||||
|
{{ t('styleCustomizer.remoteThemes') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content based on active tab -->
|
||||||
|
<div v-if="currentActiveTab === 'local'">
|
||||||
|
<!-- Flex container for search and new preset button -->
|
||||||
|
<div class="mb-4 flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="localHtmlSearchTerm"
|
||||||
|
:placeholder="t('styleCustomizer.searchLocalThemesPlaceholder', '搜索本地主题...')"
|
||||||
|
class="flex-grow border border-border px-[0.7rem] py-2 rounded text-sm bg-background text-foreground box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<button @click="openNewPresetEditor" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap flex-shrink-0">
|
||||||
|
{{ t('styleCustomizer.addNewTheme') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoadingHtmlPresets" class="text-center p-4 text-text-secondary">
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="filteredLocalHtmlPresets.length > 0" class="list-none p-0 mt-4 max-h-[200px] md:max-h-[280px] overflow-y-auto border border-border rounded bg-background">
|
||||||
|
<li v-for="(preset, index) in filteredLocalHtmlPresets" :key="preset.name"
|
||||||
|
:class="[
|
||||||
|
'block md:grid md:grid-cols-[1fr_auto] items-center px-3 py-2.5 text-sm md:text-[0.95rem] transition-colors duration-200 ease-in-out gap-2',
|
||||||
|
index < filteredLocalHtmlPresets.length - 1 ? 'border-b border-border' : '',
|
||||||
|
'hover:bg-header'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 md:col-start-1 md:col-end-2 overflow-hidden text-ellipsis whitespace-nowrap mb-2 md:mb-0">
|
||||||
|
<span class="text-foreground font-medium" :title="preset.name.replace(/\.html$/, '')">{{ preset.name.replace(/\.html$/, '') }}</span>
|
||||||
|
<span v-if="preset.type === 'preset'" class="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-700 dark:text-blue-200">
|
||||||
|
{{ t('styleCustomizer.presetTag', '预设') }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="preset.type === 'custom'" class="px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-200">
|
||||||
|
{{ t('styleCustomizer.customTag', '自定义') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex md:col-start-2 md:col-end-3 flex-shrink-0 gap-2 justify-start md:justify-end flex-wrap">
|
||||||
|
<button @click="applyLocalPreset(preset.name)"
|
||||||
|
:title="t('styleCustomizer.applyThemeTooltip', 'Apply this theme')"
|
||||||
|
class="px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap border-border bg-header text-foreground hover:bg-border hover:border-text-secondary">
|
||||||
|
{{ t('common.apply') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="preset.type === 'custom'"
|
||||||
|
@click="async () => {
|
||||||
|
try {
|
||||||
|
const content = await getLocalHtmlPresetContent(preset.name);
|
||||||
|
openEditPresetEditor({ name: preset.name, content });
|
||||||
|
} catch (e: any) {
|
||||||
|
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorFetchingPresetContentForEdit', { message: e.message }) });
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
:title="t('common.edit')"
|
||||||
|
class="px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap border-border bg-header text-foreground hover:bg-border hover:border-text-secondary">
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="preset.type === 'preset'"
|
||||||
|
@click="handleEditPresetAsNew(preset)"
|
||||||
|
:title="t('styleCustomizer.editAsNewTooltip', '编辑为新自定义主题')"
|
||||||
|
class="px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap border-border bg-header text-foreground hover:bg-border hover:border-text-secondary">
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="preset.type === 'custom'"
|
||||||
|
@click="handleDeleteLocalPreset(preset.name)"
|
||||||
|
:title="t('common.delete')"
|
||||||
|
class="px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap bg-error/10 text-error border-error/30 hover:bg-error/20">
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else-if="htmlPresetError" class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
|
||||||
|
{{ htmlPresetError }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center p-4 text-text-secondary italic border border-dashed border-border rounded-md">
|
||||||
|
{{ localHtmlSearchTerm ? t('styleCustomizer.noMatchingLocalPresetsFound', '未找到匹配的本地主题') : t('styleCustomizer.noLocalPresetsFound') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentActiveTab === 'remote'">
|
||||||
|
<!-- URL Input and Buttons Container -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="remoteRepoUrl" class="block text-sm font-medium text-foreground mb-1">{{ t('styleCustomizer.remoteHtmlPresetsRepositoryUrl') }}</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="remoteRepoUrl"
|
||||||
|
v-model="localRemoteHtmlPresetsRepositoryUrl"
|
||||||
|
class="flex-grow p-2 border border-border rounded bg-input text-foreground focus:ring-primary focus:border-primary"
|
||||||
|
:placeholder="t('styleCustomizer.remoteRepoUrlPlaceholder', 'https://github.com/Heavrnl/nexus-terminal/tree/main/doc/custom_html_theme')"
|
||||||
|
/>
|
||||||
|
<button @click="handleSaveRemoteRepositoryUrl" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap flex-shrink-0">
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</button>
|
||||||
|
<button @click="handleLoadRemotePresets" :disabled="!remoteHtmlPresetsRepositoryUrl || isLoadingHtmlPresets" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap disabled:opacity-50 flex-shrink-0">
|
||||||
|
{{ isLoadingHtmlPresets && currentActiveTab === 'remote' ? t('common.loading') : t('styleCustomizer.loadRemoteThemes') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Box Container -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="remoteHtmlSearchTerm"
|
||||||
|
:placeholder="t('styleCustomizer.searchRemoteThemesPlaceholder', '搜索远程主题...')"
|
||||||
|
class="border border-border px-[0.7rem] py-2 rounded text-sm bg-background text-foreground w-full box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoadingHtmlPresets && currentActiveTab === 'remote'" class="text-center p-4 text-text-secondary">
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="htmlPresetError && currentActiveTab === 'remote'" class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
|
||||||
|
{{ htmlPresetError }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!remoteHtmlPresetsRepositoryUrl" class="text-center p-4 text-text-secondary italic border border-dashed border-border rounded-md">
|
||||||
|
{{ t('styleCustomizer.pleaseSetRemoteUrl') }}
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="filteredRemoteHtmlPresets.length > 0 && remoteHtmlPresetsRepositoryUrl" class="list-none p-0 mt-4 max-h-[200px] md:max-h-[280px] overflow-y-auto border border-border rounded bg-background">
|
||||||
|
<li v-for="(preset, index) in filteredRemoteHtmlPresets" :key="preset.name"
|
||||||
|
:class="[
|
||||||
|
'block md:grid md:grid-cols-[1fr_auto] items-center px-3 py-2.5 text-sm md:text-[0.95rem] transition-colors duration-200 ease-in-out gap-2',
|
||||||
|
index < filteredRemoteHtmlPresets.length - 1 ? 'border-b border-border' : '',
|
||||||
|
'hover:bg-header'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span class="block md:col-start-1 md:col-end-2 overflow-hidden text-ellipsis whitespace-nowrap mb-2 md:mb-0 text-foreground font-medium" :title="preset.name.replace(/\.html$/, '')">{{ preset.name.replace(/\.html$/, '') }}</span>
|
||||||
|
<div class="flex md:col-start-2 md:col-end-3 flex-shrink-0 gap-2 justify-start md:justify-end flex-wrap">
|
||||||
|
<button @click="applyRemotePreset(preset.downloadUrl)" :disabled="!preset.downloadUrl"
|
||||||
|
:title="t('styleCustomizer.applyThemeTooltip', 'Apply this theme')"
|
||||||
|
class="px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap border-border bg-header text-foreground hover:bg-border hover:border-text-secondary disabled:opacity-50">
|
||||||
|
{{ t('common.apply') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else-if="remoteHtmlPresetsRepositoryUrl" class="text-center p-4 text-text-secondary italic border border-dashed border-border rounded-md">
|
||||||
|
{{ remoteHtmlSearchTerm ? t('styleCustomizer.noMatchingRemotePresetsFound', '未找到匹配的远程主题') : t('styleCustomizer.noRemotePresetsFound') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Preset Editor (Modal or Inline) - Simplified for now -->
|
||||||
|
<div v-if="showPresetEditor" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50" @click.self="showPresetEditor = false">
|
||||||
|
<div class="bg-background p-6 rounded-lg shadow-xl w-full max-w-lg">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="presetName" class="block text-sm font-medium text-foreground mb-1">{{ t('styleCustomizer.presetName') }}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="presetName"
|
||||||
|
v-model="newPresetName"
|
||||||
|
class="w-full p-2 border border-border rounded bg-input text-foreground focus:ring-primary focus:border-primary"
|
||||||
|
:placeholder="t('styleCustomizer.presetNamePlaceholder', 'my-theme')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="presetContent" class="block text-sm font-medium text-foreground mb-1">{{ t('styleCustomizer.presetContent') }}</label>
|
||||||
|
<textarea
|
||||||
|
id="presetContent"
|
||||||
|
v-model="newPresetContent"
|
||||||
|
rows="10"
|
||||||
|
class="w-full p-2 border border-border rounded bg-input text-foreground focus:ring-primary focus:border-primary"
|
||||||
|
:placeholder="t('styleCustomizer.customTerminalHTMLPlaceholder', '例如:<h1>Hello</h1>')"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showPresetEditor = false" class="px-4 py-2 text-sm border border-border rounded bg-header hover:bg-border transition">{{ t('common.cancel') }}</button>
|
||||||
|
<button @click="handleSaveLocalPreset" class="px-4 py-2 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 transition">{{ t('common.save') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -57,10 +57,6 @@ brightMagenta: #ff55ff
|
|||||||
brightCyan: #55ffff
|
brightCyan: #55ffff
|
||||||
brightWhite: #ffffff`;
|
brightWhite: #ffffff`;
|
||||||
// Theme preview refs
|
// Theme preview refs
|
||||||
const originalModalRootBackgroundColor = ref<string | null>(null);
|
|
||||||
const originalModalContentOpacity = ref<string | null>(null);
|
|
||||||
const originalModalRootTransition = ref<string | null>(null);
|
|
||||||
const originalModalContentTransition = ref<string | null>(null);
|
|
||||||
|
|
||||||
const initializeEditableState = () => {
|
const initializeEditableState = () => {
|
||||||
editableTerminalFontFamily.value = currentTerminalFontFamily.value;
|
editableTerminalFontFamily.value = currentTerminalFontFamily.value;
|
||||||
@@ -341,73 +337,6 @@ const handleFocusAndSelect = (event: FocusEvent) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviewButtonMouseDown = async (event: MouseEvent, themeToPreview: TerminalTheme) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (props.modalRootRef && themeToPreview._id) {
|
|
||||||
const modalContentElement = props.modalRootRef.firstElementChild as HTMLElement;
|
|
||||||
try {
|
|
||||||
const themeData = await appearanceStore.loadTerminalThemeData(themeToPreview._id);
|
|
||||||
if (!themeData) {
|
|
||||||
console.error('Preview failed: Could not load theme data for', themeToPreview.name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
appearanceStore.startTerminalThemePreview(themeData);
|
|
||||||
|
|
||||||
if (originalModalRootBackgroundColor.value === null && modalContentElement) {
|
|
||||||
originalModalRootBackgroundColor.value = window.getComputedStyle(props.modalRootRef).backgroundColor;
|
|
||||||
originalModalRootTransition.value = props.modalRootRef.style.transition;
|
|
||||||
originalModalContentOpacity.value = window.getComputedStyle(modalContentElement).opacity;
|
|
||||||
originalModalContentTransition.value = modalContentElement.style.transition;
|
|
||||||
}
|
|
||||||
|
|
||||||
props.modalRootRef.style.transition = 'background-color 0.05s ease-out, opacity 0.05s ease-out';
|
|
||||||
props.modalRootRef.style.backgroundColor = 'transparent';
|
|
||||||
if (modalContentElement) {
|
|
||||||
modalContentElement.style.transition = 'opacity 0.05s ease-out';
|
|
||||||
modalContentElement.style.opacity = '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoreOnMouseUp = () => {
|
|
||||||
appearanceStore.stopTerminalThemePreview();
|
|
||||||
if (props.modalRootRef) {
|
|
||||||
props.modalRootRef.style.backgroundColor = originalModalRootBackgroundColor.value || '';
|
|
||||||
props.modalRootRef.style.transition = originalModalRootTransition.value || '';
|
|
||||||
}
|
|
||||||
if (modalContentElement) {
|
|
||||||
modalContentElement.style.opacity = originalModalContentOpacity.value || '1';
|
|
||||||
modalContentElement.style.transition = originalModalContentTransition.value || '';
|
|
||||||
}
|
|
||||||
originalModalRootBackgroundColor.value = null;
|
|
||||||
originalModalContentOpacity.value = null;
|
|
||||||
originalModalRootTransition.value = null;
|
|
||||||
originalModalContentTransition.value = null;
|
|
||||||
document.removeEventListener('mouseup', restoreOnMouseUp);
|
|
||||||
const currentTargetButton = event.currentTarget as HTMLElement | null;
|
|
||||||
if (currentTargetButton) {
|
|
||||||
currentTargetButton.removeEventListener('mouseleave', restoreOnMouseUp);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mouseup', restoreOnMouseUp, { once: true });
|
|
||||||
const currentTargetButton = event.currentTarget as HTMLElement | null;
|
|
||||||
if (currentTargetButton) {
|
|
||||||
currentTargetButton.addEventListener('mouseleave', restoreOnMouseUp, { once: true });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during theme preview:', error);
|
|
||||||
appearanceStore.stopTerminalThemePreview();
|
|
||||||
if (props.modalRootRef && modalContentElement) {
|
|
||||||
props.modalRootRef.style.backgroundColor = originalModalRootBackgroundColor.value || '';
|
|
||||||
props.modalRootRef.style.transition = originalModalRootTransition.value || '';
|
|
||||||
modalContentElement.style.opacity = originalModalContentOpacity.value || '1';
|
|
||||||
modalContentElement.style.transition = originalModalContentTransition.value || '';
|
|
||||||
originalModalRootBackgroundColor.value = null;
|
|
||||||
originalModalContentOpacity.value = null;
|
|
||||||
originalModalRootTransition.value = null;
|
|
||||||
originalModalContentTransition.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Computed Properties
|
// Computed Properties
|
||||||
const activeThemeName = computed(() => {
|
const activeThemeName = computed(() => {
|
||||||
@@ -543,15 +472,6 @@ watch(() => props.isEditingTheme, (isEditing) => {
|
|||||||
>
|
>
|
||||||
{{ t('styleCustomizer.applyButton', 'Apply') }}
|
{{ t('styleCustomizer.applyButton', 'Apply') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
@mousedown="(event) => handlePreviewButtonMouseDown(event, theme)"
|
|
||||||
:class="[
|
|
||||||
'px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap',
|
|
||||||
theme._id === activeTerminalThemeId?.toString() ? 'text-button-text border-white/30 bg-white/10 hover:bg-white/20 hover:border-white/50' : 'border-border bg-header text-foreground hover:bg-border hover:border-text-secondary'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ t('styleCustomizer.previewButton', 'Preview') }}
|
|
||||||
</button>
|
|
||||||
<button @click="handleEditTheme(theme)" :title="theme.isPreset ? t('styleCustomizer.editAsCopy', 'Edit as Copy') : t('common.edit')"
|
<button @click="handleEditTheme(theme)" :title="theme.isPreset ? t('styleCustomizer.editAsCopy', 'Edit as Copy') : t('common.edit')"
|
||||||
:class="[
|
:class="[
|
||||||
'px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap disabled:opacity-60 disabled:cursor-not-allowed',
|
'px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap disabled:opacity-60 disabled:cursor-not-allowed',
|
||||||
|
|||||||
@@ -99,7 +99,55 @@
|
|||||||
"terminalBgOverlayOpacityDesc": "Controls the opacity of the black overlay on top of the background image. 0 is fully transparent, 1 is fully opaque.",
|
"terminalBgOverlayOpacityDesc": "Controls the opacity of the black overlay on top of the background image. 0 is fully transparent, 1 is fully opaque.",
|
||||||
"errorInvalidOpacityValue": "Invalid opacity value. Must be between 0 and 1.",
|
"errorInvalidOpacityValue": "Invalid opacity value. Must be between 0 and 1.",
|
||||||
"terminalBgOverlayOpacitySaved": "Terminal background overlay opacity saved.",
|
"terminalBgOverlayOpacitySaved": "Terminal background overlay opacity saved.",
|
||||||
"terminalBgOverlayOpacitySaveFailed": "Failed to save terminal background overlay opacity: {message}"
|
"terminalBgOverlayOpacitySaveFailed": "Failed to save terminal background overlay opacity: {message}",
|
||||||
|
"terminalBgDisabled": "Terminal background feature is disabled.",
|
||||||
|
"htmlBackgroundThemes": "HTML Background Themes",
|
||||||
|
"localThemes": "Local Themes",
|
||||||
|
"remoteThemes": "Remote Themes",
|
||||||
|
"newLocalPreset": "New Local Theme",
|
||||||
|
"noLocalPresetsFound": "No local HTML themes found.",
|
||||||
|
"errorFetchingPresetContentForEdit": "Failed to fetch theme content for editing: {message}",
|
||||||
|
"remoteHtmlPresetsRepositoryUrl": "Remote HTML Themes Repository URL",
|
||||||
|
"remoteRepoUrlPlaceholder": "e.g., https://github.com/user/repo/tree/main/themes",
|
||||||
|
"saveUrl": "Save URL",
|
||||||
|
"loadRemoteThemes": "refresh",
|
||||||
|
"pleaseSetRemoteUrl": "Please set the remote HTML themes repository URL first.",
|
||||||
|
"noRemotePresetsFound": "No HTML themes found in the remote repository, or the URL is invalid.",
|
||||||
|
"editLocalPreset": "Edit Local Theme",
|
||||||
|
"presetName": "Theme Name",
|
||||||
|
"presetNamePlaceholder": "e.g., my-theme.html",
|
||||||
|
"presetContent": "Theme Content",
|
||||||
|
"customTerminalHTMLPlaceholder": "e.g., <h1>Hello</h1>",
|
||||||
|
"errorToggleTerminalBg": "Failed to update terminal background enabled state: {message}",
|
||||||
|
"htmlPresetApplied": "HTML theme applied.",
|
||||||
|
"htmlPresetApplyFailed": "Failed to apply HTML theme: {message}",
|
||||||
|
"errorPresetContentRequired": "Theme content cannot be empty.",
|
||||||
|
"localPresetUpdated": "Local HTML theme updated.",
|
||||||
|
"localPresetUpdateFailed": "Failed to update local HTML theme: {message}",
|
||||||
|
"errorPresetNameAndContentRequired": "Theme name and content cannot be empty.",
|
||||||
|
"localPresetCreated": "Local HTML theme created.",
|
||||||
|
"localPresetCreateFailed": "Failed to create local HTML theme: {message}",
|
||||||
|
"confirmDeletePreset": "Are you sure you want to delete the HTML theme \"{name}\"?",
|
||||||
|
"localPresetDeleted": "Local HTML theme deleted.",
|
||||||
|
"localPresetDeleteFailed": "Failed to delete local HTML theme: {message}",
|
||||||
|
"errorRemoteUrlRequired": "Remote HTML themes repository URL cannot be empty.",
|
||||||
|
"remoteUrlSaved": "Remote HTML themes repository URL saved.",
|
||||||
|
"remoteUrlSaveFailed": "Failed to save remote HTML themes repository URL: {message}",
|
||||||
|
"errorSetRemoteUrlFirst": "Please set and save the remote HTML themes repository URL first.",
|
||||||
|
"remotePresetsLoaded": "Remote HTML themes list loaded.",
|
||||||
|
"remotePresetsLoadFailed": "Failed to load remote HTML themes list: {message}",
|
||||||
|
"localPresetApplyFailed": "Failed to apply local HTML theme: {message}",
|
||||||
|
"errorPresetNameRequired": "Preset name cannot be empty.",
|
||||||
|
"errorMissingDownloadUrl": "Remote theme download URL is missing.",
|
||||||
|
"remotePresetApplyFailed": "Failed to apply remote HTML theme: {message}",
|
||||||
|
"customHtmlResetSuccess":"Custom HTML has been reset.",
|
||||||
|
"searchLocalThemesPlaceholder": "Search local themes...",
|
||||||
|
"searchRemoteThemesPlaceholder": "Search remote themes...",
|
||||||
|
"noMatchingLocalPresetsFound": "No matching local themes found",
|
||||||
|
"noMatchingRemotePresetsFound": "No matching remote themes found",
|
||||||
|
"editAsNewTooltip": "Edit as new custom theme",
|
||||||
|
"presetTag": "Preset",
|
||||||
|
"customTag": "Custom"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "User Login",
|
"title": "User Login",
|
||||||
@@ -977,6 +1025,7 @@
|
|||||||
"testMessageUnsaved": "Test triggered for unsaved {channelType} configuration"
|
"testMessageUnsaved": "Test triggered for unsaved {channelType} configuration"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"apply": "Apply",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
"clearTerminal": "ターミナルをクリア"
|
"clearTerminal": "ターミナルをクリア"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"apply": "適用",
|
||||||
"all": "すべて",
|
"all": "すべて",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
@@ -1285,7 +1286,56 @@
|
|||||||
"terminalBgOverlayOpacityDesc": "背景画像上の黒いオーバーレイの不透明度を制御します。0は完全に透明、1は完全に不透明です。",
|
"terminalBgOverlayOpacityDesc": "背景画像上の黒いオーバーレイの不透明度を制御します。0は完全に透明、1は完全に不透明です。",
|
||||||
"errorInvalidOpacityValue": "無効な不透明度の値です。0から1の間でなければなりません。",
|
"errorInvalidOpacityValue": "無効な不透明度の値です。0から1の間でなければなりません。",
|
||||||
"terminalBgOverlayOpacitySaved": "ターミナル背景オーバーレイの不透明度が保存されました。",
|
"terminalBgOverlayOpacitySaved": "ターミナル背景オーバーレイの不透明度が保存されました。",
|
||||||
"terminalBgOverlayOpacitySaveFailed": "ターミナル背景オーバーレイの不透明度の保存に失敗しました: {message}"
|
"terminalBgOverlayOpacitySaveFailed": "ターミナル背景オーバーレイの不透明度の保存に失敗しました: {message}",
|
||||||
|
"terminalBgDisabled": "ターミナルの背景機能は無効です。",
|
||||||
|
"htmlBackgroundThemes": "HTML背景テーマ",
|
||||||
|
"localThemes": "ローカルテーマ",
|
||||||
|
"remoteThemes": "リモートテーマ",
|
||||||
|
"newLocalPreset": "新しいローカルテーマを作成",
|
||||||
|
"noLocalPresetsFound": "ローカルHTMLテーマが見つかりません。",
|
||||||
|
"errorFetchingPresetContentForEdit": "編集のためにテーマコンテンツの取得に失敗しました: {message}",
|
||||||
|
"remoteHtmlPresetsRepositoryUrl": "リモートHTMLテーマリポジトリURL",
|
||||||
|
"remoteRepoUrlPlaceholder": "例: https://github.com/user/repo/tree/main/themes",
|
||||||
|
"saveUrl": "URLを保存",
|
||||||
|
"loadRemoteThemes": "リフレッシュ",
|
||||||
|
"pleaseSetRemoteUrl": "最初にリモートHTMLテーマリポジトリのURLを設定してください。",
|
||||||
|
"noRemotePresetsFound": "リモートリポジトリにHTMLテーマが見つからないか、URLが無効です。",
|
||||||
|
"editLocalPreset": "ローカルテーマを編集",
|
||||||
|
"presetName": "テーマ名",
|
||||||
|
"presetNamePlaceholder": "例: my-theme.html",
|
||||||
|
"presetContent": "テーマコンテンツ",
|
||||||
|
"customTerminalHTMLPlaceholder": "例: <h1>こんにちは</h1>",
|
||||||
|
"errorToggleTerminalBg": "ターミナルの背景有効状態の更新に失敗しました: {message}",
|
||||||
|
"htmlPresetApplied": "HTMLテーマが適用されました。",
|
||||||
|
"htmlPresetApplyFailed": "HTMLテーマの適用に失敗しました: {message}",
|
||||||
|
"errorPresetContentRequired": "テーマのコンテンツは空にできません。",
|
||||||
|
"localPresetUpdated": "ローカルHTMLテーマが更新されました。",
|
||||||
|
"localPresetUpdateFailed": "ローカルHTMLテーマの更新に失敗しました: {message}",
|
||||||
|
"errorPresetNameAndContentRequired": "テーマ名とコンテンツは空にできません。",
|
||||||
|
"localPresetCreated": "ローカルHTMLテーマが作成されました。",
|
||||||
|
"localPresetCreateFailed": "ローカルHTMLテーマの作成に失敗しました: {message}",
|
||||||
|
"confirmDeletePreset": "HTMLテーマ「{name}」を削除してもよろしいですか?",
|
||||||
|
"localPresetDeleted": "ローカルHTMLテーマが削除されました。",
|
||||||
|
"localPresetDeleteFailed": "ローカルHTMLテーマの削除に失敗しました: {message}",
|
||||||
|
"errorRemoteUrlRequired": "リモートHTMLテーマリポジトリのURLは空にできません。",
|
||||||
|
"remoteUrlSaved": "リモートHTMLテーマリポジトリのURLが保存されました。",
|
||||||
|
"remoteUrlSaveFailed": "リモートHTMLテーマリポジトリのURLの保存に失敗しました: {message}",
|
||||||
|
"errorSetRemoteUrlFirst": "最初にリモートHTMLテーマリポジトリのURLを設定して保存してください。",
|
||||||
|
"remotePresetsLoaded": "リモートHTMLテーマリストが読み込まれました。",
|
||||||
|
"remotePresetsLoadFailed": "リモートHTMLテーマリストの読み込みに失敗しました: {message}",
|
||||||
|
"localPresetApplyFailed": "ローカルHTMLテーマの適用に失敗しました: {message}",
|
||||||
|
"errorMissingDownloadUrl": "リモートテーマのダウンロードURLがありません。",
|
||||||
|
"errorPresetNameRequired": "プリセット名を空にすることはできません。",
|
||||||
|
"remotePresetApplyFailed": "リモートHTMLテーマの適用に失敗しました: {message}",
|
||||||
|
"customHtmlResetSuccess":"カスタムHTMLがリセットされました。",
|
||||||
|
"searchLocalThemesPlaceholder": "ローカルテーマを検索...",
|
||||||
|
"searchRemoteThemesPlaceholder": "リモートテーマを検索...",
|
||||||
|
"noMatchingLocalPresetsFound": "一致するローカルテーマが見つかりませんでした",
|
||||||
|
"noMatchingRemotePresetsFound": "一致するリモートテーマが見つかりませんでした",
|
||||||
|
"editAsNewTooltip": "新しいカスタムテーマとして編集",
|
||||||
|
"presetTag": "プリセット",
|
||||||
|
"customTag": "カスタム"
|
||||||
|
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"addTag": "新しいタグを追加",
|
"addTag": "新しいタグを追加",
|
||||||
|
|||||||
@@ -98,7 +98,57 @@
|
|||||||
"terminalBgOverlayOpacityDesc": "控制背景图片上方黑色蒙版的透明度。0为完全透明,1为完全不透明。",
|
"terminalBgOverlayOpacityDesc": "控制背景图片上方黑色蒙版的透明度。0为完全透明,1为完全不透明。",
|
||||||
"errorInvalidOpacityValue": "无效的透明度值,必须在0到1之间",
|
"errorInvalidOpacityValue": "无效的透明度值,必须在0到1之间",
|
||||||
"terminalBgOverlayOpacitySaved": "终端背景蒙版透明度已保存",
|
"terminalBgOverlayOpacitySaved": "终端背景蒙版透明度已保存",
|
||||||
"terminalBgOverlayOpacitySaveFailed": "终端背景蒙版透明度保存失败: {message}"
|
"terminalBgOverlayOpacitySaveFailed": "终端背景蒙版透明度保存失败: {message}",
|
||||||
|
"terminalBgDisabled": "终端背景功能已禁用。",
|
||||||
|
"htmlBackgroundThemes": "HTML 背景主题",
|
||||||
|
"localThemes": "本地主题",
|
||||||
|
"remoteThemes": "远程主题",
|
||||||
|
"newLocalPreset": "新建本地主题",
|
||||||
|
"noLocalPresetsFound": "未找到本地 HTML 主题。",
|
||||||
|
"errorFetchingPresetContentForEdit": "获取主题内容以供编辑失败: {message}",
|
||||||
|
"remoteHtmlPresetsRepositoryUrl": "远程 HTML 主题仓库链接",
|
||||||
|
"remoteRepoUrlPlaceholder": "例如:https://github.com/user/repo/tree/main/themes",
|
||||||
|
"saveUrl": "保存链接",
|
||||||
|
"loadRemoteThemes": "刷新",
|
||||||
|
"pleaseSetRemoteUrl": "请先设置远程 HTML 主题仓库链接。",
|
||||||
|
"noRemotePresetsFound": "远程仓库中未找到 HTML 主题,或链接无效。",
|
||||||
|
"editLocalPreset": "编辑本地主题",
|
||||||
|
"presetName": "主题名称",
|
||||||
|
"presetNamePlaceholder": "例如:my-theme.html",
|
||||||
|
"presetContent": "主题内容",
|
||||||
|
"customTerminalHTMLPlaceholder": "例如:<h1>Hello</h1>",
|
||||||
|
"errorToggleTerminalBg": "更新终端背景启用状态失败: {message}",
|
||||||
|
"htmlPresetApplied": "HTML 主题已应用。",
|
||||||
|
"htmlPresetApplyFailed": "应用 HTML 主题失败: {message}",
|
||||||
|
"errorPresetContentRequired": "主题内容不能为空。",
|
||||||
|
"localPresetUpdated": "本地 HTML 主题已更新。",
|
||||||
|
"localPresetUpdateFailed": "更新本地 HTML 主题失败: {message}",
|
||||||
|
"errorPresetNameAndContentRequired": "主题名称和内容均不能为空。",
|
||||||
|
"localPresetCreated": "本地 HTML 主题已创建。",
|
||||||
|
"localPresetCreateFailed": "创建本地 HTML 主题失败: {message}",
|
||||||
|
"confirmDeletePreset": "确定要删除 HTML 主题 \"{name}\" 吗?",
|
||||||
|
"localPresetDeleted": "本地 HTML 主题已删除。",
|
||||||
|
"localPresetDeleteFailed": "删除本地 HTML 主题失败: {message}",
|
||||||
|
"errorRemoteUrlRequired": "远程 HTML 主题仓库链接不能为空。",
|
||||||
|
"remoteUrlSaved": "远程 HTML 主题仓库链接已保存。",
|
||||||
|
"remoteUrlSaveFailed": "保存远程 HTML 主题仓库链接失败: {message}",
|
||||||
|
"errorSetRemoteUrlFirst": "请先设置并保存远程 HTML 主题仓库链接。",
|
||||||
|
"remotePresetsLoaded": "远程 HTML 主题列表已加载。",
|
||||||
|
"remotePresetsLoadFailed": "加载远程 HTML 主题列表失败: {message}",
|
||||||
|
"localPresetApplyFailed": "应用本地 HTML 主题失败: {message}",
|
||||||
|
"errorMissingDownloadUrl": "远程主题下载链接缺失。",
|
||||||
|
"remotePresetApplyFailed": "应用远程 HTML 主题失败: {message}",
|
||||||
|
"errorPresetNameRequired": "预设名称不能为空。",
|
||||||
|
"localPresetRenamed": "本地预设 “{oldName}” 已成功重命名为 “{newName}”。",
|
||||||
|
"localPresetRenameFailed": "重命名本地预设失败: {message}",
|
||||||
|
"searchLocalThemesPlaceholder": "搜索本地主题...",
|
||||||
|
"searchRemoteThemesPlaceholder": "搜索远程主题...",
|
||||||
|
"noMatchingLocalPresetsFound": "未找到匹配的本地主题",
|
||||||
|
"noMatchingRemotePresetsFound": "未找到匹配的远程主题",
|
||||||
|
"editAsNewTooltip": "编辑为新自定义主题",
|
||||||
|
"presetTag": "预设",
|
||||||
|
"customTag": "自定义",
|
||||||
|
"customHtmlResetSuccess":"自定义 HTML 已重置。"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "用户登录",
|
"title": "用户登录",
|
||||||
@@ -977,6 +1027,7 @@
|
|||||||
"testMessageUnsaved": "为未保存的 {channelType} 配置触发的测试"
|
"testMessageUnsaved": "为未保存的 {channelType} 配置触发的测试"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"apply": "应用",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
|||||||
@@ -29,7 +29,15 @@ export const useAppearanceStore = defineStore('appearance', () => {
|
|||||||
// Appearance Settings State
|
// Appearance Settings State
|
||||||
const appearanceSettings = ref<Partial<AppearanceSettings>>({}); // 从 API 获取的原始设置
|
const appearanceSettings = ref<Partial<AppearanceSettings>>({}); // 从 API 获取的原始设置
|
||||||
const allTerminalThemes = ref<TerminalTheme[]>([]); // 重命名: 存储从后端获取的所有主题
|
const allTerminalThemes = ref<TerminalTheme[]>([]); // 重命名: 存储从后端获取的所有主题
|
||||||
|
|
||||||
|
// HTML Presets State
|
||||||
|
const localHtmlPresets = ref<Array<{ name: string, type: 'preset' | 'custom' }>>([]); // Updated type
|
||||||
|
const remoteHtmlPresets = ref<Array<{ name: string, downloadUrl?: string }>>([]);
|
||||||
|
const remoteHtmlPresetsRepositoryUrl = ref<string | null>(null);
|
||||||
|
const activeHtmlPresetTab = ref<'local' | 'remote'>('local');
|
||||||
|
const isLoadingHtmlPresets = ref(false);
|
||||||
|
const htmlPresetError = ref<string | null>(null);
|
||||||
|
|
||||||
// State for theme preview
|
// State for theme preview
|
||||||
const isPreviewingTerminalTheme = ref(false);
|
const isPreviewingTerminalTheme = ref(false);
|
||||||
const previewTerminalThemeData = ref<ITheme | null>(null);
|
const previewTerminalThemeData = ref<ITheme | null>(null);
|
||||||
@@ -134,6 +142,9 @@ export const useAppearanceStore = defineStore('appearance', () => {
|
|||||||
return typeof opacity === 'number' && opacity >= 0 && opacity <= 1 ? opacity : 0.5; // 默认 0.5
|
return typeof opacity === 'number' && opacity >= 0 && opacity <= 1 ? opacity : 0.5; // 默认 0.5
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 获取终端自定义 CSS
|
||||||
|
const terminalCustomHTML = computed(() => appearanceSettings.value.terminal_custom_html ?? null);
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +161,11 @@ export const useAppearanceStore = defineStore('appearance', () => {
|
|||||||
]);
|
]);
|
||||||
appearanceSettings.value = settingsResponse.data;
|
appearanceSettings.value = settingsResponse.data;
|
||||||
allTerminalThemes.value = themesResponse.data; // 更新 allTerminalThemes
|
allTerminalThemes.value = themesResponse.data; // 更新 allTerminalThemes
|
||||||
|
|
||||||
|
// Initialize remoteHtmlPresetsRepositoryUrl from loaded settings
|
||||||
|
// Assuming backend returns it as part of AppearanceSettings
|
||||||
|
remoteHtmlPresetsRepositoryUrl.value = appearanceSettings.value.remoteHtmlPresetsUrl || null;
|
||||||
|
|
||||||
// 应用加载的 UI 主题
|
// 应用加载的 UI 主题
|
||||||
applyUiTheme(currentUiTheme.value);
|
applyUiTheme(currentUiTheme.value);
|
||||||
// 应用背景
|
// 应用背景
|
||||||
@@ -307,6 +322,22 @@ export const useAppearanceStore = defineStore('appearance', () => {
|
|||||||
await updateAppearanceSettings({ terminalBackgroundOverlayOpacity: opacity });
|
await updateAppearanceSettings({ terminalBackgroundOverlayOpacity: opacity });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置终端自定义 HTML
|
||||||
|
* @param html HTML 字符串,或 null 清除
|
||||||
|
*/
|
||||||
|
async function setTerminalCustomHTML(html: string | null) {
|
||||||
|
try {
|
||||||
|
await updateAppearanceSettings({ terminal_custom_html: html });
|
||||||
|
// console.log('[AppearanceStore] Terminal custom HTML updated successfully.');
|
||||||
|
// 可以在此调用 uiNotifications.store 来显示成功消息
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('设置终端自定义 HTML 失败:', err);
|
||||||
|
// 可以在此调用 uiNotifications.store 来显示失败消息
|
||||||
|
throw new Error(err.response?.data?.message || err.message || '设置终端自定义 HTML 失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- 终端主题列表管理 Actions ---
|
// --- 终端主题列表管理 Actions ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -550,7 +581,146 @@ export const useAppearanceStore = defineStore('appearance', () => {
|
|||||||
isPreviewingTerminalTheme.value = false;
|
isPreviewingTerminalTheme.value = false;
|
||||||
console.log('[AppearanceStore] Stopped terminal theme preview.');
|
console.log('[AppearanceStore] Stopped terminal theme preview.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- HTML Preset Actions ---
|
||||||
|
async function fetchLocalHtmlPresets() {
|
||||||
|
isLoadingHtmlPresets.value = true;
|
||||||
|
htmlPresetError.value = null;
|
||||||
|
try {
|
||||||
|
// Updated to expect type information from the backend
|
||||||
|
const response = await apiClient.get<Array<{ name: string, type: 'preset' | 'custom' }>>('/appearance/html-presets/local');
|
||||||
|
localHtmlPresets.value = response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('获取本地 HTML 主题列表失败:', err);
|
||||||
|
htmlPresetError.value = err.response?.data?.message || err.message || '获取本地 HTML 主题列表失败';
|
||||||
|
localHtmlPresets.value = [];
|
||||||
|
} finally {
|
||||||
|
isLoadingHtmlPresets.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLocalHtmlPresetContent(name: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<string>(`/appearance/html-presets/local/${name}`, { transformResponse: (res) => res }); // Expecting plain text
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`获取本地 HTML 主题 '${name}' 内容失败:`, err);
|
||||||
|
throw new Error(err.response?.data?.message || err.message || `获取主题 '${name}' 内容失败`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLocalHtmlPreset(name: string, content: string) {
|
||||||
|
try {
|
||||||
|
await apiClient.post('/appearance/html-presets/local', { name, content });
|
||||||
|
await fetchLocalHtmlPresets(); // Refresh list
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('创建本地 HTML 主题失败:', err);
|
||||||
|
throw new Error(err.response?.data?.message || err.message || '创建本地 HTML 主题失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLocalHtmlPreset(name: string, content: string) {
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/appearance/html-presets/local/${name}`, { content });
|
||||||
|
// Optionally refresh list or update item if content is stored locally too
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`更新本地 HTML 主题 '${name}' 失败:`, err);
|
||||||
|
throw new Error(err.response?.data?.message || err.message || `更新主题 '${name}' 失败`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLocalHtmlPreset(name: string) {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/appearance/html-presets/local/${name}`);
|
||||||
|
await fetchLocalHtmlPresets(); // Refresh list
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`删除本地 HTML 主题 '${name}' 失败:`, err);
|
||||||
|
throw new Error(err.response?.data?.message || err.message || `删除主题 '${name}' 失败`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRemoteHtmlPresetsRepositoryUrl() {
|
||||||
|
isLoadingHtmlPresets.value = true; // Use main loading or a specific one
|
||||||
|
htmlPresetError.value = null;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<{ url: string | null }>('/appearance/html-presets/remote/repository-url');
|
||||||
|
remoteHtmlPresetsRepositoryUrl.value = response.data.url;
|
||||||
|
// Also update in appearanceSettings to persist if this API also saves
|
||||||
|
if (appearanceSettings.value && response.data.url !== undefined) {
|
||||||
|
// This assumes the backend GET doesn't modify, so we only update local store state from GET
|
||||||
|
// If this GET /is/ meant to also fetch latest from settings and that's the source of truth,
|
||||||
|
// then updateAppearanceSettings might not be needed here.
|
||||||
|
// The plan says "或直接从 settings.value 读取并更新到 remoteHtmlPresetsRepositoryUrl state"
|
||||||
|
// and for update: "成功后更新 store state".
|
||||||
|
// So this action fetches and updates the store's reactive ref.
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('获取远程 HTML 主题仓库链接失败:', err);
|
||||||
|
htmlPresetError.value = err.response?.data?.message || err.message || '获取远程仓库链接失败';
|
||||||
|
} finally {
|
||||||
|
isLoadingHtmlPresets.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRemoteHtmlPresetsRepositoryUrl(url: string) {
|
||||||
|
try {
|
||||||
|
await apiClient.put('/appearance/html-presets/remote/repository-url', { url });
|
||||||
|
remoteHtmlPresetsRepositoryUrl.value = url; // Update local state on success
|
||||||
|
// Persist this change in the main appearance settings object as well if needed
|
||||||
|
await updateAppearanceSettings({ remoteHtmlPresetsUrl: url });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('更新远程 HTML 主题仓库链接失败:', err);
|
||||||
|
throw new Error(err.response?.data?.message || err.message || '更新远程仓库链接失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRemoteHtmlPresets(repoUrlParam?: string) {
|
||||||
|
isLoadingHtmlPresets.value = true;
|
||||||
|
htmlPresetError.value = null;
|
||||||
|
const urlToFetch = repoUrlParam || remoteHtmlPresetsRepositoryUrl.value;
|
||||||
|
if (!urlToFetch) {
|
||||||
|
htmlPresetError.value = '远程仓库链接未设置';
|
||||||
|
isLoadingHtmlPresets.value = false;
|
||||||
|
remoteHtmlPresets.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// The API is GET /api/v1/appearance/html-presets/remote/list
|
||||||
|
// It might take repoUrl as a query param if not using the saved one.
|
||||||
|
// The plan states: `repoUrl` (可选, 如果不提供则使用已保存的链接)
|
||||||
|
// So, if repoUrlParam is provided, it should be sent.
|
||||||
|
const params: { repoUrl?: string } = {};
|
||||||
|
if (repoUrlParam) { // Only send if explicitly passed to this action
|
||||||
|
params.repoUrl = repoUrlParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.get<Array<{ name: string, downloadUrl?: string }>>('/appearance/html-presets/remote/list', { params });
|
||||||
|
remoteHtmlPresets.value = response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('获取远程 HTML 主题列表失败:', err);
|
||||||
|
htmlPresetError.value = err.response?.data?.message || err.message || '获取远程主题列表失败';
|
||||||
|
remoteHtmlPresets.value = [];
|
||||||
|
} finally {
|
||||||
|
isLoadingHtmlPresets.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRemoteHtmlPresetContent(fileUrl: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Expecting plain text response
|
||||||
|
const response = await apiClient.get<string>(`/appearance/html-presets/remote/content`, { params: { fileUrl }, transformResponse: (res) => res });
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`获取远程 HTML 主题内容 (URL: ${fileUrl}) 失败:`, err);
|
||||||
|
throw new Error(err.response?.data?.message || err.message || '获取远程主题内容失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyHtmlPreset(htmlContent: string) {
|
||||||
|
// This action internally calls setTerminalCustomHTML
|
||||||
|
await setTerminalCustomHTML(htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helper Functions ---
|
// --- Helper Functions ---
|
||||||
/**
|
/**
|
||||||
* 将 UI 主题 (CSS 变量) 应用到文档根元素。
|
* 将 UI 主题 (CSS 变量) 应用到文档根元素。
|
||||||
@@ -675,15 +845,35 @@ export const useAppearanceStore = defineStore('appearance', () => {
|
|||||||
exportTerminalTheme,
|
exportTerminalTheme,
|
||||||
uploadPageBackground,
|
uploadPageBackground,
|
||||||
uploadTerminalBackground,
|
uploadTerminalBackground,
|
||||||
setTerminalBackgroundOverlayOpacity,
|
setTerminalBackgroundOverlayOpacity,
|
||||||
|
setTerminalCustomHTML, // 设置终端自定义 HTML
|
||||||
removePageBackground,
|
removePageBackground,
|
||||||
removeTerminalBackground,
|
removeTerminalBackground,
|
||||||
loadTerminalThemeData,
|
loadTerminalThemeData,
|
||||||
isTerminalBackgroundEnabled,
|
isTerminalBackgroundEnabled,
|
||||||
|
terminalCustomHTML, // 获取终端自定义 HTML
|
||||||
startTerminalThemePreview,
|
startTerminalThemePreview,
|
||||||
stopTerminalThemePreview,
|
stopTerminalThemePreview,
|
||||||
// Visibility control
|
// Visibility control
|
||||||
isStyleCustomizerVisible,
|
isStyleCustomizerVisible,
|
||||||
toggleStyleCustomizer,
|
toggleStyleCustomizer,
|
||||||
|
|
||||||
|
// HTML Presets State & Actions
|
||||||
|
localHtmlPresets,
|
||||||
|
remoteHtmlPresets,
|
||||||
|
remoteHtmlPresetsRepositoryUrl,
|
||||||
|
activeHtmlPresetTab,
|
||||||
|
isLoadingHtmlPresets,
|
||||||
|
htmlPresetError,
|
||||||
|
fetchLocalHtmlPresets,
|
||||||
|
getLocalHtmlPresetContent,
|
||||||
|
createLocalHtmlPreset,
|
||||||
|
updateLocalHtmlPreset,
|
||||||
|
deleteLocalHtmlPreset,
|
||||||
|
fetchRemoteHtmlPresetsRepositoryUrl,
|
||||||
|
updateRemoteHtmlPresetsRepositoryUrl,
|
||||||
|
fetchRemoteHtmlPresets,
|
||||||
|
getRemoteHtmlPresetContent,
|
||||||
|
applyHtmlPreset,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface AppearanceSettings {
|
|||||||
editorFontFamily?: string | null; // Monaco Editor 字体偏好
|
editorFontFamily?: string | null; // Monaco Editor 字体偏好
|
||||||
terminalBackgroundEnabled?: boolean; // 终端背景是否启用
|
terminalBackgroundEnabled?: boolean; // 终端背景是否启用
|
||||||
terminalBackgroundOverlayOpacity?: number; // 终端背景蒙版透明度 (0-1)
|
terminalBackgroundOverlayOpacity?: number; // 终端背景蒙版透明度 (0-1)
|
||||||
|
terminal_custom_html?: string | null; // 终端自定义 HTML
|
||||||
|
remoteHtmlPresetsUrl?: string | null; // 远程 HTML 主题仓库链接
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前端用于更新外观设置的数据结构 (对应 API 请求体)
|
// 前端用于更新外观设置的数据结构 (对应 API 请求体)
|
||||||
|
|||||||
Reference in New Issue
Block a user