update
@@ -0,0 +1,126 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as appearanceService from '../services/appearance.service';
|
||||
import { UpdateAppearanceDto } from '../types/appearance.types';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// --- 背景图片上传配置 ---
|
||||
const backgroundStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadPath = path.join(__dirname, '../../uploads/backgrounds/');
|
||||
// 确保目录存在
|
||||
fs.mkdirSync(uploadPath, { recursive: true });
|
||||
cb(null, uploadPath);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// 使用时间戳和原始文件名(去除特殊字符)创建唯一文件名
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const safeOriginalName = file.originalname.replace(/[^a-zA-Z0-9.]/g, '_');
|
||||
cb(null, uniqueSuffix + '-' + safeOriginalName);
|
||||
}
|
||||
});
|
||||
|
||||
const backgroundUpload = multer({
|
||||
storage: backgroundStorage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
// 允许常见的图片格式
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('只允许上传图片文件 (JPEG, PNG, GIF, WebP, SVG)!'));
|
||||
}
|
||||
},
|
||||
limits: { fileSize: 5 * 1024 * 1024 } // 限制文件大小为 5MB
|
||||
});
|
||||
// --- End Background Image Upload Config ---
|
||||
|
||||
|
||||
/**
|
||||
* 获取外观设置
|
||||
*/
|
||||
export const getAppearanceSettingsController = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = await appearanceService.getSettings();
|
||||
res.status(200).json(settings);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ message: '获取外观设置失败', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新外观设置
|
||||
*/
|
||||
export const updateAppearanceSettingsController = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settingsDto: UpdateAppearanceDto = req.body;
|
||||
// 注意:背景图片通常通过单独的上传接口处理,这里只更新文本类设置
|
||||
const success = await appearanceService.updateSettings(settingsDto);
|
||||
if (success) {
|
||||
// 获取更新后的设置并返回
|
||||
const updatedSettings = await appearanceService.getSettings();
|
||||
res.status(200).json(updatedSettings);
|
||||
} else {
|
||||
// 理论上更新单行设置总能找到 ID=1 的行,除非数据库有问题
|
||||
res.status(500).json({ message: '更新外观设置似乎失败了' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ message: '更新外观设置失败', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 上传页面背景图片
|
||||
*/
|
||||
export const uploadPageBackgroundController = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ message: '没有上传文件' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 文件已由 multer 保存到 uploads/backgrounds/
|
||||
// 返回相对于服务器根目录的文件路径,供前端使用
|
||||
const relativePath = `/uploads/backgrounds/${req.file.filename}`;
|
||||
|
||||
// 更新数据库中的设置
|
||||
await appearanceService.updateSettings({ pageBackgroundImage: relativePath });
|
||||
|
||||
res.status(200).json({ message: '页面背景上传成功', filePath: relativePath });
|
||||
} catch (error: any) {
|
||||
// 如果出错,尝试删除已上传的文件
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
fs.unlink(req.file.path, (err) => {
|
||||
if (err) console.error("删除上传失败的背景文件时出错:", err);
|
||||
});
|
||||
}
|
||||
res.status(500).json({ message: '上传页面背景失败', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传终端背景图片
|
||||
*/
|
||||
export const uploadTerminalBackgroundController = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ message: '没有上传文件' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const relativePath = `/uploads/backgrounds/${req.file.filename}`;
|
||||
await appearanceService.updateSettings({ terminalBackgroundImage: relativePath });
|
||||
res.status(200).json({ message: '终端背景上传成功', filePath: relativePath });
|
||||
} catch (error: any) {
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
fs.unlink(req.file.path, (err) => {
|
||||
if (err) console.error("删除上传失败的背景文件时出错:", err);
|
||||
});
|
||||
}
|
||||
res.status(500).json({ message: '上传终端背景失败', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 导出 multer 中间件以便在路由中使用
|
||||
export const uploadPageBackgroundMiddleware = backgroundUpload.single('pageBackgroundFile');
|
||||
export const uploadTerminalBackgroundMiddleware = backgroundUpload.single('terminalBackgroundFile');
|
||||
@@ -0,0 +1,32 @@
|
||||
import express from 'express';
|
||||
import * as appearanceController from './appearance.controller';
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 应用认证中间件
|
||||
router.use(isAuthenticated);
|
||||
|
||||
// GET /api/v1/appearance - 获取所有外观设置
|
||||
router.get('/', appearanceController.getAppearanceSettingsController);
|
||||
|
||||
// PUT /api/v1/appearance - 更新外观设置 (文本类)
|
||||
router.put('/', appearanceController.updateAppearanceSettingsController);
|
||||
|
||||
// POST /api/v1/appearance/background/page - 上传页面背景图片
|
||||
router.post(
|
||||
'/background/page',
|
||||
appearanceController.uploadPageBackgroundMiddleware, // 使用 multer 中间件
|
||||
appearanceController.uploadPageBackgroundController
|
||||
);
|
||||
|
||||
// POST /api/v1/appearance/background/terminal - 上传终端背景图片
|
||||
router.post(
|
||||
'/background/terminal',
|
||||
appearanceController.uploadTerminalBackgroundMiddleware, // 使用 multer 中间件
|
||||
appearanceController.uploadTerminalBackgroundController
|
||||
);
|
||||
|
||||
// TODO: 可能需要添加删除背景图片的路由
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ITheme } from 'xterm';
|
||||
|
||||
// 默认 xterm 主题
|
||||
// (与 frontend/src/stores/settings.store.ts 中的定义保持一致)
|
||||
export const defaultXtermTheme: ITheme = {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
selectionBackground: '#264f78', // 使用 selectionBackground
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#e5e5e5'
|
||||
};
|
||||
|
||||
// 默认 UI 主题 (CSS 变量)
|
||||
// (与 frontend/src/stores/settings.store.ts 中的定义保持一致)
|
||||
export const defaultUiTheme: Record<string, string> = {
|
||||
'--app-bg-color': '#ffffff',
|
||||
'--text-color': '#333333',
|
||||
'--text-color-secondary': '#666666',
|
||||
'--border-color': '#cccccc',
|
||||
'--link-color': '#333',
|
||||
'--link-hover-color': '#0056b3',
|
||||
'--link-active-color': '#007bff',
|
||||
'--header-bg-color': '#f0f0f0',
|
||||
'--footer-bg-color': '#f0f0f0',
|
||||
'--button-bg-color': '#007bff',
|
||||
'--button-text-color': '#ffffff',
|
||||
'--button-hover-bg-color': '#0056b3',
|
||||
'--font-family-sans-serif': 'sans-serif',
|
||||
'--base-padding': '1rem',
|
||||
'--base-margin': '0.5rem',
|
||||
};
|
||||
|
||||
// 未来可以在这里添加其他默认主题
|
||||
@@ -18,6 +18,8 @@ import notificationRoutes from './notifications/notification.routes'; // 导入
|
||||
import auditRoutes from './audit/audit.routes'; // 导入审计路由
|
||||
import commandHistoryRoutes from './command-history/command-history.routes'; // 导入命令历史记录路由
|
||||
import quickCommandsRoutes from './quick-commands/quick-commands.routes'; // 导入快捷指令路由
|
||||
import terminalThemeRoutes from './terminal-themes/terminal-theme.routes'; // 导入终端主题路由
|
||||
import appearanceRoutes from './appearance/appearance.routes'; // 导入外观设置路由
|
||||
import { initializeWebSocket } from './websocket';
|
||||
import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; // 导入 IP 白名单中间件
|
||||
|
||||
@@ -83,6 +85,13 @@ const sessionMiddleware = session({
|
||||
});
|
||||
app.use(sessionMiddleware); // 应用会话中间件
|
||||
|
||||
// --- 静态文件服务 ---
|
||||
// 提供上传的背景图片等静态资源
|
||||
const uploadsPath = path.join(__dirname, '../uploads'); // 指向 backend/uploads 目录
|
||||
app.use('/uploads', express.static(uploadsPath));
|
||||
console.log(`静态文件服务已启动,路径: ${uploadsPath}`);
|
||||
// --- 结束静态文件服务 ---
|
||||
|
||||
|
||||
// 扩展 Express Request 类型以包含 session 数据 (如果需要更明确的类型提示)
|
||||
declare module 'express-session' {
|
||||
@@ -106,6 +115,8 @@ app.use('/api/v1/notifications', notificationRoutes); // 挂载通知相关的
|
||||
app.use('/api/v1/audit-logs', auditRoutes); // 挂载审计日志相关的路由
|
||||
app.use('/api/v1/command-history', commandHistoryRoutes); // 挂载命令历史记录相关的路由
|
||||
app.use('/api/v1/quick-commands', quickCommandsRoutes); // 挂载快捷指令相关的路由
|
||||
app.use('/api/v1/terminal-themes', terminalThemeRoutes); // 挂载终端主题路由
|
||||
app.use('/api/v1/appearance', appearanceRoutes); // 挂载外观设置路由
|
||||
|
||||
// 状态检查接口
|
||||
app.get('/api/v1/status', (req: Request, res: Response) => {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { getDb } from '../database';
|
||||
import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types';
|
||||
import { defaultUiTheme } from '../config/default-themes'; // Assuming default UI theme is here too
|
||||
|
||||
const db = getDb();
|
||||
const TABLE_NAME = 'appearance_settings';
|
||||
const SETTINGS_ID = 1; // Use a fixed ID for the single row of global settings
|
||||
|
||||
/**
|
||||
* 创建 appearance_settings 表 (如果不存在)
|
||||
*/
|
||||
const createTableIfNotExists = () => {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
|
||||
id INTEGER PRIMARY KEY, -- Fixed ID for the single settings row
|
||||
custom_ui_theme TEXT,
|
||||
active_terminal_theme_id TEXT,
|
||||
terminal_font_family TEXT,
|
||||
terminal_background_image TEXT,
|
||||
terminal_background_opacity REAL, -- Use REAL for floating point numbers
|
||||
page_background_image TEXT,
|
||||
page_background_opacity REAL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
db.run(sql, (err) => {
|
||||
if (err) {
|
||||
console.error(`创建 ${TABLE_NAME} 表失败:`, err.message);
|
||||
} else {
|
||||
// 确保默认设置行存在
|
||||
ensureDefaultSettingsExist();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 辅助函数:将数据库行转换为 AppearanceSettings 对象
|
||||
const mapRowToAppearanceSettings = (row: any): AppearanceSettings => {
|
||||
if (!row) return getDefaultAppearanceSettings(); // Return default if no row found
|
||||
return {
|
||||
_id: row.id.toString(),
|
||||
customUiTheme: row.custom_ui_theme,
|
||||
activeTerminalThemeId: row.active_terminal_theme_id,
|
||||
terminalFontFamily: row.terminal_font_family,
|
||||
terminalBackgroundImage: row.terminal_background_image,
|
||||
terminalBackgroundOpacity: row.terminal_background_opacity,
|
||||
pageBackgroundImage: row.page_background_image,
|
||||
pageBackgroundOpacity: row.page_background_opacity,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
};
|
||||
|
||||
// 获取默认外观设置
|
||||
const getDefaultAppearanceSettings = (): AppearanceSettings => {
|
||||
// TODO: Find the ID of the default preset theme from terminal_themes table later
|
||||
// For now, leave activeTerminalThemeId null or undefined
|
||||
return {
|
||||
_id: SETTINGS_ID.toString(),
|
||||
customUiTheme: JSON.stringify(defaultUiTheme), // Use default UI theme
|
||||
activeTerminalThemeId: undefined, // Needs to be set after querying default theme ID
|
||||
terminalFontFamily: 'Consolas, "Courier New", monospace, "Microsoft YaHei", "微软雅黑"', // Default font
|
||||
terminalBackgroundImage: undefined,
|
||||
terminalBackgroundOpacity: 1.0,
|
||||
pageBackgroundImage: undefined,
|
||||
pageBackgroundOpacity: 1.0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 确保默认设置行存在
|
||||
*/
|
||||
const ensureDefaultSettingsExist = () => {
|
||||
const defaults = getDefaultAppearanceSettings();
|
||||
const sqlSelect = `SELECT id FROM ${TABLE_NAME} WHERE id = ?`;
|
||||
db.get(sqlSelect, [SETTINGS_ID], (err, row) => {
|
||||
if (err) {
|
||||
console.error(`检查默认外观设置时出错:`, err.message);
|
||||
return;
|
||||
}
|
||||
if (!row) {
|
||||
const sqlInsert = `
|
||||
INSERT INTO ${TABLE_NAME} (
|
||||
id, custom_ui_theme, active_terminal_theme_id, terminal_font_family,
|
||||
terminal_background_image, terminal_background_opacity,
|
||||
page_background_image, page_background_opacity, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
db.run(sqlInsert, [
|
||||
SETTINGS_ID,
|
||||
defaults.customUiTheme,
|
||||
defaults.activeTerminalThemeId, // Initially undefined
|
||||
defaults.terminalFontFamily,
|
||||
defaults.terminalBackgroundImage,
|
||||
defaults.terminalBackgroundOpacity,
|
||||
defaults.pageBackgroundImage,
|
||||
defaults.pageBackgroundOpacity,
|
||||
defaults.updatedAt
|
||||
], (insertErr) => {
|
||||
if (insertErr) {
|
||||
console.error('插入默认外观设置失败:', insertErr.message);
|
||||
} else {
|
||||
console.log('默认外观设置已初始化。');
|
||||
// Now try to find and set the default theme ID
|
||||
findAndSetDefaultThemeId();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If row exists, still check if default theme ID needs setting
|
||||
findAndSetDefaultThemeId();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 查找默认终端主题 ID 并更新外观设置
|
||||
*/
|
||||
const findAndSetDefaultThemeId = async () => {
|
||||
try {
|
||||
// Find the default theme from the other table
|
||||
const defaultThemeSql = `SELECT id FROM terminal_themes WHERE is_system_default = 1 LIMIT 1`;
|
||||
// Explicitly type the row or use type assertion
|
||||
db.get(defaultThemeSql, [], async (err, defaultThemeRow: { id: number } | undefined) => {
|
||||
if (err) {
|
||||
console.error("查找默认终端主题 ID 失败:", err.message);
|
||||
return;
|
||||
}
|
||||
if (defaultThemeRow) {
|
||||
const defaultThemeId = defaultThemeRow.id.toString();
|
||||
// Check current appearance settings
|
||||
const currentSettings = await getAppearanceSettings();
|
||||
if (currentSettings && currentSettings.activeTerminalThemeId !== defaultThemeId) {
|
||||
// Update only if the active ID is not already the default
|
||||
console.log(`设置默认激活终端主题 ID 为: ${defaultThemeId}`);
|
||||
await updateAppearanceSettings({ activeTerminalThemeId: defaultThemeId });
|
||||
}
|
||||
} else {
|
||||
console.warn("未找到系统默认终端主题,无法设置 activeTerminalThemeId。");
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("设置默认终端主题 ID 时出错:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 获取外观设置
|
||||
* @returns Promise<AppearanceSettings>
|
||||
*/
|
||||
export const getAppearanceSettings = async (): Promise<AppearanceSettings> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(`SELECT * FROM ${TABLE_NAME} WHERE id = ?`, [SETTINGS_ID], (err, row) => {
|
||||
if (err) {
|
||||
console.error('获取外观设置失败:', err.message);
|
||||
reject(new Error('获取外观设置失败'));
|
||||
} else {
|
||||
resolve(mapRowToAppearanceSettings(row));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新外观设置
|
||||
* @param settingsDto 更新的数据
|
||||
* @returns Promise<boolean> 是否成功更新
|
||||
*/
|
||||
export const updateAppearanceSettings = async (settingsDto: UpdateAppearanceDto): Promise<boolean> => {
|
||||
const now = Date.now();
|
||||
let sql = `UPDATE ${TABLE_NAME} SET updated_at = ?`;
|
||||
const params: any[] = [now];
|
||||
|
||||
// Dynamically build the SET part of the query
|
||||
const updates: string[] = [];
|
||||
for (const key in settingsDto) {
|
||||
if (Object.prototype.hasOwnProperty.call(settingsDto, key)) {
|
||||
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); // Convert camelCase to snake_case
|
||||
// Ensure only valid keys are updated
|
||||
if (['custom_ui_theme', 'active_terminal_theme_id', 'terminal_font_family', 'terminal_background_image', 'terminal_background_opacity', 'page_background_image', 'page_background_opacity'].includes(dbKey)) {
|
||||
updates.push(`${dbKey} = ?`);
|
||||
params.push((settingsDto as any)[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return true; // Nothing to update
|
||||
}
|
||||
|
||||
sql += `, ${updates.join(', ')} WHERE id = ?`;
|
||||
params.push(SETTINGS_ID);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) {
|
||||
console.error('更新外观设置失败:', err.message);
|
||||
reject(new Error('更新外观设置失败'));
|
||||
} else {
|
||||
resolve(this.changes > 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化时创建表
|
||||
createTableIfNotExists();
|
||||
@@ -0,0 +1,203 @@
|
||||
import { getDb } from '../database';
|
||||
import { TerminalTheme, CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types';
|
||||
import { defaultXtermTheme } from '../config/default-themes'; // 假设默认主题配置在此
|
||||
|
||||
const db = getDb();
|
||||
|
||||
/**
|
||||
* 创建 terminal_themes 表 (如果不存在)
|
||||
*/
|
||||
const createTableIfNotExists = () => {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS terminal_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
theme_data TEXT NOT NULL, -- Store ITheme as JSON string
|
||||
is_preset BOOLEAN NOT NULL DEFAULT 0,
|
||||
is_system_default BOOLEAN DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
db.run(sql, (err) => {
|
||||
if (err) {
|
||||
console.error('创建 terminal_themes 表失败:', err.message);
|
||||
} else {
|
||||
// 表创建成功后,初始化预设主题
|
||||
initializePresetThemes();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 辅助函数:将数据库行转换为 TerminalTheme 对象
|
||||
const mapRowToTerminalTheme = (row: any): TerminalTheme => {
|
||||
return {
|
||||
_id: row.id.toString(), // SQLite ID 是数字,转换为字符串以匹配 NeDB 风格
|
||||
name: row.name,
|
||||
themeData: JSON.parse(row.theme_data), // 解析 JSON 字符串
|
||||
isPreset: !!row.is_preset, // 转换为布尔值
|
||||
isSystemDefault: !!row.is_system_default,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 查找所有终端主题
|
||||
* @returns Promise<TerminalTheme[]>
|
||||
*/
|
||||
export const findAllThemes = async (): Promise<TerminalTheme[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all('SELECT * FROM terminal_themes ORDER BY is_preset DESC, name ASC', [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('查询所有终端主题失败:', err.message);
|
||||
reject(new Error('查询终端主题失败'));
|
||||
} else {
|
||||
resolve(rows.map(mapRowToTerminalTheme));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 查找终端主题
|
||||
* @param id 主题 ID (注意:这里是 SQLite 的数字 ID)
|
||||
* @returns Promise<TerminalTheme | null>
|
||||
*/
|
||||
export const findThemeById = async (id: number): Promise<TerminalTheme | null> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get('SELECT * FROM terminal_themes WHERE id = ?', [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error(`查询 ID 为 ${id} 的终端主题失败:`, err.message);
|
||||
reject(new Error('查询终端主题失败'));
|
||||
} else {
|
||||
resolve(row ? mapRowToTerminalTheme(row) : null);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建一个新的终端主题
|
||||
* @param themeDto 创建主题所需的数据
|
||||
* @returns Promise<TerminalTheme> 新创建的主题
|
||||
*/
|
||||
export const createTheme = async (themeDto: CreateTerminalThemeDto): Promise<TerminalTheme> => {
|
||||
const now = Date.now();
|
||||
const themeDataJson = JSON.stringify(themeDto.themeData); // 将 ITheme 转换为 JSON 字符串
|
||||
const sql = `
|
||||
INSERT INTO terminal_themes (name, theme_data, is_preset, created_at, updated_at)
|
||||
VALUES (?, ?, 0, ?, ?)
|
||||
`;
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, [themeDto.name, themeDataJson, now, now], function (err) {
|
||||
if (err) {
|
||||
console.error('创建新终端主题失败:', err.message);
|
||||
// 特别处理唯一约束错误
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
reject(new Error(`主题名称 "${themeDto.name}" 已存在。`));
|
||||
} else {
|
||||
reject(new Error('创建终端主题失败'));
|
||||
}
|
||||
} else {
|
||||
// 获取新插入行的 ID 并查询返回完整对象
|
||||
findThemeById(this.lastID)
|
||||
.then(newTheme => {
|
||||
if (newTheme) {
|
||||
resolve(newTheme);
|
||||
} else {
|
||||
// 理论上不应该发生,但作为回退
|
||||
reject(new Error('创建主题后未能检索到该主题'));
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新一个终端主题
|
||||
* @param id 要更新的主题 ID (SQLite 数字 ID)
|
||||
* @param themeDto 更新的数据
|
||||
* @returns Promise<boolean> 是否成功更新
|
||||
*/
|
||||
export const updateTheme = async (id: number, themeDto: UpdateTerminalThemeDto): Promise<boolean> => {
|
||||
const now = Date.now();
|
||||
const themeDataJson = JSON.stringify(themeDto.themeData);
|
||||
// 只允许更新非预设主题的 name 和 theme_data
|
||||
const sql = `
|
||||
UPDATE terminal_themes
|
||||
SET name = ?, theme_data = ?, updated_at = ?
|
||||
WHERE id = ? AND is_preset = 0
|
||||
`;
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, [themeDto.name, themeDataJson, now, id], function (err) {
|
||||
if (err) {
|
||||
console.error(`更新 ID 为 ${id} 的终端主题失败:`, err.message);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
reject(new Error(`主题名称 "${themeDto.name}" 已存在。`));
|
||||
} else {
|
||||
reject(new Error('更新终端主题失败'));
|
||||
}
|
||||
} else {
|
||||
resolve(this.changes > 0); // 如果有行被改变,则更新成功
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除一个终端主题
|
||||
* @param id 要删除的主题 ID (SQLite 数字 ID)
|
||||
* @returns Promise<boolean> 是否成功删除
|
||||
*/
|
||||
export const deleteTheme = async (id: number): Promise<boolean> => {
|
||||
// 只允许删除非预设主题
|
||||
const sql = 'DELETE FROM terminal_themes WHERE id = ? AND is_preset = 0';
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, [id], function (err) {
|
||||
if (err) {
|
||||
console.error(`删除 ID 为 ${id} 的终端主题失败:`, err.message);
|
||||
reject(new Error('删除终端主题失败'));
|
||||
} else {
|
||||
resolve(this.changes > 0); // 如果有行被改变,则删除成功
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化预设主题 (如果不存在)
|
||||
*/
|
||||
export const initializePresetThemes = async () => {
|
||||
const defaultPresetName = '默认暗色'; // Default Dark
|
||||
const themeDataJson = JSON.stringify(defaultXtermTheme);
|
||||
const now = Date.now();
|
||||
|
||||
// 检查默认预设是否存在
|
||||
db.get('SELECT id FROM terminal_themes WHERE name = ? AND is_preset = 1', [defaultPresetName], (err, row) => {
|
||||
if (err) {
|
||||
console.error('检查预设主题时出错:', err.message);
|
||||
return;
|
||||
}
|
||||
if (!row) {
|
||||
// 如果不存在,则插入
|
||||
const insertSql = `
|
||||
INSERT INTO terminal_themes (name, theme_data, is_preset, is_system_default, created_at, updated_at)
|
||||
VALUES (?, ?, 1, 1, ?, ?)
|
||||
`;
|
||||
db.run(insertSql, [defaultPresetName, themeDataJson, now, now], (insertErr) => {
|
||||
if (insertErr) {
|
||||
console.error(`初始化预设主题 "${defaultPresetName}" 失败:`, insertErr.message);
|
||||
} else {
|
||||
console.log(`预设主题 "${defaultPresetName}" 已初始化。`);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 在这里可以添加更多预设主题的初始化逻辑
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化时创建表
|
||||
createTableIfNotExists();
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as appearanceRepository from '../repositories/appearance.repository';
|
||||
import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types';
|
||||
import * as terminalThemeRepository from '../repositories/terminal-theme.repository'; // 需要验证 activeTerminalThemeId
|
||||
|
||||
/**
|
||||
* 获取外观设置
|
||||
* @returns Promise<AppearanceSettings>
|
||||
*/
|
||||
export const getSettings = async (): Promise<AppearanceSettings> => {
|
||||
return appearanceRepository.getAppearanceSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新外观设置
|
||||
* @param settingsDto 更新数据
|
||||
* @returns Promise<boolean> 是否成功更新
|
||||
*/
|
||||
export const updateSettings = async (settingsDto: UpdateAppearanceDto): Promise<boolean> => {
|
||||
// 验证 activeTerminalThemeId (如果提供了)
|
||||
if (settingsDto.activeTerminalThemeId !== undefined && settingsDto.activeTerminalThemeId !== null) {
|
||||
const themeIdNum = parseInt(settingsDto.activeTerminalThemeId, 10);
|
||||
if (isNaN(themeIdNum)) {
|
||||
throw new Error(`无效的终端主题 ID 格式: ${settingsDto.activeTerminalThemeId}`);
|
||||
}
|
||||
try {
|
||||
const themeExists = await terminalThemeRepository.findThemeById(themeIdNum);
|
||||
if (!themeExists) {
|
||||
throw new Error(`指定的终端主题 ID 不存在: ${settingsDto.activeTerminalThemeId}`);
|
||||
}
|
||||
} catch (e: any) { // Catch potential errors from findThemeById as well
|
||||
console.error(`验证终端主题 ID (${settingsDto.activeTerminalThemeId}) 时出错:`, e.message);
|
||||
// Rethrow a more specific error or the original one
|
||||
throw new Error(`验证终端主题 ID 时出错: ${e.message || settingsDto.activeTerminalThemeId}`);
|
||||
}
|
||||
} else if (settingsDto.hasOwnProperty('activeTerminalThemeId')) {
|
||||
// Handle explicit setting to null/undefined (meaning reset to default/no theme)
|
||||
// The repository update logic handles null/undefined correctly, so no specific validation needed here.
|
||||
// We just need to ensure the key exists in the DTO if it's meant to be cleared.
|
||||
}
|
||||
|
||||
|
||||
// 验证透明度值 (如果提供了)
|
||||
if (settingsDto.terminalBackgroundOpacity !== undefined && (settingsDto.terminalBackgroundOpacity < 0 || settingsDto.terminalBackgroundOpacity > 1)) {
|
||||
throw new Error('终端背景透明度必须在 0 和 1 之间');
|
||||
}
|
||||
if (settingsDto.pageBackgroundOpacity !== undefined && (settingsDto.pageBackgroundOpacity < 0 || settingsDto.pageBackgroundOpacity > 1)) {
|
||||
throw new Error('页面背景透明度必须在 0 和 1 之间');
|
||||
}
|
||||
|
||||
// TODO: 如果实现了背景图片上传,这里需要处理文件路径或 URL 的验证/保存逻辑
|
||||
|
||||
return appearanceRepository.updateAppearanceSettings(settingsDto);
|
||||
};
|
||||
|
||||
// 注意:背景图片上传/处理逻辑需要根据最终决定(URL vs 上传)来添加。
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as terminalThemeRepository from '../repositories/terminal-theme.repository';
|
||||
import { TerminalTheme, CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types';
|
||||
// import { validate } from 'class-validator'; // 移除导入
|
||||
import type { ITheme } from 'xterm';
|
||||
|
||||
/**
|
||||
* 获取所有终端主题
|
||||
* @returns Promise<TerminalTheme[]>
|
||||
*/
|
||||
export const getAllThemes = async (): Promise<TerminalTheme[]> => {
|
||||
return terminalThemeRepository.findAllThemes();
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 获取单个终端主题
|
||||
* @param id 主题 ID (SQLite 数字 ID)
|
||||
* @returns Promise<TerminalTheme | null>
|
||||
*/
|
||||
export const getThemeById = async (id: number): Promise<TerminalTheme | null> => {
|
||||
if (isNaN(id)) {
|
||||
throw new Error('无效的主题 ID');
|
||||
}
|
||||
return terminalThemeRepository.findThemeById(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建新终端主题
|
||||
* @param themeDto 创建数据
|
||||
* @returns Promise<TerminalTheme>
|
||||
*/
|
||||
export const createNewTheme = async (themeDto: CreateTerminalThemeDto): Promise<TerminalTheme> => {
|
||||
// 移除验证相关的注释
|
||||
// 简单验证 themeData 结构 (确保基本字段存在)
|
||||
if (!themeDto.themeData || typeof themeDto.themeData.background !== 'string' || typeof themeDto.themeData.foreground !== 'string') {
|
||||
throw new Error('无效的主题数据格式');
|
||||
}
|
||||
|
||||
return terminalThemeRepository.createTheme(themeDto);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新终端主题
|
||||
* @param id 主题 ID (SQLite 数字 ID)
|
||||
* @param themeDto 更新数据
|
||||
* @returns Promise<boolean> 是否成功更新
|
||||
*/
|
||||
export const updateExistingTheme = async (id: number, themeDto: UpdateTerminalThemeDto): Promise<boolean> => {
|
||||
if (isNaN(id)) {
|
||||
throw new Error('无效的主题 ID');
|
||||
}
|
||||
// 可选:验证 themeDto
|
||||
if (!themeDto.name || !themeDto.themeData || typeof themeDto.themeData.background !== 'string' || typeof themeDto.themeData.foreground !== 'string') {
|
||||
throw new Error('无效的主题更新数据');
|
||||
}
|
||||
return terminalThemeRepository.updateTheme(id, themeDto);
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除终端主题
|
||||
* @param id 主题 ID (SQLite 数字 ID)
|
||||
* @returns Promise<boolean> 是否成功删除
|
||||
*/
|
||||
export const deleteExistingTheme = async (id: number): Promise<boolean> => {
|
||||
if (isNaN(id)) {
|
||||
throw new Error('无效的主题 ID');
|
||||
}
|
||||
return terminalThemeRepository.deleteTheme(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 导入终端主题
|
||||
* @param themeData 主题数据对象 (ITheme)
|
||||
* @param name 主题名称
|
||||
* @returns Promise<TerminalTheme>
|
||||
*/
|
||||
export const importTheme = async (themeData: ITheme, name: string): Promise<TerminalTheme> => {
|
||||
if (!name) {
|
||||
throw new Error('导入主题时必须提供名称');
|
||||
}
|
||||
// 验证导入的数据结构是否符合 ITheme (简化验证)
|
||||
if (typeof themeData.background !== 'string' || typeof themeData.foreground !== 'string') {
|
||||
throw new Error('导入的主题数据格式无效');
|
||||
}
|
||||
const dto: CreateTerminalThemeDto = { name, themeData };
|
||||
return createNewTheme(dto);
|
||||
};
|
||||
|
||||
// 注意:导出功能通常在 Controller 层处理,根据 ID 获取主题数据后,设置响应头并发送 JSON 文件。
|
||||
@@ -30,18 +30,38 @@ export const settingsController = {
|
||||
res.status(400).json({ message: '无效的请求体,应为 JSON 对象' });
|
||||
return;
|
||||
}
|
||||
// 可以在这里添加更严格的验证,例如检查值的类型等
|
||||
|
||||
await settingsService.setMultipleSettings(settingsToUpdate);
|
||||
// --- 过滤掉外观设置相关的键 ---
|
||||
const allowedSettingsKeys = [
|
||||
'language', 'ipWhitelist', 'maxLoginAttempts', 'loginBanDuration',
|
||||
'showPopupFileEditor', 'shareFileEditorTabs', 'ipWhitelistEnabled' // 添加 ipWhitelistEnabled
|
||||
// 在这里添加其他允许的通用设置键
|
||||
];
|
||||
const filteredSettings: Record<string, string> = {};
|
||||
for (const key in settingsToUpdate) {
|
||||
if (allowedSettingsKeys.includes(key)) {
|
||||
filteredSettings[key] = settingsToUpdate[key];
|
||||
}
|
||||
}
|
||||
// --- 结束过滤 ---
|
||||
|
||||
// 只传递过滤后的设置给 service
|
||||
if (Object.keys(filteredSettings).length > 0) {
|
||||
await settingsService.setMultipleSettings(filteredSettings);
|
||||
}
|
||||
|
||||
// 记录审计日志
|
||||
// 区分 IP 白名单更新和其他设置更新
|
||||
const updatedKeys = Object.keys(settingsToUpdate);
|
||||
if (updatedKeys.includes('ipWhitelist')) {
|
||||
auditLogService.logAction('IP_WHITELIST_UPDATED', { updatedKeys });
|
||||
} else {
|
||||
auditLogService.logAction('SETTINGS_UPDATED', { updatedKeys });
|
||||
// 注意:现在审计日志可能需要更精细的逻辑,因为它只记录了实际更新的通用设置
|
||||
const updatedKeys = Object.keys(filteredSettings); // 使用过滤后的键
|
||||
if (updatedKeys.length > 0) { // 只有实际更新了才记录
|
||||
if (updatedKeys.includes('ipWhitelist') || updatedKeys.includes('ipWhitelistEnabled')) {
|
||||
auditLogService.logAction('IP_WHITELIST_UPDATED', { updatedKeys });
|
||||
} else {
|
||||
auditLogService.logAction('SETTINGS_UPDATED', { updatedKeys });
|
||||
}
|
||||
}
|
||||
res.status(200).json({ message: '设置已成功更新' });
|
||||
res.status(200).json({ message: '设置已成功更新' }); // 即使没有更新通用设置也返回成功
|
||||
} catch (error: any) {
|
||||
console.error('更新设置时出错:', error);
|
||||
res.status(500).json({ message: '更新设置失败', error: error.message });
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as terminalThemeService from '../services/terminal-theme.service';
|
||||
import { CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types';
|
||||
import type { ITheme } from 'xterm';
|
||||
import multer from 'multer'; // 用于处理文件上传
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// 配置 multer 用于处理 JSON 文件上传 (导入)
|
||||
const upload = multer({
|
||||
dest: path.join(__dirname, '../../temp-uploads/'), // 临时存储目录
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'application/json' || file.originalname.endsWith('.json')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('只允许上传 JSON 文件!'));
|
||||
}
|
||||
},
|
||||
limits: { fileSize: 1024 * 1024 } // 限制文件大小为 1MB
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取所有终端主题
|
||||
*/
|
||||
export const getAllThemesController = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const themes = await terminalThemeService.getAllThemes();
|
||||
res.status(200).json(themes);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ message: '获取终端主题列表失败', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 获取单个终端主题
|
||||
*/
|
||||
export const getThemeByIdController = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ message: '无效的主题 ID' });
|
||||
return;
|
||||
}
|
||||
const theme = await terminalThemeService.getThemeById(id);
|
||||
if (theme) {
|
||||
res.status(200).json(theme);
|
||||
} else {
|
||||
res.status(404).json({ message: '未找到指定的主题' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ message: '获取终端主题失败', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建新终端主题
|
||||
*/
|
||||
export const createThemeController = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const themeDto: CreateTerminalThemeDto = req.body;
|
||||
// 基本验证
|
||||
if (!themeDto.name || !themeDto.themeData) {
|
||||
res.status(400).json({ message: '缺少主题名称或主题数据' });
|
||||
return;
|
||||
}
|
||||
const newTheme = await terminalThemeService.createNewTheme(themeDto);
|
||||
res.status(201).json(newTheme);
|
||||
} catch (error: any) {
|
||||
// 检查是否是名称重复错误
|
||||
if (error.message.includes('已存在')) {
|
||||
res.status(409).json({ message: error.message }); // 409 Conflict
|
||||
} else {
|
||||
res.status(400).json({ message: '创建终端主题失败', error: error.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新终端主题
|
||||
*/
|
||||
export const updateThemeController = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ message: '无效的主题 ID' });
|
||||
return;
|
||||
}
|
||||
const themeDto: UpdateTerminalThemeDto = req.body;
|
||||
// 基本验证
|
||||
if (!themeDto.name || !themeDto.themeData) {
|
||||
res.status(400).json({ message: '缺少主题名称或主题数据' });
|
||||
return;
|
||||
}
|
||||
const success = await terminalThemeService.updateExistingTheme(id, themeDto);
|
||||
if (success) {
|
||||
res.status(200).json({ message: '主题更新成功' });
|
||||
} else {
|
||||
// 可能因为 ID 不存在或主题是预设主题而更新失败
|
||||
res.status(404).json({ message: '未找到可更新的主题或该主题为预设主题' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('已存在')) {
|
||||
res.status(409).json({ message: error.message }); // 409 Conflict
|
||||
} else {
|
||||
res.status(400).json({ message: '更新终端主题失败', error: error.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除终端主题
|
||||
*/
|
||||
export const deleteThemeController = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ message: '无效的主题 ID' });
|
||||
return;
|
||||
}
|
||||
const success = await terminalThemeService.deleteExistingTheme(id);
|
||||
if (success) {
|
||||
res.status(200).json({ message: '主题删除成功' });
|
||||
} else {
|
||||
// 可能因为 ID 不存在或主题是预设主题而删除失败
|
||||
res.status(404).json({ message: '未找到可删除的主题或该主题为预设主题' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ message: '删除终端主题失败', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 导入终端主题 (处理文件上传)
|
||||
*/
|
||||
export const importThemeController = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ message: '没有上传文件' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = req.file.path;
|
||||
const originalName = req.file.originalname;
|
||||
// 尝试从文件名中提取名称 (去除 .json 后缀)
|
||||
const defaultName = originalName.endsWith('.json') ? originalName.slice(0, -5) : originalName;
|
||||
// 允许用户通过 body 传递 name,否则使用文件名
|
||||
const themeName = req.body.name || defaultName;
|
||||
|
||||
try {
|
||||
const fileContent = await fs.promises.readFile(filePath, 'utf-8');
|
||||
const themeData: ITheme = JSON.parse(fileContent);
|
||||
|
||||
// 调用 service 进行导入
|
||||
const importedTheme = await terminalThemeService.importTheme(themeData, themeName);
|
||||
|
||||
// 删除临时文件
|
||||
await fs.promises.unlink(filePath);
|
||||
|
||||
res.status(201).json(importedTheme);
|
||||
|
||||
} catch (error: any) {
|
||||
// 确保即使出错也删除临时文件
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.promises.unlink(filePath).catch(unlinkErr => console.error("删除临时导入文件失败:", unlinkErr));
|
||||
}
|
||||
|
||||
if (error instanceof SyntaxError) {
|
||||
res.status(400).json({ message: '导入失败:文件不是有效的 JSON 格式', error: error.message });
|
||||
} else if (error.message.includes('已存在')) {
|
||||
res.status(409).json({ message: `导入失败: ${error.message}` }); // 409 Conflict
|
||||
} else {
|
||||
res.status(400).json({ message: '导入终端主题失败', error: error.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出终端主题
|
||||
*/
|
||||
export const exportThemeController = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ message: '无效的主题 ID' });
|
||||
return;
|
||||
}
|
||||
const theme = await terminalThemeService.getThemeById(id);
|
||||
if (theme) {
|
||||
const themeJson = JSON.stringify(theme.themeData, null, 2); // 格式化 JSON 输出
|
||||
const fileName = `${theme.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`; // 创建安全的文件名
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(200).send(themeJson);
|
||||
} else {
|
||||
res.status(404).json({ message: '未找到指定的主题' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ message: '导出终端主题失败', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 将 upload 中间件导出,以便在路由中使用
|
||||
export const uploadMiddleware = upload;
|
||||
@@ -0,0 +1,32 @@
|
||||
import express from 'express';
|
||||
import * as themeController from './terminal-theme.controller';
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // 修正导入名称
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 应用认证中间件到所有主题路由
|
||||
router.use(isAuthenticated); // 修正使用的中间件名称
|
||||
|
||||
// GET /api/v1/terminal-themes - 获取所有主题
|
||||
router.get('/', themeController.getAllThemesController);
|
||||
|
||||
// POST /api/v1/terminal-themes - 创建新主题
|
||||
router.post('/', themeController.createThemeController);
|
||||
|
||||
// GET /api/v1/terminal-themes/:id - 获取单个主题
|
||||
router.get('/:id', themeController.getThemeByIdController);
|
||||
|
||||
// PUT /api/v1/terminal-themes/:id - 更新主题
|
||||
router.put('/:id', themeController.updateThemeController);
|
||||
|
||||
// DELETE /api/v1/terminal-themes/:id - 删除主题
|
||||
router.delete('/:id', themeController.deleteThemeController);
|
||||
|
||||
// POST /api/v1/terminal-themes/import - 导入主题 (使用 multer 中间件处理文件)
|
||||
router.post('/import', themeController.uploadMiddleware.single('themeFile'), themeController.importThemeController);
|
||||
|
||||
// GET /api/v1/terminal-themes/:id/export - 导出主题
|
||||
router.get('/:id/export', themeController.exportThemeController);
|
||||
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ITheme } from 'xterm';
|
||||
|
||||
/**
|
||||
* 外观设置数据结构
|
||||
*/
|
||||
export interface AppearanceSettings {
|
||||
_id?: string; // 通常只有一个文档,ID 固定或不使用
|
||||
userId?: string; // 如果需要区分用户设置 (当前假设为全局)
|
||||
customUiTheme?: string; // UI 主题 (CSS 变量 JSON 字符串)
|
||||
activeTerminalThemeId?: string; // 当前激活的终端主题 ID (对应 terminal_themes 表的 _id)
|
||||
terminalFontFamily?: string; // 终端字体列表字符串
|
||||
terminalBackgroundImage?: string; // 终端背景图片 URL 或路径
|
||||
terminalBackgroundOpacity?: number; // 终端背景透明度 (0-1)
|
||||
pageBackgroundImage?: string; // 页面背景图片 URL 或路径
|
||||
pageBackgroundOpacity?: number; // 页面背景透明度 (0-1)
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于更新外观设置的数据结构 (所有字段可选)
|
||||
*/
|
||||
export type UpdateAppearanceDto = Partial<Omit<AppearanceSettings, '_id' | 'userId' | 'updatedAt'>>;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ITheme } from 'xterm';
|
||||
|
||||
/**
|
||||
* 终端主题数据结构
|
||||
*/
|
||||
export interface TerminalTheme {
|
||||
_id?: string; // NeDB 自动生成的 ID
|
||||
name: string; // 主题名称,例如 "默认暗色", "Solarized Light"
|
||||
themeData: ITheme; // xterm.js 的 ITheme 对象
|
||||
isPreset: boolean; // 是否为系统预设主题
|
||||
isSystemDefault?: boolean; // (可选) 是否为系统默认主题
|
||||
createdAt?: number; // 创建时间戳
|
||||
updatedAt?: number; // 更新时间戳
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于创建新主题的数据结构 (可能不需要 _id, isPreset 等)
|
||||
*/
|
||||
export type CreateTerminalThemeDto = Omit<TerminalTheme, '_id' | 'isPreset' | 'isSystemDefault' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
/**
|
||||
* 用于更新主题的数据结构 (所有字段可选)
|
||||
*/
|
||||
export type UpdateTerminalThemeDto = Partial<Omit<TerminalTheme, '_id' | 'isPreset' | 'isSystemDefault' | 'createdAt' | 'updatedAt'>>;
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 950 KiB |
|
After Width: | Height: | Size: 950 KiB |
|
After Width: | Height: | Size: 950 KiB |
|
After Width: | Height: | Size: 950 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 950 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 366 KiB |
@@ -2,8 +2,8 @@
|
||||
import { RouterLink, RouterView } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from './stores/auth.store';
|
||||
import { useSettingsStore } from './stores/settings.store'; // 导入设置 Store
|
||||
import { ref } from 'vue'; // 导入 ref
|
||||
import { useSettingsStore } from './stores/settings.store';
|
||||
import { useAppearanceStore } from './stores/appearance.store'; // 导入外观 Store
|
||||
import { storeToRefs } from 'pinia';
|
||||
// 导入通知显示组件
|
||||
import UINotificationDisplay from './components/UINotificationDisplay.vue';
|
||||
@@ -14,25 +14,24 @@ import StyleCustomizer from './components/StyleCustomizer.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore(); // 实例化设置 Store
|
||||
const { isAuthenticated } = storeToRefs(authStore); // 获取登录状态
|
||||
const { showPopupFileEditorBoolean } = storeToRefs(settingsStore); // 获取弹窗编辑器设置
|
||||
|
||||
// 控制样式自定义器可见性的状态
|
||||
const isStyleCustomizerVisible = ref(false);
|
||||
const settingsStore = useSettingsStore();
|
||||
const appearanceStore = useAppearanceStore(); // 实例化外观 Store
|
||||
const { isAuthenticated } = storeToRefs(authStore);
|
||||
const { showPopupFileEditorBoolean } = storeToRefs(settingsStore);
|
||||
const { isStyleCustomizerVisible } = storeToRefs(appearanceStore); // 从外观 store 获取可见性状态
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout();
|
||||
};
|
||||
|
||||
// 打开样式自定义器
|
||||
// 打开样式自定义器的方法现在直接调用 store action
|
||||
const openStyleCustomizer = () => {
|
||||
isStyleCustomizerVisible.value = true;
|
||||
appearanceStore.toggleStyleCustomizer(true);
|
||||
};
|
||||
|
||||
// 关闭样式自定义器 (由子组件触发)
|
||||
// 关闭样式自定义器的方法现在也调用 store action
|
||||
const closeStyleCustomizer = () => {
|
||||
isStyleCustomizerVisible.value = false;
|
||||
appearanceStore.toggleStyleCustomizer(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -48,7 +47,7 @@ const closeStyleCustomizer = () => {
|
||||
<RouterLink to="/notifications">{{ t('nav.notifications') }}</RouterLink> | <!-- 新增通知链接 -->
|
||||
<RouterLink to="/audit-logs">{{ t('nav.auditLogs') }}</RouterLink> | <!-- 新增审计日志链接 -->
|
||||
<RouterLink to="/settings">{{ t('nav.settings') }}</RouterLink> | <!-- 新增设置链接 -->
|
||||
<a href="#" @click.prevent="openStyleCustomizer" :title="t('nav.customizeStyle')">🎨</a> | <!-- 添加调色板按钮 -->
|
||||
<a href="#" @click.prevent="openStyleCustomizer" :title="t('nav.customizeStyle')">🎨</a> | <!-- 点击调用 openStyleCustomizer -->
|
||||
<RouterLink v-if="!isAuthenticated" to="/login">{{ t('nav.login') }}</RouterLink>
|
||||
<a href="#" v-if="isAuthenticated" @click.prevent="handleLogout">{{ t('nav.logout') }}</a>
|
||||
</nav>
|
||||
@@ -64,7 +63,7 @@ const closeStyleCustomizer = () => {
|
||||
<!-- 根据设置条件渲染全局文件编辑器弹窗 -->
|
||||
<FileEditorOverlay v-if="showPopupFileEditorBoolean" />
|
||||
|
||||
<!-- 条件渲染样式自定义器 -->
|
||||
<!-- 条件渲染样式自定义器,使用 store 的状态和方法 -->
|
||||
<StyleCustomizer v-if="isStyleCustomizerVisible" @close="closeStyleCustomizer" />
|
||||
|
||||
<footer>
|
||||
|
||||
@@ -1,65 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue';
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { useAppearanceStore } from '../stores/appearance.store'; // 使用新的 store
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { ITheme } from 'xterm'; // 导入 xterm 主题类型
|
||||
import type { ITheme } from 'xterm';
|
||||
import type { TerminalTheme } from '../../../backend/src/types/terminal-theme.types'; // 引入类型
|
||||
import { defaultXtermTheme } from '../stores/default-themes'; // 引入默认主题
|
||||
|
||||
const { t } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { currentUiTheme, currentXtermTheme } = storeToRefs(settingsStore); // 获取响应式的主题状态
|
||||
const appearanceStore = useAppearanceStore();
|
||||
const {
|
||||
currentUiTheme,
|
||||
// currentTerminalTheme, // 这个是计算属性,只读,在编辑时不需要直接用
|
||||
activeTerminalThemeId,
|
||||
availableTerminalThemes,
|
||||
currentTerminalFontFamily,
|
||||
pageBackgroundImage,
|
||||
pageBackgroundOpacity,
|
||||
terminalBackgroundImage,
|
||||
terminalBackgroundOpacity,
|
||||
} = storeToRefs(appearanceStore);
|
||||
|
||||
// 创建本地响应式副本用于编辑
|
||||
// --- 本地状态用于编辑 ---
|
||||
const editableUiTheme = ref<Record<string, string>>({});
|
||||
const editableXtermTheme = ref<ITheme>({});
|
||||
const editableTerminalFontFamily = ref('');
|
||||
const editablePageBackgroundOpacity = ref(1.0);
|
||||
const editableTerminalBackgroundOpacity = ref(1.0);
|
||||
|
||||
// 初始化本地副本
|
||||
const initializeEditableThemes = () => {
|
||||
// 使用深拷贝确保不直接修改 store 状态
|
||||
// 终端主题管理相关状态
|
||||
const selectedTerminalThemeId = ref<string | null>(null); // 下拉框选择的 ID
|
||||
const isEditingTheme = ref(false); // 是否正在编辑某个主题
|
||||
// 使用 reactive 确保嵌套对象 themeData 的响应性
|
||||
// 修正:editingTheme 应该是一个 ref 包含 TerminalTheme 或 null
|
||||
const editingTheme = ref<TerminalTheme | null>(null); // 正在编辑的主题数据副本 (完整结构)
|
||||
const newThemeName = ref(''); // 新建主题的名称 (不再需要,直接编辑 editingTheme.value.name)
|
||||
|
||||
// 文件上传相关
|
||||
const pageBgFileInput = ref<HTMLInputElement | null>(null);
|
||||
const terminalBgFileInput = ref<HTMLInputElement | null>(null);
|
||||
const themeImportInput = ref<HTMLInputElement | null>(null);
|
||||
const uploadError = ref<string | null>(null);
|
||||
const importError = ref<string | null>(null);
|
||||
const saveThemeError = ref<string | null>(null); // 用于显示保存主题时的错误
|
||||
|
||||
|
||||
// 初始化本地编辑状态
|
||||
const initializeEditableState = () => {
|
||||
// 深拷贝 UI 主题
|
||||
editableUiTheme.value = JSON.parse(JSON.stringify(currentUiTheme.value || {}));
|
||||
editableXtermTheme.value = JSON.parse(JSON.stringify(currentXtermTheme.value || {}));
|
||||
editableTerminalFontFamily.value = currentTerminalFontFamily.value;
|
||||
selectedTerminalThemeId.value = activeTerminalThemeId.value ?? null; // 初始化下拉框
|
||||
editablePageBackgroundOpacity.value = pageBackgroundOpacity.value;
|
||||
editableTerminalBackgroundOpacity.value = terminalBackgroundOpacity.value;
|
||||
// 不在 store 变化时重置编辑状态,除非是显式取消或保存
|
||||
uploadError.value = null;
|
||||
importError.value = null;
|
||||
saveThemeError.value = null;
|
||||
};
|
||||
|
||||
onMounted(initializeEditableThemes);
|
||||
onMounted(initializeEditableState);
|
||||
|
||||
// 如果 store 中的主题变化(例如通过重置),也更新本地副本
|
||||
watch(currentUiTheme, initializeEditableThemes, { deep: true });
|
||||
watch(currentXtermTheme, initializeEditableThemes, { deep: true });
|
||||
// 监听 store 变化以更新本地状态 (例如重置或外部更改)
|
||||
// 只监听不需要编辑的状态或用于初始化的状态
|
||||
watch([
|
||||
currentUiTheme, currentTerminalFontFamily, activeTerminalThemeId,
|
||||
pageBackgroundOpacity, terminalBackgroundOpacity
|
||||
], (newVals, oldVals) => {
|
||||
// 仅当非编辑状态时,或活动主题ID变化时,才同步下拉框和非编辑状态
|
||||
if (!isEditingTheme.value || newVals[2] !== oldVals[2]) {
|
||||
initializeEditableState();
|
||||
} else {
|
||||
// 如果正在编辑,只更新非编辑相关的部分 (例如 UI 主题可以在编辑终端主题时同时更新)
|
||||
editableUiTheme.value = JSON.parse(JSON.stringify(newVals[0] || {}));
|
||||
editableTerminalFontFamily.value = newVals[1];
|
||||
editablePageBackgroundOpacity.value = newVals[3];
|
||||
editableTerminalBackgroundOpacity.value = newVals[4];
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const closeCustomizer = () => {
|
||||
emit('close');
|
||||
// 如果正在编辑主题,提示用户是否放弃更改
|
||||
if (isEditingTheme.value) {
|
||||
if (confirm(t('styleCustomizer.confirmCloseEditing'))) {
|
||||
isEditingTheme.value = false; // 退出编辑状态
|
||||
editingTheme.value = null;
|
||||
emit('close');
|
||||
}
|
||||
} else {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
// 临时的编辑区域占位符
|
||||
const currentTab = ref<'ui' | 'terminal'>('ui');
|
||||
// 当前活动的标签页
|
||||
const currentTab = ref<'ui' | 'terminal' | 'background'>('ui');
|
||||
|
||||
// --- 处理函数 ---
|
||||
const handleSaveChanges = async () => {
|
||||
|
||||
// 保存 UI 主题更改
|
||||
const handleSaveUiTheme = async () => {
|
||||
try {
|
||||
await settingsStore.saveCustomThemes(editableUiTheme.value, editableXtermTheme.value);
|
||||
// 可以添加一个成功提示
|
||||
closeCustomizer(); // 保存后关闭
|
||||
} catch (error) {
|
||||
console.error("保存主题失败:", error);
|
||||
// 可以添加一个错误提示
|
||||
await appearanceStore.saveCustomUiTheme(editableUiTheme.value);
|
||||
alert(t('styleCustomizer.uiThemeSaved')); // 简单提示
|
||||
} catch (error: any) {
|
||||
console.error("保存 UI 主题失败:", error);
|
||||
alert(t('styleCustomizer.uiThemeSaveFailed', { message: error.message }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetDefault = async () => {
|
||||
try {
|
||||
await settingsStore.resetCustomThemes();
|
||||
// 重置后本地副本会自动通过 watch 更新
|
||||
// 可以添加一个成功提示
|
||||
} catch (error) {
|
||||
console.error("重置主题失败:", error);
|
||||
// 可以添加一个错误提示
|
||||
}
|
||||
// 重置 UI 主题
|
||||
const handleResetUiTheme = async () => {
|
||||
if (confirm(t('styleCustomizer.confirmResetUi'))) {
|
||||
try {
|
||||
await appearanceStore.resetCustomUiTheme();
|
||||
// watch 会自动更新 editableUiTheme.value
|
||||
alert(t('styleCustomizer.uiThemeReset'));
|
||||
} catch (error: any) {
|
||||
console.error("重置 UI 主题失败:", error);
|
||||
alert(t('styleCustomizer.uiThemeResetFailed', { message: error.message }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:将 CSS 变量名转换为更友好的标签
|
||||
// 保存终端字体
|
||||
const handleSaveTerminalFont = async () => {
|
||||
try {
|
||||
await appearanceStore.setTerminalFontFamily(editableTerminalFontFamily.value);
|
||||
alert(t('styleCustomizer.terminalFontSaved'));
|
||||
} catch (error: any) {
|
||||
console.error("保存终端字体失败:", error);
|
||||
alert(t('styleCustomizer.terminalFontSaveFailed', { message: error.message }));
|
||||
}
|
||||
};
|
||||
|
||||
// 更改激活的终端主题
|
||||
const handleTerminalThemeChange = async () => {
|
||||
try {
|
||||
await appearanceStore.setActiveTerminalTheme(selectedTerminalThemeId.value);
|
||||
} catch (error: any) {
|
||||
console.error("设置激活终端主题失败:", error);
|
||||
// 恢复下拉框选择到之前的状态
|
||||
selectedTerminalThemeId.value = activeTerminalThemeId.value ?? null;
|
||||
alert(t('styleCustomizer.setActiveThemeFailed', { message: error.message }));
|
||||
}
|
||||
};
|
||||
|
||||
// --- 终端主题管理 ---
|
||||
// 开始新建主题
|
||||
const handleAddNewTheme = () => {
|
||||
saveThemeError.value = null; // 清除旧错误
|
||||
// 创建一个全新的默认主题结构用于编辑
|
||||
editingTheme.value = {
|
||||
_id: undefined, // 清除 ID 表示是新建
|
||||
name: t('styleCustomizer.newThemeDefaultName'),
|
||||
themeData: JSON.parse(JSON.stringify(defaultXtermTheme)), // 使用默认 xterm 主题作为基础
|
||||
isPreset: false, // 明确不是预设
|
||||
};
|
||||
isEditingTheme.value = true;
|
||||
};
|
||||
|
||||
|
||||
// 开始编辑现有主题
|
||||
const handleEditTheme = (theme: TerminalTheme) => {
|
||||
if (theme.isPreset) return; // 不允许编辑预设
|
||||
saveThemeError.value = null; // 清除旧错误
|
||||
// 深拷贝以避免直接修改列表中的对象
|
||||
editingTheme.value = JSON.parse(JSON.stringify(theme));
|
||||
isEditingTheme.value = true;
|
||||
};
|
||||
|
||||
// 保存主题编辑 (新建或更新)
|
||||
const handleSaveEditingTheme = async () => {
|
||||
if (!editingTheme.value || !editingTheme.value.name) {
|
||||
saveThemeError.value = t('styleCustomizer.errorThemeNameRequired');
|
||||
return;
|
||||
}
|
||||
saveThemeError.value = null; // 清除错误
|
||||
try {
|
||||
if (editingTheme.value._id) { // 更新
|
||||
// 确保传递的是 UpdateTerminalThemeDto 兼容的格式
|
||||
const updateDto = { name: editingTheme.value.name, themeData: editingTheme.value.themeData };
|
||||
await appearanceStore.updateTerminalTheme(
|
||||
editingTheme.value._id,
|
||||
updateDto.name,
|
||||
updateDto.themeData
|
||||
);
|
||||
alert(t('styleCustomizer.themeUpdatedSuccess'));
|
||||
} else { // 新建
|
||||
// 确保传递的是 CreateTerminalThemeDto 兼容的格式
|
||||
const createDto = { name: editingTheme.value.name, themeData: editingTheme.value.themeData };
|
||||
await appearanceStore.createTerminalTheme(
|
||||
createDto.name,
|
||||
createDto.themeData
|
||||
);
|
||||
alert(t('styleCustomizer.themeCreatedSuccess'));
|
||||
}
|
||||
isEditingTheme.value = false; // 关闭编辑
|
||||
editingTheme.value = null;
|
||||
} catch (error: any) {
|
||||
console.error("保存终端主题失败:", error);
|
||||
saveThemeError.value = error.message || t('styleCustomizer.themeSaveFailed');
|
||||
}
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const handleCancelEditingTheme = () => {
|
||||
isEditingTheme.value = false;
|
||||
editingTheme.value = null;
|
||||
saveThemeError.value = null;
|
||||
};
|
||||
|
||||
// 删除主题
|
||||
const handleDeleteTheme = async (theme: TerminalTheme) => {
|
||||
if (theme.isPreset) return;
|
||||
if (confirm(t('styleCustomizer.confirmDeleteTheme', { name: theme.name }))) {
|
||||
try {
|
||||
await appearanceStore.deleteTerminalTheme(theme._id!);
|
||||
alert(t('styleCustomizer.themeDeletedSuccess'));
|
||||
} catch (error: any) {
|
||||
console.error("删除终端主题失败:", error);
|
||||
alert(t('styleCustomizer.themeDeleteFailed', { message: error.message }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 触发主题导入文件选择
|
||||
const handleTriggerImport = () => {
|
||||
importError.value = null;
|
||||
themeImportInput.value?.click();
|
||||
};
|
||||
|
||||
// 处理主题导入
|
||||
const handleImportThemeFile = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
try {
|
||||
// 可以选择在前端解析文件名作为默认名称传递给后端
|
||||
const defaultName = file.name.endsWith('.json') ? file.name.slice(0, -5) : file.name;
|
||||
await appearanceStore.importTerminalTheme(file, defaultName); // 传递文件名作为备选名称
|
||||
alert(t('styleCustomizer.importSuccess'));
|
||||
input.value = ''; // 清空文件输入,以便再次选择相同文件
|
||||
} catch (error: any) {
|
||||
console.error("导入主题失败:", error);
|
||||
importError.value = error.message || t('styleCustomizer.importFailed');
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理主题导出
|
||||
const handleExportTheme = async () => {
|
||||
if (selectedTerminalThemeId.value) {
|
||||
try {
|
||||
await appearanceStore.exportTerminalTheme(selectedTerminalThemeId.value);
|
||||
} catch (error: any) {
|
||||
console.error("导出主题失败:", error);
|
||||
alert(t('styleCustomizer.exportFailed', { message: error.message }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- 背景处理 ---
|
||||
const handleTriggerPageBgUpload = () => {
|
||||
uploadError.value = null;
|
||||
pageBgFileInput.value?.click();
|
||||
};
|
||||
const handleTriggerTerminalBgUpload = () => {
|
||||
uploadError.value = null;
|
||||
terminalBgFileInput.value?.click();
|
||||
};
|
||||
|
||||
const handlePageBgUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
try {
|
||||
await appearanceStore.uploadPageBackground(file);
|
||||
alert(t('styleCustomizer.pageBgUploadSuccess'));
|
||||
input.value = ''; // 清空以便再次选择
|
||||
} catch (error: any) {
|
||||
uploadError.value = error.message || t('styleCustomizer.uploadFailed');
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminalBgUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
try {
|
||||
await appearanceStore.uploadTerminalBackground(file);
|
||||
alert(t('styleCustomizer.terminalBgUploadSuccess'));
|
||||
input.value = '';
|
||||
} catch (error: any) {
|
||||
uploadError.value = error.message || t('styleCustomizer.uploadFailed');
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePageBg = async () => {
|
||||
if (confirm(t('styleCustomizer.confirmRemovePageBg'))) {
|
||||
try {
|
||||
await appearanceStore.removePageBackground();
|
||||
alert(t('styleCustomizer.pageBgRemoved'));
|
||||
} catch (error: any) {
|
||||
console.error("移除页面背景失败:", error);
|
||||
alert(t('styleCustomizer.removeBgFailed', { message: error.message }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTerminalBg = async () => {
|
||||
if (confirm(t('styleCustomizer.confirmRemoveTerminalBg'))) {
|
||||
try {
|
||||
await appearanceStore.removeTerminalBackground();
|
||||
alert(t('styleCustomizer.terminalBgRemoved'));
|
||||
} catch (error: any) {
|
||||
console.error("移除终端背景失败:", error);
|
||||
alert(t('styleCustomizer.removeBgFailed', { message: error.message }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageOpacityChange = async () => {
|
||||
try {
|
||||
await appearanceStore.setPageBackgroundOpacity(editablePageBackgroundOpacity.value);
|
||||
} catch (error: any) {
|
||||
console.error("设置页面背景透明度失败:", error);
|
||||
// 恢复旧值
|
||||
editablePageBackgroundOpacity.value = pageBackgroundOpacity.value;
|
||||
alert(t('styleCustomizer.setOpacityFailed', { message: error.message }));
|
||||
}
|
||||
};
|
||||
const handleTerminalOpacityChange = async () => {
|
||||
try {
|
||||
await appearanceStore.setTerminalBackgroundOpacity(editableTerminalBackgroundOpacity.value);
|
||||
} catch (error: any) {
|
||||
console.error("设置终端背景透明度失败:", error);
|
||||
editableTerminalBackgroundOpacity.value = terminalBackgroundOpacity.value;
|
||||
alert(t('styleCustomizer.setOpacityFailed', { message: error.message }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- 辅助函数 ---
|
||||
// 格式化 UI 主题标签
|
||||
const formatLabel = (key: string): string => {
|
||||
// 简单的转换逻辑,可以根据需要优化
|
||||
return key
|
||||
@@ -69,12 +360,11 @@ const formatLabel = (key: string): string => {
|
||||
.replace(/^./, (str) => str.toUpperCase()); // 首字母大写
|
||||
};
|
||||
|
||||
// 辅助函数:将 xterm theme key 转换为更友好的标签
|
||||
// 格式化 xterm 主题属性标签
|
||||
const formatXtermLabel = (key: keyof ITheme): string => {
|
||||
// 简单的转换逻辑
|
||||
return key.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase());
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -89,9 +379,12 @@ const formatXtermLabel = (key: keyof ITheme): string => {
|
||||
<button @click="currentTab = 'ui'" :class="{ active: currentTab === 'ui' }">
|
||||
{{ t('styleCustomizer.uiStyles') }}
|
||||
</button>
|
||||
<button @click="currentTab = 'terminal'" :class="{ active: currentTab === 'terminal' }">
|
||||
<button @click="currentTab = 'terminal'" :class="{ active: currentTab === 'terminal' && !isEditingTheme }" :disabled="isEditingTheme">
|
||||
{{ t('styleCustomizer.terminalStyles') }}
|
||||
</button>
|
||||
<button @click="currentTab = 'background'" :class="{ active: currentTab === 'background' }" :disabled="isEditingTheme">
|
||||
{{ t('styleCustomizer.backgroundSettings') }}
|
||||
</button>
|
||||
</nav>
|
||||
<main class="panel-main">
|
||||
<section v-if="currentTab === 'ui'">
|
||||
@@ -117,34 +410,132 @@ const formatXtermLabel = (key: keyof ITheme): string => {
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="currentTab === 'terminal'">
|
||||
<section v-if="currentTab === 'terminal' && !isEditingTheme">
|
||||
<h3>{{ t('styleCustomizer.terminalStyles') }}</h3>
|
||||
<p>{{ t('styleCustomizer.terminalDescription') }}</p>
|
||||
<!-- 动态生成终端样式编辑控件 -->
|
||||
<div v-for="(value, key) in editableXtermTheme" :key="key" class="form-group">
|
||||
<label :for="`xterm-${key}`">{{ formatXtermLabel(key as keyof ITheme) }}:</label>
|
||||
<!-- 简单判断是否为颜色值 -->
|
||||
<input
|
||||
v-if="typeof value === 'string' && value.startsWith('#')"
|
||||
type="color"
|
||||
:id="`xterm-${key}`"
|
||||
v-model="(editableXtermTheme as any)[key]"
|
||||
/>
|
||||
<!-- 其他类型(如数字、布尔值)可以添加相应控件,这里简化为文本 -->
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
:id="`xterm-${key}`"
|
||||
v-model="(editableXtermTheme as any)[key]"
|
||||
class="text-input"
|
||||
/>
|
||||
<!-- 终端字体设置 -->
|
||||
<div class="form-group">
|
||||
<label for="terminalFontFamily">{{ t('styleCustomizer.terminalFontFamily') }}:</label>
|
||||
<input type="text" id="terminalFontFamily" v-model="editableTerminalFontFamily" class="text-input wide-input" :placeholder="t('styleCustomizer.terminalFontPlaceholder')"/>
|
||||
<button @click="handleSaveTerminalFont" class="button-inline">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
<p class="setting-description">{{ t('styleCustomizer.terminalFontDescription') }}</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- 终端主题选择与管理 -->
|
||||
<h4>{{ t('styleCustomizer.terminalThemeSelection') }}</h4>
|
||||
<div class="form-group">
|
||||
<label for="terminalThemeSelect">{{ t('styleCustomizer.activeTheme') }}:</label>
|
||||
<select id="terminalThemeSelect" v-model="selectedTerminalThemeId" @change="handleTerminalThemeChange">
|
||||
<option :value="null">{{ t('styleCustomizer.selectThemePrompt') }}</option> <!-- 添加一个空选项或默认选项 -->
|
||||
<option v-for="theme in availableTerminalThemes" :key="theme._id" :value="theme._id">
|
||||
{{ theme.name }} {{ theme.isPreset ? `(${t('styleCustomizer.preset')})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="handleExportTheme" :disabled="!selectedTerminalThemeId" class="button-inline">{{ t('styleCustomizer.exportTheme') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="theme-management-buttons">
|
||||
<button @click="handleAddNewTheme">{{ t('styleCustomizer.addNewTheme') }}</button>
|
||||
<button @click="handleTriggerImport">{{ t('styleCustomizer.importTheme') }}</button>
|
||||
<input type="file" ref="themeImportInput" @change="handleImportThemeFile" accept=".json" style="display: none;" />
|
||||
<p v-if="importError" class="error-message">{{ importError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 主题列表 -->
|
||||
<ul class="theme-list">
|
||||
<li v-for="theme in availableTerminalThemes" :key="theme._id" :class="{ 'preset-theme': theme.isPreset }">
|
||||
<span>{{ theme.name }} {{ theme.isPreset ? `(${t('styleCustomizer.preset')})` : '' }}</span>
|
||||
<div class="theme-actions">
|
||||
<button @click="handleEditTheme(theme)" :disabled="theme.isPreset">{{ t('common.edit') }}</button>
|
||||
<button @click="handleDeleteTheme(theme)" :disabled="theme.isPreset" class="button-danger">{{ t('common.delete') }}</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- 主题编辑器 -->
|
||||
<section v-if="isEditingTheme && editingTheme">
|
||||
<h3>{{ editingTheme._id ? t('styleCustomizer.editThemeTitle') : t('styleCustomizer.newThemeTitle') }}</h3>
|
||||
<p v-if="saveThemeError" class="error-message">{{ saveThemeError }}</p>
|
||||
<div class="form-group">
|
||||
<label for="editingThemeName">{{ t('styleCustomizer.themeName') }}:</label>
|
||||
<input type="text" id="editingThemeName" v-model="editingTheme.name" required class="text-input"/>
|
||||
</div>
|
||||
<!-- 动态生成终端样式编辑控件 -->
|
||||
<div v-for="(value, key) in editingTheme.themeData" :key="key" class="form-group">
|
||||
<label :for="`xterm-${key}`">{{ formatXtermLabel(key as keyof ITheme) }}:</label>
|
||||
<!-- 简单判断是否为颜色值 -->
|
||||
<input
|
||||
v-if="typeof value === 'string' && value.startsWith('#')"
|
||||
type="color"
|
||||
:id="`xterm-${key}`"
|
||||
v-model="(editingTheme.themeData as any)[key]"
|
||||
/>
|
||||
<!-- 其他类型(如数字、布尔值)可以添加相应控件,这里简化为文本 -->
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
:id="`xterm-${key}`"
|
||||
v-model="(editingTheme.themeData as any)[key]"
|
||||
class="text-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="editor-footer">
|
||||
<button @click="handleCancelEditingTheme" class="button-secondary">{{ t('common.cancel') }}</button>
|
||||
<button @click="handleSaveEditingTheme" class="button-primary">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="currentTab === 'background'">
|
||||
<h3>{{ t('styleCustomizer.backgroundSettings') }}</h3>
|
||||
|
||||
<!-- 页面背景 -->
|
||||
<h4>{{ t('styleCustomizer.pageBackground') }}</h4>
|
||||
<div class="background-preview" :style="{ backgroundImage: pageBackgroundImage ? `url(${pageBackgroundImage})` : 'none' }">
|
||||
{{ pageBackgroundImage ? '' : t('styleCustomizer.noBackground') }}
|
||||
</div>
|
||||
<div class="background-controls">
|
||||
<button @click="handleTriggerPageBgUpload">{{ t('styleCustomizer.uploadPageBg') }}</button>
|
||||
<button @click="handleRemovePageBg" :disabled="!pageBackgroundImage" class="button-danger">{{ t('styleCustomizer.removePageBg') }}</button>
|
||||
<input type="file" ref="pageBgFileInput" @change="handlePageBgUpload" accept="image/*" style="display: none;" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pageBgOpacity">{{ t('styleCustomizer.pageBgOpacity') }}:</label>
|
||||
<input type="range" id="pageBgOpacity" v-model.number="editablePageBackgroundOpacity" min="0" max="1" step="0.05" @change="handlePageOpacityChange"/>
|
||||
<span>{{ Math.round(editablePageBackgroundOpacity * 100) }}%</span>
|
||||
</div>
|
||||
<p v-if="uploadError" class="error-message">{{ uploadError }}</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- 终端背景 -->
|
||||
<h4>{{ t('styleCustomizer.terminalBackground') }}</h4>
|
||||
<div class="background-preview" :style="{ backgroundImage: terminalBackgroundImage ? `url(${terminalBackgroundImage})` : 'none' }">
|
||||
{{ terminalBackgroundImage ? '' : t('styleCustomizer.noBackground') }}
|
||||
</div>
|
||||
<div class="background-controls">
|
||||
<button @click="handleTriggerTerminalBgUpload">{{ t('styleCustomizer.uploadTerminalBg') }}</button>
|
||||
<button @click="handleRemoveTerminalBg" :disabled="!terminalBackgroundImage" class="button-danger">{{ t('styleCustomizer.removeTerminalBg') }}</button>
|
||||
<input type="file" ref="terminalBgFileInput" @change="handleTerminalBgUpload" accept="image/*" style="display: none;" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="terminalBgOpacity">{{ t('styleCustomizer.terminalBgOpacity') }}:</label>
|
||||
<input type="range" id="terminalBgOpacity" v-model.number="editableTerminalBackgroundOpacity" min="0" max="1" step="0.05" @change="handleTerminalOpacityChange"/>
|
||||
<span>{{ Math.round(editableTerminalBackgroundOpacity * 100) }}%</span>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<footer class="panel-footer">
|
||||
<button @click="handleResetDefault" class="button-secondary">{{ t('styleCustomizer.resetDefault') }}</button>
|
||||
<button @click="handleSaveChanges" class="button-primary">{{ t('styleCustomizer.saveChanges') }}</button>
|
||||
<!-- 根据当前 tab 或状态显示不同的按钮 -->
|
||||
<button v-if="currentTab === 'ui'" @click="handleResetUiTheme" class="button-secondary">{{ t('styleCustomizer.resetUiTheme') }}</button>
|
||||
<button v-if="currentTab === 'ui'" @click="handleSaveUiTheme" class="button-primary">{{ t('styleCustomizer.saveUiTheme') }}</button>
|
||||
<!-- 终端字体和主题选择是即时保存的,不需要单独的保存按钮 -->
|
||||
<!-- 背景设置也是即时保存的 -->
|
||||
<button @click="closeCustomizer" class="button-secondary">{{ t('common.close') }}</button> <!-- 添加一个通用的关闭按钮 -->
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,6 +601,7 @@ const formatXtermLabel = (key: keyof ITheme): string => {
|
||||
border-right: 1px solid var(--border-color, #ccc);
|
||||
padding: var(--base-padding, 1rem);
|
||||
background-color: var(--header-bg-color, #f0f0f0); /* 轻微区分背景 */
|
||||
flex-shrink: 0; /* 防止导航栏被压缩 */
|
||||
}
|
||||
|
||||
.panel-nav button {
|
||||
@@ -234,6 +626,13 @@ const formatXtermLabel = (key: keyof ITheme): string => {
|
||||
color: var(--button-text-color, #fff);
|
||||
font-weight: bold;
|
||||
}
|
||||
.panel-nav button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
background-color: transparent !important; /* 确保禁用时背景透明 */
|
||||
color: var(--text-color-secondary, #999); /* 禁用时文字颜色变灰 */
|
||||
}
|
||||
|
||||
|
||||
.panel-main {
|
||||
flex-grow: 1;
|
||||
@@ -247,6 +646,11 @@ const formatXtermLabel = (key: keyof ITheme): string => {
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.panel-main h4 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.8rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.panel-main p {
|
||||
color: var(--text-color-secondary);
|
||||
@@ -254,34 +658,65 @@ const formatXtermLabel = (key: keyof ITheme): string => {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
.setting-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: -0.5rem; /* 减少与上方元素的间距 */
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
display: flex; /* 使用 flex 布局 */
|
||||
align-items: center; /* 垂直居中对齐 */
|
||||
flex-wrap: wrap; /* 允许换行 */
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: inline-block;
|
||||
/* display: inline-block; */
|
||||
min-width: 150px; /* 调整标签最小宽度以适应更长的文本 */
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
/* vertical-align: middle; */
|
||||
text-align: right; /* 标签右对齐 */
|
||||
padding-right: 5px; /* 标签和输入框间距 */
|
||||
flex-shrink: 0; /* 防止标签被压缩 */
|
||||
}
|
||||
|
||||
.form-group input[type="color"] {
|
||||
vertical-align: middle;
|
||||
/* vertical-align: middle; */
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
width: 150px; /* 统一输入框宽度 */
|
||||
height: 30px; /* 增加高度 */
|
||||
}
|
||||
|
||||
.form-group input[type="text"].text-input {
|
||||
vertical-align: middle;
|
||||
/* vertical-align: middle; */
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
width: 150px; /* 统一文本输入框宽度 */
|
||||
flex-grow: 1; /* 允许输入框扩展 */
|
||||
min-width: 100px; /* 最小宽度 */
|
||||
}
|
||||
.form-group input[type="text"].wide-input {
|
||||
/* width: calc(100% - 200px); */ /* 调整宽度以适应按钮 */
|
||||
/* min-width: 200px; */
|
||||
flex-grow: 1; /* 占据更多空间 */
|
||||
}
|
||||
.form-group select {
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
flex-grow: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
.form-group input[type="range"] {
|
||||
flex-grow: 1;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
|
||||
.panel-footer {
|
||||
display: flex;
|
||||
@@ -318,4 +753,113 @@ const formatXtermLabel = (key: keyof ITheme): string => {
|
||||
background-color: #5a6268;
|
||||
border-color: #545b62;
|
||||
}
|
||||
|
||||
.button-inline {
|
||||
margin-left: 10px;
|
||||
padding: 4px 8px;
|
||||
/* vertical-align: middle; */
|
||||
flex-shrink: 0; /* 防止按钮被压缩 */
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color, #eee);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Theme Management Styles */
|
||||
.theme-management-buttons {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
gap: 10px; /* 按钮间距 */
|
||||
flex-wrap: wrap; /* 允许换行 */
|
||||
}
|
||||
.theme-management-buttons button {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.theme-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: 1rem;
|
||||
max-height: 200px; /* 限制列表高度并滚动 */
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.theme-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.theme-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.theme-list li.preset-theme span {
|
||||
font-style: italic;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.theme-actions {
|
||||
flex-shrink: 0; /* 防止按钮组被压缩 */
|
||||
}
|
||||
.theme-actions button {
|
||||
margin-left: 0.5rem;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.button-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border-color: #dc3545;
|
||||
}
|
||||
.button-danger:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
}
|
||||
.button-danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Background Styles */
|
||||
.background-preview {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
border: 1px dashed var(--border-color);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--text-color-secondary);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.background-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.background-controls button {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 5px;
|
||||
width: 100%; /* 确保错误消息占满宽度 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
||||
import { ITheme } from 'xterm';
|
||||
import { Terminal } from 'xterm';
|
||||
import { useSettingsStore } from '../stores/settings.store'; // 导入设置 store
|
||||
import { useAppearanceStore } from '../stores/appearance.store'; // 导入外观 store
|
||||
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||
@@ -27,25 +27,25 @@ let terminal: Terminal | null = null;
|
||||
let fitAddon: FitAddon | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let debounceTimer: number | null = null; // 用于防抖的计时器 ID
|
||||
const fontSize = ref(14); // 字体大小状态, 默认为14
|
||||
const fontSize = ref(14); // 字体大小状态, 默认为14 (这个可以保留,或者也移到 appearance store)
|
||||
|
||||
// --- Settings Store ---
|
||||
const settingsStore = useSettingsStore();
|
||||
const { currentXtermTheme } = storeToRefs(settingsStore); // 获取响应式的 xterm 主题
|
||||
// --- Appearance Store ---
|
||||
const appearanceStore = useAppearanceStore();
|
||||
const { currentTerminalTheme, currentTerminalFontFamily, terminalBackgroundImage, terminalBackgroundOpacity } = storeToRefs(appearanceStore); // 获取外观状态
|
||||
|
||||
// 防抖函数
|
||||
const debounce = (func: Function, delay: number) => {
|
||||
let timeoutId: number | null = null; // Use a local variable for the timeout ID
|
||||
return (...args: any[]) => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
func(...args);
|
||||
debounceTimer = null;
|
||||
timeoutId = null;
|
||||
}, delay);
|
||||
};
|
||||
};
|
||||
|
||||
// 防抖处理由 ResizeObserver 触发的 resize 事件
|
||||
const debouncedEmitResize = debounce((term: Terminal) => {
|
||||
if (term && props.isActive) { // 仅当标签仍处于活动状态时才发送防抖后的 resize
|
||||
@@ -75,9 +75,9 @@ onMounted(() => {
|
||||
if (terminalRef.value) {
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: fontSize.value,
|
||||
fontFamily: 'Consolas, "Courier New", monospace, "Microsoft YaHei", "微软雅黑"',
|
||||
theme: currentXtermTheme.value, // *** 使用 store 中的当前 xterm 主题 ***
|
||||
fontSize: fontSize.value, // 初始字体大小
|
||||
fontFamily: currentTerminalFontFamily.value, // 使用 store 中的字体设置
|
||||
theme: currentTerminalTheme.value, // 使用 store 中的当前 xterm 主题
|
||||
rows: 24, // 初始行数
|
||||
cols: 80, // 初始列数
|
||||
allowTransparency: true,
|
||||
@@ -185,19 +185,35 @@ onMounted(() => {
|
||||
emit('ready', { sessionId: props.sessionId, terminal: terminal });
|
||||
}
|
||||
|
||||
// --- 监听 xterm 主题变化 ---
|
||||
watch(currentXtermTheme, (newTheme) => {
|
||||
// --- 监听外观变化 ---
|
||||
watch(currentTerminalTheme, (newTheme) => {
|
||||
if (terminal) {
|
||||
console.log(`[Terminal ${props.sessionId}] Applying new xterm theme.`); // 日志改为中文
|
||||
console.log(`[Terminal ${props.sessionId}] 应用新终端主题。`);
|
||||
terminal.options.theme = newTheme;
|
||||
// 可能需要重新渲染或刷新终端以完全应用主题,但通常 xterm 会自动处理
|
||||
// terminal.refresh(0, terminal.rows - 1); // 如果需要强制刷新
|
||||
}
|
||||
}, { deep: true }); // 使用 deep watch
|
||||
}, { deep: true });
|
||||
|
||||
// 聚焦终端
|
||||
terminal.focus();
|
||||
|
||||
watch(currentTerminalFontFamily, (newFontFamily) => {
|
||||
if (terminal) {
|
||||
console.log(`[Terminal ${props.sessionId}] 应用新终端字体: ${newFontFamily}`);
|
||||
terminal.options.fontFamily = newFontFamily;
|
||||
// 字体变化可能影响尺寸,重新 fit
|
||||
fitAndEmitResizeNow(terminal);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听背景图片和透明度 (恢复之前的监听方式,因为监听整个对象可能引入其他问题)
|
||||
watch([terminalBackgroundImage, terminalBackgroundOpacity], () => {
|
||||
console.log(`[Terminal Watcher] terminalBackgroundImage or Opacity changed. New image: ${terminalBackgroundImage.value}`); // 添加日志确认 watcher 触发
|
||||
applyTerminalBackground();
|
||||
}, { immediate: true }); // 添加 immediate: true,强制立即执行一次
|
||||
// 移除 onMounted 中的 applyTerminalBackground 调用,完全依赖 watch
|
||||
// applyTerminalBackground(); // 初始应用一次
|
||||
|
||||
// 聚焦终端 (添加 null check)
|
||||
if (terminal) {
|
||||
terminal.focus();
|
||||
}
|
||||
// 重新添加鼠标滚轮缩放功能
|
||||
if (terminalRef.value) {
|
||||
terminalRef.value.addEventListener('wheel', (event: WheelEvent) => {
|
||||
@@ -264,6 +280,53 @@ const write = (data: string | Uint8Array) => {
|
||||
};
|
||||
defineExpose({ write });
|
||||
|
||||
// --- 应用终端背景 ---
|
||||
const applyTerminalBackground = () => {
|
||||
if (terminalRef.value) {
|
||||
if (terminalBackgroundImage.value) {
|
||||
// --- 修改开始 ---
|
||||
// 使用环境变量获取后端基础 URL
|
||||
const backendUrl = import.meta.env.VITE_API_BASE_URL || ''; // 提供一个默认空字符串以防万一
|
||||
const imagePath = terminalBackgroundImage.value;
|
||||
console.log(`[Terminal applyTerminalBackground] backendUrl: "${backendUrl}", imagePath: "${imagePath}"`); // 详细日志
|
||||
const fullImageUrl = `${backendUrl}${imagePath}`;
|
||||
console.log(`[Terminal applyTerminalBackground] fullImageUrl: "${fullImageUrl}"`); // 打印完整 URL
|
||||
// --- 修改结束 ---
|
||||
// --- 使用 nextTick 包装样式应用 ---
|
||||
nextTick(() => {
|
||||
if (terminalRef.value) { // 再次检查 ref 是否存在
|
||||
terminalRef.value.style.backgroundImage = `url(${fullImageUrl})`;
|
||||
terminalRef.value.style.backgroundSize = 'cover'; // Or 'contain', 'auto', etc.
|
||||
terminalRef.value.style.backgroundPosition = 'center';
|
||||
terminalRef.value.style.backgroundRepeat = 'no-repeat';
|
||||
// 添加 CSS 类
|
||||
terminalRef.value.classList.add('has-terminal-background');
|
||||
}
|
||||
});
|
||||
// 应用透明度: 通过设置背景色实现,需要 xterm 的 allowTransparency: true
|
||||
// 注意:这会影响整个终端的背景,包括文本后的背景
|
||||
// 一个常见的做法是设置一个稍微透明的背景色,让图片透出来
|
||||
// 例如,将 xterm 主题的 background 设置为 rgba(r, g, b, opacity)
|
||||
// 这里我们简单设置容器的 opacity,但这会影响文本!更好的方法是修改主题。
|
||||
// 另一种方法是用伪元素做背景层。
|
||||
// 为了简单起见,我们暂时只设置背景图,透明度让用户在主题中调整 background 的 alpha 值。
|
||||
// terminalRef.value.style.opacity = terminalBackgroundOpacity.value.toString(); // 不推荐直接设置 opacity
|
||||
console.log(`[Terminal ${props.sessionId}] 应用终端背景图片: ${terminalBackgroundImage.value}`);
|
||||
} else {
|
||||
// --- 使用 nextTick 包装样式移除 ---
|
||||
nextTick(() => {
|
||||
if (terminalRef.value) { // 再次检查 ref 是否存在
|
||||
terminalRef.value.style.backgroundImage = 'none';
|
||||
// 移除 CSS 类
|
||||
terminalRef.value.classList.remove('has-terminal-background');
|
||||
}
|
||||
});
|
||||
// terminalRef.value.style.opacity = '1'; // 移除背景时恢复不透明
|
||||
console.log(`[Terminal ${props.sessionId}] 移除终端背景图片。`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -276,7 +339,32 @@ defineExpose({ write });
|
||||
width: 100%;
|
||||
height: 100%; /* 高度需要由父容器控制 */
|
||||
overflow: hidden; /* 阻止此容器本身产生滚动条 */
|
||||
position: relative; /* 用于可能的伪元素背景 */
|
||||
}
|
||||
|
||||
/* 移除 :deep 样式,让 xterm 内部自然处理滚动 */
|
||||
|
||||
/* 示例:使用伪元素添加带透明度的背景层 (如果需要独立于主题的透明度) */
|
||||
/*
|
||||
.terminal-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image: var(--terminal-bg-image);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: var(--terminal-bg-opacity);
|
||||
z-index: -1; // 确保在 xterm 内容后面
|
||||
}
|
||||
*/
|
||||
|
||||
/* 当容器有背景图时,强制内部 xterm 视口和屏幕背景透明 */
|
||||
.terminal-container.has-terminal-background :deep(.xterm-viewport),
|
||||
.terminal-container.has-terminal-background :deep(.xterm-screen) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,13 +14,65 @@
|
||||
"customizeStyle": "Customize Style"
|
||||
},
|
||||
"styleCustomizer": {
|
||||
"title": "Style Customizer",
|
||||
"title": "Appearance Customizer",
|
||||
"uiStyles": "UI Styles",
|
||||
"terminalStyles": "Terminal Styles",
|
||||
"uiDescription": "Adjust colors, fonts, etc., for the application interface.",
|
||||
"backgroundSettings": "Background",
|
||||
"uiDescription": "Adjust colors, fonts, etc. for the application interface.",
|
||||
"terminalDescription": "Customize the color scheme and font for the terminal.",
|
||||
"resetDefault": "Reset Default",
|
||||
"saveChanges": "Save Changes"
|
||||
"resetDefault": "Reset to Default",
|
||||
"saveChanges": "Save Changes",
|
||||
"resetUiTheme": "Reset UI Theme",
|
||||
"saveUiTheme": "Save UI Theme",
|
||||
"terminalFontFamily": "Terminal Font",
|
||||
"terminalFontPlaceholder": "e.g., \"Fira Code\", Consolas, monospace",
|
||||
"terminalFontDescription": "Enter font names, separated by commas. Use quotes for names with spaces.",
|
||||
"terminalThemeSelection": "Terminal Theme",
|
||||
"activeTheme": "Active Theme",
|
||||
"selectThemePrompt": "Select a theme...",
|
||||
"preset": "Preset",
|
||||
"addNewTheme": "New Theme",
|
||||
"importTheme": "Import Theme",
|
||||
"exportTheme": "Export Selected",
|
||||
"editThemeTitle": "Edit Terminal Theme",
|
||||
"newThemeTitle": "New Terminal Theme",
|
||||
"themeName": "Theme Name",
|
||||
"confirmDeleteTheme": "Are you sure you want to delete the theme \"{name}\"? This action cannot be undone.",
|
||||
"confirmCloseEditing": "You are currently editing a theme. Closing will discard unsaved changes. Are you sure?",
|
||||
"errorThemeNameRequired": "Theme name cannot be empty.",
|
||||
"themeUpdatedSuccess": "Theme updated successfully.",
|
||||
"themeCreatedSuccess": "Theme created successfully.",
|
||||
"themeSaveFailed": "Failed to save theme.",
|
||||
"themeDeletedSuccess": "Theme deleted successfully.",
|
||||
"themeDeleteFailed": "Failed to delete theme: {message}",
|
||||
"importSuccess": "Theme imported successfully.",
|
||||
"importFailed": "Theme import failed.",
|
||||
"exportFailed": "Failed to export theme: {message}",
|
||||
"pageBackground": "Page Background",
|
||||
"terminalBackground": "Terminal Background",
|
||||
"noBackground": "No background",
|
||||
"uploadPageBg": "Upload Page Bg",
|
||||
"removePageBg": "Remove Page Bg",
|
||||
"uploadTerminalBg": "Upload Terminal Bg",
|
||||
"removeTerminalBg": "Remove Terminal Bg",
|
||||
"pageBgOpacity": "Page Bg Opacity",
|
||||
"terminalBgOpacity": "Terminal Bg Opacity",
|
||||
"uploadFailed": "Upload failed: {message}",
|
||||
"pageBgUploadSuccess": "Page background uploaded successfully.",
|
||||
"terminalBgUploadSuccess": "Terminal background uploaded successfully.",
|
||||
"confirmRemovePageBg": "Are you sure you want to remove the page background image?",
|
||||
"confirmRemoveTerminalBg": "Are you sure you want to remove the terminal background image?",
|
||||
"pageBgRemoved": "Page background removed.",
|
||||
"terminalBgRemoved": "Terminal background removed.",
|
||||
"removeBgFailed": "Failed to remove background: {message}",
|
||||
"setOpacityFailed": "Failed to set opacity: {message}",
|
||||
"uiThemeSaved": "UI theme saved.",
|
||||
"uiThemeSaveFailed": "Failed to save UI theme: {message}",
|
||||
"uiThemeReset": "UI theme reset to default.",
|
||||
"uiThemeResetFailed": "Failed to reset UI theme: {message}",
|
||||
"terminalFontSaved": "Terminal font saved.",
|
||||
"terminalFontSaveFailed": "Failed to save terminal font: {message}",
|
||||
"setActiveThemeFailed": "Failed to set active terminal theme: {message}"
|
||||
},
|
||||
"login": {
|
||||
"title": "User Login",
|
||||
@@ -308,7 +360,7 @@
|
||||
},
|
||||
"inputPlaceholder": "Type to search or create tags...",
|
||||
"removeSelection": "Remove this tag selection",
|
||||
"deleteTagGlobally": "Delete this tag globally"
|
||||
"deleteTagGlobally": "Delete this tag globally"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -491,6 +543,11 @@
|
||||
"IP_BLACKLISTED": "IP Blacklisted",
|
||||
"SERVER_ERROR": "Server Error"
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance Settings",
|
||||
"description": "Customize the visual theme and background of the application.",
|
||||
"customizeButton": "Customize Appearance"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -575,7 +632,42 @@
|
||||
"commandBar": "Command Bar",
|
||||
"fileManager": "File Manager",
|
||||
"editor": "Editor",
|
||||
"statusMonitor": "Status Monitor"
|
||||
"statusMonitor": "Status Monitor",
|
||||
"commandHistory": "Command History",
|
||||
"quickCommands": "Quick Commands"
|
||||
}
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "Command History",
|
||||
"searchPlaceholder": "Search history...",
|
||||
"clear": "Clear",
|
||||
"copy": "Copy",
|
||||
"delete": "Delete",
|
||||
"loading": "Loading...",
|
||||
"empty": "No history records",
|
||||
"confirmClear": "Are you sure you want to clear all history?",
|
||||
"copied": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed"
|
||||
},
|
||||
"quickCommands": {
|
||||
"title": "Quick Commands",
|
||||
"searchPlaceholder": "Search name or command...",
|
||||
"add": "Add",
|
||||
"sortBy": "Sort by:",
|
||||
"sortByName": "Name",
|
||||
"sortByUsage": "Usage Frequency",
|
||||
"usageCount": "Usage Count",
|
||||
"empty": "No quick commands. Click 'Add' to create one!",
|
||||
"confirmDelete": "Are you sure you want to delete the quick command \"{name}\"?",
|
||||
"form": {
|
||||
"titleAdd": "Add Quick Command",
|
||||
"titleEdit": "Edit Quick Command",
|
||||
"name": "Name:",
|
||||
"namePlaceholder": "Optional, for quick identification",
|
||||
"command": "Command:",
|
||||
"commandPlaceholder": "e.g., ls -alh /home/user",
|
||||
"errorCommandRequired": "Command cannot be empty",
|
||||
"add": "Add"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,68 @@
|
||||
"notifications": "通知管理",
|
||||
"auditLogs": "审计日志",
|
||||
"settings": "设置",
|
||||
"customizeStyle": "自定义样式"
|
||||
"customizeStyle": "自定义外观"
|
||||
},
|
||||
"styleCustomizer": {
|
||||
"title": "样式自定义",
|
||||
"title": "外观自定义",
|
||||
"uiStyles": "界面样式",
|
||||
"terminalStyles": "终端样式",
|
||||
"backgroundSettings": "背景设置",
|
||||
"uiDescription": "调整应用程序界面的颜色、字体等。",
|
||||
"terminalDescription": "自定义终端的颜色方案和字体。",
|
||||
"resetDefault": "恢复默认",
|
||||
"saveChanges": "保存更改"
|
||||
"saveChanges": "保存更改",
|
||||
"resetUiTheme": "重置界面主题",
|
||||
"saveUiTheme": "保存界面主题",
|
||||
"terminalFontFamily": "终端字体",
|
||||
"terminalFontPlaceholder": "例如:\"Fira Code\", Consolas, monospace",
|
||||
"terminalFontDescription": "输入字体名称,用英文逗号分隔。如果字体名称包含空格,请用引号括起来。",
|
||||
"terminalThemeSelection": "终端主题",
|
||||
"activeTheme": "当前主题",
|
||||
"selectThemePrompt": "选择一个主题...",
|
||||
"preset": "预设",
|
||||
"addNewTheme": "新建主题",
|
||||
"importTheme": "导入主题",
|
||||
"exportTheme": "导出选中主题",
|
||||
"editThemeTitle": "编辑终端主题",
|
||||
"newThemeTitle": "新建终端主题",
|
||||
"themeName": "主题名称",
|
||||
"confirmDeleteTheme": "确定要删除主题 \"{name}\" 吗?此操作不可撤销。",
|
||||
"confirmCloseEditing": "您正在编辑主题,关闭将丢失未保存的更改。确定要关闭吗?",
|
||||
"errorThemeNameRequired": "主题名称不能为空。",
|
||||
"themeUpdatedSuccess": "主题更新成功。",
|
||||
"themeCreatedSuccess": "主题创建成功。",
|
||||
"themeSaveFailed": "保存主题失败。",
|
||||
"themeDeletedSuccess": "主题删除成功。",
|
||||
"themeDeleteFailed": "删除主题失败: {message}",
|
||||
"importSuccess": "主题导入成功。",
|
||||
"importFailed": "主题导入失败。",
|
||||
"exportFailed": "导出主题失败: {message}",
|
||||
"pageBackground": "页面背景",
|
||||
"terminalBackground": "终端背景",
|
||||
"noBackground": "无背景",
|
||||
"uploadPageBg": "上传页面背景",
|
||||
"removePageBg": "移除页面背景",
|
||||
"uploadTerminalBg": "上传终端背景",
|
||||
"removeTerminalBg": "移除终端背景",
|
||||
"pageBgOpacity": "页面背景不透明度",
|
||||
"terminalBgOpacity": "终端背景不透明度",
|
||||
"uploadFailed": "上传失败: {message}",
|
||||
"pageBgUploadSuccess": "页面背景上传成功。",
|
||||
"terminalBgUploadSuccess": "终端背景上传成功。",
|
||||
"confirmRemovePageBg": "确定要移除页面背景图片吗?",
|
||||
"confirmRemoveTerminalBg": "确定要移除终端背景图片吗?",
|
||||
"pageBgRemoved": "页面背景已移除。",
|
||||
"terminalBgRemoved": "终端背景已移除。",
|
||||
"removeBgFailed": "移除背景失败: {message}",
|
||||
"setOpacityFailed": "设置透明度失败: {message}",
|
||||
"uiThemeSaved": "界面主题已保存。",
|
||||
"uiThemeSaveFailed": "保存界面主题失败: {message}",
|
||||
"uiThemeReset": "界面主题已重置为默认值。",
|
||||
"uiThemeResetFailed": "重置界面主题失败: {message}",
|
||||
"terminalFontSaved": "终端字体已保存。",
|
||||
"terminalFontSaveFailed": "保存终端字体失败: {message}",
|
||||
"setActiveThemeFailed": "设置激活终端主题失败: {message}"
|
||||
},
|
||||
"login": {
|
||||
"title": "用户登录",
|
||||
@@ -180,7 +232,9 @@
|
||||
"noPassword": "连接配置缺少密码信息。",
|
||||
"shellError": "打开 Shell 失败: {message}",
|
||||
"alreadyConnected": "已存在活动的 SSH 连接。",
|
||||
"unknown": "未知状态"
|
||||
"unknown": "未知状态",
|
||||
"wsClosedWillRetry": "WebSocket 连接已关闭,将在 {seconds} 秒后尝试第 {attempt} 次重连...",
|
||||
"reconnecting": "正在尝试重新连接..."
|
||||
},
|
||||
"selectConnectionPrompt": "请选择一个连接",
|
||||
"selectConnectionHint": "从左侧列表中选择一个连接,或点击'添加新连接'按钮创建一个新连接。",
|
||||
@@ -494,6 +548,11 @@
|
||||
"IP_BLACKLISTED": "IP 已被拉黑",
|
||||
"SERVER_ERROR": "服务器错误"
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"title": "外观设置",
|
||||
"description": "自定义应用程序的视觉主题和背景。",
|
||||
"customizeButton": "自定义外观"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import App from './App.vue';
|
||||
import router from './router'; // 引入我们创建的 router
|
||||
import i18n from './i18n'; // 引入 i18n 实例
|
||||
import { useSettingsStore } from './stores/settings.store'; // 引入 Settings Store
|
||||
import { useAppearanceStore } from './stores/appearance.store'; // 引入 Appearance Store
|
||||
import './style.css';
|
||||
// 导入 Font Awesome CSS
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
@@ -20,12 +21,18 @@ app.use(pinia); // 使用配置好的 Pinia 实例
|
||||
app.use(router); // 使用 Router
|
||||
app.use(i18n); // 使用 i18n
|
||||
|
||||
// 在挂载应用前加载初始设置
|
||||
const settingsStore = useSettingsStore(pinia); // 需要传递 pinia 实例
|
||||
settingsStore.loadInitialSettings().then(() => {
|
||||
app.mount('#app'); // 确保设置加载完成后再挂载
|
||||
}).catch((error: unknown) => { // 为 error 添加 unknown 类型
|
||||
console.error("Failed to load initial settings before mounting app:", error);
|
||||
// 在挂载应用前加载初始设置和外观数据
|
||||
const settingsStore = useSettingsStore(pinia);
|
||||
const appearanceStore = useAppearanceStore(pinia); // 实例化 Appearance Store
|
||||
|
||||
Promise.all([
|
||||
settingsStore.loadInitialSettings(),
|
||||
appearanceStore.loadInitialAppearanceData() // 并行加载外观数据
|
||||
]).then(() => {
|
||||
console.log("初始设置和外观数据加载完成。");
|
||||
app.mount('#app'); // 确保所有数据加载完成后再挂载
|
||||
}).catch((error: unknown) => {
|
||||
console.error("加载初始数据失败:", error);
|
||||
// 即使加载失败,也尝试挂载应用,可能使用默认设置
|
||||
app.mount('#app');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
import { ref, computed, watch, nextTick } from 'vue'; // 导入 nextTick
|
||||
import type { ITheme } from 'xterm';
|
||||
import type { TerminalTheme } from '../../../backend/src/types/terminal-theme.types'; // 引用后端类型
|
||||
import type { AppearanceSettings, UpdateAppearanceDto } from '../../../backend/src/types/appearance.types'; // 引用后端类型
|
||||
import { defaultXtermTheme, defaultUiTheme } from './default-themes.js'; // 尝试添加 .js (编译后) 或保持 .ts
|
||||
|
||||
// Helper function to safely parse JSON
|
||||
const safeJsonParse = <T>(jsonString: string | undefined | null, defaultValue: T): T => {
|
||||
if (!jsonString) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
console.error("JSON 解析失败:", e);
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
export const useAppearanceStore = defineStore('appearance', () => {
|
||||
// --- State ---
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const isStyleCustomizerVisible = ref(false); // 新增:控制样式编辑器可见性
|
||||
|
||||
// Appearance Settings State
|
||||
const appearanceSettings = ref<Partial<AppearanceSettings>>({}); // 从 API 获取的原始设置
|
||||
const availableTerminalThemes = ref<TerminalTheme[]>([]); // 终端主题列表
|
||||
|
||||
// --- Computed Properties (Getters) ---
|
||||
|
||||
// 当前应用的 UI 主题 (CSS 变量对象)
|
||||
const currentUiTheme = computed<Record<string, string>>(() => {
|
||||
return safeJsonParse(appearanceSettings.value.customUiTheme, defaultUiTheme);
|
||||
});
|
||||
|
||||
// 当前激活的终端主题 ID
|
||||
const activeTerminalThemeId = computed(() => appearanceSettings.value.activeTerminalThemeId);
|
||||
|
||||
// 当前应用的终端主题对象 (ITheme)
|
||||
const currentTerminalTheme = computed<ITheme>(() => {
|
||||
if (!activeTerminalThemeId.value || availableTerminalThemes.value.length === 0) {
|
||||
return defaultXtermTheme; // 回退到默认
|
||||
}
|
||||
const activeTheme = availableTerminalThemes.value.find(t => t._id === activeTerminalThemeId.value);
|
||||
return activeTheme ? activeTheme.themeData : defaultXtermTheme; // 找不到也回退
|
||||
});
|
||||
|
||||
// 当前终端字体设置
|
||||
const currentTerminalFontFamily = computed<string>(() => {
|
||||
return appearanceSettings.value.terminalFontFamily || 'Consolas, "Courier New", monospace, "Microsoft YaHei", "微软雅黑"'; // 提供默认值
|
||||
});
|
||||
|
||||
// 页面背景图片 URL
|
||||
const pageBackgroundImage = computed(() => appearanceSettings.value.pageBackgroundImage);
|
||||
// 页面背景透明度
|
||||
const pageBackgroundOpacity = computed(() => appearanceSettings.value.pageBackgroundOpacity ?? 1.0); // 默认 1
|
||||
|
||||
// 终端背景图片 URL
|
||||
const terminalBackgroundImage = computed(() => appearanceSettings.value.terminalBackgroundImage);
|
||||
// 终端背景透明度
|
||||
const terminalBackgroundOpacity = computed(() => appearanceSettings.value.terminalBackgroundOpacity ?? 1.0); // 默认 1
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
/**
|
||||
* 加载所有外观相关设置 (外观设置 + 终端主题列表)
|
||||
*/
|
||||
async function loadInitialAppearanceData() {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
// 并行加载外观设置和主题列表
|
||||
const [settingsResponse, themesResponse] = await Promise.all([
|
||||
axios.get<AppearanceSettings>('/api/v1/appearance'),
|
||||
axios.get<TerminalTheme[]>('/api/v1/terminal-themes')
|
||||
]);
|
||||
appearanceSettings.value = settingsResponse.data;
|
||||
availableTerminalThemes.value = themesResponse.data;
|
||||
console.log('[AppearanceStore] 外观设置已加载:', appearanceSettings.value);
|
||||
console.log('[AppearanceStore] 终端主题列表已加载:', availableTerminalThemes.value);
|
||||
|
||||
// 应用加载的 UI 主题
|
||||
applyUiTheme(currentUiTheme.value);
|
||||
// 应用背景
|
||||
applyPageBackground();
|
||||
// 终端背景和主题将在 Terminal 组件中应用
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('加载外观数据失败:', err);
|
||||
error.value = err.response?.data?.message || err.message || '加载外观数据失败';
|
||||
// 出错时应用默认值
|
||||
appearanceSettings.value = {}; // 清空可能不完整的设置
|
||||
availableTerminalThemes.value = [];
|
||||
applyUiTheme(defaultUiTheme);
|
||||
applyPageBackground(); // 应用默认背景(可能为空)
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换样式编辑器面板的可见性。
|
||||
* @param visible 可选,强制设置可见性
|
||||
*/
|
||||
function toggleStyleCustomizer(visible?: boolean) {
|
||||
isStyleCustomizerVisible.value = visible === undefined ? !isStyleCustomizerVisible.value : visible;
|
||||
console.log('[AppearanceStore] Style Customizer visibility toggled:', isStyleCustomizerVisible.value);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 更新外观设置 (不包括主题列表管理)
|
||||
* @param updates 要更新的设置项
|
||||
*/
|
||||
async function updateAppearanceSettings(updates: UpdateAppearanceDto) {
|
||||
try {
|
||||
const response = await axios.put<AppearanceSettings>('/api/v1/appearance', updates);
|
||||
// 使用后端返回的最新设置更新本地状态
|
||||
appearanceSettings.value = response.data;
|
||||
console.log('[AppearanceStore] 外观设置已更新:', appearanceSettings.value);
|
||||
// 如果 UI 主题或背景更新,重新应用
|
||||
if (updates.customUiTheme !== undefined) applyUiTheme(currentUiTheme.value);
|
||||
if (updates.pageBackgroundImage !== undefined || updates.pageBackgroundOpacity !== undefined) applyPageBackground();
|
||||
// 终端相关设置由 Terminal 组件监听应用
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('更新外观设置失败:', err);
|
||||
throw new Error(err.response?.data?.message || err.message || '更新外观设置失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前编辑器中的自定义 UI 主题到后端。
|
||||
* @param uiTheme UI 主题对象
|
||||
*/
|
||||
async function saveCustomUiTheme(uiTheme: Record<string, string>) {
|
||||
await updateAppearanceSettings({ customUiTheme: JSON.stringify(uiTheme) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置为默认 UI 主题并保存。
|
||||
*/
|
||||
async function resetCustomUiTheme() {
|
||||
await saveCustomUiTheme(defaultUiTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置激活的终端主题
|
||||
* @param themeId 主题 ID
|
||||
*/
|
||||
async function setActiveTerminalTheme(themeId: string | null) {
|
||||
await updateAppearanceSettings({ activeTerminalThemeId: themeId ?? undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置终端字体
|
||||
* @param fontFamily 字体列表字符串
|
||||
*/
|
||||
async function setTerminalFontFamily(fontFamily: string) {
|
||||
await updateAppearanceSettings({ terminalFontFamily: fontFamily });
|
||||
}
|
||||
|
||||
// --- 终端主题列表管理 Actions ---
|
||||
|
||||
/**
|
||||
* 重新加载终端主题列表
|
||||
*/
|
||||
async function reloadTerminalThemes() {
|
||||
try {
|
||||
const response = await axios.get<TerminalTheme[]>('/api/v1/terminal-themes');
|
||||
availableTerminalThemes.value = response.data;
|
||||
} catch (err: any) {
|
||||
console.error('重新加载终端主题列表失败:', err);
|
||||
// 可以选择抛出错误或显示通知
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的终端主题
|
||||
* @param name 主题名称
|
||||
* @param themeData 主题数据 (ITheme)
|
||||
*/
|
||||
async function createTerminalTheme(name: string, themeData: ITheme) {
|
||||
try {
|
||||
await axios.post('/api/v1/terminal-themes', { name, themeData });
|
||||
await reloadTerminalThemes(); // 重新加载列表
|
||||
} catch (err: any) {
|
||||
console.error('创建终端主题失败:', err);
|
||||
throw new Error(err.response?.data?.message || err.message || '创建终端主题失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新终端主题
|
||||
* @param id 主题 ID
|
||||
* @param name 新名称
|
||||
* @param themeData 新主题数据
|
||||
*/
|
||||
async function updateTerminalTheme(id: string, name: string, themeData: ITheme) {
|
||||
try {
|
||||
await axios.put(`/api/v1/terminal-themes/${id}`, { name, themeData });
|
||||
await reloadTerminalThemes(); // 重新加载列表
|
||||
} catch (err: any) {
|
||||
console.error('更新终端主题失败:', err);
|
||||
throw new Error(err.response?.data?.message || err.message || '更新终端主题失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除终端主题
|
||||
* @param id 主题 ID
|
||||
*/
|
||||
async function deleteTerminalTheme(id: string) {
|
||||
try {
|
||||
await axios.delete(`/api/v1/terminal-themes/${id}`);
|
||||
// 如果删除的是当前激活的主题,则切换回默认
|
||||
if (activeTerminalThemeId.value === id) {
|
||||
await setActiveTerminalTheme(null); // 或者设置为默认主题的 ID
|
||||
}
|
||||
await reloadTerminalThemes(); // 重新加载列表
|
||||
} catch (err: any) {
|
||||
console.error('删除终端主题失败:', err);
|
||||
throw new Error(err.response?.data?.message || err.message || '删除终端主题失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入终端主题文件
|
||||
* @param file File 对象
|
||||
* @param name 可选,如果提供则覆盖文件名作为主题名
|
||||
*/
|
||||
async function importTerminalTheme(file: File, name?: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('themeFile', file);
|
||||
if (name) {
|
||||
formData.append('name', name);
|
||||
}
|
||||
try {
|
||||
await axios.post('/api/v1/terminal-themes/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
await reloadTerminalThemes();
|
||||
} catch (err: any) {
|
||||
console.error('导入终端主题失败:', err);
|
||||
throw new Error(err.response?.data?.message || err.message || '导入终端主题失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出终端主题文件
|
||||
* @param id 主题 ID
|
||||
*/
|
||||
async function exportTerminalTheme(id: string) {
|
||||
try {
|
||||
const response = await axios.get(`/api/v1/terminal-themes/${id}/export`, {
|
||||
responseType: 'blob' // 重要:接收二进制数据
|
||||
});
|
||||
// 从响应头获取文件名
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = `terminal_theme_${id}.json`; // 默认文件名
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
|
||||
if (filenameMatch && filenameMatch.length > 1) {
|
||||
filename = filenameMatch[1];
|
||||
}
|
||||
}
|
||||
// 创建下载链接并触发下载
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err: any) {
|
||||
console.error('导出终端主题失败:', err);
|
||||
throw new Error(err.response?.data?.message || err.message || '导出终端主题失败');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 背景图片 Actions ---
|
||||
/**
|
||||
* 上传页面背景图片
|
||||
* @param file File 对象
|
||||
*/
|
||||
async function uploadPageBackground(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append('pageBackgroundFile', file);
|
||||
try {
|
||||
const response = await axios.post<{ filePath: string }>('/api/v1/appearance/background/page', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
// 更新本地状态 (虽然 updateAppearanceSettings 也会做,但这里立即反映)
|
||||
appearanceSettings.value.pageBackgroundImage = response.data.filePath;
|
||||
applyPageBackground(); // 应用新背景
|
||||
return response.data.filePath;
|
||||
} catch (err: any) {
|
||||
console.error('上传页面背景失败:', err);
|
||||
throw new Error(err.response?.data?.message || err.message || '上传页面背景失败');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 上传终端背景图片
|
||||
* @param file File 对象
|
||||
*/
|
||||
async function uploadTerminalBackground(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append('terminalBackgroundFile', file);
|
||||
try {
|
||||
const response = await axios.post<{ filePath: string }>('/api/v1/appearance/background/terminal', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
appearanceSettings.value.terminalBackgroundImage = response.data.filePath;
|
||||
// 终端背景的应用由 Terminal 组件处理
|
||||
return response.data.filePath;
|
||||
} catch (err: any) {
|
||||
console.error('上传终端背景失败:', err);
|
||||
throw new Error(err.response?.data?.message || err.message || '上传终端背景失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置页面背景透明度
|
||||
* @param opacity 0-1 之间的数字
|
||||
*/
|
||||
async function setPageBackgroundOpacity(opacity: number) {
|
||||
await updateAppearanceSettings({ pageBackgroundOpacity: opacity });
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置终端背景透明度
|
||||
* @param opacity 0-1 之间的数字
|
||||
*/
|
||||
async function setTerminalBackgroundOpacity(opacity: number) {
|
||||
await updateAppearanceSettings({ terminalBackgroundOpacity: opacity });
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除页面背景
|
||||
*/
|
||||
async function removePageBackground() {
|
||||
await updateAppearanceSettings({ pageBackgroundImage: '' }); // 设置为空字符串或其他表示移除的值
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除终端背景
|
||||
*/
|
||||
async function removeTerminalBackground() {
|
||||
await updateAppearanceSettings({ terminalBackgroundImage: '' });
|
||||
}
|
||||
|
||||
|
||||
// --- Helper Functions ---
|
||||
/**
|
||||
* 将 UI 主题 (CSS 变量) 应用到文档根元素。
|
||||
* @param theme 要应用的 UI 主题对象。
|
||||
*/
|
||||
function applyUiTheme(theme: Record<string, string>) {
|
||||
const root = document.documentElement;
|
||||
// 先移除可能存在的旧变量(可选,但更干净)
|
||||
// Object.keys(defaultUiTheme).forEach(key => root.style.removeProperty(key));
|
||||
// 应用新变量
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
console.log('[AppearanceStore] UI 主题已应用:', theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用页面背景设置到 body 元素
|
||||
*/
|
||||
function applyPageBackground() {
|
||||
const body = document.body;
|
||||
if (pageBackgroundImage.value) {
|
||||
// --- 修改开始 ---
|
||||
// 使用环境变量获取后端基础 URL
|
||||
const backendUrl = import.meta.env.VITE_API_BASE_URL || ''; // 提供一个默认空字符串以防万一
|
||||
const imagePath = pageBackgroundImage.value;
|
||||
console.log(`[AppearanceStore applyPageBackground] backendUrl: "${backendUrl}", imagePath: "${imagePath}"`); // 详细日志
|
||||
const fullImageUrl = `${backendUrl}${imagePath}`;
|
||||
console.log(`[AppearanceStore applyPageBackground] fullImageUrl: "${fullImageUrl}"`); // 打印完整 URL
|
||||
// --- 修改结束 ---
|
||||
|
||||
// Use the full URL
|
||||
// 先设置为空,强制更新
|
||||
body.style.backgroundImage = 'none';
|
||||
// 在下一个 tick 中设置图片,尝试解决时序问题
|
||||
nextTick(() => {
|
||||
body.style.backgroundImage = `url(${fullImageUrl})`;
|
||||
body.style.backgroundSize = 'cover';
|
||||
body.style.backgroundPosition = 'center';
|
||||
body.style.backgroundRepeat = 'no-repeat';
|
||||
});
|
||||
// 可以考虑添加透明度处理,例如通过伪元素
|
||||
} else {
|
||||
body.style.backgroundImage = 'none';
|
||||
}
|
||||
// 注意:直接设置 body 透明度会影响所有子元素,通常不建议。
|
||||
// 如果需要背景透明效果,通常结合伪元素或额外 div 实现。
|
||||
// 这里暂时不直接应用 pageBackgroundOpacity 到 body。
|
||||
console.log('[AppearanceStore] 页面背景已应用:', pageBackgroundImage.value);
|
||||
}
|
||||
|
||||
// --- Watchers ---
|
||||
// 监听 UI 主题变化并应用
|
||||
watch(currentUiTheme, (newTheme) => {
|
||||
applyUiTheme(newTheme);
|
||||
}, { deep: true });
|
||||
|
||||
// 监听页面背景变化并应用
|
||||
watch([pageBackgroundImage, pageBackgroundOpacity], () => {
|
||||
applyPageBackground();
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
// State refs (原始数据)
|
||||
appearanceSettings,
|
||||
availableTerminalThemes,
|
||||
// Computed Getters
|
||||
currentUiTheme,
|
||||
activeTerminalThemeId,
|
||||
currentTerminalTheme,
|
||||
currentTerminalFontFamily,
|
||||
pageBackgroundImage,
|
||||
pageBackgroundOpacity,
|
||||
terminalBackgroundImage,
|
||||
terminalBackgroundOpacity,
|
||||
// Actions
|
||||
loadInitialAppearanceData,
|
||||
updateAppearanceSettings,
|
||||
saveCustomUiTheme,
|
||||
resetCustomUiTheme,
|
||||
setActiveTerminalTheme,
|
||||
setTerminalFontFamily,
|
||||
reloadTerminalThemes,
|
||||
createTerminalTheme,
|
||||
updateTerminalTheme,
|
||||
deleteTerminalTheme,
|
||||
importTerminalTheme,
|
||||
exportTerminalTheme,
|
||||
uploadPageBackground,
|
||||
uploadTerminalBackground,
|
||||
setPageBackgroundOpacity,
|
||||
setTerminalBackgroundOpacity,
|
||||
removePageBackground,
|
||||
removeTerminalBackground,
|
||||
// Visibility control
|
||||
isStyleCustomizerVisible,
|
||||
toggleStyleCustomizer,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ITheme } from 'xterm';
|
||||
|
||||
// 默认 xterm 主题
|
||||
// (与 backend/src/config/default-themes.ts 中的定义保持一致)
|
||||
export const defaultXtermTheme: ITheme = {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
selectionBackground: '#264f78', // 使用 selectionBackground
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#e5e5e5'
|
||||
};
|
||||
|
||||
// 默认 UI 主题 (CSS 变量)
|
||||
// (与 backend/src/config/default-themes.ts 中的定义保持一致)
|
||||
export const defaultUiTheme: Record<string, string> = {
|
||||
'--app-bg-color': '#ffffff',
|
||||
'--text-color': '#333333',
|
||||
'--text-color-secondary': '#666666',
|
||||
'--border-color': '#cccccc',
|
||||
'--link-color': '#333',
|
||||
'--link-hover-color': '#0056b3',
|
||||
'--link-active-color': '#007bff',
|
||||
'--header-bg-color': '#f0f0f0',
|
||||
'--footer-bg-color': '#f0f0f0',
|
||||
'--button-bg-color': '#007bff',
|
||||
'--button-text-color': '#ffffff',
|
||||
'--button-hover-bg-color': '#0056b3',
|
||||
'--font-family-sans-serif': 'sans-serif',
|
||||
'--base-padding': '1rem',
|
||||
'--base-margin': '0.5rem',
|
||||
};
|
||||
@@ -1,305 +1,203 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
import { ref, computed, watch } from 'vue'; // Import computed and watch
|
||||
import { ref, computed } from 'vue'; // 移除 watch
|
||||
import i18n, { setLocale, defaultLng } from '../i18n'; // Import i18n instance and setLocale
|
||||
import type { ITheme } from 'xterm'; // 导入 xterm 主题类型
|
||||
// 移除 ITheme 和默认主题定义,这些移到 appearance.store.ts
|
||||
|
||||
// Define the type for settings state explicitly
|
||||
// 定义通用设置状态类型
|
||||
interface SettingsState {
|
||||
language: 'en' | 'zh';
|
||||
ipWhitelist: string;
|
||||
maxLoginAttempts: string;
|
||||
loginBanDuration: string;
|
||||
showPopupFileEditor: string; // 弹窗编辑器设置
|
||||
shareFileEditorTabs?: string; // 共享编辑器标签页设置 ('true'/'false')
|
||||
customUiTheme?: string; // UI 主题 (CSS 变量 JSON 字符串)
|
||||
customXtermTheme?: string; // xterm 主题 (JSON 字符串)
|
||||
// Add other settings keys here as needed
|
||||
[key: string]: string | undefined; // Allow other string settings, make value optional
|
||||
language?: 'en' | 'zh'; // 语言现在是可选的,因为可能在 appearance store 中处理
|
||||
ipWhitelist?: string;
|
||||
maxLoginAttempts?: string;
|
||||
loginBanDuration?: string;
|
||||
showPopupFileEditor?: string; // 'true' or 'false'
|
||||
shareFileEditorTabs?: string; // 'true' or 'false'
|
||||
ipWhitelistEnabled?: string; // 添加 IP 白名单启用状态 'true' or 'false'
|
||||
// Add other general settings keys here as needed
|
||||
[key: string]: string | undefined; // Allow other string settings
|
||||
}
|
||||
|
||||
// 默认 UI 主题 (CSS 变量)
|
||||
const defaultUiTheme: Record<string, string> = {
|
||||
'--app-bg-color': '#ffffff',
|
||||
'--text-color': '#333333',
|
||||
'--text-color-secondary': '#666666',
|
||||
'--border-color': '#cccccc',
|
||||
'--link-color': '#333',
|
||||
'--link-hover-color': '#0056b3',
|
||||
'--link-active-color': '#007bff',
|
||||
'--header-bg-color': '#f0f0f0',
|
||||
'--footer-bg-color': '#f0f0f0',
|
||||
'--button-bg-color': '#007bff',
|
||||
'--button-text-color': '#ffffff',
|
||||
'--button-hover-bg-color': '#0056b3',
|
||||
'--font-family-sans-serif': 'sans-serif',
|
||||
'--base-padding': '1rem',
|
||||
'--base-margin': '0.5rem',
|
||||
};
|
||||
|
||||
// 默认 xterm 主题
|
||||
const defaultXtermTheme: ITheme = {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
selectionBackground: '#264f78', // 使用 selectionBackground 而不是 selection
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#e5e5e5'
|
||||
};
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// --- State ---
|
||||
const settings = ref<Partial<SettingsState>>({}); // Use Partial initially
|
||||
const settings = ref<Partial<SettingsState>>({}); // 通用设置状态
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const isStyleCustomizerVisible = ref(false); // 控制样式编辑器可见性
|
||||
const currentUiTheme = ref<Record<string, string>>({ ...defaultUiTheme }); // 当前应用的 UI 主题
|
||||
const currentXtermTheme = ref<ITheme>({ ...defaultXtermTheme }); // 当前应用的 xterm 主题
|
||||
// 移除外观相关状态: isStyleCustomizerVisible, currentUiTheme, currentXtermTheme
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
/**
|
||||
* Fetches all settings from the backend and updates the store state.
|
||||
* Fetches general settings from the backend and updates the store state.
|
||||
* Also sets the i18n locale based on the fetched language setting.
|
||||
* Should be called early in the application lifecycle (e.g., main.ts).
|
||||
*/
|
||||
async function loadInitialSettings() {
|
||||
console.log('[SettingsStore] Entering loadInitialSettings function...'); // <-- 添加更早的日志
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
let fetchedLang: 'en' | 'zh' | undefined;
|
||||
|
||||
try {
|
||||
console.log('[SettingsStore] Starting loadInitialSettings...'); // 添加日志
|
||||
console.log('[SettingsStore] 加载通用设置...');
|
||||
const response = await axios.get<Record<string, string>>('/api/v1/settings');
|
||||
settings.value = response.data; // Store all fetched settings
|
||||
console.log('[SettingsStore] Fetched settings raw:', JSON.stringify(response.data)); // 打印原始响应
|
||||
console.log('[SettingsStore] Raw showPopupFileEditor from backend:', response.data.showPopupFileEditor);
|
||||
settings.value = response.data; // Store fetched general settings
|
||||
console.log('[SettingsStore] 通用设置已加载:', settings.value);
|
||||
|
||||
// --- 设置默认值 (如果后端未返回) ---
|
||||
// 弹窗编辑器设置 (保持不变)
|
||||
if (settings.value.showPopupFileEditor === undefined) {
|
||||
console.log('[SettingsStore] showPopupFileEditor is undefined, setting default: true');
|
||||
settings.value.showPopupFileEditor = 'true';
|
||||
}
|
||||
// 共享编辑器标签页设置 (保持不变)
|
||||
if (settings.value.shareFileEditorTabs === undefined) {
|
||||
console.log('[SettingsStore] Setting default for shareFileEditorTabs: true');
|
||||
settings.value.shareFileEditorTabs = 'true';
|
||||
}
|
||||
if (settings.value.ipWhitelistEnabled === undefined) {
|
||||
settings.value.ipWhitelistEnabled = 'false'; // 默认禁用 IP 白名单
|
||||
}
|
||||
if (settings.value.maxLoginAttempts === undefined) {
|
||||
settings.value.maxLoginAttempts = '5'; // 默认 5 次
|
||||
}
|
||||
if (settings.value.loginBanDuration === undefined) {
|
||||
settings.value.loginBanDuration = '300'; // 默认 300 秒
|
||||
}
|
||||
|
||||
// --- 加载自定义主题 ---
|
||||
loadAndApplyThemesFromSettings(); // 新增:加载并应用主题
|
||||
|
||||
// --- 语言设置 (保持不变) ---
|
||||
// Determine and apply language
|
||||
// --- 语言设置 ---
|
||||
const langFromSettings = settings.value.language;
|
||||
if (langFromSettings === 'en' || langFromSettings === 'zh') {
|
||||
fetchedLang = langFromSettings;
|
||||
} else {
|
||||
// Fallback logic if setting is missing or invalid
|
||||
const navigatorLang = navigator.language?.split('-')[0];
|
||||
fetchedLang = navigatorLang === 'zh' ? 'zh' : defaultLng; // Use browser lang or default
|
||||
console.warn(`[SettingsStore] Language setting not found or invalid ('${langFromSettings}'). Falling back to '${fetchedLang}'.`);
|
||||
// Optionally save the fallback language back to the backend if desired
|
||||
fetchedLang = navigatorLang === 'zh' ? 'zh' : defaultLng;
|
||||
console.warn(`[SettingsStore] 语言设置无效 ('${langFromSettings}'), 回退到 '${fetchedLang}'.`);
|
||||
// Optionally save the fallback language back
|
||||
// await updateSetting('language', fetchedLang);
|
||||
}
|
||||
|
||||
// Ensure fetchedLang is valid before calling setLocale (保持不变)
|
||||
if (fetchedLang) {
|
||||
console.log(`[SettingsStore] Determined language: ${fetchedLang}. Applying locale...`);
|
||||
console.log(`[SettingsStore] 设置语言: ${fetchedLang}`);
|
||||
setLocale(fetchedLang);
|
||||
} else {
|
||||
console.error('[SettingsStore] Could not determine a valid language to set.');
|
||||
console.error('[SettingsStore] 无法确定有效语言。');
|
||||
setLocale(defaultLng);
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load initial settings:', err);
|
||||
error.value = err.response?.data?.message || err.message || 'Failed to load settings';
|
||||
// Apply default language on error
|
||||
setLocale(defaultLng);
|
||||
console.error('加载通用设置失败:', err);
|
||||
error.value = err.response?.data?.message || err.message || '加载设置失败';
|
||||
setLocale(defaultLng); // 出错时使用默认语言
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 settings ref 加载主题设置,解析并应用它们。
|
||||
*/
|
||||
function loadAndApplyThemesFromSettings() {
|
||||
// 加载 UI 主题
|
||||
try {
|
||||
if (settings.value.customUiTheme) {
|
||||
const parsedUiTheme = JSON.parse(settings.value.customUiTheme);
|
||||
// 合并默认值,确保所有变量都存在
|
||||
currentUiTheme.value = { ...defaultUiTheme, ...parsedUiTheme };
|
||||
} else {
|
||||
currentUiTheme.value = { ...defaultUiTheme }; // 使用默认值
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SettingsStore] Failed to parse custom UI theme, using default:', e);
|
||||
currentUiTheme.value = { ...defaultUiTheme };
|
||||
}
|
||||
|
||||
// 加载 xterm 主题
|
||||
try {
|
||||
if (settings.value.customXtermTheme) {
|
||||
const parsedXtermTheme = JSON.parse(settings.value.customXtermTheme);
|
||||
// 合并默认值
|
||||
currentXtermTheme.value = { ...defaultXtermTheme, ...parsedXtermTheme };
|
||||
} else {
|
||||
currentXtermTheme.value = { ...defaultXtermTheme }; // 使用默认值
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SettingsStore] Failed to parse custom xterm theme, using default:', e);
|
||||
currentXtermTheme.value = { ...defaultXtermTheme };
|
||||
}
|
||||
|
||||
// 应用加载的主题
|
||||
applyUiTheme(currentUiTheme.value);
|
||||
// xterm 主题的应用将在 Terminal 组件内部通过 watch 监听 currentXtermTheme 实现
|
||||
}
|
||||
// 移除外观相关函数: loadAndApplyThemesFromSettings, applyUiTheme, saveCustomThemes, resetCustomThemes, toggleStyleCustomizer
|
||||
|
||||
/**
|
||||
* 将 UI 主题 (CSS 变量) 应用到文档根元素。
|
||||
* @param theme 要应用的 UI 主题对象。
|
||||
*/
|
||||
function applyUiTheme(theme: Record<string, string>) {
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
console.log('[SettingsStore] Applied UI theme:', theme);
|
||||
}
|
||||
|
||||
// 监听 currentUiTheme 的变化并自动应用
|
||||
watch(currentUiTheme, (newTheme) => {
|
||||
applyUiTheme(newTheme);
|
||||
}, { deep: true }); // 使用 deep watch 监听对象内部变化
|
||||
|
||||
/**
|
||||
* Updates a single setting value both locally and on the backend.
|
||||
* Updates a single general setting value both locally and on the backend.
|
||||
* @param key The setting key to update.
|
||||
* @param value The new value for the setting.
|
||||
*/
|
||||
async function updateSetting(key: keyof SettingsState, value: string) {
|
||||
// 移除外观相关的键检查
|
||||
const allowedKeys: Array<keyof SettingsState> = [
|
||||
'language', 'ipWhitelist', 'maxLoginAttempts', 'loginBanDuration',
|
||||
'showPopupFileEditor', 'shareFileEditorTabs', 'ipWhitelistEnabled'
|
||||
];
|
||||
if (!allowedKeys.includes(key)) {
|
||||
console.error(`[SettingsStore] 尝试更新不允许的设置键: ${key}`);
|
||||
throw new Error(`不允许更新设置项 '${key}'`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 注意:后端 controller 现在会过滤,但前端也做一层检查更好
|
||||
await axios.put('/api/v1/settings', { [key]: value });
|
||||
// Update store state *after* successful API call
|
||||
settings.value = { ...settings.value, [key]: value };
|
||||
// 如果更新的是主题设置,需要重新解析和应用
|
||||
if (key === 'customUiTheme' || key === 'customXtermTheme') {
|
||||
loadAndApplyThemesFromSettings();
|
||||
}
|
||||
|
||||
// If updating language, also update i18n
|
||||
if (key === 'language' && (value === 'en' || value === 'zh')) {
|
||||
setLocale(value);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to update setting '${key}':`, err);
|
||||
throw new Error(err.response?.data?.message || err.message || `Failed to update setting '${key}'`);
|
||||
console.error(`更新设置项 '${key}' 失败:`, err);
|
||||
throw new Error(err.response?.data?.message || err.message || `更新设置项 '${key}' 失败`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates multiple settings values both locally and on the backend.
|
||||
* Updates multiple general settings values both locally and on the backend.
|
||||
* @param updates An object containing key-value pairs of settings to update.
|
||||
*/
|
||||
async function updateMultipleSettings(updates: Partial<SettingsState>) {
|
||||
// 移除外观相关的键检查
|
||||
const allowedKeys: Array<keyof SettingsState> = [
|
||||
'language', 'ipWhitelist', 'maxLoginAttempts', 'loginBanDuration',
|
||||
'showPopupFileEditor', 'shareFileEditorTabs', 'ipWhitelistEnabled'
|
||||
];
|
||||
const filteredUpdates: Partial<SettingsState> = {};
|
||||
let languageUpdate: 'en' | 'zh' | undefined = undefined;
|
||||
|
||||
for (const key in updates) {
|
||||
if (allowedKeys.includes(key as keyof SettingsState)) {
|
||||
filteredUpdates[key as keyof SettingsState] = updates[key];
|
||||
if (key === 'language' && (updates[key] === 'en' || updates[key] === 'zh')) {
|
||||
languageUpdate = updates[key] as 'en' | 'zh';
|
||||
}
|
||||
} else {
|
||||
console.warn(`[SettingsStore] 尝试批量更新不允许的设置键: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(filteredUpdates).length === 0) {
|
||||
console.log('[SettingsStore] 没有有效的通用设置需要更新。');
|
||||
return; // 没有有效设置需要更新
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.put('/api/v1/settings', updates);
|
||||
// 注意:后端 controller 现在会过滤,但前端也做一层检查更好
|
||||
await axios.put('/api/v1/settings', filteredUpdates);
|
||||
// Update store state *after* successful API call
|
||||
settings.value = { ...settings.value, ...updates };
|
||||
// 如果更新包含主题设置,需要重新解析和应用
|
||||
if (updates.customUiTheme !== undefined || updates.customXtermTheme !== undefined) {
|
||||
loadAndApplyThemesFromSettings();
|
||||
}
|
||||
settings.value = { ...settings.value, ...filteredUpdates };
|
||||
|
||||
// If language is updated, apply it
|
||||
if (updates.language && (updates.language === 'en' || updates.language === 'zh')) {
|
||||
setLocale(updates.language);
|
||||
if (languageUpdate) {
|
||||
setLocale(languageUpdate);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to update multiple settings:', err);
|
||||
throw new Error(err.response?.data?.message || err.message || 'Failed to update settings');
|
||||
console.error('批量更新设置失败:', err);
|
||||
throw new Error(err.response?.data?.message || err.message || '批量更新设置失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前编辑器中的自定义主题到后端。
|
||||
* @param uiTheme UI 主题对象
|
||||
* @param xtermTheme xterm 主题对象
|
||||
*/
|
||||
async function saveCustomThemes(uiTheme: Record<string, string>, xtermTheme: ITheme) {
|
||||
const updates: Partial<SettingsState> = {
|
||||
customUiTheme: JSON.stringify(uiTheme),
|
||||
customXtermTheme: JSON.stringify(xtermTheme),
|
||||
};
|
||||
// 更新本地状态以立即反映(虽然 watch 也会触发应用,但这里更新 state 是必要的)
|
||||
currentUiTheme.value = { ...uiTheme };
|
||||
currentXtermTheme.value = { ...xtermTheme };
|
||||
// 调用 updateMultipleSettings 保存到后端
|
||||
await updateMultipleSettings(updates);
|
||||
}
|
||||
// 移除外观相关 actions: saveCustomThemes, resetCustomThemes, toggleStyleCustomizer
|
||||
|
||||
/**
|
||||
* 重置为默认主题并保存。
|
||||
*/
|
||||
async function resetCustomThemes() {
|
||||
await saveCustomThemes(defaultUiTheme, defaultXtermTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换样式编辑器面板的可见性。
|
||||
* @param visible 可选,强制设置可见性
|
||||
*/
|
||||
function toggleStyleCustomizer(visible?: boolean) {
|
||||
isStyleCustomizerVisible.value = visible === undefined ? !isStyleCustomizerVisible.value : visible;
|
||||
}
|
||||
|
||||
// --- Getters --- (保持不变)
|
||||
// --- Getters ---
|
||||
const language = computed(() => settings.value.language || defaultLng);
|
||||
|
||||
// Getter for the popup editor setting, returning boolean (保持不变)
|
||||
// Getter for the popup editor setting, returning boolean
|
||||
const showPopupFileEditorBoolean = computed(() => {
|
||||
return settings.value.showPopupFileEditor !== 'false';
|
||||
});
|
||||
|
||||
// Getter for sharing setting, returning boolean (保持不变)
|
||||
// Getter for sharing setting, returning boolean
|
||||
const shareFileEditorTabsBoolean = computed(() => {
|
||||
return settings.value.shareFileEditorTabs !== 'false';
|
||||
});
|
||||
|
||||
// Getter for IP Whitelist enabled status
|
||||
const ipWhitelistEnabled = computed(() => settings.value.ipWhitelistEnabled === 'true');
|
||||
|
||||
|
||||
return {
|
||||
settings, // 原始设置对象 (可能包含字符串化的主题)
|
||||
settings, // 只包含通用设置
|
||||
isLoading,
|
||||
error,
|
||||
language,
|
||||
showPopupFileEditorBoolean,
|
||||
shareFileEditorTabsBoolean,
|
||||
isStyleCustomizerVisible, // 暴露编辑器可见状态
|
||||
currentUiTheme, // 暴露当前应用的 UI 主题对象
|
||||
currentXtermTheme, // 暴露当前应用的 xterm 主题对象
|
||||
ipWhitelistEnabled, // 暴露 IP 白名单启用状态
|
||||
// 移除外观相关的 getters 和 actions
|
||||
loadInitialSettings,
|
||||
updateSetting,
|
||||
updateMultipleSettings,
|
||||
saveCustomThemes, // 暴露保存主题 action
|
||||
resetCustomThemes, // 暴露重置主题 action
|
||||
toggleStyleCustomizer, // 暴露切换编辑器 action
|
||||
};
|
||||
});
|
||||
|
||||
@@ -143,10 +143,17 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 外观设置 -->
|
||||
<div class="settings-section">
|
||||
<h2>{{ $t('settings.appearance.title') }}</h2>
|
||||
<p>{{ $t('settings.appearance.description') }}</p>
|
||||
<button @click="openStyleCustomizer">{{ t('settings.appearance.customizeButton') }}</button>
|
||||
</div>
|
||||
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- IP 黑名单管理 -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section">
|
||||
<h2>IP 黑名单管理</h2>
|
||||
<p>配置登录失败次数限制和自动封禁时长。本地地址 (127.0.0.1, ::1) 不会被封禁。</p>
|
||||
|
||||
@@ -208,17 +215,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, reactive, watch, toRefs } from 'vue';
|
||||
import { ref, onMounted, computed, reactive, watch } from 'vue'; // 移除 toRefs
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { useAppearanceStore } from '../stores/appearance.store'; // 导入外观 store
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
|
||||
import { storeToRefs } from 'pinia';
|
||||
// setLocale is handled by the store now
|
||||
import axios from 'axios';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const appearanceStore = useAppearanceStore(); // 实例化外观 store
|
||||
const { t } = useI18n();
|
||||
|
||||
// --- Reactive state from store ---
|
||||
@@ -315,6 +324,10 @@ const handleUpdateShareTabsSetting = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// --- 外观设置 ---
|
||||
const openStyleCustomizer = () => {
|
||||
appearanceStore.toggleStyleCustomizer(true);
|
||||
};
|
||||
|
||||
// --- Passkey state & methods --- (Keep as is)
|
||||
const passkeyName = ref('');
|
||||
|
||||
@@ -17,7 +17,15 @@ export default defineConfig({
|
||||
changeOrigin: true, // 需要虚拟主机站点
|
||||
// 可选:如果后端 API 路径没有 /api 前缀,可以在这里重写路径
|
||||
// rewrite: (path) => path.replace(/^\/api/, '')
|
||||
},
|
||||
// --- 新增开始 ---
|
||||
// 将所有 /uploads 开头的请求也代理到后端服务器
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001', // 后端服务器地址
|
||||
changeOrigin: true, // 对于静态资源通常也建议开启
|
||||
// 通常不需要重写静态资源的路径
|
||||
}
|
||||
// --- 新增结束 ---
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||