refactor: 代码解耦合,完善WebUI功能,添加网页VNC

This commit is contained in:
foxhui
2025-12-20 04:40:05 +08:00
Unverified
parent 41e3e022a3
commit c8c7aec0e1
74 changed files with 4984 additions and 644 deletions
+7 -1
View File
@@ -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
View File
@@ -1,2 +1,5 @@
ignoredBuiltDependencies:
- better-sqlite3
- better-sqlite3
packages:
- '!webui/**'
+1 -1
View File
@@ -6,7 +6,7 @@ import {
sleep,
safeClick,
uploadFilesViaChooser
} from '../../browser/utils.js';
} from '../engine/utils.js';
import {
fillPrompt,
normalizePageError,
+1 -1
View File
@@ -6,7 +6,7 @@ import {
sleep,
safeClick,
pasteImages
} from '../../browser/utils.js';
} from '../engine/utils.js';
import {
fillPrompt,
submit,
+1 -1
View File
@@ -6,7 +6,7 @@ import {
sleep,
safeClick,
pasteImages
} from '../../browser/utils.js';
} from '../engine/utils.js';
import {
fillPrompt,
submit,
+1 -1
View File
@@ -6,7 +6,7 @@ import {
sleep,
safeClick,
pasteImages
} from '../../browser/utils.js';
} from '../engine/utils.js';
import {
fillPrompt,
submit,
+1 -1
View File
@@ -6,7 +6,7 @@ import {
sleep,
safeClick,
pasteImages
} from '../../browser/utils.js';
} from '../engine/utils.js';
import {
fillPrompt,
submit,
+1 -1
View File
@@ -6,7 +6,7 @@ import {
sleep,
safeClick,
pasteImages
} from '../../browser/utils.js';
} from '../engine/utils.js';
import {
fillPrompt,
submit,
+1 -1
View File
@@ -10,7 +10,7 @@
import {
sleep,
safeClick
} from '../../browser/utils.js';
} from '../engine/utils.js';
import {
gotoWithCheck,
normalizePageError,
+1 -1
View File
@@ -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';
/**
* 生成指定范围内的随机数
+1 -1
View File
@@ -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';
+2 -2
View File
@@ -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';
+6 -5
View File
@@ -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 };
+1 -1
View File
@@ -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';
// ==========================================
+1 -1
View File
@@ -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 - 清理缓存
+195
View File
@@ -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
};
}
+120
View File
@@ -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';
/**
* 构造解析错误结果
+174
View File
@@ -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();
}
};
}
-213
View File
@@ -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';
}
}
-282
View File
@@ -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.htmlSPA 模式)
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
View File
@@ -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';
+39
View File
@@ -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
View File
@@ -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 封装 JSONSSE 响应和错误响应的统一处理函数
*/
import { getErrorDetails } from '../errors.js';
import { getErrorDetails } from './errors.js';
/**
* 发送 JSON 响应
+48 -37
View File
@@ -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}`);
}
});
}
+113
View File
@@ -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);
});
}
+2 -1
View File
@@ -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
View File
@@ -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);
});
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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};
+1
View File
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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
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};
+1
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+17
View File
@@ -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
View File
@@ -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>
+23
View File
@@ -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"
}
}
+1136
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- '.'
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+214
View File
@@ -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>
+82
View File
@@ -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>
+237
View File
@@ -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>
+128
View File
@@ -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>
+171
View File
@@ -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>
+123
View File
@@ -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>
+490
View File
@@ -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>
+444
View File
@@ -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>
+220
View File
@@ -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>
+28
View File
@@ -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')
+229
View File
@@ -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;
}
}
});
+112
View File
@@ -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;
}
}
}
});
+22
View File
@@ -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
}
}
}
})