mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
refactor: 代码解耦合,完善WebUI功能,添加网页VNC
This commit is contained in:
+7
-1
@@ -6,12 +6,18 @@
|
||||
"author": "foxhui",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"start": "node supervisor.js",
|
||||
"test": "node scripts/test.js",
|
||||
"genkey": "node scripts/genkey.js",
|
||||
"init": "node scripts/init.js",
|
||||
"postinstall": "node scripts/postinstall.js"
|
||||
},
|
||||
"imports": {
|
||||
"#config": "./src/config/index.js",
|
||||
"#utils/*": "./src/utils/*.js",
|
||||
"#backend/*": "./src/backend/*.js",
|
||||
"#server/*": "./src/server/*.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/prompts": "^8.0.1",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
|
||||
+4
-1
@@ -1,2 +1,5 @@
|
||||
ignoredBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- better-sqlite3
|
||||
|
||||
packages:
|
||||
- '!webui/**'
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
uploadFilesViaChooser
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
normalizePageError,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import {
|
||||
sleep,
|
||||
safeClick
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
gotoWithCheck,
|
||||
normalizePageError,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -15,8 +15,8 @@ import path from 'path';
|
||||
import os from 'os';
|
||||
import { createCursor } from 'ghost-cursor-playwright-port';
|
||||
import { getRealViewport, clamp, random, sleep } from './utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { getBrowserProxy, cleanupProxy } from '../utils/proxy.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getBrowserProxy, cleanupProxy } from '../../utils/proxy.js';
|
||||
|
||||
// 全局状态:用于在登录模式下管理残留进程与复用上下文
|
||||
let globalBrowserProcess = null;
|
||||
@@ -21,7 +21,7 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
/**
|
||||
* 生成指定范围内的随机数
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { loadConfig } from '../utils/config.js';
|
||||
import { loadConfig } from '../config/index.js';
|
||||
import { PoolManager } from './pool/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { registry } from '../registry.js';
|
||||
import { createStrategySelector } from '../strategy.js';
|
||||
import { executeWithFailover } from '../failover.js';
|
||||
import { createStrategySelector } from '../strategies/index.js';
|
||||
import { executeWithFailover } from '../strategies/failover.js';
|
||||
import { normalizeError } from '../utils/error.js';
|
||||
import { Worker } from './Worker.js';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { initBrowserBase, createCursor } from '../../browser/launcher.js';
|
||||
import { initBrowserBase, createCursor } from '../engine/launcher.js';
|
||||
import { registry } from '../registry.js';
|
||||
import { tryGotoWithCheck } from '../utils/page.js';
|
||||
|
||||
@@ -137,13 +137,14 @@ export class Worker {
|
||||
logger.info('工作池', `[${this.name}] 正在连接目标页面...`);
|
||||
await this._navigateToTarget(targetUrl);
|
||||
|
||||
// 登录模式处理
|
||||
// 登录模式:注册浏览器关闭事件(不阻塞)
|
||||
const isLoginMode = process.argv.some(arg => arg.startsWith('-login'));
|
||||
if (isLoginMode) {
|
||||
logger.info('工作池', `[${this.name}] 登录模式已就绪,请在浏览器中完成登录`);
|
||||
logger.info('工作池', `[${this.name}] 完成后可直接关闭浏览器窗口或按 Ctrl+C 退出`);
|
||||
await new Promise(resolve => this.browser.on('close', resolve));
|
||||
process.exit(0);
|
||||
this.browser.on('close', () => {
|
||||
logger.info('工作池', `[${this.name}] 浏览器已关闭,登录模式结束`);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('工作池', `[${this.name}] 初始化完成`);
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
* - 负载均衡策略在 strategy.js 中
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { RETRY } from '../utils/constants.js';
|
||||
import { isRetryableError, normalizeError } from './utils/error.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { RETRY } from '../../utils/constants.js';
|
||||
import { isRetryableError, normalizeError } from '../utils/error.js';
|
||||
|
||||
// 重新导出错误分类函数以保持兼容性
|
||||
export { isRetryableError, normalizeError };
|
||||
@@ -3,7 +3,7 @@
|
||||
* @description 页面认证锁、输入框等待、表单提交等页面级操作
|
||||
*/
|
||||
|
||||
import { sleep, humanType, safeClick, isPageValid, createPageCloseWatcher, getRealViewport, clamp, random } from '../../browser/utils.js';
|
||||
import { sleep, humanType, safeClick, isPageValid, createPageCloseWatcher, getRealViewport, clamp, random } from '../engine/utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -11,7 +11,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), 'config.yaml');
|
||||
const EXAMPLE_CONFIG_PATH = path.join(process.cwd(), 'config.example.yaml');
|
||||
@@ -6,7 +6,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
import { logger } from './logger.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), 'config.yaml');
|
||||
|
||||
@@ -292,4 +292,3 @@ export function savePoolConfig(data) {
|
||||
|
||||
writeConfig(config);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
* @description 提供管理接口,包括系统状态、配置管理、适配器元数据等
|
||||
*/
|
||||
|
||||
import { sendJson, sendApiError } from './respond.js';
|
||||
import { ERROR_CODES } from '../errors.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { sendJson, sendApiError } from '../../respond.js';
|
||||
import { ERROR_CODES } from '../../errors.js';
|
||||
import { logger } from '../../../utils/logger.js';
|
||||
import {
|
||||
getSystemStatus,
|
||||
getDataFolders,
|
||||
deleteDataFolders,
|
||||
clearTempFiles
|
||||
} from '../../utils/systemInfo.js';
|
||||
} from '../../../utils/systemInfo.js';
|
||||
import {
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
@@ -25,15 +25,16 @@ import {
|
||||
saveAdaptersConfig,
|
||||
getPoolConfig,
|
||||
savePoolConfig
|
||||
} from '../../utils/configManager.js';
|
||||
} from '../../../config/manager.js';
|
||||
import {
|
||||
validateServerConfig,
|
||||
validateBrowserConfig,
|
||||
validateInstancesConfig,
|
||||
validatePoolConfig,
|
||||
validateAdaptersConfig
|
||||
} from '../../utils/configValidator.js';
|
||||
import { registry } from '../../backend/registry.js';
|
||||
} from '../../../config/validator.js';
|
||||
import { registry } from '../../../backend/registry.js';
|
||||
import { sendRestartSignal, sendStopSignal, isUnderSupervisor, getVncInfo } from '../../../utils/ipc.js';
|
||||
|
||||
/**
|
||||
* 读取请求体
|
||||
@@ -79,26 +80,63 @@ export function createAdminRouter(context) {
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /admin/restart - 重启服务(使用子进程分离)
|
||||
// POST /admin/restart - 重启服务
|
||||
if (method === 'POST' && pathname === '/restart') {
|
||||
const { spawn } = await import('child_process');
|
||||
// 解析请求体获取重启参数
|
||||
let loginMode = null;
|
||||
let workerName = null;
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
loginMode = body.loginMode || null;
|
||||
workerName = body.workerName || null;
|
||||
} catch { /* 无请求体时使用默认值 */ }
|
||||
|
||||
sendJson(res, 200, { success: true, message: '服务正在重启...' });
|
||||
logger.info('管理器', '收到重启请求,将在 1 秒后重启');
|
||||
const modeDesc = loginMode
|
||||
? (workerName ? `登录模式 (${workerName})` : '登录模式')
|
||||
: '普通模式';
|
||||
sendJson(res, 200, { success: true, message: `服务正在以${modeDesc}重启...` });
|
||||
logger.info('管理器', `收到重启请求: ${modeDesc}`);
|
||||
|
||||
setTimeout(() => {
|
||||
// 启动新进程(完全独立)
|
||||
const child = spawn(process.execPath, process.argv.slice(1), {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: process.env
|
||||
});
|
||||
child.unref();
|
||||
setTimeout(async () => {
|
||||
// 构建启动参数(仅登录模式相关)
|
||||
const extraArgs = [];
|
||||
if (loginMode) {
|
||||
extraArgs.push(workerName ? `-login=${workerName}` : '-login');
|
||||
}
|
||||
|
||||
// 退出当前进程
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
}, 1000);
|
||||
if (isUnderSupervisor()) {
|
||||
// Supervisor 模式:通过 IPC 发送带参数的重启信号
|
||||
const sent = await sendRestartSignal(extraArgs);
|
||||
if (!sent) {
|
||||
logger.warn('管理器', 'IPC 重启信号发送失败,尝试自重启');
|
||||
// 降级到自重启
|
||||
const { spawn } = await import('child_process');
|
||||
const newArgs = process.argv.slice(1).filter(arg => !arg.startsWith('-login'));
|
||||
newArgs.push(...extraArgs);
|
||||
const child = spawn(process.execPath, newArgs, {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: process.env
|
||||
});
|
||||
child.unref();
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
}
|
||||
} else {
|
||||
// 独立模式:使用子进程自重启
|
||||
const { spawn } = await import('child_process');
|
||||
const newArgs = process.argv.slice(1).filter(arg => !arg.startsWith('-login'));
|
||||
newArgs.push(...extraArgs);
|
||||
const child = spawn(process.execPath, newArgs, {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: process.env
|
||||
});
|
||||
child.unref();
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,6 +149,23 @@ export function createAdminRouter(context) {
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /admin/vnc/status - VNC 状态
|
||||
if (method === 'GET' && pathname === '/vnc/status') {
|
||||
const vncInfo = await getVncInfo();
|
||||
if (vncInfo) {
|
||||
sendJson(res, 200, vncInfo);
|
||||
} else {
|
||||
// 非 Supervisor 模式或无法获取信息
|
||||
sendJson(res, 200, {
|
||||
enabled: false,
|
||||
port: 0,
|
||||
display: '',
|
||||
xvfbMode: false
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ==================== 缓存与数据管理 ====================
|
||||
|
||||
// POST /admin/cache/clear - 清理缓存
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @fileoverview VNC WebSocket 代理模块
|
||||
* @description 将 VNC TCP 连接转发到 WebSocket
|
||||
*/
|
||||
|
||||
import net from 'net';
|
||||
import { getVncInfo } from '../../../utils/ipc.js';
|
||||
|
||||
/**
|
||||
* 处理 VNC WebSocket 升级请求
|
||||
* @param {import('http').IncomingMessage} req - HTTP 请求
|
||||
* @param {import('net').Socket} socket - 原始 TCP socket
|
||||
* @param {Buffer} head - 升级请求的头部数据
|
||||
* @param {string} authToken - 有效的认证令牌
|
||||
*/
|
||||
export async function handleVncUpgrade(req, socket, head, authToken) {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
// 验证 token
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== authToken) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取 VNC 信息
|
||||
const vncInfo = await getVncInfo();
|
||||
if (!vncInfo || !vncInfo.enabled) {
|
||||
socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// 手动完成 WebSocket 握手
|
||||
const key = req.headers['sec-websocket-key'];
|
||||
if (!key) {
|
||||
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const crypto = await import('crypto');
|
||||
const acceptKey = crypto.createHash('sha1')
|
||||
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
||||
.digest('base64');
|
||||
|
||||
// 发送 WebSocket 握手响应
|
||||
socket.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\n' +
|
||||
'Upgrade: websocket\r\n' +
|
||||
'Connection: Upgrade\r\n' +
|
||||
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
|
||||
'Sec-WebSocket-Protocol: binary\r\n' +
|
||||
'\r\n'
|
||||
);
|
||||
|
||||
// 连接到 VNC 服务器
|
||||
const vncSocket = net.createConnection({
|
||||
host: '127.0.0.1',
|
||||
port: vncInfo.port
|
||||
});
|
||||
|
||||
vncSocket.on('error', (err) => {
|
||||
console.error('[VNC Proxy] VNC 连接错误:', err.message);
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
vncSocket.on('connect', () => {
|
||||
// 发送升级请求时可能附带的数据
|
||||
if (head && head.length > 0) {
|
||||
const data = decodeWebSocketFrame(head);
|
||||
if (data) vncSocket.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
// VNC -> WebSocket
|
||||
vncSocket.on('data', (data) => {
|
||||
try {
|
||||
const frame = encodeWebSocketFrame(data);
|
||||
socket.write(frame);
|
||||
} catch {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket -> VNC
|
||||
let buffer = Buffer.alloc(0);
|
||||
socket.on('data', (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
while (buffer.length >= 2) {
|
||||
const result = decodeWebSocketFrame(buffer);
|
||||
if (!result) break;
|
||||
|
||||
const { data, bytesConsumed, opcode } = result;
|
||||
|
||||
// 关闭帧
|
||||
if (opcode === 0x08) {
|
||||
vncSocket.destroy();
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// 二进制数据或文本
|
||||
if (data && data.length > 0) {
|
||||
vncSocket.write(data);
|
||||
}
|
||||
|
||||
buffer = buffer.slice(bytesConsumed);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => vncSocket.destroy());
|
||||
socket.on('error', () => vncSocket.destroy());
|
||||
vncSocket.on('close', () => socket.destroy());
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 WebSocket 帧(服务端发送,无掩码)
|
||||
* @param {Buffer} data - 要发送的数据
|
||||
* @returns {Buffer} WebSocket 帧
|
||||
*/
|
||||
function encodeWebSocketFrame(data) {
|
||||
const length = data.length;
|
||||
let header;
|
||||
|
||||
if (length <= 125) {
|
||||
header = Buffer.alloc(2);
|
||||
header[0] = 0x82; // FIN + Binary
|
||||
header[1] = length;
|
||||
} else if (length <= 65535) {
|
||||
header = Buffer.alloc(4);
|
||||
header[0] = 0x82;
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(length, 2);
|
||||
} else {
|
||||
header = Buffer.alloc(10);
|
||||
header[0] = 0x82;
|
||||
header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(length), 2);
|
||||
}
|
||||
|
||||
return Buffer.concat([header, data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 WebSocket 帧(客户端发送,有掩码)
|
||||
* @param {Buffer} buffer - 接收到的数据
|
||||
* @returns {{data: Buffer, bytesConsumed: number, opcode: number} | null}
|
||||
*/
|
||||
function decodeWebSocketFrame(buffer) {
|
||||
if (buffer.length < 2) return null;
|
||||
|
||||
const firstByte = buffer[0];
|
||||
const secondByte = buffer[1];
|
||||
const opcode = firstByte & 0x0F;
|
||||
const masked = (secondByte & 0x80) !== 0;
|
||||
let payloadLength = secondByte & 0x7F;
|
||||
let offset = 2;
|
||||
|
||||
if (payloadLength === 126) {
|
||||
if (buffer.length < 4) return null;
|
||||
payloadLength = buffer.readUInt16BE(2);
|
||||
offset = 4;
|
||||
} else if (payloadLength === 127) {
|
||||
if (buffer.length < 10) return null;
|
||||
payloadLength = Number(buffer.readBigUInt64BE(2));
|
||||
offset = 10;
|
||||
}
|
||||
|
||||
let maskKey = null;
|
||||
if (masked) {
|
||||
if (buffer.length < offset + 4) return null;
|
||||
maskKey = buffer.slice(offset, offset + 4);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
if (buffer.length < offset + payloadLength) return null;
|
||||
|
||||
let data = buffer.slice(offset, offset + payloadLength);
|
||||
|
||||
if (masked && maskKey) {
|
||||
data = Buffer.from(data);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] ^= maskKey[i % 4];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
bytesConsumed: offset + payloadLength,
|
||||
opcode
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @fileoverview API 路由总装配
|
||||
* @description 统一挂载 /v1 和 /admin 路由
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createOpenAIRouter } from './openai/routes.js';
|
||||
import { createAdminRouter } from './admin/routes.js';
|
||||
import { createAuthMiddleware } from '../middlewares/auth.js';
|
||||
|
||||
// MIME 类型映射
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf'
|
||||
};
|
||||
|
||||
// WebUI 静态文件目录
|
||||
const WEBUI_DIR = path.join(process.cwd(), 'webui', 'dist');
|
||||
|
||||
/**
|
||||
* 创建全局路由处理器
|
||||
* @param {object} context - 路由上下文
|
||||
* @param {boolean} [context.loginMode] - 登录模式(禁用 OpenAI API)
|
||||
* @returns {Function} 请求处理函数
|
||||
*/
|
||||
export function createGlobalRouter(context) {
|
||||
const { authToken, config, queueManager, tempDir, loginMode } = context;
|
||||
|
||||
// 创建鉴权中间件
|
||||
const checkAuth = createAuthMiddleware(authToken);
|
||||
|
||||
// 创建子路由处理器
|
||||
const handleOpenAIRequest = loginMode ? null : createOpenAIRouter(context);
|
||||
const handleAdminRequest = createAdminRouter({ config, queueManager, tempDir });
|
||||
|
||||
/**
|
||||
* 主路由处理函数
|
||||
*/
|
||||
return async function handleRequest(req, res) {
|
||||
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
const pathname = parsedUrl.pathname;
|
||||
|
||||
// ==================== 静态文件服务 ====================
|
||||
if (req.method === 'GET' && !pathname.startsWith('/v1') && !pathname.startsWith('/admin')) {
|
||||
let filePath = pathname === '/' ? '/index.html' : pathname;
|
||||
filePath = path.join(WEBUI_DIR, filePath);
|
||||
|
||||
// 安全检查
|
||||
if (!filePath.startsWith(WEBUI_DIR)) {
|
||||
res.writeHead(403);
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
const content = fs.readFileSync(filePath);
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
return;
|
||||
}
|
||||
|
||||
// SPA 模式 fallback
|
||||
const indexPath = path.join(WEBUI_DIR, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
const content = fs.readFileSync(indexPath);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 鉴权检查 ====================
|
||||
if (!checkAuth(req, res)) {
|
||||
return; // 鉴权失败,已发送错误响应
|
||||
}
|
||||
|
||||
// ==================== API 路由分发 ====================
|
||||
|
||||
// Admin API (/admin)
|
||||
if (pathname.startsWith('/admin')) {
|
||||
const adminPath = pathname.slice(6); // 去除 /admin 前缀
|
||||
await handleAdminRequest(req, res, adminPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// OpenAI API (/v1)
|
||||
if (pathname.startsWith('/v1')) {
|
||||
// 登录模式下禁用 OpenAI API
|
||||
if (!handleOpenAIRequest) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: { message: '服务运行在登录模式,OpenAI API 不可用', type: 'service_unavailable' }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const v1Path = pathname.slice(3); // 去除 /v1 前缀
|
||||
await handleOpenAIRequest(req, res, v1Path, parsedUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 404
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
};
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { IMAGE_POLICY } from '../backend/registry.js';
|
||||
import { ERROR_CODES, getErrorMessage } from './errors.js';
|
||||
import { IMAGE_POLICY } from '../../../backend/registry.js';
|
||||
import { ERROR_CODES, getErrorMessage } from '../../errors.js';
|
||||
|
||||
/**
|
||||
* 构造解析错误结果
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @fileoverview OpenAI 兼容 API 路由
|
||||
* @description 处理 /v1 路径下的所有 API 请求
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { logger } from '../../../utils/logger.js';
|
||||
import { ERROR_CODES } from '../../errors.js';
|
||||
import { sendJson, sendApiError } from '../../respond.js';
|
||||
import { parseRequest } from './parse.js';
|
||||
|
||||
/**
|
||||
* 创建 OpenAI API 路由处理器
|
||||
* @param {object} context - 路由上下文
|
||||
* @returns {Function} 路由处理函数
|
||||
*/
|
||||
export function createOpenAIRouter(context) {
|
||||
const {
|
||||
backendName,
|
||||
getModels,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
getModelType,
|
||||
tempDir,
|
||||
imageLimit,
|
||||
queueManager
|
||||
} = context;
|
||||
|
||||
/**
|
||||
* 处理 GET /v1/models
|
||||
*/
|
||||
function handleModels(res) {
|
||||
const models = getModels();
|
||||
sendJson(res, 200, models);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 GET /v1/cookies
|
||||
*/
|
||||
async function handleCookies(res, requestId, workerName, domain) {
|
||||
const poolContext = queueManager.getPoolContext();
|
||||
|
||||
if (!poolContext?.poolManager) {
|
||||
sendApiError(res, { code: ERROR_CODES.BROWSER_NOT_INITIALIZED });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await queueManager.getWorkerCookies(workerName, domain);
|
||||
sendJson(res, 200, {
|
||||
worker: result.worker,
|
||||
cookies: result.cookies
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('服务器', '获取 Cookies 失败', { id: requestId, error: err.message });
|
||||
|
||||
if (err.message.includes('Worker 不存在') || err.message.includes('Worker not found')) {
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.INVALID_MODEL,
|
||||
message: err.message
|
||||
});
|
||||
} else {
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.INTERNAL_ERROR,
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 POST /v1/chat/completions
|
||||
*/
|
||||
async function handleChatCompletions(req, res, requestId) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
const data = JSON.parse(body);
|
||||
const isStreaming = data.stream === true;
|
||||
|
||||
// 限流检查
|
||||
if (!isStreaming && !queueManager.canAcceptNonStreaming()) {
|
||||
const status = queueManager.getStatus();
|
||||
logger.warn('服务器', '非流式请求被拒绝 (队列已满)', { id: requestId, queueSize: status.total });
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.SERVER_BUSY,
|
||||
message: `服务器繁忙(队列: ${status.total}/${queueManager.maxQueueSize})。请使用流式模式 (stream: true) 或稍后重试。`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
if (isStreaming) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
const parseResult = await parseRequest(data, {
|
||||
tempDir,
|
||||
imageLimit,
|
||||
backendName,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
getModelType,
|
||||
requestId,
|
||||
logger
|
||||
});
|
||||
|
||||
if (!parseResult.success) {
|
||||
sendApiError(res, {
|
||||
code: parseResult.error.code,
|
||||
message: parseResult.error.error,
|
||||
isStreaming
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { prompt, imagePaths, modelId, modelName } = parseResult.data;
|
||||
|
||||
logger.info('服务器', `[队列] 请求入队: ${prompt.slice(0, 10)}...`, { id: requestId, images: imagePaths.length });
|
||||
|
||||
// 加入队列
|
||||
queueManager.addTask({
|
||||
req,
|
||||
res,
|
||||
prompt,
|
||||
imagePaths,
|
||||
modelId,
|
||||
modelName,
|
||||
id: requestId,
|
||||
isStreaming
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
logger.error('服务器', '请求处理失败', { id: requestId, error: err.message });
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.INTERNAL_ERROR,
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI API 路由处理函数
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @param {import('http').ServerResponse} res
|
||||
* @param {string} pathname - 去除 /v1 前缀后的路径
|
||||
* @param {URL} parsedUrl - 解析后的 URL 对象
|
||||
*/
|
||||
return async function handleOpenAIRequest(req, res, pathname, parsedUrl) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
if (req.method === 'GET' && pathname === '/models') {
|
||||
handleModels(res);
|
||||
} else if (req.method === 'GET' && pathname === '/cookies') {
|
||||
const workerName = parsedUrl.searchParams.get('name');
|
||||
const domain = parsedUrl.searchParams.get('domain');
|
||||
await handleCookies(res, requestId, workerName, domain);
|
||||
} else if (req.method === 'POST' && pathname.startsWith('/chat/completions')) {
|
||||
await handleChatCompletions(req, res, requestId);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Xvfb 和 VNC 显示参数处理模块(仅 Linux)
|
||||
* @description 处理 -xvfb 和 -vnc 命令行参数,启动虚拟显示器和 VNC 服务器
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import net from 'net';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* 检查命令是否存在
|
||||
* @param {string} cmd - 命令名称
|
||||
* @returns {boolean} 命令是否存在
|
||||
*/
|
||||
function checkCommand(cmd) {
|
||||
const result = spawnSync('which', [cmd], { encoding: 'utf8' });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查端口是否可用
|
||||
* @param {number} port - 端口号
|
||||
* @returns {Promise<boolean>} 端口是否可用
|
||||
*/
|
||||
function isPortAvailable(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
server.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找可用的 VNC 端口
|
||||
* @param {number} [startPort=5900] - 起始端口
|
||||
* @param {number} [maxAttempts=10] - 最大尝试次数
|
||||
* @returns {Promise<number|null>} 可用端口号,或 null 表示未找到
|
||||
*/
|
||||
async function findAvailableVncPort(startPort = 5900, maxAttempts = 10) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const port = startPort + i;
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 VNC 服务器
|
||||
* @param {string} display - 显示器编号(如 ':99')
|
||||
* @returns {Promise<import('child_process').ChildProcess>} VNC 进程
|
||||
*/
|
||||
async function startVncServer(display) {
|
||||
if (!checkCommand('x11vnc')) {
|
||||
logger.error('服务器', '未找到 x11vnc 命令');
|
||||
logger.error('服务器', '请先安装 x11vnc:');
|
||||
logger.error('服务器', ' - Ubuntu/Debian: sudo apt install x11vnc');
|
||||
logger.error('服务器', ' - CentOS/RHEL: sudo dnf install x11vnc');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('服务器', '正在查找可用的 VNC 端口...');
|
||||
const vncPort = await findAvailableVncPort(5900, 10);
|
||||
|
||||
if (!vncPort) {
|
||||
logger.error('服务器', '无法找到可用的 VNC 端口 (已尝试 5900-5909)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('服务器', `正在启动 VNC 服务器 (端口 ${vncPort})...`);
|
||||
|
||||
const vncProcess = spawn('x11vnc', [
|
||||
'-display', display,
|
||||
'-rfbport', vncPort.toString(),
|
||||
'-localhost',
|
||||
'-nopw',
|
||||
'-once',
|
||||
'-noxdamage',
|
||||
'-ncache', '10',
|
||||
'-forever'
|
||||
], {
|
||||
stdio: 'ignore',
|
||||
detached: false
|
||||
});
|
||||
|
||||
vncProcess.on('error', (err) => {
|
||||
logger.error('服务器', 'VNC 启动失败', { error: err.message });
|
||||
});
|
||||
|
||||
logger.info('服务器', 'VNC 服务器已成功启动');
|
||||
logger.warn('服务器', `VNC 连接端口: ${vncPort}`);
|
||||
|
||||
return vncProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Xvfb 中重启进程
|
||||
* @param {string[]} args - 当前命令行参数
|
||||
*/
|
||||
function restartInXvfb(args) {
|
||||
logger.info('服务器', '正在启动 Xvfb 虚拟显示器...');
|
||||
|
||||
// 构建新的参数列表(移除 -xvfb,保留其他参数)
|
||||
const newArgs = args.filter(arg => arg !== '-xvfb');
|
||||
|
||||
const xvfbArgs = [
|
||||
'--server-num=99',
|
||||
'--server-args=-ac -screen 0 1366x768x24',
|
||||
'env',
|
||||
'XVFB_RUNNING=true',
|
||||
'DISPLAY=:99',
|
||||
process.argv[0],
|
||||
process.argv[1],
|
||||
...newArgs
|
||||
];
|
||||
|
||||
const xvfbProcess = spawn('xvfb-run', xvfbArgs, {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
xvfbProcess.on('error', (err) => {
|
||||
logger.error('服务器', 'Xvfb 启动失败', { error: err.message });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
xvfbProcess.on('exit', (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
// 处理父进程退出信号
|
||||
process.on('SIGINT', () => {
|
||||
xvfbProcess.kill('SIGTERM');
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
xvfbProcess.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Xvfb 和 VNC 启动参数
|
||||
* @returns {Promise<'XVFB_REDIRECT'|undefined>} 如果需要重定向到 Xvfb 则返回 'XVFB_REDIRECT'
|
||||
*/
|
||||
export async function handleDisplayParams() {
|
||||
const args = process.argv.slice(2);
|
||||
const hasXvfb = args.includes('-xvfb');
|
||||
const hasVnc = args.includes('-vnc');
|
||||
const isInXvfb = process.env.XVFB_RUNNING === 'true';
|
||||
|
||||
// -vnc 必须和 -xvfb 并用(但如果已在 Xvfb 中运行则允许)
|
||||
if (hasVnc && !hasXvfb && !isInXvfb) {
|
||||
logger.error('服务器', '-vnc 参数必须和 -xvfb 参数一起使用');
|
||||
logger.error('服务器', '正确用法: node server.js -xvfb -vnc');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 非 Linux 系统检查
|
||||
if ((hasXvfb || hasVnc) && os.platform() !== 'linux') {
|
||||
logger.warn('服务器', '忽略参数: -xvfb 和 -vnc 参数仅在 Linux 系统上支持');
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 -xvfb 参数
|
||||
if ((hasXvfb || isInXvfb) && os.platform() === 'linux') {
|
||||
// 检查 xvfb-run 是否存在(仅在首次启动时需要)
|
||||
if (hasXvfb && !isInXvfb) {
|
||||
if (!checkCommand('xvfb-run')) {
|
||||
logger.error('服务器', '未找到 xvfb-run 命令');
|
||||
logger.error('服务器', '请先安装 Xvfb:');
|
||||
logger.error('服务器', ' - Ubuntu/Debian: sudo apt install xvfb');
|
||||
logger.error('服务器', ' - CentOS/RHEL: sudo dnf install xorg-x11-server-Xvfb');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 已在 Xvfb 中运行
|
||||
if (isInXvfb) {
|
||||
logger.info('服务器', '已在 Xvfb 虚拟显示器中运行', { display: process.env.DISPLAY || ':99' });
|
||||
|
||||
// 处理 VNC
|
||||
if (hasVnc) {
|
||||
const display = process.env.DISPLAY || ':99';
|
||||
const vncProcess = await startVncServer(display);
|
||||
|
||||
// 处理进程退出信号
|
||||
process.on('SIGINT', () => {
|
||||
vncProcess.kill('SIGTERM');
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
vncProcess.kill('SIGTERM');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 需要在 Xvfb 中重启
|
||||
restartInXvfb(args);
|
||||
return 'XVFB_REDIRECT';
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* @fileoverview HTTP 路由分发模块
|
||||
* @description 处理 API 路由分发和请求鉴权
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { ERROR_CODES } from '../errors.js';
|
||||
import { sendJson, sendApiError } from './respond.js';
|
||||
import { parseRequest } from '../parseChat.js';
|
||||
import { createAdminRouter } from './adminRoutes.js';
|
||||
|
||||
// MIME 类型映射
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf'
|
||||
};
|
||||
|
||||
// WebUI 静态文件目录
|
||||
const WEBUI_DIR = path.join(process.cwd(), 'webui');
|
||||
|
||||
/**
|
||||
* 鉴权检查
|
||||
* @param {import('http').IncomingMessage} req - HTTP 请求
|
||||
* @param {string} authToken - 有效的认证令牌
|
||||
* @returns {boolean} 是否通过鉴权
|
||||
*/
|
||||
function checkAuth(req, authToken) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
return authHeader === `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建路由处理器
|
||||
* @param {object} context - 路由上下文
|
||||
* @param {string} context.authToken - 认证令牌
|
||||
* @param {string} context.backendName - 后端名称
|
||||
* @param {Function} context.getModels - 获取模型列表函数
|
||||
* @param {Function} context.resolveModelId - 解析模型 ID 函数
|
||||
* @param {Function} context.getImagePolicy - 获取图片策略函数
|
||||
* @param {Function} context.getModelType - 获取模型类型函数
|
||||
* @param {string} context.tempDir - 临时目录
|
||||
* @param {number} context.imageLimit - 图片数量限制
|
||||
* @param {object} context.queueManager - 队列管理器
|
||||
* @param {object} context.config - 完整配置对象(用于 Admin API)
|
||||
* @returns {Function} 请求处理函数
|
||||
*/
|
||||
export function createRouter(context) {
|
||||
const {
|
||||
authToken,
|
||||
backendName,
|
||||
getModels,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
getModelType,
|
||||
tempDir,
|
||||
imageLimit,
|
||||
queueManager,
|
||||
config
|
||||
} = context;
|
||||
|
||||
// 创建 Admin 路由处理器
|
||||
const handleAdminRequest = createAdminRouter({ config, queueManager, tempDir });
|
||||
|
||||
/**
|
||||
* 处理 GET /v1/models
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应
|
||||
*/
|
||||
function handleModels(res) {
|
||||
const models = getModels();
|
||||
sendJson(res, 200, models);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 GET /v1/cookies
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应
|
||||
* @param {string} requestId - 请求 ID
|
||||
* @param {string} [workerName] - 可选,指定 Worker 名称
|
||||
* @param {string} [domain] - 可选,指定获取某个域名的 Cookies
|
||||
*/
|
||||
async function handleCookies(res, requestId, workerName, domain) {
|
||||
const poolContext = queueManager.getPoolContext();
|
||||
|
||||
if (!poolContext?.poolManager) {
|
||||
sendApiError(res, { code: ERROR_CODES.BROWSER_NOT_INITIALIZED });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await queueManager.getWorkerCookies(workerName, domain);
|
||||
sendJson(res, 200, {
|
||||
worker: result.worker,
|
||||
cookies: result.cookies
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('服务器', '获取 Cookies 失败', { id: requestId, error: err.message });
|
||||
|
||||
// 区分错误类型
|
||||
if (err.message.includes('Worker 不存在') || err.message.includes('Worker not found')) {
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.INVALID_MODEL,
|
||||
message: err.message
|
||||
});
|
||||
} else {
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.INTERNAL_ERROR,
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 POST /v1/chat/completions
|
||||
* @param {import('http').IncomingMessage} req - HTTP 请求
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应
|
||||
* @param {string} requestId - 请求 ID
|
||||
*/
|
||||
async function handleChatCompletions(req, res, requestId) {
|
||||
// 读取请求体
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
const data = JSON.parse(body);
|
||||
const isStreaming = data.stream === true;
|
||||
|
||||
// 限流检查:非流式请求在队列满时拒绝
|
||||
if (!isStreaming && !queueManager.canAcceptNonStreaming()) {
|
||||
const status = queueManager.getStatus();
|
||||
logger.warn('服务器', '非流式请求被拒绝 (队列已满)', { id: requestId, queueSize: status.total });
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.SERVER_BUSY,
|
||||
message: `服务器繁忙(队列: ${status.total}/${queueManager.maxQueueSize})。请使用流式模式 (stream: true) 或稍后重试。`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头(流式请求)
|
||||
if (isStreaming) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
const parseResult = await parseRequest(data, {
|
||||
tempDir,
|
||||
imageLimit,
|
||||
backendName,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
getModelType,
|
||||
requestId,
|
||||
logger
|
||||
});
|
||||
|
||||
if (!parseResult.success) {
|
||||
sendApiError(res, {
|
||||
code: parseResult.error.code,
|
||||
message: parseResult.error.error,
|
||||
isStreaming
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { prompt, imagePaths, modelId, modelName } = parseResult.data;
|
||||
|
||||
logger.info('服务器', `[队列] 请求入队: ${prompt.slice(0, 10)}...`, { id: requestId, images: imagePaths.length });
|
||||
|
||||
// 将任务加入队列
|
||||
queueManager.addTask({
|
||||
req,
|
||||
res,
|
||||
prompt,
|
||||
imagePaths,
|
||||
modelId,
|
||||
modelName,
|
||||
id: requestId,
|
||||
isStreaming
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
logger.error('服务器', '请求处理失败', { id: requestId, error: err.message });
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.INTERNAL_ERROR,
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主路由处理函数
|
||||
* @param {import('http').IncomingMessage} req - HTTP 请求
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应
|
||||
*/
|
||||
return async function handleRequest(req, res) {
|
||||
// 生成请求 ID
|
||||
const requestId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
// 路由分发
|
||||
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
const pathname = parsedUrl.pathname;
|
||||
|
||||
// WebUI 静态文件服务(无需鉴权)
|
||||
if (req.method === 'GET' && !pathname.startsWith('/v1') && !pathname.startsWith('/admin')) {
|
||||
// 处理根路径
|
||||
let filePath = pathname === '/' ? '/index.html' : pathname;
|
||||
filePath = path.join(WEBUI_DIR, filePath);
|
||||
|
||||
// 安全检查:确保不越级访问
|
||||
if (!filePath.startsWith(WEBUI_DIR)) {
|
||||
res.writeHead(403);
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
const content = fs.readFileSync(filePath);
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
return;
|
||||
}
|
||||
|
||||
// 文件不存在,返回 index.html(SPA 模式)
|
||||
const indexPath = path.join(WEBUI_DIR, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
const content = fs.readFileSync(indexPath);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 鉴权检查(API 请求)
|
||||
if (!checkAuth(req, authToken)) {
|
||||
sendApiError(res, { code: ERROR_CODES.UNAUTHORIZED });
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin API 路由
|
||||
if (pathname.startsWith('/admin')) {
|
||||
const adminPath = pathname.slice(6); // 去除 /admin 前缀
|
||||
await handleAdminRequest(req, res, adminPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/v1/models') {
|
||||
handleModels(res);
|
||||
} else if (req.method === 'GET' && pathname === '/v1/cookies') {
|
||||
const workerName = parsedUrl.searchParams.get('name');
|
||||
const domain = parsedUrl.searchParams.get('domain');
|
||||
await handleCookies(res, requestId, workerName, domain);
|
||||
} else if (req.method === 'POST' && pathname.startsWith('/v1/chat/completions')) {
|
||||
await handleChatCompletions(req, res, requestId);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
}
|
||||
+6
-4
@@ -12,8 +12,10 @@ export {
|
||||
sendApiError,
|
||||
buildChatCompletion,
|
||||
buildChatCompletionChunk
|
||||
} from './http/respond.js';
|
||||
export { handleDisplayParams } from './display.js';
|
||||
} from './respond.js';
|
||||
export { createQueueManager } from './queue.js';
|
||||
export { parseRequest } from './parseChat.js';
|
||||
export { createRouter } from './http/routes.js';
|
||||
export { parseRequest } from './api/openai/parse.js';
|
||||
export { createGlobalRouter } from './api/index.js';
|
||||
export { createAuthMiddleware } from './middlewares/auth.js';
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @fileoverview 鉴权中间件
|
||||
* @description 提取自 routes.js 的鉴权逻辑
|
||||
*/
|
||||
|
||||
import { sendApiError } from '../respond.js';
|
||||
import { ERROR_CODES } from '../errors.js';
|
||||
|
||||
/**
|
||||
* 鉴权检查
|
||||
* @param {import('http').IncomingMessage} req - HTTP 请求
|
||||
* @param {string} authToken - 有效的认证令牌
|
||||
* @returns {boolean} 是否通过鉴权
|
||||
*/
|
||||
export function checkAuth(req, authToken) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
return authHeader === `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建鉴权中间件
|
||||
* @param {string} authToken - 认证令牌
|
||||
* @returns {Function} 中间件函数
|
||||
*/
|
||||
export function createAuthMiddleware(authToken) {
|
||||
/**
|
||||
* 鉴权中间件
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @param {import('http').ServerResponse} res
|
||||
* @returns {boolean} 是否通过鉴权
|
||||
*/
|
||||
return function authMiddleware(req, res) {
|
||||
if (!checkAuth(req, authToken)) {
|
||||
sendApiError(res, { code: ERROR_CODES.UNAUTHORIZED });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@ import {
|
||||
sendApiError,
|
||||
buildChatCompletion,
|
||||
buildChatCompletionChunk
|
||||
} from './http/respond.js';
|
||||
} from './respond.js';
|
||||
import { ERROR_CODES } from './errors.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @description 封装 JSON、SSE 响应和错误响应的统一处理函数
|
||||
*/
|
||||
|
||||
import { getErrorDetails } from '../errors.js';
|
||||
import { getErrorDetails } from './errors.js';
|
||||
|
||||
/**
|
||||
* 发送 JSON 响应
|
||||
@@ -7,34 +7,24 @@
|
||||
* - GET /v1/cookies - 获取当前浏览器 Cookies
|
||||
* - POST /v1/chat/completions - 生成图像(OpenAI 兼容格式)
|
||||
*
|
||||
* 启动方式:
|
||||
* - 通过 supervisor.js 启动(推荐,支持自动重启和 Xvfb 管理)
|
||||
* - 直接运行 node server.js
|
||||
*
|
||||
* 命令行参数:
|
||||
* - -xvfb 启用 Xvfb 虚拟显示器(仅 Linux)
|
||||
* - -vnc 启用 VNC 服务器(需配合 -xvfb 使用)
|
||||
* - -login 启动时打开登录页面
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
|
||||
// ==================== 启动前自检 ====================
|
||||
import { runPreflight } from './src/utils/preflight.js';
|
||||
// Xvfb 子进程跳过自检(父进程已完成)
|
||||
if (!process.env.XVFB_RUNNING) {
|
||||
runPreflight();
|
||||
}
|
||||
import { runPreflight } from '../utils/preflight.js';
|
||||
runPreflight();
|
||||
// ==================== 加载其他依赖 ====================
|
||||
const { getBackend } = await import('./src/backend/index.js');
|
||||
const { logger } = await import('./src/utils/logger.js');
|
||||
const { handleDisplayParams, createQueueManager, createRouter } = await import('./src/server/index.js');
|
||||
|
||||
// ==================== 命令行参数处理 ====================
|
||||
|
||||
// 处理 Xvfb/VNC 参数(仅 Linux)
|
||||
const displayResult = await handleDisplayParams();
|
||||
if (displayResult === 'XVFB_REDIRECT') {
|
||||
// 已重定向到 Xvfb,阻止继续执行
|
||||
process.on('exit', () => { });
|
||||
await new Promise(() => { });
|
||||
}
|
||||
const { getBackend } = await import('../backend/index.js');
|
||||
const { logger } = await import('../utils/logger.js');
|
||||
const { createQueueManager, createGlobalRouter } = await import('./index.js');
|
||||
const { isUnderSupervisor } = await import('../utils/ipc.js');
|
||||
|
||||
// ==================== 初始化配置 ====================
|
||||
|
||||
@@ -103,7 +93,15 @@ const queueManager = createQueueManager(
|
||||
: null
|
||||
}
|
||||
);
|
||||
const handleRequest = createRouter({
|
||||
|
||||
// ==================== 创建路由 ====================
|
||||
|
||||
/**
|
||||
* 检测是否为登录模式
|
||||
*/
|
||||
const isLoginMode = process.argv.some(arg => arg.startsWith('-login'));
|
||||
|
||||
const handleRequest = createGlobalRouter({
|
||||
authToken: AUTH_TOKEN,
|
||||
backendName,
|
||||
getModels,
|
||||
@@ -113,21 +111,23 @@ const handleRequest = createRouter({
|
||||
tempDir: TEMP_DIR,
|
||||
imageLimit: IMAGE_LIMIT,
|
||||
queueManager,
|
||||
config
|
||||
config,
|
||||
loginMode: isLoginMode
|
||||
});
|
||||
|
||||
// ==================== 启动服务器 ====================
|
||||
|
||||
/**
|
||||
* 检测是否为登录模式
|
||||
*/
|
||||
const isLoginMode = process.argv.some(arg => arg.startsWith('-login'));
|
||||
|
||||
/**
|
||||
* 启动 HTTP 服务器
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function startServer() {
|
||||
// 登录模式提示
|
||||
if (isLoginMode) {
|
||||
logger.info('服务器', '登录模式已就绪,请在浏览器中完成登录操作');
|
||||
logger.info('服务器', '完成后可直接关闭浏览器窗口或按 Ctrl+C 退出');
|
||||
}
|
||||
|
||||
// 预先启动 Pool
|
||||
try {
|
||||
await queueManager.initializePool();
|
||||
@@ -136,20 +136,31 @@ async function startServer() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 登录模式:不启动 HTTP 服务器,只等待用户登录
|
||||
if (isLoginMode) {
|
||||
logger.info('服务器', '登录模式已就绪,请在浏览器中完成登录操作');
|
||||
logger.info('服务器', '完成后可直接关闭浏览器窗口或按 Ctrl+C 退出');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建并启动 HTTP 服务器
|
||||
const server = http.createServer(handleRequest);
|
||||
|
||||
// 处理 WebSocket 升级请求(VNC 代理)
|
||||
server.on('upgrade', async (req, socket, head) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
// 只处理 /admin/vnc 路径
|
||||
if (url.pathname === '/admin/vnc') {
|
||||
const { handleVncUpgrade } = await import('./api/admin/vncProxy.js');
|
||||
await handleVncUpgrade(req, socket, head, AUTH_TOKEN);
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
logger.info('服务器', `HTTP 服务器已启动,端口: ${PORT}`);
|
||||
logger.info('服务器', `流式心跳模式: ${KEEPALIVE_MODE}`);
|
||||
logger.info('服务器', `最大并发: ${MAX_CONCURRENT},队列缓冲: ${QUEUE_BUFFER},最大图片数量: ${IMAGE_LIMIT}`);
|
||||
const mode = isUnderSupervisor() ? 'Supervisor 托管' : '独立运行';
|
||||
const modeExtra = isLoginMode ? ' (登录模式)' : '';
|
||||
logger.info('服务器', `HTTP 服务器已启动,端口: ${PORT}${modeExtra}`);
|
||||
logger.info('服务器', `运行模式: ${mode}`);
|
||||
if (!isLoginMode) {
|
||||
logger.info('服务器', `流式心跳模式: ${KEEPALIVE_MODE}`);
|
||||
logger.info('服务器', `最大并发: ${MAX_CONCURRENT},队列缓冲: ${QUEUE_BUFFER},最大图片数量: ${IMAGE_LIMIT}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @fileoverview IPC 通信模块
|
||||
* @description 提供与 Supervisor 进程通信的能力
|
||||
*/
|
||||
|
||||
import net from 'net';
|
||||
|
||||
/**
|
||||
* 发送重启信号给 Supervisor
|
||||
* @param {string[]} [extraArgs] - 额外的命令行参数
|
||||
* @returns {Promise<boolean>} 是否成功发送
|
||||
*/
|
||||
export async function sendRestartSignal(extraArgs = []) {
|
||||
const ipcPath = process.env.SUPERVISOR_IPC;
|
||||
|
||||
if (!ipcPath) {
|
||||
console.warn('[IPC] 未运行在 Supervisor 模式下,无法发送重启信号');
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const client = net.createConnection(ipcPath, () => {
|
||||
// 格式: RESTART 或 RESTART:arg1 arg2
|
||||
const command = extraArgs.length > 0
|
||||
? `RESTART:${extraArgs.join(' ')}`
|
||||
: 'RESTART';
|
||||
client.write(command);
|
||||
client.end();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
console.error('[IPC] 连接 Supervisor 失败:', err.message);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送停止信号给 Supervisor
|
||||
* @returns {Promise<boolean>} 是否成功发送
|
||||
*/
|
||||
export async function sendStopSignal() {
|
||||
const ipcPath = process.env.SUPERVISOR_IPC;
|
||||
|
||||
if (!ipcPath) {
|
||||
console.warn('[IPC] 未运行在 Supervisor 模式下,无法发送停止信号');
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const client = net.createConnection(ipcPath, () => {
|
||||
client.write('STOP');
|
||||
client.end();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
console.error('[IPC] 连接 Supervisor 失败:', err.message);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否运行在 Supervisor 模式下
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isUnderSupervisor() {
|
||||
return !!process.env.SUPERVISOR_IPC;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 VNC 状态信息
|
||||
* @returns {Promise<{enabled: boolean, port: number, display: string, xvfbMode: boolean} | null>}
|
||||
*/
|
||||
export async function getVncInfo() {
|
||||
const ipcPath = process.env.SUPERVISOR_IPC;
|
||||
|
||||
if (!ipcPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const client = net.createConnection(ipcPath, () => {
|
||||
client.write('GET_VNC_INFO');
|
||||
});
|
||||
|
||||
let data = '';
|
||||
client.on('data', (chunk) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
client.on('end', () => {
|
||||
try {
|
||||
const info = JSON.parse(data.trim());
|
||||
resolve(info);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
client.on('error', () => {
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
// 超时处理
|
||||
setTimeout(() => {
|
||||
client.destroy();
|
||||
resolve(null);
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
@@ -118,7 +118,8 @@ export function runPreflight() {
|
||||
logger.error('服务器', ` - ${err}`);
|
||||
}
|
||||
logger.error('服务器', '提示: 您可以使用 npm run init -- -custom 来自定义初始化步骤');
|
||||
process.exit(1);
|
||||
// 退出码 78 表示配置/依赖错误,看门狗不应自动重启
|
||||
process.exit(78);
|
||||
}
|
||||
|
||||
logger.info('服务器', '自检通过');
|
||||
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* @fileoverview Supervisor 进程管理器
|
||||
* @description 负责管理 Xvfb 环境和子服务的生命周期
|
||||
*
|
||||
* 功能:
|
||||
* - Linux 环境下启动 xvfb-run
|
||||
* - 使用 child_process.spawn 启动 server.js
|
||||
* - 监听 IPC 通道接收重启指令
|
||||
* - 子进程崩溃时自动重启
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// ==================== 配置 ====================
|
||||
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
// IPC 通道路径
|
||||
const IPC_PATH = isWindows
|
||||
? '\\\\.\\pipe\\webai2api-supervisor'
|
||||
: path.join(os.tmpdir(), 'webai2api-supervisor.sock');
|
||||
|
||||
// 重启延迟(毫秒)
|
||||
const RESTART_DELAY = 1000;
|
||||
|
||||
// 下次重启使用的参数(由 IPC 设置)
|
||||
let restartArgs = null;
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 简单日志
|
||||
* @param {string} level
|
||||
* @param {string} message
|
||||
*/
|
||||
function log(level, message) {
|
||||
const now = new Date();
|
||||
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
||||
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`;
|
||||
const levelTag = level === 'ERROR' ? 'ERRO' : level;
|
||||
console.log(`${date} ${time} [${levelTag}] [看门狗] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查命令是否存在(Linux)
|
||||
* @param {string} cmd
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function checkCommand(cmd) {
|
||||
if (isWindows) return true;
|
||||
const result = spawnSync('which', [cmd], { encoding: 'utf8' });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查端口是否可用
|
||||
* @param {number} port
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function isPortAvailable(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => resolve(false));
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
server.listen(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找可用端口
|
||||
* @param {number} startPort - 起始端口
|
||||
* @param {number} maxTries - 最大尝试次数
|
||||
* @returns {Promise<number|null>}
|
||||
*/
|
||||
async function findAvailablePort(startPort, maxTries = 10) {
|
||||
for (let i = 0; i < maxTries; i++) {
|
||||
const port = startPort + i;
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Xvfb 显示号是否可用
|
||||
* @param {number} displayNum
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDisplayAvailable(displayNum) {
|
||||
const lockFile = `/tmp/.X${displayNum}-lock`;
|
||||
const socketFile = `/tmp/.X11-unix/X${displayNum}`;
|
||||
return !fs.existsSync(lockFile) && !fs.existsSync(socketFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找可用的显示号
|
||||
* @param {number} startNum - 起始显示号
|
||||
* @param {number} maxTries - 最大尝试次数
|
||||
* @returns {number}
|
||||
*/
|
||||
function findAvailableDisplay(startNum = 50, maxTries = 50) {
|
||||
for (let i = 0; i < maxTries; i++) {
|
||||
const num = startNum + i;
|
||||
if (isDisplayAvailable(num)) {
|
||||
return num;
|
||||
}
|
||||
}
|
||||
// 回退:使用随机显示号
|
||||
return 50 + Math.floor(Math.random() * 50);
|
||||
}
|
||||
|
||||
// ==================== IPC 服务器 ====================
|
||||
|
||||
let serverProcess = null;
|
||||
let isRestarting = false;
|
||||
|
||||
// VNC 状态追踪
|
||||
let vncInfo = {
|
||||
enabled: false,
|
||||
port: 5900,
|
||||
display: ':99',
|
||||
xvfbMode: false
|
||||
};
|
||||
|
||||
/**
|
||||
* 启动 IPC 服务器
|
||||
*/
|
||||
function startIpcServer() {
|
||||
// 清理旧的 socket 文件(Linux)
|
||||
if (!isWindows && fs.existsSync(IPC_PATH)) {
|
||||
try {
|
||||
fs.unlinkSync(IPC_PATH);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const ipcServer = net.createServer((socket) => {
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
|
||||
if (command === 'RESTART' || command.startsWith('RESTART:')) {
|
||||
// 支持 RESTART:参数 格式
|
||||
const extraArgs = command.includes(':') ? command.split(':')[1].split(' ').filter(Boolean) : [];
|
||||
log('INFO', `收到 IPC 指令: RESTART${extraArgs.length ? ' (参数: ' + extraArgs.join(' ') + ')' : ''}`);
|
||||
socket.write('OK\n');
|
||||
socket.end();
|
||||
restartServer(extraArgs);
|
||||
} else if (command === 'STOP') {
|
||||
log('INFO', '收到 IPC 指令: STOP');
|
||||
socket.write('OK\n');
|
||||
socket.end();
|
||||
stopAll();
|
||||
} else if (command === 'GET_VNC_INFO') {
|
||||
// 返回 VNC 状态信息并关闭连接
|
||||
socket.write(JSON.stringify(vncInfo) + '\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('UNKNOWN_COMMAND\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcServer.listen(IPC_PATH, () => {
|
||||
log('INFO', `IPC 服务器已启动: ${IPC_PATH}`);
|
||||
});
|
||||
|
||||
ipcServer.on('error', (err) => {
|
||||
log('ERROR', `IPC 服务器错误: ${err.message}`);
|
||||
});
|
||||
|
||||
return ipcServer;
|
||||
}
|
||||
|
||||
// ==================== 子进程管理 ====================
|
||||
|
||||
// 不可恢复的退出码(不应自动重启)
|
||||
const FATAL_EXIT_CODES = [
|
||||
78, // 配置/依赖错误
|
||||
];
|
||||
|
||||
/**
|
||||
* 启动 server.js 子进程
|
||||
* @param {string[]} [extraArgs] - 额外的命令行参数
|
||||
*/
|
||||
function startServer(extraArgs = []) {
|
||||
const serverPath = path.join(process.cwd(), 'src', 'server', 'server.js');
|
||||
|
||||
// 检查 server.js 是否存在
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
log('ERROR', `未找到 server.js: ${serverPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = [serverPath, ...extraArgs];
|
||||
const env = {
|
||||
...process.env,
|
||||
SUPERVISOR_IPC: IPC_PATH
|
||||
};
|
||||
|
||||
log('INFO', '正在启动子服务 (src/server/server.js)...');
|
||||
|
||||
serverProcess = spawn(process.execPath, args, {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: 'inherit' // 将子进程 stdio 直接输出到主控制台
|
||||
});
|
||||
|
||||
serverProcess.on('exit', (code, signal) => {
|
||||
if (isRestarting) {
|
||||
log('INFO', '子服务已停止,准备重启...');
|
||||
isRestarting = false;
|
||||
// 如果有新参数,使用新参数;否则使用原参数
|
||||
const argsToUse = restartArgs !== null ? restartArgs : extraArgs;
|
||||
restartArgs = null; // 重置
|
||||
setTimeout(() => startServer(argsToUse), RESTART_DELAY);
|
||||
} else if (code !== 0 && code !== null) {
|
||||
// 检查是否为不可恢复的错误
|
||||
if (FATAL_EXIT_CODES.includes(code)) {
|
||||
log('ERROR', `子服务因配置/依赖错误退出 (code: ${code}),不会自动重启`);
|
||||
process.exit(code);
|
||||
}
|
||||
log('WARN', `子服务异常退出 (code: ${code}),将自动重启...`);
|
||||
setTimeout(() => startServer(extraArgs), RESTART_DELAY);
|
||||
} else {
|
||||
log('INFO', '子服务已正常退出');
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on('error', (err) => {
|
||||
log('ERROR', `子服务启动失败: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重启子服务
|
||||
* @param {string[]} [newArgs] - 新的启动参数(将覆盖原有参数)
|
||||
*/
|
||||
function restartServer(newArgs = null) {
|
||||
if (isRestarting) {
|
||||
log('WARN', '重启已在进行中,忽略重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
isRestarting = true;
|
||||
log('INFO', '正在重启子服务...');
|
||||
|
||||
// 如果提供了新参数,更新启动参数
|
||||
if (newArgs !== null) {
|
||||
restartArgs = newArgs;
|
||||
}
|
||||
|
||||
if (serverProcess) {
|
||||
serverProcess.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有服务
|
||||
*/
|
||||
function stopAll() {
|
||||
log('INFO', '正在停止所有服务...');
|
||||
|
||||
if (serverProcess) {
|
||||
serverProcess.kill('SIGTERM');
|
||||
}
|
||||
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
}
|
||||
|
||||
// ==================== Xvfb 处理(Linux) ====================
|
||||
|
||||
/**
|
||||
* 在 Xvfb 中启动
|
||||
* @param {string[]} originalArgs - 原始命令行参数
|
||||
*/
|
||||
function startInXvfb(originalArgs) {
|
||||
if (!checkCommand('xvfb-run')) {
|
||||
log('ERROR', '未找到 xvfb-run 命令');
|
||||
log('ERROR', '请先安装 Xvfb:');
|
||||
log('ERROR', ' - Ubuntu/Debian: sudo apt install xvfb');
|
||||
log('ERROR', ' - CentOS/RHEL: sudo dnf install xorg-x11-server-Xvfb');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 查找可用的显示号(从 50 开始,避免与常用的冲突)
|
||||
const displayNum = findAvailableDisplay(50);
|
||||
log('INFO', `正在启动 Xvfb 虚拟显示器 (显示号: :${displayNum})...`);
|
||||
|
||||
// 移除 -xvfb 参数
|
||||
const newArgs = originalArgs.filter(arg => arg !== '-xvfb');
|
||||
|
||||
const xvfbArgs = [
|
||||
`--server-num=${displayNum}`,
|
||||
'--server-args=-ac -screen 0 1366x768x24',
|
||||
'env',
|
||||
'XVFB_RUNNING=true',
|
||||
`DISPLAY=:${displayNum}`,
|
||||
process.argv[0],
|
||||
process.argv[1],
|
||||
...newArgs
|
||||
];
|
||||
|
||||
const xvfbProcess = spawn('xvfb-run', xvfbArgs, {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
xvfbProcess.on('error', (err) => {
|
||||
log('ERROR', `Xvfb 启动失败: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
xvfbProcess.on('exit', (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
// 处理退出信号
|
||||
process.on('SIGINT', () => xvfbProcess.kill('SIGTERM'));
|
||||
process.on('SIGTERM', () => xvfbProcess.kill('SIGTERM'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 VNC 服务器
|
||||
* @param {string} display - 显示器编号
|
||||
*/
|
||||
async function startVncServer(display) {
|
||||
if (!checkCommand('x11vnc')) {
|
||||
log('WARN', '未找到 x11vnc 命令,跳过 VNC 启动');
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找可用的 VNC 端口(从 5900 开始)
|
||||
const vncPort = await findAvailablePort(5900, 100);
|
||||
if (!vncPort) {
|
||||
log('ERROR', '无法找到可用的 VNC 端口 (5900-5999)');
|
||||
return;
|
||||
}
|
||||
|
||||
log('INFO', `正在启动 VNC 服务器 (端口: ${vncPort})...`);
|
||||
|
||||
const vncProcess = spawn('x11vnc', [
|
||||
'-display', display,
|
||||
'-rfbport', String(vncPort),
|
||||
'-localhost',
|
||||
'-nopw',
|
||||
'-shared',
|
||||
'-forever',
|
||||
'-noxdamage',
|
||||
'-norc',
|
||||
'-geometry', '1366x768'
|
||||
], {
|
||||
stdio: 'ignore',
|
||||
detached: false
|
||||
});
|
||||
|
||||
vncProcess.on('error', (err) => {
|
||||
log('WARN', `VNC 启动失败: ${err.message}`);
|
||||
vncInfo.enabled = false;
|
||||
});
|
||||
|
||||
vncProcess.on('exit', () => {
|
||||
vncInfo.enabled = false;
|
||||
});
|
||||
|
||||
// 更新 VNC 状态
|
||||
vncInfo.enabled = true;
|
||||
vncInfo.port = vncPort;
|
||||
vncInfo.display = display;
|
||||
|
||||
log('INFO', `VNC 服务器已启动,端口: ${vncPort}`);
|
||||
|
||||
// 处理退出信号
|
||||
process.on('SIGINT', () => vncProcess.kill('SIGTERM'));
|
||||
process.on('SIGTERM', () => vncProcess.kill('SIGTERM'));
|
||||
}
|
||||
|
||||
// ==================== 主入口 ====================
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const hasXvfb = args.includes('-xvfb');
|
||||
const hasVnc = args.includes('-vnc');
|
||||
const isInXvfb = process.env.XVFB_RUNNING === 'true';
|
||||
const isLinux = os.platform() === 'linux';
|
||||
|
||||
log('INFO', '主进程已启动');
|
||||
|
||||
// 处理 Xvfb 参数(仅 Linux)
|
||||
if (hasXvfb && isLinux && !isInXvfb) {
|
||||
startInXvfb(args);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 xvfbMode 标识
|
||||
vncInfo.xvfbMode = isInXvfb;
|
||||
|
||||
// 如果在 Xvfb 中运行,启动 VNC
|
||||
if (isInXvfb && hasVnc) {
|
||||
const display = process.env.DISPLAY || ':99';
|
||||
await startVncServer(display);
|
||||
}
|
||||
|
||||
// 启动 IPC 服务器
|
||||
startIpcServer();
|
||||
|
||||
// 启动子服务(过滤掉 -xvfb 和 -vnc 参数)
|
||||
const serverArgs = args.filter(arg => arg !== '-xvfb' && arg !== '-vnc');
|
||||
startServer(serverArgs);
|
||||
|
||||
// 处理退出信号
|
||||
process.on('SIGINT', stopAll);
|
||||
process.on('SIGTERM', stopAll);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log('ERROR', `启动失败: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
# 禁止使用根目录的 lockfile
|
||||
shared-workspace-lockfile=false
|
||||
# 忽略工作区根目录
|
||||
use-workspace-root-ver=false
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o,b as a,d as r,w as s,g as n,e as c}from"./index-CdkAYAR1.js";const d={};function _(l,e){const t=c("a-card");return r(),a(t,{title:"VNC转发",bordered:!1,style:{height:"100%"}},{default:s(()=>[...e[0]||(e[0]=[n(" 开发中... ",-1)])]),_:1})}const i=o(d,[["render",_]]);export{i as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{v as o,j as a,q as r}from"./index-CdkAYAR1.js";const n=o("system",{state:()=>({status:"",version:"1.0.0",systemVersion:"",uptime:0,cpuUsage:0,memoryUsage:{total:0,used:0,free:0},stats:{totalRequests:0,successRate:0,activeWorkers:0,totalWorkers:0,avgResponseTime:0}}),actions:{async fetchStatus(){const t=a();try{const e=await fetch("/admin/status",{headers:t.getHeaders()});if(e.ok){const s=await e.json();this.$patch(s)}}catch(e){console.error("Failed to fetch system status:",e)}},async fetchStats(){const t=a();try{const e=await fetch("/admin/stats",{headers:t.getHeaders()});if(e.ok){const s=await e.json();this.stats=s}}catch(e){console.error("Failed to fetch stats:",e)}},async restartService(){const t=a();try{const s=await(await fetch("/admin/restart",{method:"POST",headers:t.getHeaders()})).json();return s.success?(r.success(s.message||"Service restarting..."),!0):(r.error("Restart failed"),!1)}catch{return r.error("Restart request failed"),!1}},async stopService(){const t=a();try{const s=await(await fetch("/admin/stop",{method:"POST",headers:t.getHeaders()})).json();return s.success?(r.success(s.message||"Service stopping..."),!0):(r.error("Stop failed"),!1)}catch{return r.error("Stop request failed"),!1}}}});export{n as u};
|
||||
@@ -0,0 +1 @@
|
||||
import{c as i,I as u}from"./index-N039yHLa.js";var l={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M928 140H96c-17.7 0-32 14.3-32 32v496c0 17.7 14.3 32 32 32h380v112H304c-8.8 0-16 7.2-16 16v48c0 4.4 3.6 8 8 8h432c4.4 0 8-3.6 8-8v-48c0-8.8-7.2-16-16-16H548V700h380c17.7 0 32-14.3 32-32V172c0-17.7-14.3-32-32-32zm-40 488H136V212h752v416z"}}]},name:"desktop",theme:"outlined"};function c(r){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(e);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(e).filter(function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.forEach(function(a){s(r,a,e[a])})}return r}function s(r,t,e){return t in r?Object.defineProperty(r,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):r[t]=e,r}var o=function(t,e){var n=c({},t,e.attrs);return i(u,c({},n,{icon:l}),null)};o.displayName="DesktopOutlined";o.inheritAttrs=!1;export{o as D};
|
||||
@@ -1 +1 @@
|
||||
import{c as l,I as q,j as I,r as x,k as D,o as F,l as T,b as _,d as i,w as s,i as y,e as c,f as v,u as g,t as S,S as L,g as h,h as f,m as G,F as J}from"./index-CdkAYAR1.js";var Q={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H212V212h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H612V212h200v200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H212V612h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H612V612h200v200z"}}]},name:"appstore",theme:"outlined"};function w(p){for(var n=1;n<arguments.length;n++){var a=arguments[n]!=null?Object(arguments[n]):{},o=Object.keys(a);typeof Object.getOwnPropertySymbols=="function"&&(o=o.concat(Object.getOwnPropertySymbols(a).filter(function(t){return Object.getOwnPropertyDescriptor(a,t).enumerable}))),o.forEach(function(t){R(p,t,a[t])})}return p}function R(p,n,a){return n in p?Object.defineProperty(p,n,{value:a,enumerable:!0,configurable:!0,writable:!0}):p[n]=a,p}var k=function(n,a){var o=w({},n,a.attrs);return l(q,w({},o,{icon:Q}),null)};k.displayName="AppstoreOutlined";k.inheritAttrs=!1;const W={style:{display:"flex","align-items":"center","justify-content":"space-between"}},X={style:{display:"flex","align-items":"center"}},Y={style:{"font-weight":"600","font-size":"15px"}},Z={key:0},K={key:2},ee={key:4,style:{"font-size":"12px",color:"#8c8c8c","margin-top":"4px"}},te={style:{"text-align":"right"}},ae={__name:"adapters",setup(p){const n=I(),a=x(!1),o=x(null),t=D({});F(async()=>{await Promise.all([n.fetchAdaptersMeta(),n.fetchAdapterConfig()])});const O=T(()=>n.adaptersMeta),C=m=>{o.value=m;const r=n.adapterConfig[m.id]||{};Object.keys(t).forEach(u=>delete t[u]),m.configSchema&&m.configSchema.forEach(u=>{r[u.key]!==void 0?t[u.key]=r[u.key]:t[u.key]=u.default}),a.value=!0},V=async()=>{if(!o.value)return;const m={[o.value.id]:{...t}};await n.saveAdapterConfig(m)&&(a.value=!1)};return(m,r)=>{const u=c("a-button"),b=c("a-card"),z=c("a-list-item"),A=c("a-list"),U=c("a-empty"),H=c("a-input"),j=c("a-input-number"),M=c("a-switch"),P=c("a-select"),B=c("a-form-item"),E=c("a-form"),N=c("a-drawer"),$=c("a-layout");return i(),_($,{style:{background:"transparent"}},{default:s(()=>[l(b,{title:"适配器管理",bordered:!1},{extra:s(()=>[l(u,{type:"link",onClick:g(n).fetchAdaptersMeta},{default:s(()=>[...r[2]||(r[2]=[h("刷新列表",-1)])]),_:1},8,["onClick"])]),default:s(()=>[l(A,{grid:{gutter:16,xs:1,sm:2,md:3,lg:3,xl:4,xxl:4},"data-source":O.value},{renderItem:s(({item:e})=>[l(z,null,{default:s(()=>[l(b,{hoverable:"",onClick:d=>C(e),bodyStyle:{padding:"16px"}},{default:s(()=>[v("div",W,[v("div",X,[l(g(k),{style:{"font-size":"20px",color:"#1890ff","margin-right":"12px"}}),v("span",Y,S(e.id),1)]),l(g(L),{style:{"font-size":"16px",color:"#8c8c8c"}})])]),_:2},1032,["onClick"])]),_:2},1024)]),_:1},8,["data-source"])]),_:1}),o.value?(i(),_(N,{key:0,open:a.value,"onUpdate:open":r[1]||(r[1]=e=>a.value=e),title:`配置适配器 - ${o.value.name}`,width:"500",placement:"right"},{footer:s(()=>[v("div",te,[l(u,{style:{"margin-right":"8px"},onClick:r[0]||(r[0]=e=>a.value=!1)},{default:s(()=>[...r[3]||(r[3]=[h("取消",-1)])]),_:1}),l(u,{type:"primary",onClick:V},{default:s(()=>[...r[4]||(r[4]=[h("保存配置",-1)])]),_:1})])]),default:s(()=>[!o.value.configSchema||o.value.configSchema.length===0?(i(),f("div",Z,[l(U,{description:"该适配器没有可配置项"})])):(i(),_(E,{key:1,layout:"vertical"},{default:s(()=>[(i(!0),f(J,null,G(o.value.configSchema,e=>(i(),_(B,{key:e.key,label:e.label,required:e.required},{default:s(()=>[e.type==="string"?(i(),_(H,{key:0,value:t[e.key],"onUpdate:value":d=>t[e.key]=d,placeholder:e.placeholder},null,8,["value","onUpdate:value","placeholder"])):y("",!0),e.type==="number"?(i(),_(j,{key:1,value:t[e.key],"onUpdate:value":d=>t[e.key]=d,min:e.min,max:e.max,style:{width:"100%"}},null,8,["value","onUpdate:value","min","max"])):y("",!0),e.type==="boolean"?(i(),f("div",K,[l(M,{checked:t[e.key],"onUpdate:checked":d=>t[e.key]=d},null,8,["checked","onUpdate:checked"])])):y("",!0),e.type==="select"?(i(),_(P,{key:3,value:t[e.key],"onUpdate:value":d=>t[e.key]=d,options:e.options},null,8,["value","onUpdate:value","options"])):y("",!0),e.note?(i(),f("div",ee,S(e.note),1)):y("",!0)]),_:2},1032,["label","required"]))),128))]),_:1}))]),_:1},8,["open","title"])):y("",!0)]),_:1})}}};export{ae as default};
|
||||
import{c as l,I as q,j as I,r as x,k as D,o as F,l as T,b as _,d as i,w as s,i as y,e as c,f as v,u as g,t as S,S as L,g as h,h as f,m as G,F as J}from"./index-N039yHLa.js";var Q={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H212V212h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H612V212h200v200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H212V612h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H612V612h200v200z"}}]},name:"appstore",theme:"outlined"};function w(p){for(var n=1;n<arguments.length;n++){var a=arguments[n]!=null?Object(arguments[n]):{},o=Object.keys(a);typeof Object.getOwnPropertySymbols=="function"&&(o=o.concat(Object.getOwnPropertySymbols(a).filter(function(t){return Object.getOwnPropertyDescriptor(a,t).enumerable}))),o.forEach(function(t){R(p,t,a[t])})}return p}function R(p,n,a){return n in p?Object.defineProperty(p,n,{value:a,enumerable:!0,configurable:!0,writable:!0}):p[n]=a,p}var k=function(n,a){var o=w({},n,a.attrs);return l(q,w({},o,{icon:Q}),null)};k.displayName="AppstoreOutlined";k.inheritAttrs=!1;const W={style:{display:"flex","align-items":"center","justify-content":"space-between"}},X={style:{display:"flex","align-items":"center"}},Y={style:{"font-weight":"600","font-size":"15px"}},Z={key:0},K={key:2},ee={key:4,style:{"font-size":"12px",color:"#8c8c8c","margin-top":"4px"}},te={style:{"text-align":"right"}},ae={__name:"adapters",setup(p){const n=I(),a=x(!1),o=x(null),t=D({});F(async()=>{await Promise.all([n.fetchAdaptersMeta(),n.fetchAdapterConfig()])});const O=T(()=>n.adaptersMeta),C=m=>{o.value=m;const r=n.adapterConfig[m.id]||{};Object.keys(t).forEach(u=>delete t[u]),m.configSchema&&m.configSchema.forEach(u=>{r[u.key]!==void 0?t[u.key]=r[u.key]:t[u.key]=u.default}),a.value=!0},V=async()=>{if(!o.value)return;const m={[o.value.id]:{...t}};await n.saveAdapterConfig(m)&&(a.value=!1)};return(m,r)=>{const u=c("a-button"),b=c("a-card"),z=c("a-list-item"),A=c("a-list"),U=c("a-empty"),H=c("a-input"),j=c("a-input-number"),M=c("a-switch"),P=c("a-select"),B=c("a-form-item"),E=c("a-form"),N=c("a-drawer"),$=c("a-layout");return i(),_($,{style:{background:"transparent"}},{default:s(()=>[l(b,{title:"适配器管理",bordered:!1},{extra:s(()=>[l(u,{type:"link",onClick:g(n).fetchAdaptersMeta},{default:s(()=>[...r[2]||(r[2]=[h("刷新列表",-1)])]),_:1},8,["onClick"])]),default:s(()=>[l(A,{grid:{gutter:16,xs:1,sm:2,md:3,lg:3,xl:4,xxl:4},"data-source":O.value},{renderItem:s(({item:e})=>[l(z,null,{default:s(()=>[l(b,{hoverable:"",onClick:d=>C(e),bodyStyle:{padding:"16px"}},{default:s(()=>[v("div",W,[v("div",X,[l(g(k),{style:{"font-size":"20px",color:"#1890ff","margin-right":"12px"}}),v("span",Y,S(e.id),1)]),l(g(L),{style:{"font-size":"16px",color:"#8c8c8c"}})])]),_:2},1032,["onClick"])]),_:2},1024)]),_:1},8,["data-source"])]),_:1}),o.value?(i(),_(N,{key:0,open:a.value,"onUpdate:open":r[1]||(r[1]=e=>a.value=e),title:`配置适配器 - ${o.value.name}`,width:"500",placement:"right"},{footer:s(()=>[v("div",te,[l(u,{style:{"margin-right":"8px"},onClick:r[0]||(r[0]=e=>a.value=!1)},{default:s(()=>[...r[3]||(r[3]=[h("取消",-1)])]),_:1}),l(u,{type:"primary",onClick:V},{default:s(()=>[...r[4]||(r[4]=[h("保存配置",-1)])]),_:1})])]),default:s(()=>[!o.value.configSchema||o.value.configSchema.length===0?(i(),f("div",Z,[l(U,{description:"该适配器没有可配置项"})])):(i(),_(E,{key:1,layout:"vertical"},{default:s(()=>[(i(!0),f(J,null,G(o.value.configSchema,e=>(i(),_(B,{key:e.key,label:e.label,required:e.required},{default:s(()=>[e.type==="string"?(i(),_(H,{key:0,value:t[e.key],"onUpdate:value":d=>t[e.key]=d,placeholder:e.placeholder},null,8,["value","onUpdate:value","placeholder"])):y("",!0),e.type==="number"?(i(),_(j,{key:1,value:t[e.key],"onUpdate:value":d=>t[e.key]=d,min:e.min,max:e.max,style:{width:"100%"}},null,8,["value","onUpdate:value","min","max"])):y("",!0),e.type==="boolean"?(i(),f("div",K,[l(M,{checked:t[e.key],"onUpdate:checked":d=>t[e.key]=d},null,8,["checked","onUpdate:checked"])])):y("",!0),e.type==="select"?(i(),_(P,{key:3,value:t[e.key],"onUpdate:value":d=>t[e.key]=d,options:e.options},null,8,["value","onUpdate:value","options"])):y("",!0),e.note?(i(),f("div",ee,S(e.note),1)):y("",!0)]),_:2},1032,["label","required"]))),128))]),_:1}))]),_:1},8,["open","title"])):y("",!0)]),_:1})}}};export{ae as default};
|
||||
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
[data-v-7bbb1a7b]::-webkit-scrollbar{width:6px;height:6px}[data-v-7bbb1a7b]::-webkit-scrollbar-thumb{background:#ccc;border-radius:3px}[data-v-7bbb1a7b]::-webkit-scrollbar-track{background:#f1f1f1}html,body{width:100%;height:100%}input::-ms-clear,input::-ms-reveal{display:none}*,*:before,*:after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}@-ms-viewport{width:device-width}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[title],abbr[data-original-title]{-webkit-text-decoration:underline dotted;text-decoration:underline;text-decoration:underline dotted;border-bottom:0;cursor:help}address{margin-bottom:1em;font-style:normal;line-height:inherit}input[type=text],input[type=password],input[type=number],textarea{-webkit-appearance:none}ol,ul,dl{margin-top:0;margin-bottom:1em}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}pre,code,kbd,samp{font-size:1em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}a,area,button,[role=button],input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75em;padding-bottom:.3em;text-align:left;caption-side:bottom}input,button,select,optgroup,textarea{margin:0;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{padding:0;border-style:none}input[type=radio],input[type=checkbox]{box-sizing:border-box;padding:0}input[type=date],input[type=time],input[type=datetime-local],input[type=month]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;margin:0;padding:0;border:0}legend{display:block;width:100%;max-width:100%;margin-bottom:.5em;padding:0;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{padding:.2em;background-color:#feffe6}
|
||||
[data-v-9531bced]::-webkit-scrollbar{width:6px;height:6px}[data-v-9531bced]::-webkit-scrollbar-thumb{background:#ccc;border-radius:3px}[data-v-9531bced]::-webkit-scrollbar-track{background:#f1f1f1}html,body{width:100%;height:100%}input::-ms-clear,input::-ms-reveal{display:none}*,*:before,*:after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}@-ms-viewport{width:device-width}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[title],abbr[data-original-title]{-webkit-text-decoration:underline dotted;text-decoration:underline;text-decoration:underline dotted;border-bottom:0;cursor:help}address{margin-bottom:1em;font-style:normal;line-height:inherit}input[type=text],input[type=password],input[type=number],textarea{-webkit-appearance:none}ol,ul,dl{margin-top:0;margin-bottom:1em}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}pre,code,kbd,samp{font-size:1em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}a,area,button,[role=button],input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75em;padding-bottom:.3em;text-align:left;caption-side:bottom}input,button,select,optgroup,textarea{margin:0;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{padding:0;border-style:none}input[type=radio],input[type=checkbox]{box-sizing:border-box;padding:0}input[type=date],input[type=time],input[type=datetime-local],input[type=month]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;margin:0;padding:0;border:0}legend{display:block;width:100%;max-width:100%;margin-bottom:.5em;padding:0;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{padding:.2em;background-color:#feffe6}
|
||||
File diff suppressed because one or more lines are too long
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{_ as b,j as w,k,o as c,b as C,d as S,w as n,c as o,f as e,e as a,g as i}from"./index-CdkAYAR1.js";const B={style:{"margin-bottom":"8px"}},T={style:{"margin-bottom":"8px"}},z={style:{"margin-bottom":"8px"}},U={style:{display:"flex","justify-content":"flex-end","margin-top":"24px"}},j={style:{"margin-bottom":"8px"}},M={style:{"margin-bottom":"8px"}},q={style:{display:"flex","justify-content":"flex-end","margin-top":"24px"}},L={__name:"server",setup(N){const d=w(),s=k({port:5173,authToken:"",keepaliveMode:"comment",queueBuffer:2,imageLimit:5});c(async()=>{await d.fetchServerConfig(),Object.assign(s,d.serverConfig)});const m=async()=>{await d.saveServerConfig(s)};return(V,t)=>{const p=a("a-input-number"),r=a("a-col"),y=a("a-input-password"),u=a("a-select-option"),g=a("a-select"),f=a("a-row"),v=a("a-button"),x=a("a-card"),_=a("a-layout");return S(),C(_,{style:{background:"transparent"}},{default:n(()=>[o(x,{title:"服务器设置",bordered:!1,style:{width:"100%"}},{default:n(()=>[o(f,{gutter:[16,16]},{default:n(()=>[o(r,{xs:24,md:12},{default:n(()=>[e("div",B,[t[5]||(t[5]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"监听端口",-1)),t[6]||(t[6]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 设置服务器监听的端口号,默认为 5173 ",-1)),o(p,{value:s.port,"onUpdate:value":t[0]||(t[0]=l=>s.port=l),min:1,max:65535,placeholder:"请输入端口号",style:{width:"100%"}},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",T,[t[7]||(t[7]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"鉴权 Token",-1)),t[8]||(t[8]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 用于 API 请求鉴权的密钥,留空则不启用鉴权 ",-1)),o(y,{value:s.authToken,"onUpdate:value":t[1]||(t[1]=l=>s.authToken=l),placeholder:"请输入 Token",type:"password"},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",z,[t[11]||(t[11]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"心跳包类型",-1)),t[12]||(t[12]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 选择 SSE 流式响应的心跳包格式 ",-1)),o(g,{value:s.keepaliveMode,"onUpdate:value":t[2]||(t[2]=l=>s.keepaliveMode=l),style:{width:"100%"},placeholder:"请选择心跳包类型"},{default:n(()=>[o(u,{value:"comment"},{default:n(()=>[...t[9]||(t[9]=[i("Comment - 注释格式",-1)])]),_:1}),o(u,{value:"content"},{default:n(()=>[...t[10]||(t[10]=[i("Content - 内容格式",-1)])]),_:1})]),_:1},8,["value"])])]),_:1})]),_:1}),e("div",U,[o(v,{type:"primary",onClick:m},{default:n(()=>[...t[13]||(t[13]=[i(" 保存设置 ",-1)])]),_:1})])]),_:1}),o(x,{title:"队列设置",bordered:!1,style:{width:"100%","margin-top":"10px"}},{default:n(()=>[o(f,{gutter:[16,16]},{default:n(()=>[o(r,{xs:24,md:12},{default:n(()=>[e("div",j,[t[14]||(t[14]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"队列缓冲区大小",-1)),t[15]||(t[15]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}},[i(" 非流式请求的额外排队数(设为 0 则不限制非流式请求数量)"),e("br"),i(" 实际队列上限 = Workers数量 + 缓冲区大小 ")],-1)),o(p,{value:s.queueBuffer,"onUpdate:value":t[3]||(t[3]=l=>s.queueBuffer=l),min:0,max:100,placeholder:"默认为 2",style:{width:"100%"}},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",M,[t[16]||(t[16]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"图片数量上限",-1)),t[17]||(t[17]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}},[i(" 单次请求最多支持的图片附件数量"),e("br"),i(" 网页最多支持10个附件,超出会被丢弃 ")],-1)),o(p,{value:s.imageLimit,"onUpdate:value":t[4]||(t[4]=l=>s.imageLimit=l),min:1,max:10,placeholder:"默认为 5",style:{width:"100%"}},null,8,["value"])])]),_:1})]),_:1}),e("div",q,[o(v,{type:"primary",onClick:m},{default:n(()=>[...t[18]||(t[18]=[i(" 保存设置 ",-1)])]),_:1})])]),_:1})]),_:1})}}},A=b(L,[["__scopeId","data-v-bd32923f"]]);export{A as default};
|
||||
import{_ as b,j as w,k,o as c,b as C,d as S,w as n,c as o,f as e,e as a,g as i}from"./index-N039yHLa.js";const B={style:{"margin-bottom":"8px"}},T={style:{"margin-bottom":"8px"}},z={style:{"margin-bottom":"8px"}},U={style:{display:"flex","justify-content":"flex-end","margin-top":"24px"}},j={style:{"margin-bottom":"8px"}},M={style:{"margin-bottom":"8px"}},q={style:{display:"flex","justify-content":"flex-end","margin-top":"24px"}},L={__name:"server",setup(N){const d=w(),s=k({port:5173,authToken:"",keepaliveMode:"comment",queueBuffer:2,imageLimit:5});c(async()=>{await d.fetchServerConfig(),Object.assign(s,d.serverConfig)});const m=async()=>{await d.saveServerConfig(s)};return(V,t)=>{const p=a("a-input-number"),r=a("a-col"),y=a("a-input-password"),u=a("a-select-option"),g=a("a-select"),f=a("a-row"),v=a("a-button"),x=a("a-card"),_=a("a-layout");return S(),C(_,{style:{background:"transparent"}},{default:n(()=>[o(x,{title:"服务器设置",bordered:!1,style:{width:"100%"}},{default:n(()=>[o(f,{gutter:[16,16]},{default:n(()=>[o(r,{xs:24,md:12},{default:n(()=>[e("div",B,[t[5]||(t[5]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"监听端口",-1)),t[6]||(t[6]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 设置服务器监听的端口号,默认为 5173 ",-1)),o(p,{value:s.port,"onUpdate:value":t[0]||(t[0]=l=>s.port=l),min:1,max:65535,placeholder:"请输入端口号",style:{width:"100%"}},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",T,[t[7]||(t[7]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"鉴权 Token",-1)),t[8]||(t[8]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 用于 API 请求鉴权的密钥,留空则不启用鉴权 ",-1)),o(y,{value:s.authToken,"onUpdate:value":t[1]||(t[1]=l=>s.authToken=l),placeholder:"请输入 Token",type:"password"},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",z,[t[11]||(t[11]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"心跳包类型",-1)),t[12]||(t[12]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 选择 SSE 流式响应的心跳包格式 ",-1)),o(g,{value:s.keepaliveMode,"onUpdate:value":t[2]||(t[2]=l=>s.keepaliveMode=l),style:{width:"100%"},placeholder:"请选择心跳包类型"},{default:n(()=>[o(u,{value:"comment"},{default:n(()=>[...t[9]||(t[9]=[i("Comment - 注释格式",-1)])]),_:1}),o(u,{value:"content"},{default:n(()=>[...t[10]||(t[10]=[i("Content - 内容格式",-1)])]),_:1})]),_:1},8,["value"])])]),_:1})]),_:1}),e("div",U,[o(v,{type:"primary",onClick:m},{default:n(()=>[...t[13]||(t[13]=[i(" 保存设置 ",-1)])]),_:1})])]),_:1}),o(x,{title:"队列设置",bordered:!1,style:{width:"100%","margin-top":"10px"}},{default:n(()=>[o(f,{gutter:[16,16]},{default:n(()=>[o(r,{xs:24,md:12},{default:n(()=>[e("div",j,[t[14]||(t[14]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"队列缓冲区大小",-1)),t[15]||(t[15]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}},[i(" 非流式请求的额外排队数(设为 0 则不限制非流式请求数量)"),e("br"),i(" 实际队列上限 = Workers数量 + 缓冲区大小 ")],-1)),o(p,{value:s.queueBuffer,"onUpdate:value":t[3]||(t[3]=l=>s.queueBuffer=l),min:0,max:100,placeholder:"默认为 2",style:{width:"100%"}},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",M,[t[16]||(t[16]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"图片数量上限",-1)),t[17]||(t[17]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}},[i(" 单次请求最多支持的图片附件数量"),e("br"),i(" 网页最多支持10个附件,超出会被丢弃 ")],-1)),o(p,{value:s.imageLimit,"onUpdate:value":t[4]||(t[4]=l=>s.imageLimit=l),min:1,max:10,placeholder:"默认为 5",style:{width:"100%"}},null,8,["value"])])]),_:1})]),_:1}),e("div",q,[o(v,{type:"primary",onClick:m},{default:n(()=>[...t[18]||(t[18]=[i(" 保存设置 ",-1)])]),_:1})])]),_:1})]),_:1})}}},A=b(L,[["__scopeId","data-v-bd32923f"]]);export{A as default};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
import{x as i,j as a,s as r}from"./index-N039yHLa.js";const u=i("system",{state:()=>({status:"",version:"1.0.0",systemVersion:"",uptime:0,cpuUsage:0,memoryUsage:{total:0,used:0,free:0},stats:{totalRequests:0,successRate:0,activeWorkers:0,totalWorkers:0,avgResponseTime:0}}),actions:{async fetchStatus(){const t=a();try{const e=await fetch("/admin/status",{headers:t.getHeaders()});if(e.ok){const s=await e.json();this.$patch(s)}}catch(e){console.error("Failed to fetch system status:",e)}},async fetchStats(){const t=a();try{const e=await fetch("/admin/stats",{headers:t.getHeaders()});if(e.ok){const s=await e.json();this.stats=s}}catch(e){console.error("Failed to fetch stats:",e)}},async restartService(t={}){const e=a(),{loginMode:s,workerName:n}=t;try{const o=await(await fetch("/admin/restart",{method:"POST",headers:{...e.getHeaders(),"Content-Type":"application/json"},body:JSON.stringify({loginMode:s,workerName:n})})).json();return o.success?(r.success(o.message||"服务重启中..."),!0):(r.error("重启失败"),!1)}catch{return r.error("重启请求失败"),!1}},async stopService(){const t=a();try{const s=await(await fetch("/admin/stop",{method:"POST",headers:t.getHeaders()})).json();return s.success?(r.success(s.message||"服务停止中..."),!0):(r.error("停止失败"),!1)}catch{return r.error("停止请求失败"),!1}}}});export{u};
|
||||
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebAI2API</title>
|
||||
<script type="module" crossorigin src="/assets/index-N039yHLa.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CAMDRWk5.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+2
-2
@@ -3,14 +3,14 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebAI2API</title>
|
||||
<script type="module" crossorigin src="/assets/index-CdkAYAR1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BxnGFPqi.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "webui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@novnc/novnc": "1.4.0",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
Generated
+1136
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- '.'
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,214 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
SettingOutlined,
|
||||
ToolOutlined,
|
||||
PoweroffOutlined,
|
||||
GithubOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import LoginModal from '@/components/auth/LoginModal.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const selectedKeys = ref(['dash']);
|
||||
const collapsed = ref(false);
|
||||
const loginVisible = ref(false);
|
||||
|
||||
const iconLoading = ref(false);
|
||||
const enterIconLoading = () => {
|
||||
iconLoading.value = true;
|
||||
settingsStore.setToken('');
|
||||
setTimeout(() => {
|
||||
iconLoading.value = false;
|
||||
loginVisible.value = true;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 菜单 key 到路由路径的映射
|
||||
const menuRoutes = {
|
||||
'dash': '/',
|
||||
'settings-server': '/settings/server',
|
||||
'settings-workers': '/settings/workers',
|
||||
'settings-browser': '/settings/browser',
|
||||
'settings-adapters': '/settings/adapters',
|
||||
'tools-display': '/tools/display',
|
||||
'tools-cache': '/tools/cache'
|
||||
};
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
const route = menuRoutes[key];
|
||||
if (route) {
|
||||
router.push(route);
|
||||
}
|
||||
};
|
||||
|
||||
const isInitializing = ref(true);
|
||||
|
||||
// 后端连接检测
|
||||
let connectionCheckInterval = null;
|
||||
let disconnectModalShown = false;
|
||||
|
||||
async function checkConnection() {
|
||||
try {
|
||||
const res = await fetch('/admin/status', {
|
||||
headers: settingsStore.getHeaders(),
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (res.ok && disconnectModalShown) {
|
||||
// 连接恢复,刷新页面
|
||||
disconnectModalShown = false;
|
||||
Modal.destroyAll();
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!disconnectModalShown && !isInitializing.value) {
|
||||
disconnectModalShown = true;
|
||||
Modal.warning({
|
||||
title: '后端连接断开',
|
||||
content: '无法连接到后端服务,请检查服务是否正在运行。连接恢复后页面将自动刷新。',
|
||||
okText: '我知道了',
|
||||
centered: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 挂载时检查身份验证
|
||||
onMounted(async () => {
|
||||
// 响应式侧边栏
|
||||
const checkScreenSize = () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
collapsed.value = true;
|
||||
}
|
||||
};
|
||||
checkScreenSize();
|
||||
window.addEventListener('resize', checkScreenSize);
|
||||
|
||||
// 身份验证
|
||||
try {
|
||||
if (!settingsStore.token) {
|
||||
loginVisible.value = true;
|
||||
} else {
|
||||
// 使用真实API验证
|
||||
const isValid = await settingsStore.checkAuth();
|
||||
if (!isValid) {
|
||||
settingsStore.setToken(''); // 清除无效token
|
||||
loginVisible.value = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Auth check failed', e);
|
||||
loginVisible.value = true;
|
||||
} finally {
|
||||
// 隐藏加载状态
|
||||
isInitializing.value = false;
|
||||
}
|
||||
|
||||
// 启动后端连接检测(每 5 秒检测一次)
|
||||
connectionCheckInterval = setInterval(checkConnection, 5000);
|
||||
|
||||
// 清理监听器
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkScreenSize);
|
||||
if (connectionCheckInterval) {
|
||||
clearInterval(connectionCheckInterval);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-spin :spinning="isInitializing" tip="正在验证身份..." size="large"
|
||||
style="height: 100vh; display: flex; align-items: center; justify-content: center;" v-if="isInitializing" />
|
||||
<div v-else>
|
||||
<LoginModal v-model:visible="loginVisible" />
|
||||
<a-layout style="min-height: 100vh" theme="light">
|
||||
<a-layout-header class="header"
|
||||
style="background: rgba(255, 255, 255, 0.7); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-bottom: 1.5px solid rgba(0, 0, 0, 0.05); display: flex; align-items: center; padding: 0 24px; position: fixed; width: 100%; top: 0; z-index: 1000;">
|
||||
<div class="logo" style="font-size: 1.25rem; font-weight: bold; color: #1890ff; margin-right: 24px;">
|
||||
WebAI2API
|
||||
</div>
|
||||
<a-flex justify="end" align="center" style="flex: 1;">
|
||||
<a-button danger :loading="iconLoading" @click="enterIconLoading">
|
||||
<template #icon>
|
||||
<PoweroffOutlined />
|
||||
</template>
|
||||
退出登录
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-layout-header>
|
||||
<a-layout style="margin-top: 64px;">
|
||||
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light"
|
||||
style="position: fixed; left: 0; top: 64px; height: calc(100vh - 64px); overflow-y: auto; z-index: 100;">
|
||||
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" @click="handleMenuClick">
|
||||
<a-menu-item key="dash">
|
||||
<DashboardOutlined />
|
||||
<span>状态概览</span>
|
||||
</a-menu-item>
|
||||
<a-sub-menu key="settings">
|
||||
<template #title>
|
||||
<span>
|
||||
<SettingOutlined />
|
||||
<span>系统设置</span>
|
||||
</span>
|
||||
</template>
|
||||
<a-menu-item key="settings-server">服务器</a-menu-item>
|
||||
<a-menu-item key="settings-workers">工作池</a-menu-item>
|
||||
<a-menu-item key="settings-browser">浏览器</a-menu-item>
|
||||
<a-menu-item key="settings-adapters">适配器</a-menu-item>
|
||||
</a-sub-menu>
|
||||
<a-sub-menu key="tools">
|
||||
<template #title>
|
||||
<span>
|
||||
<ToolOutlined />
|
||||
<span>系统管理</span>
|
||||
</span>
|
||||
</template>
|
||||
<a-menu-item key="tools-display">虚拟显示器</a-menu-item>
|
||||
<a-menu-item key="tools-cache">缓存与重启</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<a-layout
|
||||
:style="{ marginLeft: collapsed ? '80px' : '200px', padding: '16px', transition: 'margin-left 0.2s' }">
|
||||
<a-layout-content style="min-height: 280px">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
<a-layout-footer class="footer" style="padding: 0px; margin-top: 10px;">
|
||||
<a-card :bordered="false"
|
||||
:bodyStyle="{ padding: '16px 24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }">
|
||||
<div>
|
||||
<a href="https://github.com/foxhui/WebAI2API" target="_blank" style="color: #8c8c8c; font-size: 20px;">
|
||||
<GithubOutlined />
|
||||
</a>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-layout-footer>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 滚动条美化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { LockOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success']);
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const token = ref(settingsStore.token);
|
||||
const loading = ref(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!token.value) {
|
||||
message.warning('请输入 Token');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const originalToken = settingsStore.token;
|
||||
settingsStore.setToken(token.value);
|
||||
|
||||
const success = await settingsStore.checkAuth();
|
||||
if (success) {
|
||||
message.success('验证成功');
|
||||
emit('success');
|
||||
emit('update:visible', false);
|
||||
} else {
|
||||
message.error('Token 验证失败,请检查是否正确');
|
||||
settingsStore.setToken(originalToken);
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('验证过程发生错误');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="visible" title="需要身份验证" :closable="false" :maskClosable="false" :footer="null" width="400px"
|
||||
centered>
|
||||
<div style="padding: 20px 0;">
|
||||
<div style="text-align: center; margin-bottom: 24px;">
|
||||
<a-avatar :size="64" style="background-color: #1890ff">
|
||||
<template #icon>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<div style="margin-top: 16px; font-size: 16px; font-weight: 500;">
|
||||
WebAI2API 管理面板
|
||||
</div>
|
||||
<div style="color: #8c8c8c; margin-top: 8px;">
|
||||
请输入访问 Token 以继续
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="Access Token">
|
||||
<a-input-password v-model:value="token" placeholder="请输入 Token" size="large"
|
||||
@pressEnter="handleLogin">
|
||||
<template #prefix>
|
||||
<LockOutlined style="color: rgba(0,0,0,.25)" />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-button type="primary" block size="large" :loading="loading" @click="handleLogin">
|
||||
验证并登录
|
||||
</a-button>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useSystemStore } from '@/stores/system';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import {
|
||||
DesktopOutlined,
|
||||
PieChartOutlined,
|
||||
ChromeOutlined,
|
||||
FieldTimeOutlined,
|
||||
LineChartOutlined,
|
||||
SyncOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CheckCircleOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const systemStore = useSystemStore();
|
||||
const queueData = ref([]);
|
||||
const timer = ref(null);
|
||||
const queueStats = ref({ processing: 0, waiting: 0, total: 0 });
|
||||
|
||||
// 获取队列数据
|
||||
const fetchQueue = async () => {
|
||||
const settingsStore = useSettingsStore(); // 获取store
|
||||
try {
|
||||
const res = await fetch('/admin/queue', { headers: settingsStore.getHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
|
||||
// 更新统计信息
|
||||
queueStats.value = {
|
||||
processing: data.processing || 0,
|
||||
waiting: data.waiting || 0,
|
||||
total: data.total || 0
|
||||
};
|
||||
|
||||
const processing = (data.processingTasks || []).map(t => ({ ...t, status: 'processing' }));
|
||||
const waiting = (data.waitingTasks || []).map(t => ({ ...t, status: 'waiting' }));
|
||||
queueData.value = [...processing, ...waiting];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fetch queue failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
await Promise.all([
|
||||
systemStore.fetchStatus(),
|
||||
systemStore.fetchStats(),
|
||||
fetchQueue()
|
||||
]);
|
||||
};
|
||||
|
||||
const formatUptime = (seconds) => {
|
||||
const d = Math.floor(seconds / (3600 * 24));
|
||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}天 ${h}小时 ${m}分`;
|
||||
if (h > 0) return `${h}小时 ${m}分`;
|
||||
return `${m}分`;
|
||||
};
|
||||
|
||||
const formatMemory = (mb) => {
|
||||
if (!mb || mb === 0) return '0 MB';
|
||||
if (mb > 1024) {
|
||||
return parseFloat((mb / 1024).toFixed(2)) + ' GB';
|
||||
}
|
||||
return parseFloat(Number(mb).toFixed(2)) + ' MB';
|
||||
};
|
||||
|
||||
const getLoadColor = (usage) => {
|
||||
if (usage < 50) return '#52c41a'; // 绿色
|
||||
if (usage < 80) return '#faad14'; // 橙色
|
||||
return '#f5222d'; // 红色
|
||||
};
|
||||
|
||||
// 状态映射
|
||||
const getStatusConfig = (status) => {
|
||||
const map = {
|
||||
'normal': { color: 'green', text: '正常模式 (Normal)' },
|
||||
'headless': { color: 'blue', text: '无头模式 (Headless)' },
|
||||
'xvfb': { color: 'purple', text: '虚拟显示 (Xvfb)' }
|
||||
};
|
||||
return map[status] || { color: 'red', text: '未运行' };
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refreshData();
|
||||
timer.value = setInterval(refreshData, 5000); // 每5秒轮询
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="width: 100%; background: transparent;">
|
||||
<!-- 响应式布局:手机竖向,电脑横向 -->
|
||||
<a-row :gutter="[16, 16]" style="margin-bottom: 24px">
|
||||
<!-- 系统信息卡片 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="系统状态" :bordered="false" style="height: 100%">
|
||||
<a-space direction="vertical" style="width: 100%" size="middle">
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>
|
||||
<DesktopOutlined /> 系统版本:
|
||||
</span>
|
||||
<b>{{ systemStore.systemVersion }}</b>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>
|
||||
<FieldTimeOutlined /> 运行时间:
|
||||
</span>
|
||||
<b>{{ formatUptime(systemStore.uptime) }}</b>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>
|
||||
<ChromeOutlined /> 状态:
|
||||
</span>
|
||||
<a-tag :color="getStatusConfig(systemStore.status).color">
|
||||
{{ getStatusConfig(systemStore.status).text }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>
|
||||
<LineChartOutlined /> CPU 使用率:
|
||||
</span>
|
||||
<span>{{ systemStore.cpuUsage }}%</span>
|
||||
</div>
|
||||
<a-progress :percent="systemStore.cpuUsage"
|
||||
:stroke-color="getLoadColor(systemStore.cpuUsage)" :show-info="false" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>
|
||||
<PieChartOutlined /> 内存使用:
|
||||
</span>
|
||||
<span>{{ formatMemory(systemStore.memoryUsage.used) }} / {{
|
||||
formatMemory(systemStore.memoryUsage.total) }}</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="Math.round((systemStore.memoryUsage.used / systemStore.memoryUsage.total) * 100) || 0"
|
||||
:stroke-color="getLoadColor((systemStore.memoryUsage.used / systemStore.memoryUsage.total) * 100)"
|
||||
:show-info="false" />
|
||||
</div>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 统计数据卡片 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="业务统计" :bordered="false" style="height: 100%">
|
||||
<a-row :gutter="16" style="margin-bottom: 24px">
|
||||
<a-col :span="12">
|
||||
<a-statistic title="窗口数量" :value="systemStore.stats.workers || 0">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px; color: #8c8c8c;">个</span>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-statistic title="实例数量" :value="systemStore.stats.instances || 0">
|
||||
<template #suffix>
|
||||
<span style=" font-size: 14px; color: #8c8c8c;">个</span>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-statistic title="正在进行" :value="queueStats.processing">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px; color: #8c8c8c;">/ {{ queueStats.total }}</span>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-statistic title="等待排队" :value="queueStats.waiting">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px; color: #8c8c8c;">/ {{ queueStats.total }}</span>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 任务队列列表 -->
|
||||
<a-card title="任务队列实时监控" :bordered="false" style="width: 100%" :bodyStyle="{ padding: '0 24px' }">
|
||||
<template #extra>
|
||||
<div style="color: #8c8c8c; font-size: 12px;">
|
||||
<SyncOutlined :spin="true" style="margin-right: 4px" /> 实时刷新中
|
||||
</div>
|
||||
</template>
|
||||
<a-list item-layout="horizontal" :data-source="queueData">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :description="`ID: ${item.id}`">
|
||||
<template #title>
|
||||
<span style="font-weight: 500; margin-right: 8px;">{{ item.model }}</span>
|
||||
<a-tag v-if="item.worker" color="blue">{{ item.worker }}</a-tag>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
|
||||
<div>
|
||||
<a-tag v-if="item.status === 'processing'" color="processing">
|
||||
<template #icon>
|
||||
<SyncOutlined :spin="true" />
|
||||
</template>
|
||||
进行中
|
||||
</a-tag>
|
||||
<a-tag v-else-if="item.status === 'waiting'" color="warning">
|
||||
<template #icon>
|
||||
<ExclamationCircleOutlined />
|
||||
</template>
|
||||
等待中
|
||||
</a-tag>
|
||||
<a-tag v-else-if="item.status === 'success'" color="success">
|
||||
<template #icon>
|
||||
<CheckCircleOutlined />
|
||||
</template>
|
||||
已完成
|
||||
</a-tag>
|
||||
</div>
|
||||
</a-list-item>
|
||||
</template>
|
||||
<div v-if="queueData.length === 0" style="text-align: center; padding: 24px; color: #8c8c8c;">
|
||||
暂无任务
|
||||
</div>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive, computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { SettingOutlined, AppstoreOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const currentAdapter = ref(null);
|
||||
const currentConfig = reactive({});
|
||||
|
||||
// 挂载时获取数据
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
settingsStore.fetchAdaptersMeta(),
|
||||
settingsStore.fetchAdapterConfig()
|
||||
]);
|
||||
});
|
||||
|
||||
// 适配器列表
|
||||
const adapters = computed(() => settingsStore.adaptersMeta);
|
||||
|
||||
// 打开抽屉进行编辑
|
||||
const handleEdit = (adapter) => {
|
||||
currentAdapter.value = adapter;
|
||||
// 加载现有配置或默认值
|
||||
const existing = settingsStore.adapterConfig[adapter.id] || {};
|
||||
|
||||
// 重置当前配置表单
|
||||
Object.keys(currentConfig).forEach(key => delete currentConfig[key]);
|
||||
|
||||
// 使用现有值或schema中的默认值初始化表单
|
||||
if (adapter.configSchema) {
|
||||
adapter.configSchema.forEach(field => {
|
||||
if (existing[field.key] !== undefined) {
|
||||
currentConfig[field.key] = existing[field.key];
|
||||
} else {
|
||||
currentConfig[field.key] = field.default;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async () => {
|
||||
if (!currentAdapter.value) return;
|
||||
|
||||
const configToSave = {
|
||||
[currentAdapter.value.id]: { ...currentConfig }
|
||||
};
|
||||
|
||||
const success = await settingsStore.saveAdapterConfig(configToSave);
|
||||
if (success) {
|
||||
drawerVisible.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="background: transparent;">
|
||||
<a-card title="适配器管理" :bordered="false">
|
||||
<template #extra>
|
||||
<a-button type="link" @click="settingsStore.fetchAdaptersMeta">刷新列表</a-button>
|
||||
</template>
|
||||
|
||||
<a-list :grid="{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 3, xl: 4, xxl: 4 }" :data-source="adapters">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-card hoverable @click="handleEdit(item)" :bodyStyle="{ padding: '16px' }">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<AppstoreOutlined style="font-size: 20px; color: #1890ff; margin-right: 12px;" />
|
||||
<span style="font-weight: 600; font-size: 15px;">{{ item.id }}</span>
|
||||
</div>
|
||||
<SettingOutlined style="font-size: 16px; color: #8c8c8c;" />
|
||||
</div>
|
||||
</a-card>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
|
||||
<!-- 配置抽屉 -->
|
||||
<a-drawer v-if="currentAdapter" v-model:open="drawerVisible" :title="`配置适配器 - ${currentAdapter.name}`"
|
||||
width="500" placement="right">
|
||||
<div v-if="!currentAdapter.configSchema || currentAdapter.configSchema.length === 0">
|
||||
<a-empty description="该适配器没有可配置项" />
|
||||
</div>
|
||||
|
||||
<a-form layout="vertical" v-else>
|
||||
<template v-for="field in currentAdapter.configSchema" :key="field.key">
|
||||
<a-form-item :label="field.label" :required="field.required">
|
||||
<!-- 字符串输入 -->
|
||||
<a-input v-if="field.type === 'string'" v-model:value="currentConfig[field.key]"
|
||||
:placeholder="field.placeholder" />
|
||||
|
||||
<!-- 数字输入 -->
|
||||
<a-input-number v-if="field.type === 'number'" v-model:value="currentConfig[field.key]"
|
||||
:min="field.min" :max="field.max" style="width: 100%;" />
|
||||
|
||||
<!-- 布尔开关 -->
|
||||
<div v-if="field.type === 'boolean'">
|
||||
<a-switch v-model:checked="currentConfig[field.key]" />
|
||||
</div>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<a-select v-if="field.type === 'select'" v-model:value="currentConfig[field.key]"
|
||||
:options="field.options" />
|
||||
|
||||
<div v-if="field.note" style="font-size: 12px; color: #8c8c8c; margin-top: 4px;">
|
||||
{{ field.note }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<div style="text-align: right;">
|
||||
<a-button style="margin-right: 8px" @click="drawerVisible = false">取消</a-button>
|
||||
<a-button type="primary" @click="handleSave">保存配置</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
path: '',
|
||||
headless: false,
|
||||
// 全局代理
|
||||
proxyEnable: false,
|
||||
proxyType: 'http',
|
||||
proxyHost: '127.0.0.1',
|
||||
proxyPort: 7890,
|
||||
proxyAuth: false,
|
||||
proxyUser: '',
|
||||
proxyPasswd: ''
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchBrowserConfig();
|
||||
const cfg = settingsStore.browserConfig || {};
|
||||
formData.path = cfg.path || '';
|
||||
formData.headless = cfg.headless || false;
|
||||
|
||||
if (cfg.proxy) {
|
||||
formData.proxyEnable = cfg.proxy.enable || false;
|
||||
formData.proxyType = cfg.proxy.type || 'http';
|
||||
formData.proxyHost = cfg.proxy.host || '';
|
||||
formData.proxyPort = cfg.proxy.port || 7890;
|
||||
formData.proxyAuth = cfg.proxy.auth || false;
|
||||
formData.proxyUser = cfg.proxy.username || '';
|
||||
formData.proxyPasswd = cfg.proxy.password || '';
|
||||
}
|
||||
});
|
||||
|
||||
// 保存设置
|
||||
const handleSave = async () => {
|
||||
const config = {
|
||||
path: formData.path,
|
||||
headless: formData.headless,
|
||||
proxy: {
|
||||
enable: formData.proxyEnable,
|
||||
type: formData.proxyType,
|
||||
host: formData.proxyHost,
|
||||
port: formData.proxyPort,
|
||||
auth: formData.proxyAuth,
|
||||
username: formData.proxyUser,
|
||||
password: formData.proxyPasswd
|
||||
}
|
||||
};
|
||||
await settingsStore.saveBrowserConfig(config);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="background: transparent;">
|
||||
<a-card title="浏览器设置" :bordered="false" style="width: 100%;">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<!-- 浏览器可执行文件路径 -->
|
||||
<a-col :xs="24" :md="24">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">浏览器可执行文件路径</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
留空则使用 Camoufox 默认下载路径<br>
|
||||
Windows示例: C:\camoufox\camoufox.exe<br>
|
||||
Linux示例: /opt/camoufox/camoufox
|
||||
</div>
|
||||
<a-input v-model:value="formData.path" placeholder="留空使用默认路径" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 无头模式 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">无头模式</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
启用后浏览器将在后台运行,不显示窗口
|
||||
</div>
|
||||
<a-switch v-model:checked="formData.headless" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ formData.headless ? '已启用' : '未启用' }}
|
||||
</span>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 全局代理设置(折叠面板) -->
|
||||
<div style="margin-top: 16px;">
|
||||
<a-collapse>
|
||||
<a-collapse-panel key="proxy" header="全局代理设置">
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 16px;">
|
||||
如果实例没有独立配置代理,将使用此全局代理配置
|
||||
</div>
|
||||
|
||||
<!-- 是否启用代理 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-switch v-model:checked="formData.proxyEnable" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ formData.proxyEnable ? '已启用全局代理' : '未启用全局代理' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 代理类型 -->
|
||||
<div style="margin-bottom: 16px;" v-if="formData.proxyEnable">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">代理类型</div>
|
||||
<a-segmented v-model:value="formData.proxyType" block :options="[
|
||||
{ label: 'HTTP', value: 'http' },
|
||||
{ label: 'SOCKS5', value: 'socks5' }
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<a-row :gutter="16" v-if="formData.proxyEnable">
|
||||
<!-- 代理主机 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">代理主机</div>
|
||||
<a-input v-model:value="formData.proxyHost" placeholder="例如: 127.0.0.1" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 代理端口 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">代理端口</div>
|
||||
<a-input-number v-model:value="formData.proxyPort" :min="1" :max="65535"
|
||||
style="width: 100%" placeholder="例如: 7890" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 是否需要验证 -->
|
||||
<div style="margin-bottom: 16px;" v-if="formData.proxyEnable">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">代理认证</div>
|
||||
<a-switch v-model:checked="formData.proxyAuth" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ formData.proxyAuth ? '需要认证' : '无需认证' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="16" v-if="formData.proxyEnable && formData.proxyAuth">
|
||||
<!-- 用户名 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">用户名</div>
|
||||
<a-input v-model:value="formData.proxyUser" placeholder="请输入用户名" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 密码 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">密码</div>
|
||||
<a-input-password v-model:value="formData.proxyPasswd" placeholder="请输入密码" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮(右下角) -->
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 24px;">
|
||||
<a-button type="primary" @click="handleSave">
|
||||
保存设置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
port: 5173,
|
||||
authToken: '',
|
||||
keepaliveMode: 'comment',
|
||||
queueBuffer: 2,
|
||||
imageLimit: 5
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchServerConfig();
|
||||
Object.assign(formData, settingsStore.serverConfig);
|
||||
});
|
||||
|
||||
// 保存设置
|
||||
const handleSave = async () => {
|
||||
await settingsStore.saveServerConfig(formData);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="background: transparent;">
|
||||
<a-card title="服务器设置" :bordered="false" style="width: 100%;">
|
||||
<!-- 4宫格表单布局 -->
|
||||
<a-row :gutter="[16, 16]">
|
||||
<!-- 监听端口 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">监听端口</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
设置服务器监听的端口号,默认为 5173
|
||||
</div>
|
||||
<a-input-number v-model:value="formData.port" :min="1" :max="65535" placeholder="请输入端口号"
|
||||
style="width: 100%" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 鉴权 Token -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">鉴权 Token</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
用于 API 请求鉴权的密钥,留空则不启用鉴权
|
||||
</div>
|
||||
<a-input-password v-model:value="formData.authToken" placeholder="请输入 Token" type="password" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 心跳包类型 (Keepalive Mode) -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">心跳包类型</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
选择 SSE 流式响应的心跳包格式
|
||||
</div>
|
||||
<a-select v-model:value="formData.keepaliveMode" style="width: 100%" placeholder="请选择心跳包类型">
|
||||
<a-select-option value="comment">Comment - 注释格式</a-select-option>
|
||||
<a-select-option value="content">Content - 内容格式</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 保存按钮(右下角) -->
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 24px;">
|
||||
<a-button type="primary" @click="handleSave">
|
||||
保存设置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 队列设置 -->
|
||||
<a-card title="队列设置" :bordered="false" style="width: 100%; margin-top: 10px;">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<!-- 队列缓冲区大小 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">队列缓冲区大小</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
非流式请求的额外排队数(设为 0 则不限制非流式请求数量)<br>
|
||||
实际队列上限 = Workers数量 + 缓冲区大小
|
||||
</div>
|
||||
<a-input-number v-model:value="formData.queueBuffer" :min="0" :max="100" placeholder="默认为 2"
|
||||
style="width: 100%" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 图片数量上限 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">图片数量上限</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
单次请求最多支持的图片附件数量<br>
|
||||
网页最多支持10个附件,超出会被丢弃
|
||||
</div>
|
||||
<a-input-number v-model:value="formData.imageLimit" :min="1" :max="10" placeholder="默认为 5"
|
||||
style="width: 100%" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 保存按钮(右下角) -->
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 24px;">
|
||||
<a-button type="primary" @click="handleSave">
|
||||
保存设置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 确保在手机端也能正常显示 */
|
||||
.ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,490 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const poolConfig = computed({
|
||||
get: () => settingsStore.poolConfig,
|
||||
set: (val) => settingsStore.poolConfig = val
|
||||
});
|
||||
|
||||
const handleSavePool = async () => {
|
||||
await settingsStore.savePoolConfig(poolConfig.value);
|
||||
};
|
||||
|
||||
// 获取初始数据
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
settingsStore.fetchWorkerConfig(),
|
||||
settingsStore.fetchPoolConfig(),
|
||||
settingsStore.fetchAdaptersMeta()
|
||||
]);
|
||||
});
|
||||
|
||||
// 计算属性:适配器选项
|
||||
const adapterOptions = computed(() => {
|
||||
const options = settingsStore.adaptersMeta.map(a => ({
|
||||
label: a.name,
|
||||
value: a.id
|
||||
}));
|
||||
// 手动添加 Merge 选项(如果没有在元数据中返回)
|
||||
if (!options.find(o => o.value === 'merge')) {
|
||||
options.push({ label: 'Merge(聚合模式)', value: 'merge' });
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
// 实例列表表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '实例名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Worker 数量',
|
||||
dataIndex: 'workerCount',
|
||||
key: 'workerCount',
|
||||
},
|
||||
{
|
||||
title: '代理',
|
||||
dataIndex: 'proxy',
|
||||
key: 'proxy',
|
||||
},
|
||||
{
|
||||
title: '数据标记',
|
||||
key: 'userDataMark',
|
||||
dataIndex: 'userDataMark',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
},
|
||||
];
|
||||
|
||||
// 实例列表数据 (从 Store 获取)
|
||||
const instanceData = computed({
|
||||
get: () => settingsStore.workerConfig,
|
||||
set: (val) => { settingsStore.workerConfig = val; }
|
||||
});
|
||||
|
||||
// 抽屉状态
|
||||
const drawerOpen = ref(false);
|
||||
const editingInstance = ref(null);
|
||||
|
||||
// 编辑表单数据
|
||||
const editForm = ref({
|
||||
name: '',
|
||||
userDataMark: '',
|
||||
proxy: false,
|
||||
proxyType: 'socks5',
|
||||
proxyHost: '',
|
||||
proxyPort: 1080,
|
||||
proxyAuth: false,
|
||||
proxyUsername: '',
|
||||
proxyPassword: '',
|
||||
workers: []
|
||||
});
|
||||
|
||||
// 创建实例
|
||||
const handleCreateInstance = () => {
|
||||
editingInstance.value = null; // null表示创建新实例
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 7);
|
||||
// 重置表单为默认值
|
||||
editForm.value = {
|
||||
name: `instance-${(instanceData.value || []).length + 1}-${randomSuffix}`,
|
||||
userDataMark: '',
|
||||
proxy: false,
|
||||
proxyType: 'socks5',
|
||||
proxyHost: '',
|
||||
proxyPort: 1080,
|
||||
proxyAuth: false,
|
||||
proxyUsername: '',
|
||||
proxyPassword: '',
|
||||
workers: []
|
||||
};
|
||||
drawerOpen.value = true;
|
||||
};
|
||||
|
||||
// 编辑实例
|
||||
const handleEdit = (record) => {
|
||||
editingInstance.value = record;
|
||||
// 填充表单数据
|
||||
editForm.value = {
|
||||
name: record.name,
|
||||
userDataMark: record.userDataMark || '',
|
||||
proxy: record.proxy ? true : false,
|
||||
proxyType: record.proxy?.type || 'socks5',
|
||||
proxyHost: record.proxy?.host || '',
|
||||
proxyPort: record.proxy?.port || 1080,
|
||||
proxyAuth: record.proxy?.auth || false,
|
||||
proxyUsername: record.proxy?.username || '',
|
||||
proxyPassword: record.proxy?.password || '',
|
||||
workers: record.workers ? [...record.workers] : []
|
||||
};
|
||||
// 兼容前端展示用的 proxy 布尔值
|
||||
if (record.proxy === null || record.proxy === undefined) {
|
||||
editForm.value.proxy = false;
|
||||
}
|
||||
drawerOpen.value = true;
|
||||
};
|
||||
|
||||
// 删除实例
|
||||
const handleDelete = async (record) => {
|
||||
const newList = instanceData.value.filter(item => item.id !== record.id);
|
||||
await settingsStore.saveWorkerConfig(newList);
|
||||
};
|
||||
|
||||
// 保存编辑
|
||||
const handleSaveEdit = async () => {
|
||||
// 构建要保存的对象结构
|
||||
const instanceToSave = {
|
||||
id: editingInstance.value ? editingInstance.value.id : `inst_${Date.now()}`,
|
||||
name: editForm.value.name,
|
||||
userDataMark: editForm.value.userDataMark,
|
||||
workers: editForm.value.workers,
|
||||
// 如果启用了代理,则构建代理对象,否则为 null
|
||||
proxy: editForm.value.proxy ? {
|
||||
enable: true,
|
||||
type: editForm.value.proxyType,
|
||||
host: editForm.value.proxyHost,
|
||||
port: editForm.value.proxyPort,
|
||||
auth: editForm.value.proxyAuth,
|
||||
username: editForm.value.proxyUsername,
|
||||
password: editForm.value.proxyPassword
|
||||
} : null
|
||||
};
|
||||
|
||||
let newList = [...(instanceData.value || [])];
|
||||
if (editingInstance.value === null) {
|
||||
// 创建
|
||||
newList.push(instanceToSave);
|
||||
} else {
|
||||
// 更新
|
||||
const index = newList.findIndex(item => item.id === editingInstance.value.id);
|
||||
if (index > -1) {
|
||||
newList[index] = instanceToSave;
|
||||
}
|
||||
}
|
||||
|
||||
const success = await settingsStore.saveWorkerConfig(newList);
|
||||
if (success) {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑中的Worker索引
|
||||
const editingWorkerIndex = ref(-1);
|
||||
const workerFormVisible = ref(false);
|
||||
const workerForm = ref({
|
||||
name: '',
|
||||
type: 'lmarena',
|
||||
mergeTypes: [],
|
||||
mergeMonitor: ''
|
||||
});
|
||||
|
||||
// 添加Worker
|
||||
const handleAddWorker = () => {
|
||||
editingWorkerIndex.value = -1;
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 7);
|
||||
workerForm.value = {
|
||||
name: `worker-${editForm.value.workers.length + 1}-${randomSuffix}`,
|
||||
type: 'lmarena',
|
||||
mergeTypes: [],
|
||||
mergeMonitor: ''
|
||||
};
|
||||
workerFormVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑Worker
|
||||
const handleEditWorker = (index) => {
|
||||
editingWorkerIndex.value = index;
|
||||
const worker = editForm.value.workers[index];
|
||||
workerForm.value = {
|
||||
name: worker.name,
|
||||
type: worker.type,
|
||||
mergeTypes: worker.mergeTypes ? [...worker.mergeTypes] : [],
|
||||
mergeMonitor: worker.mergeMonitor || ''
|
||||
};
|
||||
workerFormVisible.value = true;
|
||||
};
|
||||
|
||||
// 保存Worker配置
|
||||
const handleSaveWorker = () => {
|
||||
if (editingWorkerIndex.value === -1) {
|
||||
// 新增
|
||||
editForm.value.workers.push({ ...workerForm.value });
|
||||
} else {
|
||||
// 编辑
|
||||
editForm.value.workers[editingWorkerIndex.value] = { ...workerForm.value };
|
||||
}
|
||||
workerFormVisible.value = false;
|
||||
};
|
||||
|
||||
// 删除Worker
|
||||
const handleRemoveWorker = (index) => {
|
||||
editForm.value.workers.splice(index, 1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="background: transparent;">
|
||||
<a-card title="负载均衡" :bordered="false" style="width: 100%; margin-bottom: 10px;">
|
||||
<!-- 调度策略 -->
|
||||
<div style="margin-bottom: 24px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">调度策略</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 12px;">
|
||||
选择任务分配到工作实例的调度算法
|
||||
</div>
|
||||
<a-segmented v-model:value="poolConfig.strategy" block :options="[
|
||||
{ label: '最少繁忙', value: 'least_busy' },
|
||||
{ label: '轮询', value: 'round_robin' },
|
||||
{ label: '随机', value: 'random' }
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<!-- 故障转移 -->
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">故障转移</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 12px;">
|
||||
启用后,任务失败时会自动切换到其他可用实例重试
|
||||
</div>
|
||||
<a-switch v-model:checked="poolConfig.failover.enabled" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">重试次数</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 12px;">
|
||||
故障转移时最大重试次数,范围 1-10
|
||||
</div>
|
||||
<a-input-number v-model:value="poolConfig.failover.maxRetries" :min="1" :max="10"
|
||||
:disabled="!poolConfig.failover.enabled" style="width: 100%" placeholder="请输入重试次数" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 24px;">
|
||||
<a-button type="primary" @click="handleSavePool">
|
||||
保存设置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
|
||||
<a-card :bordered="false" style="width: 100%;">
|
||||
<!-- 卡片标题和创建按钮 -->
|
||||
<template #title>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>实例列表</span>
|
||||
<a-button type="primary" @click="handleCreateInstance">
|
||||
创建实例
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 实例表格 -->
|
||||
<a-table :columns="columns" :data-source="instanceData" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 实例名称 -->
|
||||
<template v-if="column.key === 'name'">
|
||||
<a>{{ record.name }}</a>
|
||||
</template>
|
||||
|
||||
<!-- Worker 数量 -->
|
||||
<template v-else-if="column.key === 'workerCount'">
|
||||
{{ record.workers ? record.workers.length : 0 }}
|
||||
</template>
|
||||
|
||||
<!-- 代理状态 -->
|
||||
<template v-else-if="column.key === 'proxy'">
|
||||
<a-tag :color="record.proxy ? 'green' : 'default'">
|
||||
{{ record.proxy ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<span>
|
||||
<a @click="handleEdit(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a style="color: #ff4d4f" @click="handleDelete(record)">删除</a>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 编辑/创建抽屉 -->
|
||||
<a-drawer v-model:open="drawerOpen"
|
||||
:title="editingInstance === null ? '创建实例' : `编辑实例 - ${editingInstance.name}`" placement="right" width="500">
|
||||
<div style="margin-bottom: 24px;">
|
||||
<!-- 实例名称 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">实例名称</div>
|
||||
<div style="font-size: 12px; color: #ff4d4f; margin-bottom: 8px;">
|
||||
* 名称必须全局唯一,不可重复
|
||||
</div>
|
||||
<a-input v-model:value="editForm.name" placeholder="请输入实例名称" />
|
||||
</div>
|
||||
|
||||
<!-- 数据标记 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">数据标记</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
用于区分实例数据存储的文件夹名称 (userDataMark)
|
||||
</div>
|
||||
<a-input v-model:value="editForm.userDataMark" placeholder="请输入数据标记,如: main-gemini" />
|
||||
</div>
|
||||
|
||||
<!-- 代理设置(折叠面板) -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-collapse>
|
||||
<a-collapse-panel key="proxy" header="代理设置">
|
||||
<!-- 是否启用代理 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-switch v-model:checked="editForm.proxy" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ editForm.proxy ? '已启用代理' : '未启用代理' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 代理类型 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">代理类型</div>
|
||||
<a-segmented v-model:value="editForm.proxyType" block :options="[
|
||||
{ label: 'SOCKS5', value: 'socks5' },
|
||||
{ label: 'HTTP', value: 'http' }
|
||||
]" style="width: 100%" />
|
||||
</div>
|
||||
|
||||
<!-- 服务器地址 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">服务器地址</div>
|
||||
<a-input v-model:value="editForm.proxyHost" placeholder="例如: 127.0.0.1" />
|
||||
</div>
|
||||
|
||||
<!-- 端口 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">端口</div>
|
||||
<a-input-number v-model:value="editForm.proxyPort" :min="1" :max="65535"
|
||||
style="width: 100%" placeholder="例如: 1080" />
|
||||
</div>
|
||||
|
||||
<!-- 是否需要验证 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">身份验证</div>
|
||||
<a-switch v-model:checked="editForm.proxyAuth" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ editForm.proxyAuth ? '需要验证' : '无需验证' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 用户名 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy && editForm.proxyAuth">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">用户名</div>
|
||||
<a-input v-model:value="editForm.proxyUsername" placeholder="请输入用户名" />
|
||||
</div>
|
||||
|
||||
<!-- 密码 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy && editForm.proxyAuth">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">密码</div>
|
||||
<a-input-password v-model:value="editForm.proxyPassword" placeholder="请输入密码" />
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
|
||||
<!-- Worker 列表 -->
|
||||
<div>
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<div style="font-weight: 600;">Worker 列表</div>
|
||||
<a-button size="small" type="primary" @click="handleAddWorker">
|
||||
添加 Worker
|
||||
</a-button>
|
||||
</div>
|
||||
<a-list bordered :data-source="editForm.workers" style="margin-top: 8px;">
|
||||
<template #renderItem="{ item, index }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a @click="handleEditWorker(index)">编辑</a>
|
||||
<a style="color: #ff4d4f" @click="handleRemoveWorker(index)">删除</a>
|
||||
</template>
|
||||
<div>
|
||||
<div style="font-weight: 600;">{{ item.name }}</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c;">
|
||||
类型: {{ item.type }}
|
||||
<span v-if="item.type === 'merge'">
|
||||
| 聚合: {{ item.mergeTypes?.join(', ') || '无' }}
|
||||
<span v-if="item.mergeMonitor">
|
||||
| 监控: {{ item.mergeMonitor }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 抽屉底部保存按钮 -->
|
||||
<template #footer>
|
||||
<div style="text-align: right;">
|
||||
<a-button style="margin-right: 8px" @click="drawerOpen = false">取消</a-button>
|
||||
<a-button type="primary" @click="handleSaveEdit">保存</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<!-- Worker配置模态框 -->
|
||||
<a-modal v-model:open="workerFormVisible" :title="editingWorkerIndex === -1 ? '添加 Worker' : '编辑 Worker'"
|
||||
okText="确定" cancelText="取消" @ok="handleSaveWorker">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">Worker 名称</div>
|
||||
<div style="font-size: 12px; color: #ff4d4f; margin-bottom: 8px;">
|
||||
* 名称必须全局唯一,不可重复
|
||||
</div>
|
||||
<a-input v-model:value="workerForm.name" placeholder="例如: default" />
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">适配器类型</div>
|
||||
<a-select v-model:value="workerForm.type" style="width: 100%" :options="adapterOptions" />
|
||||
</div>
|
||||
|
||||
<!-- Merge 模式额外配置 -->
|
||||
<template v-if="workerForm.type === 'merge'">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">聚合类型</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
选择要聚合的后端适配器(可多选)
|
||||
</div>
|
||||
<a-select v-model:value="workerForm.mergeTypes" mode="multiple" style="width: 100%"
|
||||
placeholder="选择要聚合的适配器" :options="adapterOptions">
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">空闲监控后端</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
空闲时挂机监控的后端(可选)
|
||||
</div>
|
||||
<a-select v-model:value="workerForm.mergeMonitor" style="width: 100%" placeholder="选择监控后端(可留空)"
|
||||
allow-clear>
|
||||
<a-select-option value="">无</a-select-option>
|
||||
<a-select-option v-for="type in workerForm.mergeTypes" :key="type" :value="type">
|
||||
{{ type }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,444 @@
|
||||
<script setup>
|
||||
import { h, ref, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useSystemStore } from '@/stores/system';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import {
|
||||
PoweroffOutlined,
|
||||
CheckCircleOutlined,
|
||||
LoadingOutlined,
|
||||
ClockCircleOutlined,
|
||||
DeleteOutlined,
|
||||
FolderOutlined,
|
||||
StopOutlined,
|
||||
LoginOutlined,
|
||||
DownOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const systemStore = useSystemStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 重启步骤当前状态
|
||||
const currentStep = ref(0);
|
||||
const restarting = ref(false);
|
||||
|
||||
// 重启步骤定义
|
||||
const restartSteps = ref([
|
||||
{
|
||||
title: '准备重启',
|
||||
status: 'wait',
|
||||
icon: h(ClockCircleOutlined),
|
||||
},
|
||||
{
|
||||
title: '发送指令',
|
||||
status: 'wait',
|
||||
icon: h(PoweroffOutlined),
|
||||
},
|
||||
{
|
||||
title: '等待重启',
|
||||
status: 'wait',
|
||||
icon: h(LoadingOutlined),
|
||||
},
|
||||
{
|
||||
title: '重启完成',
|
||||
status: 'wait',
|
||||
icon: h(CheckCircleOutlined),
|
||||
},
|
||||
]);
|
||||
|
||||
// 实例文件夹抽屉
|
||||
const instanceDrawerOpen = ref(false);
|
||||
const selectedFolders = ref([]);
|
||||
|
||||
// 实例文件夹列表
|
||||
const instanceFolders = ref([]);
|
||||
|
||||
// 重启弹窗状态
|
||||
const restartModalVisible = ref(false);
|
||||
|
||||
// Workers 列表(用于登录模式选择)
|
||||
const workers = ref([]);
|
||||
|
||||
// 确认重启弹窗
|
||||
const restartConfirmVisible = ref(false);
|
||||
const pendingRestartOptions = ref({});
|
||||
|
||||
// 获取 workers 列表
|
||||
const fetchWorkers = async () => {
|
||||
try {
|
||||
const res = await fetch('/admin/config/instances', {
|
||||
headers: settingsStore.getHeaders()
|
||||
});
|
||||
if (res.ok) {
|
||||
const instances = await res.json();
|
||||
// 从 instances 中提取所有 workers
|
||||
const allWorkers = [];
|
||||
for (const inst of instances) {
|
||||
for (const w of (inst.workers || [])) {
|
||||
allWorkers.push({ name: w.name, instance: inst.name });
|
||||
}
|
||||
}
|
||||
workers.value = allWorkers;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取 Workers 列表失败', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 显示重启确认
|
||||
const showRestartConfirm = (options = {}) => {
|
||||
pendingRestartOptions.value = options;
|
||||
restartConfirmVisible.value = true;
|
||||
};
|
||||
|
||||
// 确认重启
|
||||
const confirmRestart = () => {
|
||||
restartConfirmVisible.value = false;
|
||||
handleRestart(pendingRestartOptions.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorkers();
|
||||
});
|
||||
|
||||
// 辅助函数:延迟
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 执行重启
|
||||
const handleRestart = async (options = {}) => {
|
||||
restartModalVisible.value = true;
|
||||
restarting.value = true;
|
||||
currentStep.value = 0;
|
||||
|
||||
// 步骤1: 准备
|
||||
restartSteps.value[0].status = 'process';
|
||||
await sleep(500);
|
||||
restartSteps.value[0].status = 'finish';
|
||||
currentStep.value = 1;
|
||||
|
||||
// 步骤2: 发送指令 (调用API)
|
||||
restartSteps.value[1].status = 'process';
|
||||
try {
|
||||
await systemStore.restartService(options);
|
||||
restartSteps.value[1].status = 'finish';
|
||||
currentStep.value = 2;
|
||||
} catch (e) {
|
||||
restartSteps.value[1].status = 'error';
|
||||
message.error('无法连接到服务器');
|
||||
return;
|
||||
}
|
||||
|
||||
// 步骤3: 等待服务恢复 (轮询检查)
|
||||
restartSteps.value[2].status = 'process';
|
||||
// 先等待一小段时间让服务重启
|
||||
await sleep(3000);
|
||||
let retries = 20;
|
||||
while (retries > 0) {
|
||||
try {
|
||||
await systemStore.fetchStatus();
|
||||
if (systemStore.status) {
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
await sleep(2000);
|
||||
retries--;
|
||||
}
|
||||
restartSteps.value[2].status = 'finish';
|
||||
currentStep.value = 3;
|
||||
|
||||
// 步骤4: 完成
|
||||
restartSteps.value[3].status = 'finish';
|
||||
|
||||
message.success('服务重启成功');
|
||||
|
||||
// 延迟关闭弹窗并重置状态
|
||||
setTimeout(() => {
|
||||
restartModalVisible.value = false;
|
||||
restarting.value = false;
|
||||
restartSteps.value.forEach(step => step.status = 'wait');
|
||||
currentStep.value = 0;
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// 停止服务
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
const success = await systemStore.stopService();
|
||||
if (success) {
|
||||
message.success('服务已停止');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('停止服务失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 清理缓存
|
||||
const handleClearCache = async () => {
|
||||
try {
|
||||
const res = await fetch('/admin/cache/clear', {
|
||||
method: 'POST',
|
||||
headers: settingsStore.getHeaders()
|
||||
});
|
||||
if (res.ok) {
|
||||
message.success('缓存文件夹已清理');
|
||||
} else {
|
||||
message.error('清理失败');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('请求失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开实例文件夹管理抽屉
|
||||
const handleOpenInstanceDrawer = async () => {
|
||||
selectedFolders.value = [];
|
||||
instanceDrawerOpen.value = true;
|
||||
try {
|
||||
const res = await fetch('/admin/data-folders', {
|
||||
headers: settingsStore.getHeaders()
|
||||
});
|
||||
if (res.ok) {
|
||||
instanceFolders.value = await res.json();
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('获取文件夹列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 选中/取消选中文件夹
|
||||
const handleFolderSelect = (name, checked) => {
|
||||
if (checked) {
|
||||
if (!selectedFolders.value.includes(name)) {
|
||||
selectedFolders.value.push(name);
|
||||
}
|
||||
} else {
|
||||
selectedFolders.value = selectedFolders.value.filter(n => n !== name);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除选中的实例数据
|
||||
const handleDeleteSelectedFolders = async () => {
|
||||
if (selectedFolders.value.length === 0) {
|
||||
message.warning('请先选择要删除的文件夹');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/admin/data-folders/delete', {
|
||||
method: 'POST',
|
||||
headers: settingsStore.getHeaders(),
|
||||
body: JSON.stringify({ folders: selectedFolders.value })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
message.success(`已删除 ${selectedFolders.value.length} 个实例数据文件夹`);
|
||||
// 刷新列表
|
||||
await handleOpenInstanceDrawer();
|
||||
} else {
|
||||
message.error('删除失败');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('删除请求失败');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="background: transparent;">
|
||||
<!-- 项目管理板块 -->
|
||||
<a-card title="项目管理" :bordered="false" style="width: 100%; margin-bottom: 10px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="margin-right: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">系统服务控制</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c;">
|
||||
控制后端服务的运行状态 (重启或停止)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a-space>
|
||||
<!-- 下拉式重启按钮 -->
|
||||
<a-dropdown-button type="primary" size="large" @click="showRestartConfirm()">
|
||||
<PoweroffOutlined />
|
||||
重启
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="normal" @click="showRestartConfirm()">
|
||||
<PoweroffOutlined />
|
||||
普通重启
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="login" @click="showRestartConfirm({ loginMode: true })">
|
||||
<LoginOutlined />
|
||||
登录模式重启
|
||||
</a-menu-item>
|
||||
<a-sub-menu v-if="workers.length > 1" key="login-worker" title="指定 Worker 登录">
|
||||
<template #icon>
|
||||
<LoginOutlined />
|
||||
</template>
|
||||
<a-menu-item v-for="worker in workers" :key="worker.name"
|
||||
@click="showRestartConfirm({ loginMode: true, workerName: worker.name })">
|
||||
{{ worker.name }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown-button>
|
||||
|
||||
<a-popconfirm ok-text="确定" cancel-text="取消" @confirm="handleStop" placement="topRight">
|
||||
<template #title>
|
||||
<div style="width: 240px;">
|
||||
<div style="font-weight: 500; margin-bottom: 4px;">确定要停止服务吗?</div>
|
||||
<div style="font-size: 12px; color: #f5222d;">停止后服务将完全终止,需要手动重新启动。</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-button type="primary" danger size="large">
|
||||
<template #icon>
|
||||
<StopOutlined />
|
||||
</template>
|
||||
停止
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 重启确认模态框 -->
|
||||
<a-modal v-model:open="restartConfirmVisible" title="确认重启" @ok="confirmRestart" ok-text="确定" cancel-text="取消"
|
||||
:width="400">
|
||||
<div style="padding: 12px 0;">
|
||||
<p v-if="!pendingRestartOptions.loginMode">确定要重启服务吗?</p>
|
||||
<p v-else-if="pendingRestartOptions.workerName">
|
||||
确定要以<b>登录模式</b>重启服务吗?<br />
|
||||
<span style="color: #1890ff;">仅初始化 Worker: {{ pendingRestartOptions.workerName }}</span>
|
||||
</p>
|
||||
<p v-else>确定要以<b>登录模式</b>重启服务吗?</p>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 重启进度模态框 -->
|
||||
<a-modal v-model:open="restartModalVisible" title="系统服务重启中" :footer="null" :closable="false"
|
||||
:maskClosable="false" width="500px">
|
||||
<div style="padding: 24px 0;">
|
||||
<a-steps :current="currentStep" :items="restartSteps" />
|
||||
<div style="text-align: center; margin-top: 24px; color: #8c8c8c;">
|
||||
请稍候,系统正在执行重启操作...
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 缓存管理板块 -->
|
||||
<a-card title="缓存管理" :bordered="false" style="width: 100%;">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<!-- 清理缓存 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card style="height: 100%;"
|
||||
:body-style="{ display: 'flex', flexDirection: 'column', height: '100%' }">
|
||||
<div style="flex: 1;">
|
||||
<div style="display: flex; align-items: center; margin-bottom: 12px;">
|
||||
<DeleteOutlined style="font-size: 24px; color: #1890ff; margin-right: 8px;" />
|
||||
<div style="font-weight: 600; font-size: 16px;">清理缓存文件夹</div>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 16px;">
|
||||
清理项目运行过程中可能会遗留的临时缓存文件(如遇到错误时遗留的图片),<br>
|
||||
不会影响用户数据和配置<strong style="color: #ff4d4f;">有任务运行时请勿执行</strong>
|
||||
</div>
|
||||
</div>
|
||||
<a-popconfirm title="确定要清理缓存文件夹吗?" ok-text="确定" cancel-text="取消" @confirm="handleClearCache">
|
||||
<a-button type="primary" block>
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
清理缓存
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 删除实例数据 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card style="height: 100%;"
|
||||
:body-style="{ display: 'flex', flexDirection: 'column', height: '100%' }">
|
||||
<div style="flex: 1;">
|
||||
<div style="display: flex; align-items: center; margin-bottom: 12px;">
|
||||
<FolderOutlined style="font-size: 24px; color: #ff4d4f; margin-right: 8px;" />
|
||||
<div style="font-weight: 600; font-size: 16px;">删除实例数据文件夹</div>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 16px;">
|
||||
删除所有浏览器实例的用户数据文件夹,<br>
|
||||
包括 Cookie、本地存储等,<strong style="color: #ff4d4f;">请谨慎操作</strong>
|
||||
</div>
|
||||
</div>
|
||||
<a-button danger block @click="handleOpenInstanceDrawer">
|
||||
<template #icon>
|
||||
<FolderOutlined />
|
||||
</template>
|
||||
管理实例数据
|
||||
</a-button>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 实例数据文件夹管理抽屉 -->
|
||||
<a-drawer v-model:open="instanceDrawerOpen" title="管理实例数据文件夹" placement="right" width="500">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 12px;">
|
||||
选择要删除的实例数据文件夹,删除后无法恢复,请谨慎操作
|
||||
</div>
|
||||
|
||||
<!-- 文件夹列表 -->
|
||||
<a-list :data-source="instanceFolders" bordered>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<a-checkbox :checked="selectedFolders.includes(item.name)"
|
||||
@change="e => handleFolderSelect(item.name, e.target.checked)">
|
||||
{{ item.name }}
|
||||
</a-checkbox>
|
||||
</template>
|
||||
<template #description>
|
||||
<div style="font-size: 12px; margin-top: 4px;">
|
||||
<div>路径: {{ item.path }}</div>
|
||||
<div>关联实例: {{ item.instance }}</div>
|
||||
<div>大小: {{ item.size }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</div>
|
||||
|
||||
<!-- 抽屉底部操作按钮 -->
|
||||
<template #footer>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="font-size: 12px; color: #8c8c8c;">
|
||||
已选择 {{ selectedFolders.length }} 个文件夹
|
||||
</div>
|
||||
<div>
|
||||
<a-button style="margin-right: 8px;" @click="instanceDrawerOpen = false">
|
||||
取消
|
||||
</a-button>
|
||||
<a-popconfirm placement="topRight" ok-text="确定删除" cancel-text="取消"
|
||||
@confirm="handleDeleteSelectedFolders">
|
||||
<template #title>
|
||||
<div style="white-space: nowrap;">
|
||||
确定要删除选中的 {{ selectedFolders.length }} 个文件夹吗?
|
||||
</div>
|
||||
</template>
|
||||
<a-button type="primary" danger :disabled="selectedFolders.length === 0">
|
||||
删除选中项
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import {
|
||||
DesktopOutlined,
|
||||
DisconnectOutlined,
|
||||
ExpandOutlined,
|
||||
CompressOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 状态
|
||||
const loading = ref(true);
|
||||
const vncStatus = ref(null);
|
||||
const connectionState = ref('disconnected'); // disconnected, connecting, connected, error
|
||||
const errorMessage = ref('');
|
||||
const isFullscreen = ref(false);
|
||||
|
||||
// DOM 引用
|
||||
const vncContainer = ref(null);
|
||||
|
||||
// noVNC 实例
|
||||
let rfb = null;
|
||||
let RFB = null;
|
||||
|
||||
// 获取 VNC 状态
|
||||
async function fetchVncStatus() {
|
||||
try {
|
||||
const res = await fetch('/admin/vnc/status', {
|
||||
headers: settingsStore.getHeaders()
|
||||
});
|
||||
if (res.ok) {
|
||||
vncStatus.value = await res.json();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取 VNC 状态失败', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 连接 VNC
|
||||
async function connectVnc() {
|
||||
if (!vncStatus.value?.enabled) return;
|
||||
|
||||
connectionState.value = 'connecting';
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
// 动态导入 noVNC
|
||||
if (!RFB) {
|
||||
const module = await import('@novnc/novnc/core/rfb.js');
|
||||
RFB = module.default;
|
||||
}
|
||||
|
||||
// 构建 WebSocket URL
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/admin/vnc?token=${settingsStore.token}`;
|
||||
|
||||
// 创建 RFB 实例
|
||||
rfb = new RFB(vncContainer.value, wsUrl, {
|
||||
wsProtocols: ['binary']
|
||||
});
|
||||
|
||||
// 配置
|
||||
rfb.scaleViewport = true; // 缩放远程画面以适应容器
|
||||
rfb.clipViewport = false; // 不裁剪视口
|
||||
rfb.resizeSession = false; // 允许调整远程会话分辨率
|
||||
|
||||
// 事件监听
|
||||
rfb.addEventListener('connect', () => {
|
||||
connectionState.value = 'connected';
|
||||
});
|
||||
|
||||
rfb.addEventListener('disconnect', (e) => {
|
||||
connectionState.value = 'disconnected';
|
||||
if (e.detail.clean === false) {
|
||||
errorMessage.value = '连接意外断开';
|
||||
}
|
||||
rfb = null;
|
||||
});
|
||||
|
||||
rfb.addEventListener('credentialsrequired', () => {
|
||||
rfb.sendCredentials({ password: '' });
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
connectionState.value = 'error';
|
||||
errorMessage.value = e.message || '连接失败';
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
function disconnectVnc() {
|
||||
if (rfb) {
|
||||
rfb.disconnect();
|
||||
rfb = null;
|
||||
}
|
||||
connectionState.value = 'disconnected';
|
||||
}
|
||||
|
||||
// 切换全屏
|
||||
function toggleFullscreen() {
|
||||
if (!vncContainer.value) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
vncContainer.value.requestFullscreen();
|
||||
isFullscreen.value = true;
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
isFullscreen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 监听全屏变化
|
||||
function handleFullscreenChange() {
|
||||
isFullscreen.value = !!document.fullscreenElement;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchVncStatus();
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectVnc();
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="background: transparent;">
|
||||
<a-card title="虚拟显示器" :bordered="false" style="height: 100%">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" style="text-align: center; padding: 48px;">
|
||||
<a-spin size="large" />
|
||||
<div style="margin-top: 16px; color: #8c8c8c;">正在检查 VNC 状态...</div>
|
||||
</div>
|
||||
|
||||
<!-- 非 xvfbMode -->
|
||||
<div v-else-if="!vncStatus?.xvfbMode" style="text-align: center; padding: 48px;">
|
||||
<DisconnectOutlined style="font-size: 64px; color: #bfbfbf;" />
|
||||
<div style="margin-top: 16px; font-size: 16px; color: #595959;">程序未使用虚拟显示器运行</div>
|
||||
<div style="margin-top: 8px; color: #8c8c8c;">
|
||||
VNC 远程显示功能仅在 Linux 环境下使用 <code>-xvfb -vnc</code> 参数启动时可用
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- xvfbMode 但 VNC 未启用 -->
|
||||
<div v-else-if="!vncStatus?.enabled" style="text-align: center; padding: 48px;">
|
||||
<DesktopOutlined style="font-size: 64px; color: #bfbfbf;" />
|
||||
<div style="margin-top: 16px; font-size: 16px; color: #595959;">VNC 服务未启动</div>
|
||||
<div style="margin-top: 8px; color: #8c8c8c;">
|
||||
请确保启动时包含 <code>-vnc</code> 参数,并已安装 x11vnc
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VNC 可用 -->
|
||||
<div v-else>
|
||||
<!-- 控制栏 -->
|
||||
<div style="margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<a-tag v-if="connectionState === 'connected'" color="success">已连接</a-tag>
|
||||
<a-tag v-else-if="connectionState === 'connecting'" color="processing">连接中...</a-tag>
|
||||
<a-tag v-else-if="connectionState === 'error'" color="error">连接错误</a-tag>
|
||||
<a-tag v-else color="default">未连接</a-tag>
|
||||
<span v-if="errorMessage" style="margin-left: 8px; color: #ff4d4f; font-size: 12px;">
|
||||
{{ errorMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button v-if="connectionState !== 'connected'" type="primary" @click="connectVnc"
|
||||
:loading="connectionState === 'connecting'">
|
||||
<template #icon>
|
||||
<DesktopOutlined />
|
||||
</template>
|
||||
连接
|
||||
</a-button>
|
||||
<a-button v-else danger @click="disconnectVnc">
|
||||
<template #icon>
|
||||
<DisconnectOutlined />
|
||||
</template>
|
||||
断开
|
||||
</a-button>
|
||||
<a-button @click="toggleFullscreen" :disabled="connectionState !== 'connected'">
|
||||
<template #icon>
|
||||
<CompressOutlined v-if="isFullscreen" />
|
||||
<ExpandOutlined v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button @click="fetchVncStatus">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- VNC 显示区域 -->
|
||||
<div ref="vncContainer"
|
||||
style="width: 100%; aspect-ratio: 16/9; min-height: 400px; max-height: 70vh; background: #000; border-radius: 8px; overflow: hidden;">
|
||||
<div v-if="connectionState === 'disconnected'"
|
||||
style="height: 100%; display: flex; align-items: center; justify-content: center; color: #595959;">
|
||||
<div style="text-align: center;">
|
||||
<DesktopOutlined style="font-size: 48px; color: #434343;" />
|
||||
<div style="margin-top: 16px;">点击"连接"按钮查看远程显示器</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息 -->
|
||||
<div style="margin-top: 12px; font-size: 12px; color: #8c8c8c;">
|
||||
显示器: {{ vncStatus.display }} | VNC 端口: {{ vncStatus.port }}
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: () => import('@/components/dash.vue') },
|
||||
{ path: '/settings/server', component: () => import('@/components/settings/server.vue') },
|
||||
{ path: '/settings/workers', component: () => import('@/components/settings/workers.vue') },
|
||||
{ path: '/settings/browser', component: () => import('@/components/settings/browser.vue') },
|
||||
{ path: '/settings/adapters', component: () => import('@/components/settings/adapters.vue') },
|
||||
{ path: '/tools/display', component: () => import('@/components/tools/display.vue') },
|
||||
{ path: '/tools/cache', component: () => import('@/components/tools/cache.vue') },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App);
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,229 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
export const useSettingsStore = defineStore('settings', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('admin_token') || '',
|
||||
serverConfig: {},
|
||||
browserConfig: {},
|
||||
workerConfig: [],
|
||||
poolConfig: {
|
||||
strategy: 'least_busy',
|
||||
failover: {
|
||||
enabled: false,
|
||||
maxRetries: 3
|
||||
}
|
||||
},
|
||||
adapterConfig: {},
|
||||
adaptersMeta: []
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
if (token) {
|
||||
localStorage.setItem('admin_token', token);
|
||||
} else {
|
||||
localStorage.removeItem('admin_token');
|
||||
}
|
||||
},
|
||||
|
||||
getHeaders() {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
|
||||
async checkAuth() {
|
||||
try {
|
||||
const res = await fetch('/admin/status', {
|
||||
headers: this.getHeaders()
|
||||
});
|
||||
return res.status !== 401;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 错误处理辅助函数
|
||||
async handleResponse(res, successMsg) {
|
||||
let data = {};
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
// 忽略JSON解析错误
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
if (successMsg) message.success(successMsg);
|
||||
return { success: true, data };
|
||||
} else {
|
||||
console.error('Request failed:', res.status, data);
|
||||
Modal.error({
|
||||
title: '保存失败',
|
||||
content: data.message || `请求未成功: ${res.status} ${res.statusText}`,
|
||||
okText: '好的'
|
||||
});
|
||||
return { success: false, data };
|
||||
}
|
||||
},
|
||||
|
||||
// --- 服务器配置 ---
|
||||
async fetchServerConfig() {
|
||||
try {
|
||||
const res = await fetch('/admin/config/server', { headers: this.getHeaders() });
|
||||
if (res.ok) this.serverConfig = await res.json();
|
||||
} catch (e) {
|
||||
console.error('Fetch server config failed', e);
|
||||
}
|
||||
},
|
||||
async saveServerConfig(config) {
|
||||
try {
|
||||
const res = await fetch('/admin/config/server', {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
const result = await this.handleResponse(res, '服务器设置保存成功');
|
||||
if (result.success) {
|
||||
this.serverConfig = config;
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
Modal.error({ title: '保存失败 (网络异常)', content: e.message });
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// --- 浏览器配置 ---
|
||||
async fetchBrowserConfig() {
|
||||
try {
|
||||
const res = await fetch('/admin/config/browser', { headers: this.getHeaders() });
|
||||
if (res.ok) this.browserConfig = await res.json();
|
||||
} catch (e) {
|
||||
console.error('Fetch browser config failed', e);
|
||||
}
|
||||
},
|
||||
async saveBrowserConfig(config) {
|
||||
try {
|
||||
const res = await fetch('/admin/config/browser', {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
const result = await this.handleResponse(res, '浏览器设置保存成功');
|
||||
if (result.success) {
|
||||
this.browserConfig = config;
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
Modal.error({ title: '保存失败 (网络异常)', content: e.message });
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// --- 工作实例配置 ---
|
||||
async fetchWorkerConfig() {
|
||||
try {
|
||||
// 端点已更改为 /admin/config/instances
|
||||
const res = await fetch('/admin/config/instances', { headers: this.getHeaders() });
|
||||
if (res.ok) this.workerConfig = await res.json();
|
||||
} catch (e) {
|
||||
console.error('Fetch instance configuration failed', e);
|
||||
}
|
||||
},
|
||||
async saveWorkerConfig(config) {
|
||||
try {
|
||||
// 端点已更改为 /admin/config/instances
|
||||
const res = await fetch('/admin/config/instances', {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
const result = await this.handleResponse(res, '实例配置保存成功');
|
||||
if (result.success) {
|
||||
this.workerConfig = config;
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
Modal.error({ title: '保存失败 (网络异常)', content: e.message });
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// --- 工作池配置 ---
|
||||
async fetchPoolConfig() {
|
||||
try {
|
||||
const res = await fetch('/admin/config/pool', { headers: this.getHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// 合并以确保结构存在
|
||||
this.poolConfig = {
|
||||
strategy: data.strategy || 'least_busy',
|
||||
failover: {
|
||||
enabled: data.failover?.enabled || false,
|
||||
maxRetries: data.failover?.maxRetries || 3
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fetch pool config failed', e);
|
||||
}
|
||||
},
|
||||
async savePoolConfig(config) {
|
||||
try {
|
||||
const res = await fetch('/admin/config/pool', {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
const result = await this.handleResponse(res, '工作池设置保存成功');
|
||||
if (result.success) {
|
||||
this.poolConfig = config;
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
Modal.error({ title: '保存失败 (网络异常)', content: e.message });
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// --- 适配器配置与元数据 ---
|
||||
async fetchAdaptersMeta() {
|
||||
try {
|
||||
const res = await fetch('/admin/adapters', { headers: this.getHeaders() });
|
||||
if (res.ok) this.adaptersMeta = await res.json();
|
||||
} catch (e) {
|
||||
console.error('Fetch adapters meta failed', e);
|
||||
}
|
||||
},
|
||||
async fetchAdapterConfig() {
|
||||
try {
|
||||
const res = await fetch('/admin/config/adapters', { headers: this.getHeaders() });
|
||||
if (res.ok) this.adapterConfig = await res.json();
|
||||
} catch (e) {
|
||||
console.error('Fetch adapter config failed', e);
|
||||
}
|
||||
},
|
||||
async saveAdapterConfig(config) {
|
||||
try {
|
||||
const res = await fetch('/admin/config/adapters', {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
const result = await this.handleResponse(res, '适配器设置保存成功');
|
||||
if (result.success) {
|
||||
// 通过合并更新本地状态
|
||||
this.adapterConfig = { ...this.adapterConfig, ...config };
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
Modal.error({ title: '保存失败 (网络异常)', content: e.message });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useSettingsStore } from './settings';
|
||||
|
||||
export const useSystemStore = defineStore('system', {
|
||||
state: () => ({
|
||||
// 系统状态
|
||||
status: '',
|
||||
version: '1.0.0',
|
||||
systemVersion: '',
|
||||
uptime: 0,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: {
|
||||
total: 0,
|
||||
used: 0,
|
||||
free: 0
|
||||
},
|
||||
|
||||
// 仪表盘统计信息
|
||||
stats: {
|
||||
totalRequests: 0,
|
||||
successRate: 0,
|
||||
activeWorkers: 0,
|
||||
totalWorkers: 0,
|
||||
avgResponseTime: 0
|
||||
}
|
||||
}),
|
||||
|
||||
actions: {
|
||||
// 获取系统状态
|
||||
async fetchStatus() {
|
||||
const settingsStore = useSettingsStore();
|
||||
try {
|
||||
const response = await fetch('/admin/status', {
|
||||
headers: settingsStore.getHeaders()
|
||||
});
|
||||
// 如果返回401,状态更新将失败,由App.vue的身份验证检查处理
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.$patch(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch system status:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取仪表盘统计信息
|
||||
async fetchStats() {
|
||||
const settingsStore = useSettingsStore();
|
||||
try {
|
||||
const response = await fetch('/admin/stats', {
|
||||
headers: settingsStore.getHeaders()
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.stats = data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 重启服务
|
||||
async restartService(options = {}) {
|
||||
const settingsStore = useSettingsStore();
|
||||
const { loginMode, workerName } = options;
|
||||
try {
|
||||
const response = await fetch('/admin/restart', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...settingsStore.getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ loginMode, workerName })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
message.success(data.message || '服务重启中...');
|
||||
return true;
|
||||
} else {
|
||||
message.error('重启失败');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('重启请求失败');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 停止服务
|
||||
async stopService() {
|
||||
const settingsStore = useSettingsStore();
|
||||
try {
|
||||
const response = await fetch('/admin/stop', {
|
||||
method: 'POST',
|
||||
headers: settingsStore.getHeaders()
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
message.success(data.message || '服务停止中...');
|
||||
return true;
|
||||
} else {
|
||||
message.error('停止失败');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('停止请求失败');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/admin': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user