This commit is contained in:
Baobhan Sith
2025-04-17 20:26:30 +08:00
parent 09cba0b3d3
commit 9eb0bcc5f3
40 changed files with 2607 additions and 326 deletions
@@ -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',
};
// 未来可以在这里添加其他默认主题
+11
View File
@@ -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'>>;