update
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { Statement } from 'sqlite3'; // 引入 Statement 类型
|
||||
import { getDb } from '../database';
|
||||
import { encrypt } from '../utils/crypto'; // 引入加密函数
|
||||
import { encrypt, decrypt } from '../utils/crypto'; // 引入加解密函数
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// 连接数据结构 (仅用于类型提示,不包含敏感信息)
|
||||
interface ConnectionInfo {
|
||||
// 连接数据结构 (用于类型提示,不包含敏感信息)
|
||||
interface ConnectionInfoBase {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: 'password'; // MVP 仅支持密码
|
||||
auth_method: 'password' | 'key'; // 支持密码或密钥
|
||||
// proxy_id: number | null; // 待添加代理支持
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
last_connected_at: number | null;
|
||||
@@ -22,13 +23,24 @@ interface ConnectionInfo {
|
||||
* 创建新连接 (POST /api/v1/connections)
|
||||
*/
|
||||
export const createConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
const { name, host, port = 22, username, password } = req.body;
|
||||
const auth_method = 'password'; // MVP 强制为 password
|
||||
const { name, host, port = 22, username, auth_method, password, private_key, passphrase } = req.body;
|
||||
const userId = req.session.userId; // 从会话获取用户 ID
|
||||
|
||||
// 输入验证 (基础)
|
||||
if (!name || !host || !username || !password) {
|
||||
res.status(400).json({ message: '缺少必要的连接信息 (name, host, username, password)。' });
|
||||
if (!name || !host || !username || !auth_method) {
|
||||
res.status(400).json({ message: '缺少必要的连接信息 (name, host, username, auth_method)。' });
|
||||
return;
|
||||
}
|
||||
if (auth_method === 'password' && !password) {
|
||||
res.status(400).json({ message: '密码认证方式需要提供 password。' });
|
||||
return;
|
||||
}
|
||||
if (auth_method === 'key' && !private_key) {
|
||||
res.status(400).json({ message: '密钥认证方式需要提供 private_key。' });
|
||||
return;
|
||||
}
|
||||
if (auth_method !== 'password' && auth_method !== 'key') {
|
||||
res.status(400).json({ message: '无效的认证方式 (auth_method),必须是 "password" 或 "key"。' });
|
||||
return;
|
||||
}
|
||||
if (typeof port !== 'number' || port <= 0 || port > 65535) {
|
||||
@@ -37,31 +49,44 @@ export const createConnection = async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
|
||||
try {
|
||||
// 加密密码
|
||||
const encryptedPassword = encrypt(password);
|
||||
let encryptedPassword = null;
|
||||
let encryptedPrivateKey = null;
|
||||
let encryptedPassphrase = null;
|
||||
|
||||
if (auth_method === 'password') {
|
||||
encryptedPassword = encrypt(password);
|
||||
} else if (auth_method === 'key') {
|
||||
encryptedPrivateKey = encrypt(private_key);
|
||||
if (passphrase) {
|
||||
encryptedPassphrase = encrypt(passphrase);
|
||||
}
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000); // 当前 Unix 时间戳 (秒)
|
||||
|
||||
// 插入数据库
|
||||
const result = await new Promise<{ lastID: number }>((resolve, reject) => {
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
`INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
// 注意:这里没有存储 userId,因为 MVP 只有一个用户。如果未来支持多用户,需要添加 user_id 字段。
|
||||
// 使用 function 关键字以保留正确的 this 上下文,并为 err 和 this 添加类型注解
|
||||
stmt.run(name, host, port, username, auth_method, encryptedPassword, now, now, function (this: Statement, err: Error | null) {
|
||||
if (err) {
|
||||
console.error('插入连接时出错:', err.message);
|
||||
return reject(new Error('创建连接失败'));
|
||||
stmt.run(
|
||||
name, host, port, username, auth_method,
|
||||
encryptedPassword, encryptedPrivateKey, encryptedPassphrase,
|
||||
now, now,
|
||||
function (this: Statement, err: Error | null) {
|
||||
if (err) {
|
||||
console.error('插入连接时出错:', err.message);
|
||||
return reject(new Error('创建连接失败'));
|
||||
}
|
||||
resolve({ lastID: (this as any).lastID });
|
||||
}
|
||||
// this.lastID 包含新插入行的 ID
|
||||
// 使用类型断言 (as any) 来解决 TS 类型检查问题
|
||||
resolve({ lastID: (this as any).lastID });
|
||||
});
|
||||
);
|
||||
stmt.finalize(); // 完成语句执行
|
||||
});
|
||||
|
||||
// 返回成功响应
|
||||
// 返回成功响应 (不包含敏感信息)
|
||||
res.status(201).json({
|
||||
message: '连接创建成功。',
|
||||
connection: {
|
||||
@@ -71,9 +96,9 @@ export const createConnection = async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('创建连接时发生错误:', error);
|
||||
res.status(500).json({ message: '创建连接时发生内部服务器错误。' });
|
||||
res.status(500).json({ message: error.message || '创建连接时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,14 +109,14 @@ export const getConnections = async (req: Request, res: Response): Promise<void>
|
||||
const userId = req.session.userId; // 虽然 MVP 只有一个用户,但保留以备将来使用
|
||||
|
||||
try {
|
||||
// 查询数据库,排除敏感字段 encrypted_password
|
||||
// 查询数据库,排除敏感字段 encrypted_password, encrypted_private_key, encrypted_passphrase
|
||||
// 注意:如果未来支持多用户,需要添加 WHERE user_id = ? 条件
|
||||
const connections = await new Promise<ConnectionInfo[]>((resolve, reject) => {
|
||||
const connections = await new Promise<ConnectionInfoBase[]>((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT id, name, host, port, username, auth_method, created_at, updated_at, last_connected_at
|
||||
FROM connections
|
||||
ORDER BY name ASC`, // 按名称排序
|
||||
(err, rows: ConnectionInfo[]) => {
|
||||
(err, rows: ConnectionInfoBase[]) => { // 使用更新后的接口
|
||||
if (err) {
|
||||
console.error('查询连接列表时出错:', err.message);
|
||||
return reject(new Error('获取连接列表失败'));
|
||||
@@ -103,14 +128,238 @@ export const getConnections = async (req: Request, res: Response): Promise<void>
|
||||
|
||||
res.status(200).json(connections);
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('获取连接列表时发生错误:', error);
|
||||
res.status(500).json({ message: '获取连接列表时发生内部服务器错误。' });
|
||||
res.status(500).json({ message: error.message || '获取连接列表时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
// 其他控制器函数的占位符
|
||||
// export const getConnectionById = ...
|
||||
// export const updateConnection = ...
|
||||
// export const deleteConnection = ...
|
||||
/**
|
||||
* 获取单个连接信息 (GET /api/v1/connections/:id)
|
||||
*/
|
||||
export const getConnectionById = async (req: Request, res: Response): Promise<void> => {
|
||||
const connectionId = parseInt(req.params.id, 10);
|
||||
const userId = req.session.userId;
|
||||
|
||||
if (isNaN(connectionId)) {
|
||||
res.status(400).json({ message: '无效的连接 ID。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 查询数据库,排除敏感字段
|
||||
// 注意:如果未来支持多用户,需要添加 AND user_id = ? 条件
|
||||
const connection = await new Promise<ConnectionInfoBase | null>((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT id, name, host, port, username, auth_method, created_at, updated_at, last_connected_at
|
||||
FROM connections
|
||||
WHERE id = ?`,
|
||||
[connectionId],
|
||||
(err, row: ConnectionInfoBase) => { // 使用更新后的接口
|
||||
if (err) {
|
||||
console.error(`查询连接 ${connectionId} 时出错:`, err.message);
|
||||
return reject(new Error('获取连接信息失败'));
|
||||
}
|
||||
resolve(row || null); // 如果找不到则返回 null
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
res.status(404).json({ message: '连接未找到。' });
|
||||
} else {
|
||||
res.status(200).json(connection);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`获取连接 ${connectionId} 时发生错误:`, error);
|
||||
res.status(500).json({ message: error.message || '获取连接信息时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新连接信息 (PUT /api/v1/connections/:id)
|
||||
*/
|
||||
export const updateConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
const connectionId = parseInt(req.params.id, 10);
|
||||
const { name, host, port, username, auth_method, password, private_key, passphrase } = req.body;
|
||||
const userId = req.session.userId;
|
||||
|
||||
if (isNaN(connectionId)) {
|
||||
res.status(400).json({ message: '无效的连接 ID。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 输入验证 (与创建类似,但允许部分更新)
|
||||
if (!name && !host && port === undefined && !username && !auth_method && !password && !private_key && passphrase === undefined) {
|
||||
res.status(400).json({ message: '没有提供要更新的字段。' });
|
||||
return;
|
||||
}
|
||||
if (auth_method && auth_method !== 'password' && auth_method !== 'key') {
|
||||
res.status(400).json({ message: '无效的认证方式 (auth_method),必须是 "password" 或 "key"。' });
|
||||
return;
|
||||
}
|
||||
// 如果提供了 auth_method,需要确保对应的凭证也提供了或已存在
|
||||
// (更复杂的验证逻辑可能需要先查询现有记录)
|
||||
|
||||
try {
|
||||
const fieldsToUpdate: { [key: string]: any } = {};
|
||||
const params: any[] = [];
|
||||
|
||||
// 构建要更新的字段和参数
|
||||
if (name !== undefined) { fieldsToUpdate.name = name; params.push(name); }
|
||||
if (host !== undefined) { fieldsToUpdate.host = host; params.push(host); }
|
||||
if (port !== undefined) {
|
||||
if (typeof port !== 'number' || port <= 0 || port > 65535) {
|
||||
res.status(400).json({ message: '端口号无效。' });
|
||||
return;
|
||||
}
|
||||
fieldsToUpdate.port = port; params.push(port);
|
||||
}
|
||||
if (username !== undefined) { fieldsToUpdate.username = username; params.push(username); }
|
||||
|
||||
// 处理认证方式和凭证更新
|
||||
if (auth_method) {
|
||||
fieldsToUpdate.auth_method = auth_method;
|
||||
params.push(auth_method);
|
||||
if (auth_method === 'password') {
|
||||
if (!password) {
|
||||
res.status(400).json({ message: '更新为密码认证时需要提供 password。' });
|
||||
return;
|
||||
}
|
||||
fieldsToUpdate.encrypted_password = encrypt(password);
|
||||
params.push(fieldsToUpdate.encrypted_password);
|
||||
fieldsToUpdate.encrypted_private_key = null; // 清除旧密钥
|
||||
params.push(null);
|
||||
fieldsToUpdate.encrypted_passphrase = null; // 清除旧密码
|
||||
params.push(null);
|
||||
} else if (auth_method === 'key') {
|
||||
if (!private_key) {
|
||||
res.status(400).json({ message: '更新为密钥认证时需要提供 private_key。' });
|
||||
return;
|
||||
}
|
||||
fieldsToUpdate.encrypted_private_key = encrypt(private_key);
|
||||
params.push(fieldsToUpdate.encrypted_private_key);
|
||||
fieldsToUpdate.encrypted_passphrase = passphrase ? encrypt(passphrase) : null;
|
||||
params.push(fieldsToUpdate.encrypted_passphrase);
|
||||
fieldsToUpdate.encrypted_password = null; // 清除旧密码
|
||||
params.push(null);
|
||||
}
|
||||
} else {
|
||||
// 如果只更新凭证而不改变 auth_method (需要先查询当前 auth_method)
|
||||
// 为了简化,这里假设如果提供了 password/private_key,则 auth_method 也被提供了
|
||||
// 或者,可以先查询记录再决定如何更新
|
||||
if (password) {
|
||||
// 假设当前是 password 方式或要切换到 password
|
||||
fieldsToUpdate.encrypted_password = encrypt(password);
|
||||
params.push(fieldsToUpdate.encrypted_password);
|
||||
}
|
||||
if (private_key) {
|
||||
// 假设当前是 key 方式或要切换到 key
|
||||
fieldsToUpdate.encrypted_private_key = encrypt(private_key);
|
||||
params.push(fieldsToUpdate.encrypted_private_key);
|
||||
fieldsToUpdate.encrypted_passphrase = passphrase ? encrypt(passphrase) : null;
|
||||
params.push(fieldsToUpdate.encrypted_passphrase);
|
||||
} else if (passphrase !== undefined && auth_method === 'key') { // 仅更新 passphrase
|
||||
fieldsToUpdate.encrypted_passphrase = passphrase ? encrypt(passphrase) : null;
|
||||
params.push(fieldsToUpdate.encrypted_passphrase);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
fieldsToUpdate.updated_at = now;
|
||||
params.push(now);
|
||||
|
||||
const setClauses = Object.keys(fieldsToUpdate).map(key => `${key} = ?`).join(', ');
|
||||
|
||||
if (!setClauses) {
|
||||
res.status(400).json({ message: '没有有效的字段进行更新。' });
|
||||
return;
|
||||
}
|
||||
|
||||
params.push(connectionId); // 添加 WHERE id = ? 的参数
|
||||
|
||||
// 更新数据库
|
||||
// 注意:如果未来支持多用户,需要添加 AND user_id = ? 条件
|
||||
const result = await new Promise<{ changes: number }>((resolve, reject) => {
|
||||
const stmt = db.prepare(
|
||||
`UPDATE connections SET ${setClauses} WHERE id = ?`
|
||||
);
|
||||
stmt.run(...params, function (this: Statement, err: Error | null) {
|
||||
if (err) {
|
||||
console.error(`更新连接 ${connectionId} 时出错:`, err.message);
|
||||
return reject(new Error('更新连接失败'));
|
||||
}
|
||||
// this.changes 包含受影响的行数
|
||||
// 使用类型断言 (as any) 来解决 TS 类型检查问题
|
||||
resolve({ changes: (this as any).changes });
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ message: '连接未找到或未作更改。' });
|
||||
} else {
|
||||
// 获取更新后的信息(不含敏感数据)并返回
|
||||
const updatedConnection = await new Promise<ConnectionInfoBase | null>((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT id, name, host, port, username, auth_method, created_at, updated_at, last_connected_at
|
||||
FROM connections WHERE id = ?`,
|
||||
[connectionId],
|
||||
(err, row: ConnectionInfoBase) => err ? reject(err) : resolve(row || null)
|
||||
);
|
||||
});
|
||||
res.status(200).json({ message: '连接更新成功。', connection: updatedConnection });
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`更新连接 ${connectionId} 时发生错误:`, error);
|
||||
res.status(500).json({ message: error.message || '更新连接时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除连接 (DELETE /api/v1/connections/:id)
|
||||
*/
|
||||
export const deleteConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
const connectionId = parseInt(req.params.id, 10);
|
||||
const userId = req.session.userId;
|
||||
|
||||
if (isNaN(connectionId)) {
|
||||
res.status(400).json({ message: '无效的连接 ID。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 删除数据库中的记录
|
||||
// 注意:如果未来支持多用户,需要添加 AND user_id = ? 条件
|
||||
const result = await new Promise<{ changes: number }>((resolve, reject) => {
|
||||
const stmt = db.prepare(
|
||||
`DELETE FROM connections WHERE id = ?`
|
||||
);
|
||||
stmt.run(connectionId, function (this: Statement, err: Error | null) {
|
||||
if (err) {
|
||||
console.error(`删除连接 ${connectionId} 时出错:`, err.message);
|
||||
return reject(new Error('删除连接失败'));
|
||||
}
|
||||
// this.changes 包含受影响的行数
|
||||
resolve({ changes: (this as any).changes });
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ message: '连接未找到。' });
|
||||
} else {
|
||||
res.status(200).json({ message: '连接删除成功。' }); // 也可以使用 204 No Content
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`删除连接 ${connectionId} 时发生错误:`, error);
|
||||
res.status(500).json({ message: error.message || '删除连接时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: 实现 testConnection
|
||||
// export const testConnection = ...
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // 引入认证中间件
|
||||
import { createConnection, getConnections } from './connections.controller';
|
||||
import {
|
||||
createConnection,
|
||||
getConnections,
|
||||
getConnectionById, // 引入获取单个连接的控制器
|
||||
updateConnection, // 引入更新连接的控制器
|
||||
deleteConnection // 引入删除连接的控制器
|
||||
} from './connections.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -13,10 +19,16 @@ router.get('/', getConnections);
|
||||
// POST /api/v1/connections - 创建新连接
|
||||
router.post('/', createConnection);
|
||||
|
||||
// 未来可以添加其他路由,如获取单个连接、更新、删除、测试连接等
|
||||
// router.get('/:id', getConnectionById);
|
||||
// router.put('/:id', updateConnection);
|
||||
// router.delete('/:id', deleteConnection);
|
||||
// GET /api/v1/connections/:id - 获取单个连接信息
|
||||
router.get('/:id', getConnectionById);
|
||||
|
||||
// PUT /api/v1/connections/:id - 更新连接信息
|
||||
router.put('/:id', updateConnection);
|
||||
|
||||
// DELETE /api/v1/connections/:id - 删除连接
|
||||
router.delete('/:id', deleteConnection); // 使用占位符
|
||||
|
||||
// TODO: 添加测试连接路由
|
||||
// router.post('/:id/test', testConnection);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
);
|
||||
`;
|
||||
|
||||
// MVP (最小可行产品) 阶段: 只包含基础字段,支持密码认证,暂不考虑代理和标签
|
||||
// 更新后的 Schema,支持密码和密钥认证
|
||||
const createConnectionsTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -20,11 +20,11 @@ CREATE TABLE IF NOT EXISTS connections (
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
username TEXT NOT NULL,
|
||||
auth_method TEXT NOT NULL CHECK(auth_method IN ('password')), -- MVP 阶段仅支持密码认证
|
||||
encrypted_password TEXT NULL, -- 加密存储的密码占位符 (加密逻辑在应用层实现)
|
||||
-- encrypted_private_key TEXT NULL, -- MVP 阶段跳过密钥认证相关字段
|
||||
-- encrypted_passphrase TEXT NULL, -- MVP 阶段跳过密钥认证相关字段
|
||||
-- proxy_id INTEGER NULL, -- MVP 阶段跳过代理相关字段
|
||||
auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')), -- 更新 CHECK 约束
|
||||
encrypted_password TEXT NULL,
|
||||
encrypted_private_key TEXT NULL, -- 取消注释
|
||||
encrypted_passphrase TEXT NULL, -- 取消注释
|
||||
-- proxy_id INTEGER NULL, -- 代理相关字段 (暂未实现)
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_connected_at INTEGER NULL
|
||||
@@ -39,35 +39,99 @@ CREATE TABLE IF NOT EXISTS connections (
|
||||
// const createAuditLogsTableSQL = \`...\`; // 审计日志表
|
||||
// const createApiKeysTableSQL = \`...\`; // API 密钥表
|
||||
|
||||
// Interface for PRAGMA table_info result rows
|
||||
interface TableInfoColumn {
|
||||
cid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: any;
|
||||
pk: number;
|
||||
}
|
||||
|
||||
// Helper function to add a column if it doesn't exist
|
||||
const addColumnIfNotExists = (db: Database, tableName: string, columnName: string, columnDefinition: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if the column exists using PRAGMA table_info
|
||||
// Explicitly type the 'columns' parameter
|
||||
db.all(`PRAGMA table_info(${tableName})`, (err, columns: TableInfoColumn[]) => {
|
||||
if (err) {
|
||||
console.error(`Error checking table info for ${tableName}:`, err.message);
|
||||
return reject(err);
|
||||
}
|
||||
// Now 'col' inside .some() will have the correct type
|
||||
const columnExists = columns.some(col => col.name === columnName);
|
||||
if (!columnExists) {
|
||||
// Column doesn't exist, add it
|
||||
const sql = `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDefinition}`;
|
||||
db.run(sql, (alterErr) => {
|
||||
if (alterErr) {
|
||||
console.error(`Error adding column ${columnName} to ${tableName}:`, alterErr.message);
|
||||
// Don't reject immediately, maybe it's a harmless error (like constraint issue)
|
||||
// Let subsequent migrations try. If it's critical, the app might fail later.
|
||||
console.warn(`Potential harmless error adding column ${columnName}. Continuing migration.`);
|
||||
resolve();
|
||||
// return reject(alterErr);
|
||||
} else {
|
||||
console.log(`Column ${columnName} added to table ${tableName}.`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Column already exists
|
||||
// console.log(`Column ${columnName} already exists in table ${tableName}.`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 执行数据库迁移 (创建表)
|
||||
* 执行数据库迁移 (创建表和添加列)
|
||||
* @param db - 数据库实例
|
||||
* @returns Promise,在所有迁移完成后 resolve
|
||||
*/
|
||||
export const runMigrations = (db: Database): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
export const runMigrations = async (db: Database): Promise<void> => {
|
||||
// Use async/await for better readability with sequential operations
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
db.run(createUsersTableSQL, (err) => {
|
||||
if (err) {
|
||||
console.error('创建 users 表时出错:', err.message);
|
||||
return reject(err);
|
||||
}
|
||||
if (err) return reject(new Error(`创建 users 表时出错: ${err.message}`));
|
||||
console.log('Users 表已检查/创建。');
|
||||
resolve();
|
||||
});
|
||||
|
||||
db.run(createConnectionsTableSQL, (err) => {
|
||||
if (err) {
|
||||
console.error('创建 connections 表时出错:', err.message);
|
||||
return reject(err);
|
||||
}
|
||||
console.log('Connections 表已检查/创建。');
|
||||
resolve(); // 所有表创建完成后 resolve Promise
|
||||
});
|
||||
|
||||
// 如果未来添加了更多表,在此处继续链式调用 db.run(...)
|
||||
// db.run(createProxiesTableSQL, callback);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
db.run(createConnectionsTableSQL, (err) => {
|
||||
// Ignore "duplicate column name" error if table already exists partially
|
||||
if (err && !err.message.includes('duplicate column name')) {
|
||||
return reject(new Error(`创建 connections 表时出错: ${err.message}`));
|
||||
}
|
||||
if (err && err.message.includes('duplicate column name')) {
|
||||
console.warn('创建 connections 表时遇到 "duplicate column name" 错误,可能表已部分存在,将尝试 ALTER TABLE。');
|
||||
}
|
||||
console.log('Connections 表已检查/尝试创建。');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Add columns to connections table if they don't exist
|
||||
// Add auth_method first in case it's missing from very old schema
|
||||
await addColumnIfNotExists(db, 'connections', 'auth_method', "TEXT NOT NULL DEFAULT 'password'"); // Add default for existing rows
|
||||
await addColumnIfNotExists(db, 'connections', 'encrypted_private_key', 'TEXT NULL');
|
||||
await addColumnIfNotExists(db, 'connections', 'encrypted_passphrase', 'TEXT NULL');
|
||||
|
||||
// Add other tables or columns here in the future
|
||||
// await addColumnIfNotExists(db, 'connections', 'proxy_id', 'INTEGER NULL');
|
||||
|
||||
console.log('数据库迁移检查完成。');
|
||||
|
||||
} catch (error) {
|
||||
console.error('数据库迁移过程中发生错误:', error);
|
||||
throw error; // Re-throw the error to be caught by the caller
|
||||
}
|
||||
};
|
||||
|
||||
// 允许通过命令行直接运行此文件来执行迁移 (例如: node dist/migrations.js)
|
||||
|
||||
@@ -15,24 +15,28 @@ interface AuthenticatedWebSocket extends WebSocket {
|
||||
sshClient?: Client; // 关联的 SSH Client 实例
|
||||
sshShellStream?: ClientChannel; // 关联的 SSH Shell Stream
|
||||
sftpStream?: SFTPWrapper; // 关联的 SFTP Stream
|
||||
statusIntervalId?: NodeJS.Timeout; // 用于存储状态轮询的 Interval ID
|
||||
}
|
||||
|
||||
// 存储活跃的 SSH/SFTP 连接 (导出以便其他模块访问)
|
||||
export const activeSshConnections = new Map<AuthenticatedWebSocket, { client: Client, shell: ClientChannel, sftp?: SFTPWrapper }>();
|
||||
export const activeSshConnections = new Map<AuthenticatedWebSocket, { client: Client, shell: ClientChannel, sftp?: SFTPWrapper, statusIntervalId?: NodeJS.Timeout }>();
|
||||
|
||||
// 存储正在进行的 SFTP 上传操作 (key: uploadId, value: WriteStream)
|
||||
// 注意:WriteStream 类型来自 'fs',但 ssh2 的流行为类似
|
||||
const activeUploads = new Map<string, WriteStream>();
|
||||
|
||||
// 数据库连接信息接口 (包含加密密码)
|
||||
// 数据库连接信息接口 (包含所有可能的凭证字段)
|
||||
interface DbConnectionInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: 'password';
|
||||
encrypted_password?: string; // 注意是可选的,因为可能没有密码 (虽然 MVP 要求有)
|
||||
auth_method: 'password' | 'key'; // 支持密码或密钥
|
||||
encrypted_password?: string | null;
|
||||
encrypted_private_key?: string | null;
|
||||
encrypted_passphrase?: string | null;
|
||||
// proxy_id: number | null; // 待添加代理支持
|
||||
// 其他字段...
|
||||
}
|
||||
|
||||
@@ -48,11 +52,355 @@ const cleanupSshConnection = (ws: AuthenticatedWebSocket) => {
|
||||
// 注意:SFTP 流通常不需要显式关闭,它依赖于 SSH Client 的关闭
|
||||
// connection.sftp?.end(); // SFTPWrapper 没有 end 方法
|
||||
connection.shell?.end(); // 尝试结束 shell 流
|
||||
// 清除状态轮询定时器
|
||||
if (connection.statusIntervalId) {
|
||||
clearInterval(connection.statusIntervalId);
|
||||
console.log(`WebSocket: 清理用户 ${ws.username} 的状态轮询定时器。`);
|
||||
}
|
||||
connection.client?.end(); // 结束 SSH 客户端连接会隐式关闭 SFTP
|
||||
activeSshConnections.delete(ws); // 从 Map 中移除
|
||||
}
|
||||
};
|
||||
|
||||
// --- 状态获取相关 ---
|
||||
const STATUS_POLL_INTERVAL = 5000; // 每 5 秒获取一次状态
|
||||
|
||||
// Helper function to execute a command and return its stdout
|
||||
const executeSshCommand = (client: Client, command: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
let stderrOutput = ''; // Capture stderr too
|
||||
client.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
console.error(`SSH Command (${command}) exec error:`, err);
|
||||
return reject(err); // Reject on initial exec error
|
||||
}
|
||||
stream.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
}).stderr.on('data', (data: Buffer) => {
|
||||
stderrOutput += data.toString(); // Capture stderr
|
||||
// Log stderr as warning, but don't reject based on it unless needed
|
||||
// console.warn(`SSH Command (${command}) stderr: ${data.toString().trim()}`);
|
||||
}).on('close', (code: number | null | undefined, signal: string | null) => {
|
||||
const trimmedOutput = output.trim();
|
||||
const trimmedStderr = stderrOutput.trim();
|
||||
|
||||
if (signal) {
|
||||
console.error(`Command "${command}" terminated by signal: ${signal}. Stderr: ${trimmedStderr}`);
|
||||
return reject(new Error(`Command "${command}" terminated by signal: ${signal}`));
|
||||
}
|
||||
|
||||
// **Crucial Change:** Prioritize resolving if we have ANY stdout, regardless of exit code.
|
||||
if (trimmedOutput) {
|
||||
if (code !== 0 && code != null) {
|
||||
console.warn(`Command "${command}" exited with code ${code} but produced output. Resolving with output. Stderr: ${trimmedStderr}`);
|
||||
} else if (code == null) {
|
||||
console.warn(`Command "${command}" exited with code undefined but produced output. Resolving with output. Stderr: ${trimmedStderr}`);
|
||||
}
|
||||
return resolve(trimmedOutput);
|
||||
}
|
||||
|
||||
// If NO stdout, then reject based on error code or lack thereof.
|
||||
if (code !== 0 && code != null) {
|
||||
console.error(`Command "${command}" failed with code ${code} and no output. Stderr: ${trimmedStderr}`);
|
||||
return reject(new Error(`Command "${command}" failed with code ${code} and no output. Stderr: ${trimmedStderr}`));
|
||||
}
|
||||
if (code == null) {
|
||||
// This case now specifically means no output AND undefined code - likely a genuine failure
|
||||
console.error(`Command "${command}" failed with code undefined and no output. Stderr: ${trimmedStderr}`);
|
||||
return reject(new Error(`Command "${command}" failed with code undefined and no output. Stderr: ${trimmedStderr}`));
|
||||
}
|
||||
|
||||
// If code is 0 and no output, resolve with empty string (command succeeded but printed nothing)
|
||||
resolve('');
|
||||
|
||||
}).on('error', (streamErr: Error) => { // Handle stream-specific errors
|
||||
reject(streamErr);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Interface for the detailed status object
|
||||
interface ServerStatusDetails {
|
||||
cpuPercent?: number; // Percentage
|
||||
memPercent?: number; // Percentage
|
||||
memUsed?: number; // MB
|
||||
memTotal?: number; // MB
|
||||
swapPercent?: number; // Percentage
|
||||
swapUsed?: number; // MB
|
||||
swapTotal?: number; // MB
|
||||
diskPercent?: number; // Percentage for /
|
||||
diskUsed?: number; // KB
|
||||
diskTotal?: number; // KB
|
||||
cpuModel?: string;
|
||||
netRxRate?: number; // Bytes per second
|
||||
netTxRate?: number; // Bytes per second
|
||||
netInterface?: string; // Detected network interface
|
||||
osName?: string; // Added OS Name
|
||||
}
|
||||
|
||||
// Store previous network stats for rate calculation
|
||||
interface NetStats {
|
||||
rx: number;
|
||||
tx: number;
|
||||
timestamp: number;
|
||||
}
|
||||
const previousNetStats = new Map<AuthenticatedWebSocket, NetStats>();
|
||||
|
||||
|
||||
// Function to fetch server status metrics
|
||||
const fetchServerStatus = async (ws: AuthenticatedWebSocket, client: Client): Promise<ServerStatusDetails> => {
|
||||
const status: ServerStatusDetails = {};
|
||||
const connection = activeSshConnections.get(ws); // Needed for network stats
|
||||
|
||||
try {
|
||||
// CPU Usage (%) using vmstat (100 - idle)
|
||||
// Try vmstat first
|
||||
try {
|
||||
const cpuCmd = `vmstat 1 2 | tail -1 | awk '{print 100-$15}'`;
|
||||
const cpuOutput = await executeSshCommand(client, cpuCmd);
|
||||
const cpuUsage = parseFloat(cpuOutput);
|
||||
if (!isNaN(cpuUsage)) status.cpuPercent = parseFloat(cpuUsage.toFixed(1));
|
||||
} catch (vmstatError) {
|
||||
console.warn(`获取 CPU 使用率失败 (vmstat):`, vmstatError, `尝试 top...`);
|
||||
// Fallback attempt using top if vmstat failed
|
||||
try {
|
||||
const cpuCmdFallback = `top -bn1 | grep '%Cpu(s)' | head -1 | awk '{print $2+$4}'`; // Sum User + System CPU %
|
||||
const cpuOutputFallback = await executeSshCommand(client, cpuCmdFallback);
|
||||
const cpuUsageFallback = parseFloat(cpuOutputFallback);
|
||||
if (!isNaN(cpuUsageFallback)) status.cpuPercent = parseFloat(cpuUsageFallback.toFixed(1));
|
||||
} catch (topError) {
|
||||
console.warn(`获取 CPU 使用率失败 (top fallback):`, topError);
|
||||
}
|
||||
}
|
||||
} catch (error) { // Catch potential outer errors, though unlikely now
|
||||
console.error(`获取 CPU 使用率时发生意外错误:`, error);
|
||||
}
|
||||
|
||||
// --- Corrected CPU Model Fetch ---
|
||||
try {
|
||||
// CPU Model Name from /proc/cpuinfo
|
||||
const cpuModelCmd = `cat /proc/cpuinfo | grep 'model name' | head -1 | cut -d ':' -f 2 | sed 's/^[ \t]*//'`;
|
||||
const cpuModelOutput = await executeSshCommand(client, cpuModelCmd); // Use correct command and variable
|
||||
if (cpuModelOutput) status.cpuModel = cpuModelOutput;
|
||||
} catch (error) { // Use standard 'error' variable name and remove the incorrect logic/extra brace
|
||||
console.warn(`获取 CPU 型号失败:`, error);
|
||||
}
|
||||
// Removed duplicated CPU Model fetch block here (Comment remains from previous step, actual change is above)
|
||||
|
||||
// --- Fetch OS Name ---
|
||||
try {
|
||||
const osCmd = `cat /etc/os-release`;
|
||||
const osOutput = await executeSshCommand(client, osCmd);
|
||||
const lines = osOutput.split('\n');
|
||||
const prettyNameLine = lines.find(line => line.startsWith('PRETTY_NAME='));
|
||||
if (prettyNameLine) {
|
||||
// Extract value, remove potential quotes
|
||||
status.osName = prettyNameLine.split('=')[1]?.trim().replace(/^"(.*)"$/, '$1');
|
||||
} else {
|
||||
// Fallback or alternative methods if needed (e.g., uname -a)
|
||||
const unameCmd = `uname -a`; // Less pretty, but usually available
|
||||
const unameOutput = await executeSshCommand(client, unameCmd);
|
||||
if (unameOutput) status.osName = unameOutput.trim(); // Trim uname output
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`获取操作系统名称失败:`, error);
|
||||
// Attempt uname as a last resort even if os-release failed
|
||||
try {
|
||||
const unameCmd = `uname -a`;
|
||||
const unameOutput = await executeSshCommand(client, unameCmd);
|
||||
if (unameOutput) status.osName = unameOutput.trim(); // Trim uname output
|
||||
} catch (unameError) {
|
||||
console.warn(`获取操作系统名称失败 (uname fallback):`, unameError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Memory Usage (Total and Used in MB, and Percentage)
|
||||
const memCmd = `free -m | awk 'NR==2{print $2 " " $3}'`; // Output: "total used"
|
||||
const memOutput = await executeSshCommand(client, memCmd);
|
||||
const memValues = memOutput.split(' ');
|
||||
if (memValues.length === 2) {
|
||||
const total = parseInt(memValues[0], 10);
|
||||
const used = parseInt(memValues[1], 10);
|
||||
if (!isNaN(total) && !isNaN(used) && total > 0) {
|
||||
status.memTotal = total;
|
||||
status.memUsed = used;
|
||||
status.memPercent = parseFloat(((used / total) * 100).toFixed(1));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`获取内存状态失败:`, error);
|
||||
}
|
||||
// Removed duplicated Memory fetch block here
|
||||
|
||||
try {
|
||||
// Swap Usage (Total and Used in MB, and Percentage)
|
||||
const swapCmd = `free -m | awk 'NR==3{print $2 " " $3}'`; // Output: "total used" for swap
|
||||
const swapOutput = await executeSshCommand(client, swapCmd);
|
||||
const swapValues = swapOutput.split(' ');
|
||||
if (swapValues.length === 2) {
|
||||
const total = parseInt(swapValues[0], 10);
|
||||
const used = parseInt(swapValues[1], 10);
|
||||
// Only report swap if total > 0
|
||||
if (!isNaN(total) && !isNaN(used) && total > 0) {
|
||||
status.swapTotal = total;
|
||||
status.swapUsed = used;
|
||||
status.swapPercent = parseFloat(((used / total) * 100).toFixed(1));
|
||||
} else if (!isNaN(total) && total === 0) {
|
||||
status.swapTotal = 0;
|
||||
status.swapUsed = 0;
|
||||
status.swapPercent = 0;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`获取 Swap 状态失败:`, error);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Disk Usage - Using POSIX standard output 'df -Pk /' for reliable parsing
|
||||
const diskCmd = `df -Pk /`; // Use -P flag for POSIX standard output
|
||||
const diskOutput = await executeSshCommand(client, diskCmd);
|
||||
const lines = diskOutput.trim().split('\n'); // Trim output and split into lines
|
||||
|
||||
if (lines.length >= 2) {
|
||||
// Skip header line (usually the first line)
|
||||
let dataLine = '';
|
||||
// Find the line ending with ' /' (mount point)
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
// Trim the line before checking the ending
|
||||
if (lines[i].trim().endsWith(' /')) {
|
||||
dataLine = lines[i].trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The second line (index 1) should contain the data in POSIX format
|
||||
if (lines.length >= 2) {
|
||||
const dataLine = lines[1].trim();
|
||||
console.log(`[Disk P Debug] dataLine: "${dataLine}"`); // Log the line
|
||||
const parts = dataLine.split(/\s+/);
|
||||
console.log(`[Disk P Debug] parts:`, parts); // Log the split parts
|
||||
// POSIX format: Filesystem, 1024-blocks (Total), Used, Available, Capacity, Mounted on
|
||||
if (parts.length >= 4) { // Need at least up to 'Available' column
|
||||
const totalKb = parseInt(parts[1], 10);
|
||||
const usedKb = parseInt(parts[2], 10);
|
||||
// const availableKb = parseInt(parts[3], 10); // Available if needed
|
||||
// const capacityPercent = parts[4]; // Percentage string like "20%"
|
||||
|
||||
if (!isNaN(totalKb) && !isNaN(usedKb) && totalKb >= 0) {
|
||||
status.diskTotal = totalKb;
|
||||
status.diskUsed = usedKb;
|
||||
// Calculate percent only if total > 0 to avoid division by zero
|
||||
status.diskPercent = totalKb > 0 ? parseFloat(((usedKb / totalKb) * 100).toFixed(1)) : 0;
|
||||
// Optional: Could also try parsing parts[4] if calculation seems off
|
||||
} else {
|
||||
console.warn(`无法从 'df -Pk /' 行解析有效的磁盘大小 (Total=${parts[1]}, Used=${parts[2]}):`, dataLine);
|
||||
}
|
||||
} else {
|
||||
console.warn(`'df -Pk /' 数据行格式不符合预期 (列数不足):`, dataLine);
|
||||
}
|
||||
} else {
|
||||
console.warn(`无法从 'df -k /' 输出中找到根目录 ('/') 的数据行:`, diskOutput);
|
||||
}
|
||||
} else {
|
||||
console.warn(`'df -k /' 命令输出格式不符合预期 (行数不足):`, diskOutput);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`获取磁盘状态失败 (df -k):`, error);
|
||||
}
|
||||
|
||||
// Network Rate Calculation
|
||||
let defaultInterface = '';
|
||||
try {
|
||||
const routeCmd = `ip route | grep default | awk '{print $5}' | head -1`;
|
||||
defaultInterface = await executeSshCommand(client, routeCmd);
|
||||
status.netInterface = defaultInterface; // Store detected interface
|
||||
} catch (error) {
|
||||
console.warn(`获取默认网络接口失败:`, error);
|
||||
}
|
||||
|
||||
if (defaultInterface && connection) {
|
||||
try {
|
||||
const netCmd = `cat /proc/net/dev | grep '${defaultInterface}:' | awk '{print $2 " " $10}'`; // RX bytes (col 2), TX bytes (col 10)
|
||||
const netOutput = await executeSshCommand(client, netCmd);
|
||||
const netValues = netOutput.split(' ');
|
||||
if (netValues.length === 2) {
|
||||
const currentRx = parseInt(netValues[0], 10);
|
||||
const currentTx = parseInt(netValues[1], 10);
|
||||
const currentTime = Date.now();
|
||||
|
||||
const prevStats = previousNetStats.get(ws);
|
||||
|
||||
if (prevStats && !isNaN(currentRx) && !isNaN(currentTx)) {
|
||||
const timeDiffSeconds = (currentTime - prevStats.timestamp) / 1000;
|
||||
if (timeDiffSeconds > 0) {
|
||||
status.netRxRate = Math.max(0, Math.round((currentRx - prevStats.rx) / timeDiffSeconds)); // Corrected property name
|
||||
status.netTxRate = Math.max(0, Math.round((currentTx - prevStats.tx) / timeDiffSeconds)); // Corrected property name
|
||||
}
|
||||
}
|
||||
|
||||
// Store current stats for next calculation
|
||||
if (!isNaN(currentRx) && !isNaN(currentTx)) {
|
||||
previousNetStats.set(ws, { rx: currentRx, tx: currentTx, timestamp: currentTime });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`获取网络速率失败 (${defaultInterface}):`, error);
|
||||
}
|
||||
} else if (!defaultInterface) {
|
||||
console.warn(`无法计算网络速率,因为未找到默认接口。`);
|
||||
}
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
// Function to start status polling for a connection
|
||||
const startStatusPolling = (ws: AuthenticatedWebSocket, client: Client) => {
|
||||
const connection = activeSshConnections.get(ws);
|
||||
if (!connection || connection.statusIntervalId) {
|
||||
console.warn(`用户 ${ws.username} 的状态轮询已启动或连接不存在。`);
|
||||
return; // Already polling or connection gone
|
||||
}
|
||||
|
||||
console.log(`WebSocket: 为用户 ${ws.username} 启动状态轮询 (间隔: ${STATUS_POLL_INTERVAL}ms)...`);
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
// Double check connection still exists before fetching
|
||||
const currentConnection = activeSshConnections.get(ws);
|
||||
if (!currentConnection || !currentConnection.client || !ws || ws.readyState !== WebSocket.OPEN) {
|
||||
console.log(`WebSocket: 用户 ${ws.username} 连接已关闭或无效,停止状态轮询。`);
|
||||
if (intervalId) clearInterval(intervalId); // Clear interval if connection is gone
|
||||
// Also ensure it's cleared from the map if cleanup didn't catch it
|
||||
if (currentConnection?.statusIntervalId === intervalId) {
|
||||
delete currentConnection.statusIntervalId;
|
||||
}
|
||||
previousNetStats.delete(ws); // Clear previous stats on disconnect/error
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await fetchServerStatus(ws, currentConnection.client); // Pass ws for net stats map
|
||||
// Send status only if we got at least one metric
|
||||
if (Object.keys(status).length > 0) {
|
||||
// console.log(`[Status Poll] Sending status for ${ws.username}:`, status); // Debug log
|
||||
ws.send(JSON.stringify({ type: 'ssh:status:update', payload: status }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`用户 ${ws.username} 状态轮询时出错:`, error);
|
||||
// Optionally send an error message to the client
|
||||
// ws.send(JSON.stringify({ type: 'ssh:status:error', payload: '无法获取服务器状态' }));
|
||||
// Consider stopping polling if errors persist? For now, continue polling.
|
||||
}
|
||||
}, STATUS_POLL_INTERVAL);
|
||||
|
||||
connection.statusIntervalId = intervalId; // Store the interval ID
|
||||
// Initialize previous network stats
|
||||
previousNetStats.set(ws, { rx: 0, tx: 0, timestamp: Date.now() - STATUS_POLL_INTERVAL }); // Initialize with dummy past data
|
||||
};
|
||||
|
||||
export const initializeWebSocket = (server: http.Server, sessionParser: RequestHandler): WebSocketServer => {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
@@ -146,13 +494,22 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
|
||||
console.log(`WebSocket: 用户 ${ws.username} 请求连接到 ID: ${connectionId}`);
|
||||
ws.send(JSON.stringify({ type: 'ssh:status', payload: '正在获取连接信息...' }));
|
||||
|
||||
// 1. 从数据库获取连接信息
|
||||
// 1. 从数据库获取连接信息 (包括所有凭证字段)
|
||||
const connInfo = await new Promise<DbConnectionInfo | null>((resolve, reject) => {
|
||||
// 注意:如果多用户,需要验证 connectionId 是否属于当前 userId
|
||||
db.get('SELECT * FROM connections WHERE id = ?', [connectionId], (err, row: DbConnectionInfo) => {
|
||||
if (err) return reject(new Error('查询连接信息失败'));
|
||||
resolve(row ?? null);
|
||||
});
|
||||
db.get(
|
||||
`SELECT id, name, host, port, username, auth_method,
|
||||
encrypted_password, encrypted_private_key, encrypted_passphrase
|
||||
FROM connections WHERE id = ?`,
|
||||
[connectionId],
|
||||
(err, row: DbConnectionInfo) => {
|
||||
if (err) {
|
||||
console.error(`查询连接 ${connectionId} 详细信息时出错:`, err);
|
||||
return reject(new Error('查询连接信息失败'));
|
||||
}
|
||||
resolve(row ?? null);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!connInfo) {
|
||||
@@ -161,18 +518,43 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
|
||||
}
|
||||
if (!connInfo.encrypted_password) {
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: '连接配置缺少密码信息。' }));
|
||||
return;
|
||||
// This check might be too early if key auth is used
|
||||
// ws.send(JSON.stringify({ type: 'ssh:error', payload: '连接配置缺少密码信息。' }));
|
||||
// return;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在连接到 ${connInfo.host}...` }));
|
||||
|
||||
// 2. 解密密码
|
||||
let password = '';
|
||||
// 2. 解密凭证并构建连接配置
|
||||
let connectConfig: any = {
|
||||
host: connInfo.host,
|
||||
port: connInfo.port,
|
||||
username: connInfo.username,
|
||||
keepaliveInterval: 30000, // Send keep-alive every 30 seconds (milliseconds)
|
||||
keepaliveCountMax: 3, // Disconnect after 3 missed keep-alives
|
||||
readyTimeout: 20000 // 连接超时时间 (毫秒)
|
||||
};
|
||||
|
||||
try {
|
||||
password = decrypt(connInfo.encrypted_password);
|
||||
if (connInfo.auth_method === 'password') {
|
||||
if (!connInfo.encrypted_password) {
|
||||
throw new Error('连接配置缺少密码信息。');
|
||||
}
|
||||
connectConfig.password = decrypt(connInfo.encrypted_password);
|
||||
} else if (connInfo.auth_method === 'key') {
|
||||
if (!connInfo.encrypted_private_key) {
|
||||
throw new Error('连接配置缺少私钥信息。');
|
||||
}
|
||||
connectConfig.privateKey = decrypt(connInfo.encrypted_private_key);
|
||||
if (connInfo.encrypted_passphrase) {
|
||||
connectConfig.passphrase = decrypt(connInfo.encrypted_passphrase);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`不支持的认证方式: ${connInfo.auth_method}`);
|
||||
}
|
||||
} catch (decryptError: any) {
|
||||
console.error(`解密连接 ${connectionId} 密码失败:`, decryptError);
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: '无法解密连接凭证。' }));
|
||||
console.error(`处理连接 ${connectionId} 凭证失败:`, decryptError);
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: `无法处理连接凭证: ${decryptError.message}` }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -214,12 +596,18 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
|
||||
const existingConn = activeSshConnections.get(ws);
|
||||
if (existingConn) {
|
||||
existingConn.sftp = sftp;
|
||||
// SFTP 就绪后,才真正通知前端连接完成
|
||||
ws.send(JSON.stringify({ type: 'ssh:connected' }));
|
||||
// 启动状态轮询
|
||||
startStatusPolling(ws, sshClient);
|
||||
} else {
|
||||
// This case should ideally not happen if the connection was set earlier
|
||||
console.error(`SFTP: 无法找到用户 ${ws.username} 的活动连接记录以存储 SFTP 或启动轮询。`);
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: '内部服务器错误:无法关联 SFTP 会话。' }));
|
||||
cleanupSshConnection(ws);
|
||||
}
|
||||
// SFTP 就绪后,才真正通知前端连接完成
|
||||
ws.send(JSON.stringify({ type: 'ssh:connected' }));
|
||||
});
|
||||
|
||||
|
||||
// 5. 数据转发:Shell -> WebSocket (发送 Base64 编码的数据)
|
||||
stream.on('data', (data: Buffer) => {
|
||||
// console.log('SSH Output Buffer Length:', data.length); // Debug log
|
||||
@@ -257,18 +645,7 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
|
||||
ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'SSH 连接已关闭。' }));
|
||||
cleanupSshConnection(ws);
|
||||
}
|
||||
}).connect({
|
||||
host: connInfo.host,
|
||||
port: connInfo.port,
|
||||
username: connInfo.username,
|
||||
password: password, // 使用解密后的密码
|
||||
// TODO: 添加对密钥认证的支持
|
||||
// privateKey: require('fs').readFileSync('/path/to/key'),
|
||||
// passphrase: 'key passphrase'
|
||||
keepaliveInterval: 30000, // Send keep-alive every 30 seconds (milliseconds)
|
||||
keepaliveCountMax: 3, // Disconnect after 3 missed keep-alives
|
||||
readyTimeout: 20000 // 连接超时时间 (毫秒)
|
||||
});
|
||||
}).connect(connectConfig); // 使用前面构建的 connectConfig 对象
|
||||
break;
|
||||
} // end case 'ssh:connect'
|
||||
|
||||
|
||||
Binary file not shown.
Generated
-1524
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
"axios": "^1.8.4",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pinia": "^3.0.2",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-i18n": "^9.14.4",
|
||||
|
||||
@@ -1,54 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import { useConnectionsStore } from '../stores/connections.store';
|
||||
import { ref, reactive, watch, computed } from 'vue'; // 引入 watch 和 computed
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo
|
||||
|
||||
// 定义组件发出的事件
|
||||
const emit = defineEmits(['close', 'connection-added']);
|
||||
// 定义组件发出的事件 (添加 connection-updated)
|
||||
const emit = defineEmits(['close', 'connection-added', 'connection-updated']);
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
// 定义 Props
|
||||
const props = defineProps<{
|
||||
connectionToEdit: ConnectionInfo | null; // 接收要编辑的连接对象
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const { isLoading, error } = storeToRefs(connectionsStore); // 获取加载和错误状态
|
||||
const { isLoading, error: storeError } = storeToRefs(connectionsStore); // 重命名 error 避免冲突
|
||||
|
||||
// 表单数据模型
|
||||
const formData = reactive({
|
||||
const initialFormData = {
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
auth_method: 'password' as 'password' | 'key',
|
||||
password: '',
|
||||
});
|
||||
private_key: '',
|
||||
passphrase: '',
|
||||
};
|
||||
const formData = reactive({ ...initialFormData });
|
||||
|
||||
const formError = ref<string | null>(null); // 表单级别的错误信息
|
||||
|
||||
// 计算属性判断是否为编辑模式
|
||||
const isEditMode = computed(() => !!props.connectionToEdit);
|
||||
|
||||
// 计算属性动态设置表单标题
|
||||
const formTitle = computed(() => {
|
||||
return isEditMode.value ? t('connections.form.titleEdit') : t('connections.form.title');
|
||||
});
|
||||
|
||||
// 计算属性动态设置提交按钮文本
|
||||
const submitButtonText = computed(() => {
|
||||
if (isLoading.value) {
|
||||
return isEditMode.value ? t('connections.form.saving') : t('connections.form.adding');
|
||||
}
|
||||
return isEditMode.value ? t('connections.form.confirmEdit') : t('connections.form.confirm');
|
||||
});
|
||||
|
||||
// 监听 prop 变化以填充或重置表单
|
||||
watch(() => props.connectionToEdit, (newVal) => {
|
||||
formError.value = null; // 清除错误
|
||||
if (newVal) {
|
||||
// 编辑模式:填充表单,但不填充敏感信息
|
||||
formData.name = newVal.name;
|
||||
formData.host = newVal.host;
|
||||
formData.port = newVal.port;
|
||||
formData.username = newVal.username;
|
||||
formData.auth_method = newVal.auth_method;
|
||||
// 清空敏感字段,要求用户重新输入以更新
|
||||
formData.password = '';
|
||||
formData.private_key = '';
|
||||
formData.passphrase = '';
|
||||
} else {
|
||||
// 添加模式:重置表单
|
||||
Object.assign(formData, initialFormData);
|
||||
}
|
||||
}, { immediate: true }); // immediate: true 确保初始加载时也执行
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = async () => {
|
||||
formError.value = null; // 清除之前的错误
|
||||
connectionsStore.error = null; // 清除 store 中的旧错误
|
||||
|
||||
// 基础前端验证 (可以添加更复杂的验证)
|
||||
if (!formData.name || !formData.host || !formData.username || !formData.password) {
|
||||
formError.value = t('connections.form.errorRequired');
|
||||
// 基础前端验证 (保持不变)
|
||||
if (!formData.name || !formData.host || !formData.username) {
|
||||
formError.value = t('connections.form.errorRequiredFields'); // 更通用的错误消息
|
||||
return;
|
||||
}
|
||||
if (formData.port <= 0 || formData.port > 65535) {
|
||||
formError.value = t('connections.form.errorPort');
|
||||
return;
|
||||
}
|
||||
// 根据认证方式验证特定字段
|
||||
if (formData.auth_method === 'password' && !formData.password) {
|
||||
formError.value = t('connections.form.errorPasswordRequired');
|
||||
return;
|
||||
}
|
||||
if (formData.auth_method === 'key' && !formData.private_key) {
|
||||
formError.value = t('connections.form.errorPrivateKeyRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await connectionsStore.addConnection({
|
||||
// 构建要发送的数据 (区分添加和编辑)
|
||||
const dataToSend: any = {
|
||||
name: formData.name,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
});
|
||||
auth_method: formData.auth_method,
|
||||
};
|
||||
|
||||
if (success) {
|
||||
emit('connection-added'); // 通知父组件添加成功
|
||||
// 只有当用户输入了新的密码/密钥时才包含它们
|
||||
if (formData.auth_method === 'password' && formData.password) {
|
||||
dataToSend.password = formData.password;
|
||||
} else if (formData.auth_method === 'key') {
|
||||
if (formData.private_key) { // 只有输入了新私钥才发送
|
||||
dataToSend.private_key = formData.private_key;
|
||||
}
|
||||
if (formData.passphrase) { // 只有输入了新密码短语才发送
|
||||
dataToSend.passphrase = formData.passphrase;
|
||||
} else if (isEditMode.value && formData.private_key && !formData.passphrase) {
|
||||
// 如果是编辑模式,输入了新私钥但清空了密码短语,需要显式发送空字符串或 null
|
||||
// 取决于后端如何处理清空密码短语。假设发送空字符串。
|
||||
dataToSend.passphrase = '';
|
||||
}
|
||||
}
|
||||
|
||||
let success = false;
|
||||
if (isEditMode.value && props.connectionToEdit) {
|
||||
// 调用更新 action
|
||||
success = await connectionsStore.updateConnection(props.connectionToEdit.id, dataToSend);
|
||||
if (success) {
|
||||
emit('connection-updated'); // 发出更新成功事件
|
||||
} else {
|
||||
formError.value = t('connections.form.errorUpdate', { error: connectionsStore.error || '未知错误' });
|
||||
}
|
||||
} else {
|
||||
// 如果 store action 返回 false,则显示 store 中的错误信息
|
||||
formError.value = t('connections.form.errorAdd', { error: connectionsStore.error || '未知错误' });
|
||||
// 调用添加 action
|
||||
success = await connectionsStore.addConnection(dataToSend);
|
||||
if (success) {
|
||||
emit('connection-added'); // 发出添加成功事件
|
||||
} else {
|
||||
formError.value = t('connections.form.errorAdd', { error: connectionsStore.error || '未知错误' });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -56,7 +140,7 @@ const handleSubmit = async () => {
|
||||
<template>
|
||||
<div class="add-connection-form-overlay">
|
||||
<div class="add-connection-form">
|
||||
<h3>{{ t('connections.form.title') }}</h3>
|
||||
<h3>{{ formTitle }}</h3> <!-- 使用计算属性 -->
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="form-group">
|
||||
<label for="conn-name">{{ t('connections.form.name') }}</label>
|
||||
@@ -74,19 +158,45 @@ const handleSubmit = async () => {
|
||||
<label for="conn-username">{{ t('connections.form.username') }}</label>
|
||||
<input type="text" id="conn-username" v-model="formData.username" required />
|
||||
</div>
|
||||
|
||||
<!-- 认证方式选择 -->
|
||||
<div class="form-group">
|
||||
<label for="conn-password">{{ t('connections.form.password') }}</label>
|
||||
<input type="password" id="conn-password" v-model="formData.password" required />
|
||||
<!-- 提示:MVP 仅支持密码认证 -->
|
||||
<label for="conn-auth-method">{{ t('connections.form.authMethod') }}</label>
|
||||
<select id="conn-auth-method" v-model="formData.auth_method">
|
||||
<option value="password">{{ t('connections.form.authMethodPassword') }}</option>
|
||||
<option value="key">{{ t('connections.form.authMethodKey') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="formError || error" class="error-message">
|
||||
{{ formError || error }} <!-- 保持显示具体错误 -->
|
||||
<!-- 密码输入 (条件渲染) -->
|
||||
<div class="form-group" v-if="formData.auth_method === 'password'">
|
||||
<label for="conn-password">{{ t('connections.form.password') }}</label>
|
||||
<input type="password" id="conn-password" v-model="formData.password" :required="formData.auth_method === 'password'" />
|
||||
</div>
|
||||
|
||||
<!-- 密钥输入 (条件渲染) -->
|
||||
<div v-if="formData.auth_method === 'key'">
|
||||
<div class="form-group">
|
||||
<label for="conn-private-key">{{ t('connections.form.privateKey') }}</label>
|
||||
<textarea id="conn-private-key" v-model="formData.private_key" rows="6" :required="formData.auth_method === 'key'"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="conn-passphrase">{{ t('connections.form.passphrase') }} ({{ t('connections.form.optional') }})</label>
|
||||
<input type="password" id="conn-passphrase" v-model="formData.passphrase" />
|
||||
</div>
|
||||
<div class="form-group" v-if="isEditMode && formData.auth_method === 'key'">
|
||||
<small>{{ t('connections.form.keyUpdateNote') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 显示 storeError 或 formError -->
|
||||
<div v-if="formError || storeError" class="error-message">
|
||||
{{ formError || storeError }}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" :disabled="isLoading">
|
||||
{{ isLoading ? t('connections.form.adding') : t('connections.form.confirm') }}
|
||||
{{ submitButtonText }} <!-- 使用计算属性 -->
|
||||
</button>
|
||||
<button type="button" @click="emit('close')" :disabled="isLoading">{{ t('connections.form.cancel') }}</button>
|
||||
</div>
|
||||
@@ -136,7 +246,9 @@ label {
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="password"] {
|
||||
input[type="password"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
|
||||
@@ -11,6 +11,9 @@ const connectionsStore = useConnectionsStore();
|
||||
// 使用 storeToRefs 来保持 state 属性的响应性
|
||||
const { connections, isLoading, error } = storeToRefs(connectionsStore);
|
||||
|
||||
// 定义组件发出的事件 (添加 edit-connection)
|
||||
const emit = defineEmits(['edit-connection']);
|
||||
|
||||
// 组件挂载时获取连接列表
|
||||
onMounted(() => {
|
||||
connectionsStore.fetchConnections();
|
||||
@@ -22,6 +25,22 @@ const formatTimestamp = (timestamp: number | null): string => {
|
||||
// TODO: 可以考虑使用更专业的日期格式化库 (如 date-fns 或 dayjs) 并结合 i18n locale
|
||||
return new Date(timestamp * 1000).toLocaleString(); // 乘以 1000 转换为毫秒
|
||||
};
|
||||
|
||||
// 新增:处理删除连接的方法
|
||||
const handleDelete = async (conn: ConnectionInfo) => {
|
||||
// 使用 i18n 获取确认消息
|
||||
const confirmMessage = t('connections.prompts.confirmDelete', { name: conn.name });
|
||||
if (window.confirm(confirmMessage)) {
|
||||
const success = await connectionsStore.deleteConnection(conn.id);
|
||||
if (!success) {
|
||||
// 如果删除失败,显示 store 中的错误信息 (或自定义错误)
|
||||
// 可以考虑使用更友好的提示方式,例如 toast 通知库
|
||||
alert(t('connections.errors.deleteFailed', { error: connectionsStore.error || '未知错误' }));
|
||||
}
|
||||
// 成功时列表会自动更新,无需额外操作
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -54,8 +73,8 @@ const formatTimestamp = (timestamp: number | null): string => {
|
||||
<td>{{ formatTimestamp(conn.last_connected_at) }}</td>
|
||||
<td>
|
||||
<button @click="connectToServer(conn.id)">{{ t('connections.actions.connect') }}</button>
|
||||
<button @click="">{{ t('connections.actions.edit') }}</button> <!-- TODO: 实现编辑逻辑 -->
|
||||
<button @click="">{{ t('connections.actions.delete') }}</button> <!-- TODO: 实现删除逻辑 -->
|
||||
<button @click="emit('edit-connection', conn)">{{ t('connections.actions.edit') }}</button>
|
||||
<button @click="handleDelete(conn)">{{ t('connections.actions.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -57,6 +57,21 @@ const isDraggingOver = ref(false); // State for drag-over visual feedback
|
||||
const sortKey = ref<keyof FileListItem | 'type' | 'size' | 'mtime'>('filename'); // Default sort key
|
||||
const sortDirection = ref<'asc' | 'desc'>('asc'); // Default sort direction
|
||||
|
||||
// --- Column Resizing State ---
|
||||
const tableRef = ref<HTMLTableElement | null>(null);
|
||||
const colWidths = ref({ // Initial widths (adjust as needed)
|
||||
type: 50,
|
||||
name: 300,
|
||||
size: 100,
|
||||
permissions: 120,
|
||||
modified: 180,
|
||||
});
|
||||
const isResizing = ref(false);
|
||||
const resizingColumnIndex = ref(-1);
|
||||
const startX = ref(0);
|
||||
const startWidth = ref(0);
|
||||
|
||||
|
||||
// --- Editor State ---
|
||||
const isEditorVisible = ref(false);
|
||||
const editingFilePath = ref<string | null>(null);
|
||||
@@ -210,17 +225,21 @@ const showContextMenu = (event: MouseEvent, item?: FileListItem) => {
|
||||
];
|
||||
} else if (targetItem && targetItem.filename !== '..') {
|
||||
menu = [
|
||||
{ label: t('fileManager.actions.rename'), action: () => handleRenameClick(targetItem) },
|
||||
{ label: t('fileManager.actions.changePermissions'), action: () => handleChangePermissionsClick(targetItem) },
|
||||
{ label: t('fileManager.actions.delete'), action: handleDeleteClick },
|
||||
{ label: t('fileManager.actions.newFolder'), action: handleNewFolderClick },
|
||||
{ label: t('fileManager.actions.newFile'), action: handleNewFileClick }, // 添加新建文件选项
|
||||
{ label: t('fileManager.actions.upload'), action: triggerFileUpload },
|
||||
{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) },
|
||||
];
|
||||
if (targetItem.attrs.isFile) {
|
||||
menu.splice(1, 0, { label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => triggerDownload(targetItem) });
|
||||
}
|
||||
menu.push({ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) });
|
||||
if (targetItem.attrs.isFile) {
|
||||
menu.splice(1, 0, { label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => triggerDownload(targetItem) });
|
||||
}
|
||||
// Add Delete option for single item
|
||||
menu.push({ label: t('fileManager.actions.delete'), action: handleDeleteClick });
|
||||
// Removed duplicate refresh: menu.push({ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) });
|
||||
} else if (!targetItem) {
|
||||
menu = [
|
||||
{ label: t('fileManager.actions.newFolder'), action: handleNewFolderClick },
|
||||
{ label: t('fileManager.actions.newFile'), action: handleNewFileClick }, // 添加新建文件选项
|
||||
{ label: t('fileManager.actions.upload'), action: triggerFileUpload },
|
||||
{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) },
|
||||
];
|
||||
@@ -403,15 +422,26 @@ const handleWebSocketMessage = (event: MessageEvent) => {
|
||||
editingFileContent.value = `// ${editorError.value}`; // Show error in editor
|
||||
}
|
||||
// --- Handle Editor Save Status ---
|
||||
else if (type === 'sftp:writefile:success' && path === editingFilePath.value) {
|
||||
isSaving.value = false;
|
||||
saveStatus.value = 'success';
|
||||
saveError.value = null;
|
||||
// Optionally close editor on successful save, or just show status
|
||||
// closeEditor();
|
||||
// Reset status after a short delay
|
||||
setTimeout(() => { if (saveStatus.value === 'success') saveStatus.value = 'idle'; }, 2000);
|
||||
} else if (type === 'sftp:writefile:error' && path === editingFilePath.value) {
|
||||
else if (type === 'sftp:writefile:success') { // Handle ALL successful writes
|
||||
// Extract parent directory
|
||||
const parentDir = path.substring(0, path.lastIndexOf('/')) || '/';
|
||||
|
||||
// Refresh if the write occurred in the current directory
|
||||
if (parentDir === currentPath.value) {
|
||||
loadDirectory(currentPath.value);
|
||||
}
|
||||
|
||||
// Update editor status ONLY if the saved file is the one being edited
|
||||
if (path === editingFilePath.value) {
|
||||
isSaving.value = false;
|
||||
saveStatus.value = 'success';
|
||||
saveError.value = null;
|
||||
// Optionally close editor on successful save, or just show status
|
||||
// closeEditor();
|
||||
// Reset status after a short delay
|
||||
setTimeout(() => { if (saveStatus.value === 'success') saveStatus.value = 'idle'; }, 2000);
|
||||
}
|
||||
} else if (type === 'sftp:writefile:error' && path === editingFilePath.value) { // Error only relevant if editing this file
|
||||
isSaving.value = false;
|
||||
saveStatus.value = 'error';
|
||||
saveError.value = `${t('fileManager.errors.saveFailed')}: ${payload}`;
|
||||
@@ -633,9 +663,38 @@ const handleNewFolderClick = () => {
|
||||
if (folderName) {
|
||||
const newFolderPath = joinPath(currentPath.value, folderName);
|
||||
props.ws.send(JSON.stringify({ type: 'sftp:mkdir', payload: { path: newFolderPath } }));
|
||||
// 移除立即刷新,依赖 sftp:mkdir:success 消息
|
||||
// loadDirectory(currentPath.value);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理新建文件点击事件
|
||||
const handleNewFileClick = () => {
|
||||
if (!props.ws || props.ws.readyState !== WebSocket.OPEN) return;
|
||||
const fileName = prompt(t('fileManager.prompts.enterFileName'));
|
||||
if (fileName) {
|
||||
// 检查文件名是否已存在
|
||||
if (fileList.value.some(item => item.filename === fileName)) {
|
||||
alert(t('fileManager.errors.fileExists', { name: fileName }));
|
||||
return;
|
||||
}
|
||||
const newFilePath = joinPath(currentPath.value, fileName);
|
||||
// 发送创建空文件的请求到后端 (通过写入空内容)
|
||||
props.ws.send(JSON.stringify({
|
||||
type: 'sftp:writefile',
|
||||
payload: {
|
||||
path: newFilePath,
|
||||
content: '', // 发送空内容来创建文件
|
||||
encoding: 'utf8',
|
||||
}
|
||||
}));
|
||||
// 显式调用刷新,即使成功消息处理程序也会刷新
|
||||
loadDirectory(currentPath.value); // 确保在发送请求后立即尝试刷新
|
||||
// 成功或失败的消息会触发 sftp:writefile:success/error,进而刷新目录
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Sorting Logic ---
|
||||
const sortedFileList = computed(() => {
|
||||
const list = [...fileList.value]; // Create a shallow copy to avoid mutating original
|
||||
@@ -719,6 +778,61 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', hideContextMenu, { capture: true });
|
||||
});
|
||||
|
||||
// --- Column Resizing Logic ---
|
||||
const getColumnKeyByIndex = (index: number): keyof typeof colWidths.value | null => {
|
||||
const keys = Object.keys(colWidths.value) as Array<keyof typeof colWidths.value>;
|
||||
return keys[index] ?? null;
|
||||
};
|
||||
|
||||
const startResize = (event: MouseEvent, index: number) => {
|
||||
event.preventDefault(); // Prevent text selection during drag
|
||||
isResizing.value = true;
|
||||
resizingColumnIndex.value = index;
|
||||
startX.value = event.clientX;
|
||||
const colKey = getColumnKeyByIndex(index);
|
||||
if (colKey) {
|
||||
startWidth.value = colWidths.value[colKey];
|
||||
} else {
|
||||
// Fallback or error handling if index is out of bounds
|
||||
const thElement = (event.target as HTMLElement).closest('th');
|
||||
startWidth.value = thElement?.offsetWidth ?? 100; // Estimate if key not found
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('mousemove', handleResize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
document.body.style.cursor = 'col-resize'; // Change cursor globally
|
||||
document.body.style.userSelect = 'none'; // Prevent text selection globally
|
||||
};
|
||||
|
||||
const handleResize = (event: MouseEvent) => {
|
||||
if (!isResizing.value || resizingColumnIndex.value < 0) return;
|
||||
|
||||
const currentX = event.clientX;
|
||||
const diffX = currentX - startX.value;
|
||||
const newWidth = Math.max(30, startWidth.value + diffX); // Minimum width 30px
|
||||
|
||||
const colKey = getColumnKeyByIndex(resizingColumnIndex.value);
|
||||
if (colKey) {
|
||||
colWidths.value[colKey] = newWidth;
|
||||
}
|
||||
// Note: Direct manipulation of <col> width via style might be needed
|
||||
// if reactive updates to :style don't work reliably with table-layout:fixed.
|
||||
// Let's try with reactive refs first.
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
if (isResizing.value) {
|
||||
isResizing.value = false;
|
||||
resizingColumnIndex.value = -1;
|
||||
document.removeEventListener('mousemove', handleResize);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
document.body.style.cursor = ''; // Reset cursor
|
||||
document.body.style.userSelect = ''; // Reset text selection
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -734,6 +848,7 @@ onBeforeUnmount(() => {
|
||||
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple style="display: none;" />
|
||||
<button @click="triggerFileUpload" :disabled="isLoading || !isConnected" :title="t('fileManager.actions.uploadFile')">📤 {{ t('fileManager.actions.upload') }}</button>
|
||||
<button @click="handleNewFolderClick" :disabled="isLoading || !isConnected" :title="t('fileManager.actions.newFolder')">➕ {{ t('fileManager.actions.newFolder') }}</button>
|
||||
<button @click="handleNewFileClick" :disabled="isLoading || !isConnected" :title="t('fileManager.actions.newFile')">📄 {{ t('fileManager.actions.newFile') }}</button> <!-- 新建文件按钮 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -749,25 +864,40 @@ onBeforeUnmount(() => {
|
||||
<div v-if="isLoading && fileList.length === 0" class="loading">{{ t('fileManager.loading') }}</div>
|
||||
<div v-else-if="error" class="error">{{ t('fileManager.errors.generic') }}: {{ error }}</div>
|
||||
|
||||
<table v-if="sortedFileList.length > 0 || currentPath !== '/'" @contextmenu.prevent>
|
||||
<table v-if="sortedFileList.length > 0 || currentPath !== '/'" ref="tableRef" class="resizable-table" @contextmenu.prevent>
|
||||
<colgroup>
|
||||
<col :style="{ width: `${colWidths.type}px` }">
|
||||
<col :style="{ width: `${colWidths.name}px` }">
|
||||
<col :style="{ width: `${colWidths.size}px` }">
|
||||
<col :style="{ width: `${colWidths.permissions}px` }">
|
||||
<col :style="{ width: `${colWidths.modified}px` }">
|
||||
<!-- Add more cols if needed -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th @click="handleSort('type')" class="sortable">
|
||||
{{ t('fileManager.headers.type') }}
|
||||
<span v-if="sortKey === 'type'">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<span class="resizer" @mousedown.prevent="startResize($event, 0)" @click.stop></span>
|
||||
</th>
|
||||
<th @click="handleSort('filename')" class="sortable">
|
||||
{{ t('fileManager.headers.name') }}
|
||||
<span v-if="sortKey === 'filename'">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<span class="resizer" @mousedown.prevent="startResize($event, 1)" @click.stop></span>
|
||||
</th>
|
||||
<th @click="handleSort('size')" class="sortable">
|
||||
{{ t('fileManager.headers.size') }}
|
||||
<span v-if="sortKey === 'size'">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<span class="resizer" @mousedown.prevent="startResize($event, 2)" @click.stop></span>
|
||||
</th>
|
||||
<th>{{ t('fileManager.headers.permissions') }}</th> <!-- Permissions not sortable for now -->
|
||||
<th @click="handleSort('mtime')" class="sortable">
|
||||
<th> <!-- Permissions not sortable for now -->
|
||||
{{ t('fileManager.headers.permissions') }}
|
||||
<span class="resizer" @mousedown.prevent="startResize($event, 3)" @click.stop></span>
|
||||
</th>
|
||||
<th @click="handleSort('mtime')" class="sortable"> <!-- Last column doesn't need a resizer -->
|
||||
{{ t('fileManager.headers.modified') }}
|
||||
<span v-if="sortKey === 'mtime'">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<!-- No resizer on the last column -->
|
||||
</th>
|
||||
<!-- Removed Actions Header -->
|
||||
</tr>
|
||||
@@ -853,6 +983,7 @@ onBeforeUnmount(() => {
|
||||
:language="editingFileLanguage"
|
||||
theme="vs-dark"
|
||||
class="editor-instance"
|
||||
@request-save="handleSaveFile"
|
||||
/>
|
||||
<!-- Save button added above -->
|
||||
</div>
|
||||
@@ -898,14 +1029,28 @@ onBeforeUnmount(() => {
|
||||
pointer-events: none; /* Allow drop event to pass through */
|
||||
z-index: 2; /* Above table */
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
table.resizable-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed; /* Crucial for resizing */
|
||||
overflow: hidden; /* Prevent resizer overflow */
|
||||
}
|
||||
thead { background-color: #f8f8f8; position: sticky; top: 0; z-index: 1; }
|
||||
th, td { border: 1px solid #eee; padding: 0.4rem 0.6rem; text-align: left; white-space: nowrap; }
|
||||
th, td {
|
||||
border: 1px solid #eee;
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden; /* Hide overflow text */
|
||||
text-overflow: ellipsis; /* Show ellipsis for overflow */
|
||||
}
|
||||
th {
|
||||
position: relative; /* Needed for absolute positioning of resizer */
|
||||
}
|
||||
th.sortable { cursor: pointer; }
|
||||
th.sortable:hover { background-color: #e9e9e9; }
|
||||
/* Set a smaller default width for the first column (Type) */
|
||||
th:first-child, td:first-child {
|
||||
width: 40px; /* Adjust as needed */
|
||||
/* Removed fixed width for first column, handled by colgroup */
|
||||
td:first-child {
|
||||
text-align: center; /* Center the icon */
|
||||
}
|
||||
tbody tr:hover { background-color: #f5f5f5; }
|
||||
@@ -919,6 +1064,22 @@ tbody tr.selected:hover { background-color: #b8daff; }
|
||||
.context-menu li:hover { background-color: #eee; }
|
||||
.context-menu li.disabled { color: #aaa; cursor: not-allowed; background-color: white; }
|
||||
|
||||
/* Resizer Handle Styles */
|
||||
.resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px; /* Position slightly outside the cell border */
|
||||
width: 6px; /* Hit area width */
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2; /* Above cell content */
|
||||
/* background-color: rgba(0, 0, 255, 0.1); */ /* Optional: Make handle visible for debugging */
|
||||
}
|
||||
.resizer:hover {
|
||||
background-color: rgba(0, 100, 255, 0.2); /* Visual feedback on hover */
|
||||
}
|
||||
|
||||
|
||||
/* Editor Styles */
|
||||
.editor-overlay {
|
||||
position: absolute; /* Position over the file list */
|
||||
|
||||
@@ -26,8 +26,8 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
// Emits for v-model update
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
// Emits for v-model update and save request
|
||||
const emit = defineEmits(['update:modelValue', 'request-save']);
|
||||
|
||||
const editorContainer = ref<HTMLElement | null>(null);
|
||||
let editorInstance: monaco.editor.IStandaloneCodeEditor | null = null;
|
||||
@@ -55,6 +55,24 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add Ctrl+S / Cmd+S keybinding for saving
|
||||
editorInstance.addAction({
|
||||
id: 'save-file',
|
||||
label: 'Save File',
|
||||
keybindings: [
|
||||
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
|
||||
],
|
||||
precondition: undefined, // Fix: Use undefined instead of null
|
||||
keybindingContext: undefined, // Fix: Use undefined instead of null
|
||||
contextMenuGroupId: 'navigation', // Optional: where to show in context menu
|
||||
contextMenuOrder: 1.5, // Optional: order in context menu
|
||||
run: () => {
|
||||
console.log('[MonacoEditor] Save action triggered (Ctrl+S / Cmd+S)');
|
||||
emit('request-save');
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div class="status-monitor">
|
||||
<h4>服务器状态</h4>
|
||||
<div v-if="!statusData" class="loading-status">
|
||||
等待数据...
|
||||
</div>
|
||||
<div v-else class="status-grid">
|
||||
<div class="status-item cpu-model">
|
||||
<label>CPU 型号:</label>
|
||||
<span class="cpu-model-value" :title="statusData.cpuModel">{{ statusData.cpuModel ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<!-- Added OS Name Display -->
|
||||
<div class="status-item os-name">
|
||||
<label>系统:</label>
|
||||
<span class="os-name-value" :title="statusData.osName">{{ statusData.osName ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>CPU:</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" :style="{ width: `${statusData.cpuPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span>{{ statusData.cpuPercent?.toFixed(1) ?? 'N/A' }}%</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>内存:</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" :style="{ width: `${statusData.memPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span class="mem-disk-details">{{ memDisplay }}</span>
|
||||
</div>
|
||||
<!-- Removed v-if, Swap will always show -->
|
||||
<div class="status-item">
|
||||
<label>Swap:</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar swap-bar" :style="{ width: `${statusData.swapPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span class="mem-disk-details">{{ swapDisplay }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>磁盘 (/):</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" :style="{ width: `${statusData.diskPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span class="mem-disk-details">{{ diskDisplay }}</span>
|
||||
</div>
|
||||
<div class="status-item network-rate">
|
||||
<label>网络 ({{ statusData.netInterface || '...' }}):</label>
|
||||
<span class="rate down">⬇ {{ formatBytesPerSecond(statusData.netRxRate) }}</span>
|
||||
<span class="rate up">⬆ {{ formatBytesPerSecond(statusData.netTxRate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="status-error">
|
||||
错误: {{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
// Interface matching the backend's ServerStatusDetails
|
||||
interface ServerStatus {
|
||||
cpuPercent?: number;
|
||||
memPercent?: number;
|
||||
memUsed?: number; // MB
|
||||
memTotal?: number; // MB
|
||||
swapPercent?: number;
|
||||
swapUsed?: number; // MB
|
||||
swapTotal?: number; // MB
|
||||
diskPercent?: number;
|
||||
diskUsed?: number; // KB
|
||||
diskTotal?: number; // KB
|
||||
cpuModel?: string;
|
||||
netRxRate?: number; // Bytes per second
|
||||
netTxRate?: number; // Bytes per second
|
||||
netInterface?: string;
|
||||
osName?: string; // Added OS Name
|
||||
}
|
||||
|
||||
// Props to receive status data from parent
|
||||
const props = defineProps<{
|
||||
statusData: ServerStatus | null;
|
||||
error?: string | null;
|
||||
}>();
|
||||
|
||||
// Helper function to format bytes into appropriate units (B, KB, MB, GB) /s
|
||||
const formatBytesPerSecond = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes)) return 'N/A';
|
||||
if (bytes < 1024) return `${bytes} B/s`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB/s`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB/s`;
|
||||
};
|
||||
|
||||
|
||||
// Helper function to format bytes (KB from backend) into GB
|
||||
const formatKbToGb = (kb?: number): string => {
|
||||
if (kb === undefined || kb === null) return 'N/A';
|
||||
if (kb === 0) return '0.0 GB';
|
||||
const gb = kb / 1024 / 1024;
|
||||
return `${gb.toFixed(1)} GB`;
|
||||
};
|
||||
|
||||
// Computed properties for display
|
||||
const memDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
if (!data || data.memUsed === undefined || data.memTotal === undefined) return 'N/A';
|
||||
const percent = data.memPercent !== undefined ? `(${data.memPercent.toFixed(1)}%)` : '';
|
||||
// Ensure MB values are displayed without decimals if they are integers
|
||||
const usedMb = Number.isInteger(data.memUsed) ? data.memUsed : data.memUsed.toFixed(1);
|
||||
const totalMb = Number.isInteger(data.memTotal) ? data.memTotal : data.memTotal.toFixed(1);
|
||||
return `${usedMb} MB / ${totalMb} MB ${percent}`;
|
||||
});
|
||||
|
||||
const diskDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return 'N/A';
|
||||
// Percentage represents used space
|
||||
const percent = data.diskPercent !== undefined ? `(${data.diskPercent.toFixed(1)}%)` : '';
|
||||
// Display Used / Total
|
||||
return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)} ${percent}`;
|
||||
});
|
||||
|
||||
const swapDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
// Handle cases where swap might be undefined or 0
|
||||
const used = data?.swapUsed ?? 0;
|
||||
const total = data?.swapTotal ?? 0;
|
||||
const percentVal = data?.swapPercent ?? 0;
|
||||
|
||||
const percent = `(${percentVal.toFixed(1)}%)`;
|
||||
const usedMb = Number.isInteger(used) ? used : used.toFixed(1);
|
||||
const totalMb = Number.isInteger(total) ? total : total.toFixed(1);
|
||||
return `${usedMb} MB / ${totalMb} MB ${percent}`;
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-monitor {
|
||||
padding: 1rem;
|
||||
border-left: 1px solid #ccc;
|
||||
background-color: #f9f9f9;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.status-monitor h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 1em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.loading-status, .status-error {
|
||||
color: #888;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.status-error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: grid;
|
||||
/* Adjusted grid columns for better alignment */
|
||||
grid-template-columns: 65px 1fr auto; /* Label slightly wider */
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Specific style for CPU model row */
|
||||
.status-item.cpu-model {
|
||||
grid-template-columns: 65px 1fr; /* Label, Value */
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem; /* Add some space below CPU model */
|
||||
}
|
||||
.cpu-model-value {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
grid-column: 2 / 4; /* Span across the value and percentage columns */
|
||||
text-align: left;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Specific style for OS name row */
|
||||
.status-item.os-name {
|
||||
grid-template-columns: 65px 1fr; /* Label, Value */
|
||||
/* Ensure the item itself doesn't align right if the parent has text-align */
|
||||
text-align: left;
|
||||
}
|
||||
/* Increased specificity to override generic span rule */
|
||||
.status-item.os-name .os-name-value {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left; /* Explicitly left align text */
|
||||
justify-self: start; /* Align grid item to start */
|
||||
color: #333;
|
||||
min-width: auto; /* Override generic min-width */
|
||||
}
|
||||
|
||||
|
||||
.status-item label {
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
background-color: #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
height: 1rem; /* Adjust height */
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background-color: #007bff; /* Blue for CPU/Mem/Disk */
|
||||
height: 100%;
|
||||
transition: width 0.3s ease-in-out;
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 0.75em;
|
||||
line-height: 1rem; /* Match container height */
|
||||
}
|
||||
.progress-bar.swap-bar {
|
||||
background-color: #ffc107; /* Yellow for Swap */
|
||||
}
|
||||
|
||||
|
||||
.status-item span:not(.cpu-model-value) { /* Style for percentage spans */
|
||||
font-variant-numeric: tabular-nums; /* Keep numbers aligned */
|
||||
min-width: 45px; /* Ensure space for percentage */
|
||||
text-align: right;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mem-disk-details {
|
||||
font-size: 0.9em; /* Slightly smaller font for details */
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Network Rate Styles */
|
||||
.status-item.network-rate {
|
||||
grid-template-columns: 65px auto auto; /* Label, Down Rate, Up Rate */
|
||||
margin-top: 0.5rem; /* Add space above network */
|
||||
}
|
||||
.network-rate .rate {
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
min-width: 80px; /* Adjust as needed */
|
||||
}
|
||||
.network-rate .rate.down {
|
||||
color: #28a745; /* Green for download */
|
||||
}
|
||||
.network-rate .rate.up {
|
||||
color: #fd7e14; /* Orange for upload */
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -40,13 +40,32 @@
|
||||
"host": "Host/IP:",
|
||||
"port": "Port:",
|
||||
"username": "Username:",
|
||||
"authMethod": "Authentication Method:",
|
||||
"authMethodPassword": "Password",
|
||||
"authMethodKey": "SSH Key",
|
||||
"password": "Password:",
|
||||
"privateKey": "Private Key:",
|
||||
"passphrase": "Passphrase:",
|
||||
"optional": "Optional",
|
||||
"confirm": "Confirm Add",
|
||||
"adding": "Adding...",
|
||||
"cancel": "Cancel",
|
||||
"errorRequired": "All fields are required.",
|
||||
"errorRequiredFields": "Please fill in all required fields.",
|
||||
"errorPasswordRequired": "Password is required for password authentication.",
|
||||
"errorPrivateKeyRequired": "Private key is required for key authentication.",
|
||||
"errorPort": "Port must be between 1 and 65535.",
|
||||
"errorAdd": "Failed to add connection: {error}"
|
||||
"errorAdd": "Failed to add connection: {error}",
|
||||
"titleEdit": "Edit Connection",
|
||||
"confirmEdit": "Confirm Edit",
|
||||
"saving": "Saving...",
|
||||
"errorUpdate": "Failed to update connection: {error}",
|
||||
"keyUpdateNote": "Leave private key and passphrase blank to keep the existing key."
|
||||
},
|
||||
"prompts": {
|
||||
"confirmDelete": "Are you sure you want to delete the connection \"{name}\"? This cannot be undone."
|
||||
},
|
||||
"errors": {
|
||||
"deleteFailed": "Failed to delete connection: {error}"
|
||||
},
|
||||
"status": {
|
||||
"never": "Never"
|
||||
|
||||
@@ -40,13 +40,32 @@
|
||||
"host": "主机/IP:",
|
||||
"port": "端口:",
|
||||
"username": "用户名:",
|
||||
"authMethod": "认证方式:",
|
||||
"authMethodPassword": "密码",
|
||||
"authMethodKey": "SSH 密钥",
|
||||
"password": "密码:",
|
||||
"privateKey": "私钥:",
|
||||
"passphrase": "私钥密码:",
|
||||
"optional": "可选",
|
||||
"confirm": "确认添加",
|
||||
"adding": "正在添加...",
|
||||
"cancel": "取消",
|
||||
"errorRequired": "所有字段均为必填项。",
|
||||
"errorRequiredFields": "请填写所有必填字段。",
|
||||
"errorPasswordRequired": "使用密码认证时,密码为必填项。",
|
||||
"errorPrivateKeyRequired": "使用密钥认证时,私钥为必填项。",
|
||||
"errorPort": "端口号必须在 1 到 65535 之间。",
|
||||
"errorAdd": "添加连接失败: {error}"
|
||||
"errorAdd": "添加连接失败: {error}",
|
||||
"titleEdit": "编辑连接",
|
||||
"confirmEdit": "确认编辑",
|
||||
"saving": "正在保存...",
|
||||
"errorUpdate": "更新连接失败: {error}",
|
||||
"keyUpdateNote": "将私钥和密码短语留空以保留现有密钥。"
|
||||
},
|
||||
"prompts": {
|
||||
"confirmDelete": "确定要删除连接 \"{name}\" 吗?此操作不可撤销。"
|
||||
},
|
||||
"errors": {
|
||||
"deleteFailed": "删除连接失败: {error}"
|
||||
},
|
||||
"status": {
|
||||
"never": "从未"
|
||||
@@ -94,6 +113,7 @@
|
||||
"uploadFile": "上传文件",
|
||||
"upload": "上传",
|
||||
"newFolder": "新建文件夹",
|
||||
"newFile": "新建文件",
|
||||
"rename": "重命名",
|
||||
"changePermissions": "修改权限",
|
||||
"delete": "删除",
|
||||
@@ -131,7 +151,8 @@
|
||||
"readFileFailed": "读取文件失败",
|
||||
"fileDecodeError": "文件解码失败 (可能不是 UTF-8 编码)",
|
||||
"saveFailed": "保存文件失败",
|
||||
"saveTimeout": "保存超时"
|
||||
"saveTimeout": "保存超时",
|
||||
"fileExists": "文件 \"{name}\" 已存在。"
|
||||
},
|
||||
"prompts": {
|
||||
"enterFolderName": "请输入新文件夹的名称:",
|
||||
@@ -140,7 +161,8 @@
|
||||
"confirmDeleteFolder": "确定要删除目录 \"{name}\" 及其所有内容吗?此操作不可撤销。",
|
||||
"confirmDeleteFile": "确定要删除文件 \"{name}\" 吗?此操作不可撤销。",
|
||||
"enterNewName": "请输入 \"{oldName}\" 的新名称:",
|
||||
"enterNewPermissions": "请输入 \"{name}\" 的新权限 (八进制, 例如 755):"
|
||||
"enterNewPermissions": "请输入 \"{name}\" 的新权限 (八进制, 例如 755):",
|
||||
"enterFileName": "请输入新文件的名称:"
|
||||
},
|
||||
"editingFile": "正在编辑",
|
||||
"loadingFile": "正在加载文件...",
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia'; // 引入 Pinia
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; // 引入持久化插件
|
||||
import App from './App.vue';
|
||||
import router from './router'; // 引入我们创建的 router
|
||||
import i18n from './i18n'; // 引入 i18n 实例
|
||||
import './style.css';
|
||||
|
||||
const pinia = createPinia(); // 创建 Pinia 实例
|
||||
pinia.use(piniaPluginPersistedstate); // 使用持久化插件
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia()); // 使用 Pinia
|
||||
app.use(pinia); // 使用配置好的 Pinia 实例
|
||||
app.use(router); // 使用 Router
|
||||
app.use(i18n); // 使用 i18n
|
||||
|
||||
|
||||
@@ -75,8 +75,32 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
|
||||
// TODO: 添加检查登录状态的 Action (例如应用启动时调用)
|
||||
// async checkAuthStatus() { ... }
|
||||
// TODO: 添加检查登录状态的 Action (例如应用启动时调用)
|
||||
// async checkAuthStatus() {
|
||||
// const token = localStorage.getItem('authToken'); // 假设 token 存储在 localStorage
|
||||
// if (token) {
|
||||
// try {
|
||||
// // 可选: 向后端发送请求验证 token 有效性
|
||||
// // const response = await axios.get('/api/v1/auth/me', { headers: { Authorization: `Bearer ${token}` } });
|
||||
// // this.isAuthenticated = true;
|
||||
// // this.user = response.data.user;
|
||||
//
|
||||
// // 暂时只基于 localStorage 状态恢复
|
||||
// const storedAuth = localStorage.getItem('auth'); // pinia-plugin-persistedstate 默认 key
|
||||
// if (storedAuth) {
|
||||
// const parsedAuth = JSON.parse(storedAuth);
|
||||
// if (parsedAuth.isAuthenticated && parsedAuth.user) {
|
||||
// this.isAuthenticated = true;
|
||||
// this.user = parsedAuth.user;
|
||||
// console.log('Auth status restored from localStorage');
|
||||
// }
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('Failed to restore auth status:', error);
|
||||
// this.logout(); // 如果验证失败或出错,则登出
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
},
|
||||
// 可选:开启持久化 (例如使用 pinia-plugin-persistedstate)
|
||||
// persist: true,
|
||||
persist: true, // 使用默认持久化配置 (localStorage, 持久化所有 state)
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface ConnectionInfo {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: 'password';
|
||||
auth_method: 'password' | 'key'; // 允许 key 类型
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
last_connected_at: number | null;
|
||||
@@ -51,7 +51,17 @@ export const useConnectionsStore = defineStore('connections', {
|
||||
},
|
||||
|
||||
// 添加新连接 Action
|
||||
async addConnection(newConnectionData: { name: string; host: string; port: number; username: string; password: string }) {
|
||||
// 更新参数类型以接受新的认证字段
|
||||
async addConnection(newConnectionData: {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: 'password' | 'key';
|
||||
password?: string; // 密码变为可选
|
||||
private_key?: string; // 私钥是可选的 (仅在 auth_method 为 key 时需要)
|
||||
passphrase?: string; // 私钥密码是可选的
|
||||
}) {
|
||||
this.isLoading = true; // 可以为添加操作单独设置加载状态,或共用 isLoading
|
||||
this.error = null;
|
||||
try {
|
||||
@@ -70,5 +80,61 @@ export const useConnectionsStore = defineStore('connections', {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新连接 Action
|
||||
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { password?: string; private_key?: string; passphrase?: string }>) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
// 发送 PUT 请求到 /api/v1/connections/:id
|
||||
// 注意:后端 API 需要支持接收这些字段并进行更新
|
||||
const response = await axios.put<{ message: string; connection: ConnectionInfo }>(`/api/v1/connections/${connectionId}`, updatedData);
|
||||
|
||||
// 更新成功后,在列表中找到并更新对应的连接信息
|
||||
const index = this.connections.findIndex(conn => conn.id === connectionId);
|
||||
if (index !== -1) {
|
||||
// 使用更新后的完整信息替换旧信息
|
||||
// 注意:后端返回的 connection 可能不包含敏感信息,但应包含更新后的非敏感字段
|
||||
this.connections[index] = { ...this.connections[index], ...response.data.connection };
|
||||
} else {
|
||||
// 如果本地找不到,可能需要重新获取列表
|
||||
await this.fetchConnections();
|
||||
}
|
||||
return true; // 表示成功
|
||||
} catch (err: any) {
|
||||
console.error(`更新连接 ${connectionId} 失败:`, err);
|
||||
this.error = err.response?.data?.message || err.message || `更新连接时发生未知错误。`;
|
||||
if (err.response?.status === 401) {
|
||||
console.warn('未授权,需要登录才能更新连接。');
|
||||
}
|
||||
return false; // 表示失败
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 删除连接 Action
|
||||
async deleteConnection(connectionId: number) {
|
||||
this.isLoading = true; // 可以为删除操作单独设置加载状态
|
||||
this.error = null;
|
||||
try {
|
||||
// 发送 DELETE 请求到 /api/v1/connections/:id
|
||||
await axios.delete(`/api/v1/connections/${connectionId}`);
|
||||
|
||||
// 删除成功后,从本地列表中移除该连接
|
||||
this.connections = this.connections.filter(conn => conn.id !== connectionId);
|
||||
return true; // 表示成功
|
||||
} catch (err: any) {
|
||||
console.error(`删除连接 ${connectionId} 失败:`, err);
|
||||
this.error = err.response?.data?.message || err.message || `删除连接时发生未知错误。`;
|
||||
if (err.response?.status === 401) {
|
||||
console.warn('未授权,需要登录才能删除连接。');
|
||||
}
|
||||
// 即使删除失败,也可能需要通知用户
|
||||
return false; // 表示失败
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,33 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import ConnectionList from '../components/ConnectionList.vue'; // 引入列表组件
|
||||
import AddConnectionForm from '../components/AddConnectionForm.vue'; // 引入表单组件
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ConnectionList from '../components/ConnectionList.vue';
|
||||
import AddConnectionForm from '../components/AddConnectionForm.vue';
|
||||
import { ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const showAddForm = ref(false); // 控制添加表单的显示状态
|
||||
const { t } = useI18n();
|
||||
const showForm = ref(false); // 重命名,控制表单显示状态
|
||||
const editingConnection = ref<ConnectionInfo | null>(null); // 存储正在编辑的连接
|
||||
|
||||
const handleConnectionAdded = () => {
|
||||
showAddForm.value = false; // 添加成功后隐藏表单
|
||||
showForm.value = false; // 使用新变量名
|
||||
// ConnectionList 组件会自动从 store 获取更新后的列表
|
||||
};
|
||||
|
||||
// 新增:处理编辑成功后的逻辑
|
||||
const handleConnectionUpdated = () => {
|
||||
editingConnection.value = null; // 清除正在编辑的连接
|
||||
showForm.value = false; // 编辑成功后隐藏表单
|
||||
};
|
||||
|
||||
// 新增:处理来自 ConnectionList 的编辑请求
|
||||
const handleEditRequest = (connection: ConnectionInfo) => {
|
||||
editingConnection.value = connection; // 设置要编辑的连接
|
||||
showForm.value = true; // 显示表单
|
||||
};
|
||||
|
||||
// 新增:显式打开添加表单的方法
|
||||
const openAddForm = () => {
|
||||
editingConnection.value = null; // 确保不在编辑模式
|
||||
showForm.value = true;
|
||||
};
|
||||
|
||||
// 新增:统一的关闭表单方法
|
||||
const closeForm = () => {
|
||||
editingConnection.value = null; // 清除编辑状态
|
||||
showForm.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="connections-view">
|
||||
<h2>{{ t('connections.title') }}</h2>
|
||||
|
||||
<button @click="showAddForm = true" v-if="!showAddForm">{{ t('connections.addConnection') }}</button>
|
||||
<button @click="openAddForm" v-if="!showForm">{{ t('connections.addConnection') }}</button>
|
||||
|
||||
<!-- 添加连接表单 (条件渲染) -->
|
||||
<!-- 添加/编辑连接表单 (条件渲染) -->
|
||||
<AddConnectionForm
|
||||
v-if="showAddForm"
|
||||
@close="showAddForm = false"
|
||||
v-if="showForm"
|
||||
:connection-to-edit="editingConnection"
|
||||
@close="closeForm"
|
||||
@connection-added="handleConnectionAdded"
|
||||
@connection-updated="handleConnectionUpdated"
|
||||
/>
|
||||
|
||||
<!-- 连接列表 -->
|
||||
<ConnectionList />
|
||||
<!-- 连接列表,监听 edit-connection 事件 -->
|
||||
<ConnectionList @edit-connection="handleEditRequest" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,8 +4,22 @@ import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import TerminalComponent from '../components/Terminal.vue'; // 引入终端组件
|
||||
import FileManagerComponent from '../components/FileManager.vue'; // 引入文件管理器组件
|
||||
import StatusMonitorComponent from '../components/StatusMonitor.vue'; // 引入状态监控组件
|
||||
import type { Terminal } from 'xterm'; // 引入 Terminal 类型
|
||||
|
||||
// --- Interfaces ---
|
||||
// Updated interface to match StatusMonitor and backend
|
||||
interface ServerStatus {
|
||||
cpuPercent?: number;
|
||||
memPercent?: number;
|
||||
memUsed?: number; // MB
|
||||
memTotal?: number; // MB
|
||||
diskPercent?: number;
|
||||
diskUsed?: number; // KB
|
||||
diskTotal?: number; // KB
|
||||
cpuModel?: string;
|
||||
}
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const route = useRoute();
|
||||
const connectionId = computed(() => route.params.connectionId as string); // 从路由获取 connectionId
|
||||
@@ -15,6 +29,8 @@ const ws = ref<WebSocket | null>(null); // WebSocket 实例引用
|
||||
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
|
||||
const statusMessage = ref<string>(t('workspace.status.initializing')); // 使用 i18n
|
||||
const terminalOutputBuffer = ref<string[]>([]); // 缓冲 WebSocket 消息直到终端准备好
|
||||
const serverStatus = ref<ServerStatus | null>(null); // 存储服务器状态数据
|
||||
const statusError = ref<string | null>(null); // 存储状态获取错误
|
||||
|
||||
// 辅助函数:根据状态码获取 i18n 状态文本
|
||||
const getStatusText = (statusKey: string, params?: Record<string, any>): string => {
|
||||
@@ -144,6 +160,18 @@ const initializeWebSocketConnection = () => {
|
||||
statusMessage.value = getStatusText('error', { message: message.payload });
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${message.payload}\x1b[0m`);
|
||||
break;
|
||||
// --- Handle Status Updates ---
|
||||
case 'ssh:status:update':
|
||||
// console.log('收到状态更新:', message.payload); // Debug log
|
||||
serverStatus.value = message.payload;
|
||||
statusError.value = null; // Clear previous error on successful update
|
||||
break;
|
||||
// Optional: Handle status errors if backend sends them
|
||||
// case 'ssh:status:error':
|
||||
// console.error('获取服务器状态时出错:', message.payload);
|
||||
// statusError.value = message.payload || '无法获取服务器状态';
|
||||
// serverStatus.value = null; // Clear status data on error
|
||||
// break;
|
||||
// default: // Removed default case to allow other components to handle messages
|
||||
// console.warn('WorkspaceView: 收到未处理的 WebSocket 消息类型:', message.type);
|
||||
}
|
||||
@@ -171,6 +199,8 @@ const initializeWebSocketConnection = () => {
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('wsCloseMsg', { code: event.code })}\x1b[0m`);
|
||||
}
|
||||
ws.value = null; // 清理引用
|
||||
serverStatus.value = null; // Clear server status on disconnect
|
||||
statusError.value = null; // Clear status error on disconnect
|
||||
};
|
||||
};
|
||||
|
||||
@@ -201,16 +231,24 @@ onBeforeUnmount(() => {
|
||||
<!-- 状态颜色仍然通过 class 绑定 -->
|
||||
<span :class="`status-${connectionStatus}`"></span>
|
||||
</div>
|
||||
<div class="terminal-wrapper">
|
||||
<TerminalComponent
|
||||
@ready="onTerminalReady"
|
||||
@data="onTerminalData"
|
||||
@resize="onTerminalResize"
|
||||
/>
|
||||
</div>
|
||||
<!-- 文件管理器窗格 -->
|
||||
<div class="file-manager-wrapper">
|
||||
<FileManagerComponent :ws="ws" :is-connected="connectionStatus === 'connected'" />
|
||||
<div class="main-content-area">
|
||||
<div class="left-pane">
|
||||
<div class="terminal-wrapper">
|
||||
<TerminalComponent
|
||||
@ready="onTerminalReady"
|
||||
@data="onTerminalData"
|
||||
@resize="onTerminalResize"
|
||||
/>
|
||||
</div>
|
||||
<!-- 文件管理器窗格 -->
|
||||
<div class="file-manager-wrapper">
|
||||
<FileManagerComponent :ws="ws" :is-connected="connectionStatus === 'connected'" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 状态监控窗格 -->
|
||||
<div class="status-monitor-wrapper">
|
||||
<StatusMonitorComponent :status-data="serverStatus" :error="statusError" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -237,16 +275,57 @@ onBeforeUnmount(() => {
|
||||
.status-disconnected { color: grey; }
|
||||
.status-error { color: red; }
|
||||
|
||||
.main-content-area {
|
||||
display: flex;
|
||||
flex-grow: 1; /* Take remaining vertical space */
|
||||
overflow: hidden; /* Prevent this container from scrolling */
|
||||
}
|
||||
|
||||
.left-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 80%; /* Example width, adjust as needed */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
/* flex-grow: 1; */ /* 不再让终端独占剩余空间 */
|
||||
height: 60%; /* 示例:终端占 60% 高度 */
|
||||
/* width: 50%; */ /* Removed width */
|
||||
/* height: 100%; */ /* Removed height */
|
||||
background-color: #1e1e1e; /* 终端背景色 */
|
||||
overflow: hidden; /* 内部滚动由 xterm 处理 */
|
||||
display: flex; /* Ensure TerminalComponent fills this wrapper */
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal-wrapper > * {
|
||||
flex-grow: 1; /* Make TerminalComponent fill the wrapper */
|
||||
}
|
||||
|
||||
|
||||
.file-manager-wrapper {
|
||||
height: 40%; /* 示例:文件管理器占 40% 高度 */
|
||||
border-top: 2px solid #ccc; /* 添加分隔线 */
|
||||
/* width: 30%; */ /* Removed width */
|
||||
/* height: 100%; */ /* Removed height */
|
||||
/* border-left: 2px solid #ccc; */ /* Removed left border */
|
||||
border-top: 2px solid #ccc; /* Add top border */
|
||||
overflow: hidden; /* 防止自身滚动 */
|
||||
display: flex; /* Ensure FileManagerComponent fills this wrapper */
|
||||
flex-direction: column;
|
||||
}
|
||||
.file-manager-wrapper > * {
|
||||
flex-grow: 1; /* Make FileManagerComponent fill the wrapper */
|
||||
}
|
||||
|
||||
.status-monitor-wrapper {
|
||||
width: 20%; /* Example width */
|
||||
height: 100%;
|
||||
border-left: 2px solid #ccc; /* Separator */
|
||||
overflow: hidden; /* Prevent scrolling */
|
||||
display: flex; /* Ensure StatusMonitorComponent fills this wrapper */
|
||||
flex-direction: column;
|
||||
}
|
||||
.status-monitor-wrapper > * {
|
||||
flex-grow: 1; /* Make StatusMonitorComponent fill the wrapper */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"paths": {
|
||||
"@/*": ["src/*"] // 路径别名,例如 @/components/* 指向 src/components/*
|
||||
},
|
||||
"types": ["vite/client"] // **关键:包含 Vite 客户端类型定义**
|
||||
"types": ["vite/client", "pinia-plugin-persistedstate"] // **关键:包含 Vite 客户端和 Pinia 持久化插件类型定义**
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], // 需要进行类型检查的文件
|
||||
"exclude": ["node_modules"] // 排除检查的目录
|
||||
|
||||
Reference in New Issue
Block a user