mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat(webui): 请求历史记录与请求模型页面
后端: - 新增 SQLite 存储请求/响应历史 (src/utils/history.js) - Admin API 历史查询、删除、媒体重试等端点 - PoolManager.downloadMedia 用于媒体下载 WebUI: - 请求历史页面:筛选、分页、详情、媒体预览 - 请求历史升级为请求模型页面,支持直接发送请求 - 移动端适配与体验优化 - 批量代理设置与删除实例 - 实例配置编辑删除改用 name 匹配
This commit is contained in:
@@ -313,6 +313,30 @@ export class PoolManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Worker 的浏览器上下文下载媒体
|
||||
* @param {string} url - 媒体 URL
|
||||
* @returns {Promise<{image?: string, error?: string}>}
|
||||
*/
|
||||
async downloadMedia(url) {
|
||||
// 找一个空闲的 worker
|
||||
let worker = this.workers.find(w => w.page && w.busyCount === 0);
|
||||
if (!worker) {
|
||||
// 如果没有空闲的,用第一个有 page 的
|
||||
worker = this.workers.find(w => w.page);
|
||||
}
|
||||
if (!worker || !worker.page) {
|
||||
return { error: '没有可用的浏览器实例' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { useContextDownload } = await import('../utils/download.js');
|
||||
return await useContextDownload(url, worker.page);
|
||||
} catch (e) {
|
||||
return { error: `下载失败: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第一个 Worker 的 page
|
||||
*/
|
||||
|
||||
@@ -36,6 +36,18 @@ import {
|
||||
import { registry } from '../../../backend/registry.js';
|
||||
import { sendRestartSignal, sendStopSignal, isUnderSupervisor, getVncInfo } from '../../../utils/ipc.js';
|
||||
import { getTodayStats, getStatsRange, clearStatsRange } from '../../../utils/stats.js';
|
||||
import {
|
||||
getList as getHistoryList,
|
||||
getDetail as getHistoryDetail,
|
||||
deleteRecords as deleteHistoryRecords,
|
||||
deleteByDateRange as deleteHistoryByDateRange,
|
||||
retryMediaDownload,
|
||||
getStats as getHistoryStats,
|
||||
getModelList as getHistoryModelList,
|
||||
getMediaDir
|
||||
} from '../../../utils/history.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
/**
|
||||
* 读取请求体
|
||||
@@ -458,6 +470,143 @@ export function createAdminRouter(context) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ==================== 请求历史 ====================
|
||||
|
||||
// GET /admin/history - 历史记录列表
|
||||
if (method === 'GET' && pathname === '/history') {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const page = parseInt(url.searchParams.get('page') || '1', 10);
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '20', 10);
|
||||
const filters = {
|
||||
status: url.searchParams.get('status') || null,
|
||||
modelId: url.searchParams.get('model') || null,
|
||||
search: url.searchParams.get('search') || null,
|
||||
startDate: url.searchParams.get('startDate') || null,
|
||||
endDate: url.searchParams.get('endDate') || null
|
||||
};
|
||||
|
||||
const result = getHistoryList(filters, page, pageSize);
|
||||
sendJson(res, 200, result);
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /admin/history/stats - 历史统计摘要
|
||||
if (method === 'GET' && pathname === '/history/stats') {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const filters = {
|
||||
startDate: url.searchParams.get('startDate') || null,
|
||||
endDate: url.searchParams.get('endDate') || null
|
||||
};
|
||||
|
||||
const stats = getHistoryStats(filters);
|
||||
sendJson(res, 200, stats);
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /admin/history/models - 获取历史中使用过的模型列表
|
||||
if (method === 'GET' && pathname === '/history/models') {
|
||||
const models = getHistoryModelList();
|
||||
sendJson(res, 200, models);
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /admin/history/media/:filename - 静态媒体文件服务
|
||||
if (method === 'GET' && pathname.startsWith('/history/media/')) {
|
||||
const filename = pathname.replace('/history/media/', '');
|
||||
if (!filename || filename.includes('..') || filename.includes('/')) {
|
||||
sendApiError(res, { code: ERROR_CODES.INVALID_REQUEST_BODY, message: '无效的文件名' });
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaDir = getMediaDir();
|
||||
const filePath = path.join(mediaDir, filename);
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(filePath);
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const mimeTypes = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm'
|
||||
};
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Length': data.length,
|
||||
'Cache-Control': 'public, max-age=31536000'
|
||||
});
|
||||
res.end(data);
|
||||
} catch (e) {
|
||||
sendApiError(res, { code: ERROR_CODES.NOT_FOUND, message: '文件不存在', status: 404 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /admin/history/:id - 单条记录详情
|
||||
const historyDetailMatch = pathname.match(/^\/history\/([^/]+)$/);
|
||||
if (method === 'GET' && historyDetailMatch && !pathname.includes('/retry-media')) {
|
||||
const id = historyDetailMatch[1];
|
||||
const record = getHistoryDetail(id);
|
||||
if (record) {
|
||||
sendJson(res, 200, record);
|
||||
} else {
|
||||
sendApiError(res, { code: ERROR_CODES.NOT_FOUND, message: '记录不存在', status: 404 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /admin/history/:id/retry-media - 重试下载媒体
|
||||
const retryMediaMatch = pathname.match(/^\/history\/([^/]+)\/retry-media$/);
|
||||
if (method === 'POST' && retryMediaMatch) {
|
||||
const id = retryMediaMatch[1];
|
||||
const body = await readBody(req);
|
||||
const mediaIndex = body.mediaIndex ?? 0;
|
||||
|
||||
// 使用 queueManager 的浏览器下载(如果可用)
|
||||
let downloadFn = null;
|
||||
try {
|
||||
if (queueManager && queueManager.downloadMedia) {
|
||||
downloadFn = (url) => queueManager.downloadMedia(url);
|
||||
}
|
||||
} catch { /* queueManager 不可用,使用后备方案 */ }
|
||||
|
||||
const result = await retryMediaDownload(id, mediaIndex, downloadFn);
|
||||
if (result.success) {
|
||||
sendJson(res, 200, result);
|
||||
} else {
|
||||
sendApiError(res, { code: ERROR_CODES.INTERNAL_ERROR, message: result.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// DELETE /admin/history - 批量删除记录
|
||||
if (method === 'DELETE' && pathname === '/history') {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const startDate = url.searchParams.get('startDate');
|
||||
const endDate = url.searchParams.get('endDate');
|
||||
|
||||
// 支持按日期范围删除
|
||||
if (startDate && endDate) {
|
||||
const deleted = await deleteHistoryByDateRange(startDate, endDate);
|
||||
sendJson(res, 200, { success: true, deleted });
|
||||
return;
|
||||
}
|
||||
|
||||
// 支持按 ID 列表删除
|
||||
const body = await readBody(req);
|
||||
if (body.ids && Array.isArray(body.ids)) {
|
||||
const deleted = await deleteHistoryRecords(body.ids);
|
||||
sendJson(res, 200, { success: true, deleted });
|
||||
return;
|
||||
}
|
||||
|
||||
sendApiError(res, { code: ERROR_CODES.INVALID_REQUEST_BODY, message: '缺少 ids 数组或日期范围参数' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 404
|
||||
res.writeHead(404);
|
||||
res.end(JSON.stringify({ error: 'Not Found' }));
|
||||
|
||||
+85
-12
@@ -15,6 +15,7 @@ import {
|
||||
} from './respond.js';
|
||||
import { ERROR_CODES } from './errors.js';
|
||||
import { incrementSuccess, incrementFailed } from '../utils/stats.js';
|
||||
import { createRecord, updateRecord, processResponseMedia } from '../utils/history.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} TaskContext
|
||||
@@ -94,9 +95,25 @@ export function createQueueManager(queueConfig, callbacks) {
|
||||
*/
|
||||
async function processTask(task) {
|
||||
const { res, prompt, imagePaths, modelId, modelName, id, isStreaming } = task;
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.info('服务器', '[队列] 开始处理任务', { id, remaining: queue.length });
|
||||
|
||||
// 创建历史记录
|
||||
try {
|
||||
createRecord({
|
||||
id,
|
||||
modelId,
|
||||
modelName,
|
||||
prompt,
|
||||
inputImages: imagePaths,
|
||||
isStreaming,
|
||||
status: 'pending'
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug('服务器', `创建历史记录失败: ${e.message}`);
|
||||
}
|
||||
|
||||
// 启动心跳(流式请求)
|
||||
let heartbeatInterval = null;
|
||||
if (isStreaming) {
|
||||
@@ -123,8 +140,17 @@ export function createQueueManager(queueConfig, callbacks) {
|
||||
|
||||
// 处理结果
|
||||
if (result.error) {
|
||||
// 生成失败:记录统计并返回错误
|
||||
// 生成失败:记录统计和历史
|
||||
await incrementFailed();
|
||||
try {
|
||||
updateRecord(id, {
|
||||
status: 'failed',
|
||||
errorMessage: result.error,
|
||||
durationMs: Date.now() - startTime
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug('服务器', `更新历史记录失败: ${e.message}`);
|
||||
}
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.GENERATION_FAILED,
|
||||
message: result.error,
|
||||
@@ -136,28 +162,53 @@ export function createQueueManager(queueConfig, callbacks) {
|
||||
|
||||
// 生成成功
|
||||
let finalContent = '';
|
||||
let reasoningContent = null; // 思考过程内容
|
||||
let historyResponseText = ''; // 历史记录中存储的文本(不含 base64)
|
||||
|
||||
if (result.image) {
|
||||
// 只有图片格式才使用 markdown,视频等其他格式直接返回 data URI
|
||||
if (result.image.startsWith('data:image/')) {
|
||||
finalContent = ``;
|
||||
} else {
|
||||
finalContent = result.image;
|
||||
}
|
||||
// 直接返回 base64 数据,不加 Markdown 包装
|
||||
finalContent = result.image;
|
||||
// 历史记录只存原始 URL,不存 base64
|
||||
historyResponseText = result.imageUrl || '';
|
||||
} else {
|
||||
finalContent = result.text || '生成失败';
|
||||
historyResponseText = result.text || '';
|
||||
}
|
||||
|
||||
// 提取思考过程(如果有)
|
||||
if (result.reasoning) {
|
||||
reasoningContent = result.reasoning;
|
||||
}
|
||||
|
||||
logger.info('服务器', '结果已准备就绪', { id });
|
||||
await incrementSuccess();
|
||||
|
||||
// 更新历史记录(异步处理媒体,不阻塞响应)
|
||||
processResponseMedia(result, id).then(responseMedia => {
|
||||
try {
|
||||
updateRecord(id, {
|
||||
status: 'success',
|
||||
responseText: historyResponseText,
|
||||
reasoningContent,
|
||||
responseMedia,
|
||||
durationMs: Date.now() - startTime
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug('服务器', `更新历史记录失败: ${e.message}`);
|
||||
}
|
||||
}).catch(e => {
|
||||
logger.debug('服务器', `处理响应媒体失败: ${e.message}`);
|
||||
});
|
||||
|
||||
// 发送成功响应
|
||||
logger.info('服务器', '准备发送响应...', { id, isStreaming, contentLength: finalContent.length });
|
||||
logger.info('服务器', '准备发送响应...', { id, isStreaming, contentLength: finalContent.length, hasReasoning: !!reasoningContent });
|
||||
if (isStreaming) {
|
||||
const chunk = buildChatCompletionChunk(finalContent, modelName);
|
||||
const chunk = buildChatCompletionChunk(finalContent, modelName, 'stop', reasoningContent);
|
||||
sendSse(res, chunk);
|
||||
sendSseDone(res);
|
||||
logger.info('服务器', '流式响应已结束', { id });
|
||||
} else {
|
||||
const response = buildChatCompletion(finalContent, modelName);
|
||||
const response = buildChatCompletion(finalContent, modelName, reasoningContent);
|
||||
sendJson(res, 200, response);
|
||||
logger.info('服务器', 'JSON 响应已发送', { id });
|
||||
}
|
||||
@@ -166,8 +217,17 @@ export function createQueueManager(queueConfig, callbacks) {
|
||||
// 清除心跳
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
|
||||
// 记录失败统计
|
||||
// 记录失败统计和历史
|
||||
await incrementFailed();
|
||||
try {
|
||||
updateRecord(id, {
|
||||
status: 'failed',
|
||||
errorMessage: err.message,
|
||||
durationMs: Date.now() - startTime
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug('服务器', `更新历史记录失败: ${e.message}`);
|
||||
}
|
||||
logger.error('服务器', '任务处理失败', { id, error: err.message });
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.INTERNAL_ERROR,
|
||||
@@ -291,6 +351,18 @@ export function createQueueManager(queueConfig, callbacks) {
|
||||
return await getCookies(workerName, domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Worker 的浏览器下载媒体
|
||||
* @param {string} url - 媒体 URL
|
||||
* @returns {Promise<{image?: string, imageUrl?: string, error?: string}>}
|
||||
*/
|
||||
async function downloadMedia(url) {
|
||||
if (!poolContext || !poolContext.downloadMedia) {
|
||||
throw new Error('Pool 未初始化或不支持 downloadMedia');
|
||||
}
|
||||
return await poolContext.downloadMedia(url);
|
||||
}
|
||||
|
||||
return {
|
||||
addTask,
|
||||
getStatus,
|
||||
@@ -298,6 +370,7 @@ export function createQueueManager(queueConfig, callbacks) {
|
||||
canAcceptNonStreaming,
|
||||
initializePool,
|
||||
getPoolContext,
|
||||
getWorkerCookies
|
||||
getWorkerCookies,
|
||||
downloadMedia
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const { logger } = await import('../utils/logger.js');
|
||||
const { createQueueManager, createGlobalRouter } = await import('./index.js');
|
||||
const { isUnderSupervisor } = await import('../utils/ipc.js');
|
||||
const { loadTodayStats } = await import('../utils/stats.js');
|
||||
const { initHistoryDb } = await import('../utils/history.js');
|
||||
|
||||
// ==================== 初始化配置 ====================
|
||||
|
||||
@@ -135,6 +136,13 @@ async function startServer() {
|
||||
// 加载今日统计
|
||||
await loadTodayStats();
|
||||
|
||||
// 初始化历史记录数据库
|
||||
try {
|
||||
await initHistoryDb();
|
||||
} catch (err) {
|
||||
logger.warn('服务器', '历史记录数据库初始化失败,功能可能不可用', { error: err.message });
|
||||
}
|
||||
|
||||
// 登录模式提示
|
||||
if (isLoginMode) {
|
||||
logger.info('服务器', '登录模式已就绪,请在浏览器中完成登录操作');
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* @fileoverview 请求历史记录管理模块
|
||||
* @description 使用 SQLite 存储请求/响应历史,支持媒体文件本地存储
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const DATA_DIR = 'data/history';
|
||||
const DB_PATH = path.join(DATA_DIR, 'history.db');
|
||||
const MEDIA_DIR = path.join(DATA_DIR, 'media');
|
||||
|
||||
/** @type {Database.Database|null} */
|
||||
let db = null;
|
||||
|
||||
/**
|
||||
* 初始化历史记录数据库
|
||||
* @returns {Database.Database}
|
||||
*/
|
||||
export async function initHistoryDb() {
|
||||
if (db) return db;
|
||||
|
||||
// 确保目录存在
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||
|
||||
db = new Database(DB_PATH);
|
||||
|
||||
// 创建表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
model_id TEXT,
|
||||
model_name TEXT,
|
||||
prompt TEXT,
|
||||
input_images TEXT,
|
||||
response_text TEXT,
|
||||
reasoning_content TEXT,
|
||||
response_media TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
duration_ms INTEGER,
|
||||
is_streaming INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON requests(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_status ON requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_id ON requests(model_id);
|
||||
`);
|
||||
|
||||
logger.info('历史记录', '数据库初始化完成');
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库实例
|
||||
* @returns {Database.Database}
|
||||
*/
|
||||
function getDb() {
|
||||
if (!db) {
|
||||
throw new Error('历史记录数据库未初始化,请先调用 initHistoryDb()');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建历史记录
|
||||
* @param {object} data - 记录数据
|
||||
* @returns {string} 记录 ID
|
||||
*/
|
||||
export function createRecord(data) {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO requests (id, created_at, model_id, model_name, prompt, input_images, status, is_streaming)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
data.id,
|
||||
Date.now(),
|
||||
data.modelId || null,
|
||||
data.modelName || null,
|
||||
data.prompt || null,
|
||||
data.inputImages ? JSON.stringify(data.inputImages) : null,
|
||||
data.status || 'pending',
|
||||
data.isStreaming ? 1 : 0
|
||||
);
|
||||
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新历史记录
|
||||
* @param {string} id - 记录 ID
|
||||
* @param {object} updates - 更新数据
|
||||
*/
|
||||
export function updateRecord(id, updates) {
|
||||
const db = getDb();
|
||||
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (updates.status !== undefined) {
|
||||
fields.push('status = ?');
|
||||
values.push(updates.status);
|
||||
}
|
||||
if (updates.responseText !== undefined) {
|
||||
fields.push('response_text = ?');
|
||||
values.push(updates.responseText);
|
||||
}
|
||||
if (updates.reasoningContent !== undefined) {
|
||||
fields.push('reasoning_content = ?');
|
||||
values.push(updates.reasoningContent);
|
||||
}
|
||||
if (updates.responseMedia !== undefined) {
|
||||
fields.push('response_media = ?');
|
||||
values.push(typeof updates.responseMedia === 'string' ? updates.responseMedia : JSON.stringify(updates.responseMedia));
|
||||
}
|
||||
if (updates.errorMessage !== undefined) {
|
||||
fields.push('error_message = ?');
|
||||
values.push(updates.errorMessage);
|
||||
}
|
||||
if (updates.durationMs !== undefined) {
|
||||
fields.push('duration_ms = ?');
|
||||
values.push(updates.durationMs);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
values.push(id);
|
||||
const stmt = db.prepare(`UPDATE requests SET ${fields.join(', ')} WHERE id = ?`);
|
||||
stmt.run(...values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史记录列表
|
||||
* @param {object} filters - 筛选条件
|
||||
* @param {number} page - 页码(从 1 开始)
|
||||
* @param {number} pageSize - 每页数量
|
||||
* @returns {{items: object[], total: number, page: number, pageSize: number}}
|
||||
*/
|
||||
export function getList(filters = {}, page = 1, pageSize = 20) {
|
||||
const db = getDb();
|
||||
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
|
||||
if (filters.status && filters.status !== 'all') {
|
||||
conditions.push('status = ?');
|
||||
params.push(filters.status);
|
||||
}
|
||||
if (filters.modelId) {
|
||||
conditions.push('model_id LIKE ?');
|
||||
params.push(`%${filters.modelId}%`);
|
||||
}
|
||||
if (filters.search) {
|
||||
conditions.push('(prompt LIKE ? OR response_text LIKE ?)');
|
||||
params.push(`%${filters.search}%`, `%${filters.search}%`);
|
||||
}
|
||||
if (filters.startDate) {
|
||||
conditions.push('created_at >= ?');
|
||||
params.push(new Date(filters.startDate).setHours(0, 0, 0, 0));
|
||||
}
|
||||
if (filters.endDate) {
|
||||
conditions.push('created_at <= ?');
|
||||
params.push(new Date(filters.endDate).setHours(23, 59, 59, 999));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 获取总数
|
||||
const countStmt = db.prepare(`SELECT COUNT(*) as count FROM requests ${whereClause}`);
|
||||
const { count: total } = countStmt.get(...params);
|
||||
|
||||
// 获取分页数据
|
||||
const offset = (page - 1) * pageSize;
|
||||
const dataStmt = db.prepare(`
|
||||
SELECT * FROM requests ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
const items = dataStmt.all(...params, pageSize, offset).map(row => ({
|
||||
...row,
|
||||
inputImages: row.input_images ? JSON.parse(row.input_images) : [],
|
||||
responseMedia: row.response_media ? JSON.parse(row.response_media) : [],
|
||||
isStreaming: row.is_streaming === 1
|
||||
}));
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条记录详情
|
||||
* @param {string} id - 记录 ID
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getDetail(id) {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare('SELECT * FROM requests WHERE id = ?');
|
||||
const row = stmt.get(id);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
...row,
|
||||
inputImages: row.input_images ? JSON.parse(row.input_images) : [],
|
||||
responseMedia: row.response_media ? JSON.parse(row.response_media) : [],
|
||||
isStreaming: row.is_streaming === 1
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除记录
|
||||
* @param {string[]} ids - 要删除的记录 ID 数组
|
||||
* @returns {number} 删除的记录数
|
||||
*/
|
||||
export async function deleteRecords(ids) {
|
||||
if (!ids || ids.length === 0) return 0;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// 先获取要删除记录的媒体文件
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const selectStmt = db.prepare(`SELECT response_media FROM requests WHERE id IN (${placeholders})`);
|
||||
const rows = selectStmt.all(...ids);
|
||||
|
||||
// 删除关联的媒体文件
|
||||
for (const row of rows) {
|
||||
if (row.response_media) {
|
||||
try {
|
||||
const media = JSON.parse(row.response_media);
|
||||
for (const item of media) {
|
||||
if (item.localPath) {
|
||||
try {
|
||||
await fs.unlink(item.localPath);
|
||||
} catch (e) {
|
||||
// 文件可能不存在,忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 解析失败,忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
const deleteStmt = db.prepare(`DELETE FROM requests WHERE id IN (${placeholders})`);
|
||||
const result = deleteStmt.run(...ids);
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日期范围删除记录
|
||||
* @param {string} startDate - 开始日期 (YYYY-MM-DD)
|
||||
* @param {string} endDate - 结束日期 (YYYY-MM-DD)
|
||||
* @returns {number} 删除的记录数
|
||||
*/
|
||||
export async function deleteByDateRange(startDate, endDate) {
|
||||
const db = getDb();
|
||||
|
||||
const start = new Date(startDate).setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate).setHours(23, 59, 59, 999);
|
||||
|
||||
// 先获取要删除的记录 ID
|
||||
const selectStmt = db.prepare('SELECT id FROM requests WHERE created_at >= ? AND created_at <= ?');
|
||||
const rows = selectStmt.all(start, end);
|
||||
const ids = rows.map(r => r.id);
|
||||
|
||||
return await deleteRecords(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存媒体文件到本地
|
||||
* @param {string} dataUri - data URI 格式的媒体数据
|
||||
* @param {string} requestId - 请求 ID
|
||||
* @param {string|null} originalUrl - 原始下载 URL(用于重试)
|
||||
* @returns {{type: string, originalUrl: string, localPath: string, status: string}}
|
||||
*/
|
||||
export async function saveMediaToFile(dataUri, requestId, originalUrl = null) {
|
||||
const result = {
|
||||
type: 'unknown',
|
||||
originalUrl: originalUrl, // 保存原始 URL 用于重试
|
||||
localPath: null,
|
||||
status: 'pending'
|
||||
};
|
||||
|
||||
try {
|
||||
// 解析 data URI
|
||||
const match = dataUri.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!match) {
|
||||
// 如果不是 data URI,可能是普通 URL
|
||||
result.originalUrl = dataUri;
|
||||
result.status = 'external';
|
||||
return result;
|
||||
}
|
||||
|
||||
const [, mimeType, base64Data] = match;
|
||||
result.type = mimeType.startsWith('image/') ? 'image' : mimeType.startsWith('video/') ? 'video' : 'file';
|
||||
|
||||
// 确定文件扩展名
|
||||
const extMap = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'video/mp4': 'mp4',
|
||||
'video/webm': 'webm'
|
||||
};
|
||||
const ext = extMap[mimeType] || mimeType.split('/')[1] || 'bin';
|
||||
|
||||
// 生成文件名
|
||||
const filename = `${requestId}_${Date.now()}.${ext}`;
|
||||
const filePath = path.join(MEDIA_DIR, filename);
|
||||
|
||||
// 写入文件
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
result.localPath = filePath;
|
||||
result.status = 'downloaded';
|
||||
|
||||
logger.debug('历史记录', `媒体文件已保存: ${filename}`);
|
||||
} catch (error) {
|
||||
logger.error('历史记录', `保存媒体文件失败: ${error.message}`);
|
||||
result.status = 'failed';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应中的媒体内容
|
||||
* @param {object} result - 生成结果 {text, image, imageUrl, reasoning}
|
||||
* @param {string} requestId - 请求 ID
|
||||
* @returns {object[]} 媒体信息数组
|
||||
*/
|
||||
export async function processResponseMedia(result, requestId) {
|
||||
const media = [];
|
||||
|
||||
// 处理直接返回的图片/视频(带原始 URL)
|
||||
if (result.image) {
|
||||
const mediaInfo = await saveMediaToFile(result.image, requestId, result.imageUrl || null);
|
||||
media.push(mediaInfo);
|
||||
}
|
||||
|
||||
// 从 markdown 中提取图片(这些没有原始 URL)
|
||||
if (result.text) {
|
||||
const mdImagePattern = /!\[([^\]]*)\]\((data:[^)]+)\)/g;
|
||||
let match;
|
||||
while ((match = mdImagePattern.exec(result.text)) !== null) {
|
||||
const dataUri = match[2];
|
||||
const mediaInfo = await saveMediaToFile(dataUri, requestId, null);
|
||||
media.push(mediaInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试下载失败的媒体
|
||||
* @param {string} id - 记录 ID
|
||||
* @param {number} mediaIndex - 媒体索引
|
||||
* @param {Function} [downloadFn] - 可选的下载函数(使用浏览器上下文下载)
|
||||
* @returns {{success: boolean, message: string}}
|
||||
*/
|
||||
export async function retryMediaDownload(id, mediaIndex, downloadFn = null) {
|
||||
const record = getDetail(id);
|
||||
if (!record) {
|
||||
return { success: false, message: '记录不存在' };
|
||||
}
|
||||
|
||||
const media = record.responseMedia;
|
||||
if (!media || mediaIndex >= media.length) {
|
||||
return { success: false, message: '媒体索引无效' };
|
||||
}
|
||||
|
||||
const item = media[mediaIndex];
|
||||
if (item.status === 'downloaded' && item.localPath) {
|
||||
// 检查文件是否存在
|
||||
try {
|
||||
await fs.access(item.localPath);
|
||||
return { success: true, message: '媒体已下载' };
|
||||
} catch {
|
||||
// 文件不存在,继续重试
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.originalUrl) {
|
||||
return { success: false, message: '无原始 URL,无法重试下载' };
|
||||
}
|
||||
|
||||
// 使用浏览器上下文下载(推荐)或简单 HTTP 下载(后备)
|
||||
try {
|
||||
let dataUri = null;
|
||||
|
||||
if (downloadFn) {
|
||||
// 使用浏览器上下文下载
|
||||
logger.info('历史记录', `使用浏览器下载: ${item.originalUrl}`);
|
||||
const result = await downloadFn(item.originalUrl);
|
||||
if (result.error) {
|
||||
return { success: false, message: result.error };
|
||||
}
|
||||
dataUri = result.image;
|
||||
} else {
|
||||
// 后备:简单 HTTP 下载(对于需要认证的 URL 可能会失败)
|
||||
logger.info('历史记录', `使用 HTTP 下载: ${item.originalUrl}`);
|
||||
const response = await fetch(item.originalUrl, {
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, message: `下载失败: HTTP ${response.status}(可能需要认证)` };
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
||||
const mimeType = contentType.split(';')[0].trim();
|
||||
dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
// 保存到文件
|
||||
const saved = await saveMediaToFile(dataUri, id, item.originalUrl);
|
||||
|
||||
if (saved.status === 'downloaded') {
|
||||
// 更新记录
|
||||
media[mediaIndex] = {
|
||||
...item,
|
||||
...saved
|
||||
};
|
||||
updateRecord(id, { responseMedia: media });
|
||||
|
||||
logger.info('历史记录', `媒体重试下载成功: ${saved.localPath}`);
|
||||
return { success: true, message: '下载成功' };
|
||||
} else {
|
||||
return { success: false, message: '保存文件失败' };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('历史记录', `媒体重试下载失败: ${error.message}`);
|
||||
return { success: false, message: `下载失败: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计摘要
|
||||
* @param {object} filters - 筛选条件
|
||||
* @returns {{total: number, success: number, failed: number, avgDuration: number}}
|
||||
*/
|
||||
export function getStats(filters = {}) {
|
||||
const db = getDb();
|
||||
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
|
||||
if (filters.startDate) {
|
||||
conditions.push('created_at >= ?');
|
||||
params.push(new Date(filters.startDate).setHours(0, 0, 0, 0));
|
||||
}
|
||||
if (filters.endDate) {
|
||||
conditions.push('created_at <= ?');
|
||||
params.push(new Date(filters.endDate).setHours(23, 59, 59, 999));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
|
||||
AVG(CASE WHEN duration_ms IS NOT NULL THEN duration_ms ELSE NULL END) as avgDuration
|
||||
FROM requests ${whereClause}
|
||||
`);
|
||||
|
||||
const result = stmt.get(...params);
|
||||
return {
|
||||
total: result.total || 0,
|
||||
success: result.success || 0,
|
||||
failed: result.failed || 0,
|
||||
avgDuration: Math.round(result.avgDuration || 0)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的模型列表(从历史记录中)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getModelList() {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare('SELECT DISTINCT model_id FROM requests WHERE model_id IS NOT NULL ORDER BY model_id');
|
||||
return stmt.all().map(r => r.model_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取媒体目录路径
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getMediaDir() {
|
||||
return MEDIA_DIR;
|
||||
}
|
||||
+39
-12
@@ -13,7 +13,10 @@ import {
|
||||
CloseCircleOutlined,
|
||||
LoadingOutlined,
|
||||
InboxOutlined,
|
||||
PictureOutlined
|
||||
PictureOutlined,
|
||||
HistoryOutlined,
|
||||
RocketOutlined,
|
||||
MenuOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import LoginModal from '@/components/auth/LoginModal.vue';
|
||||
@@ -23,6 +26,7 @@ const settingsStore = useSettingsStore();
|
||||
|
||||
const selectedKeys = ref(['dash']);
|
||||
const collapsed = ref(false);
|
||||
const isMobile = ref(false);
|
||||
const loginVisible = ref(false);
|
||||
|
||||
const iconLoading = ref(false);
|
||||
@@ -292,6 +296,7 @@ const openApiTestDrawer = () => {
|
||||
// 菜单 key 到路由路径的映射
|
||||
const menuRoutes = {
|
||||
'dash': '/',
|
||||
'history': '/tools/request',
|
||||
'settings-server': '/settings/server',
|
||||
'settings-workers': '/settings/workers',
|
||||
'settings-browser': '/settings/browser',
|
||||
@@ -306,6 +311,7 @@ const handleMenuClick = ({ key }) => {
|
||||
const route = menuRoutes[key];
|
||||
if (route) {
|
||||
router.push(route);
|
||||
if (isMobile.value) collapsed.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -344,7 +350,8 @@ async function checkConnection() {
|
||||
onMounted(async () => {
|
||||
// 响应式侧边栏
|
||||
const checkScreenSize = () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
isMobile.value = window.innerWidth <= 768;
|
||||
if (isMobile.value) {
|
||||
collapsed.value = true;
|
||||
}
|
||||
};
|
||||
@@ -391,33 +398,43 @@ onMounted(async () => {
|
||||
<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;">
|
||||
:style="{ background: 'rgba(255, 255, 255, 0.7)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', borderBottom: '1.5px solid rgba(0, 0, 0, 0.05)', display: 'flex', alignItems: 'center', padding: isMobile ? '0 12px' : '0 24px', position: 'fixed', width: '100%', top: 0, zIndex: 1000 }">
|
||||
<a-button v-if="isMobile" type="text" @click="collapsed = !collapsed" style="margin-right: 8px; font-size: 18px;">
|
||||
<template #icon><MenuOutlined /></template>
|
||||
</a-button>
|
||||
<div class="logo" :style="{ fontSize: '1.25rem', fontWeight: 'bold', color: '#1890ff', marginRight: isMobile ? '8px' : '24px' }">
|
||||
WebAI2API
|
||||
</div>
|
||||
<a-flex justify="end" align="center" style="flex: 1;" :gap="12">
|
||||
<a-button @click="openApiTestDrawer">
|
||||
<a-flex justify="end" align="center" style="flex: 1;" :gap="8">
|
||||
<a-button @click="openApiTestDrawer" :size="isMobile ? 'small' : 'middle'">
|
||||
<template #icon>
|
||||
<ApiOutlined />
|
||||
</template>
|
||||
接口测试
|
||||
<span v-if="!isMobile">接口测试</span>
|
||||
</a-button>
|
||||
<a-button danger :loading="iconLoading" @click="enterIconLoading">
|
||||
<a-button danger :loading="iconLoading" @click="enterIconLoading" :size="isMobile ? 'small' : 'middle'">
|
||||
<template #icon>
|
||||
<PoweroffOutlined />
|
||||
</template>
|
||||
退出登录
|
||||
<span v-if="!isMobile">退出登录</span>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-layout-header>
|
||||
<a-layout style="margin-top: 64px;">
|
||||
<div v-if="isMobile && !collapsed" class="sider-mask" @click="collapsed = true"></div>
|
||||
<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;">
|
||||
:collapsed-width="isMobile ? 0 : 80"
|
||||
:trigger="isMobile ? null : undefined"
|
||||
:style="{ position: 'fixed', left: 0, top: '64px', height: 'calc(100vh - 64px)', overflowY: 'auto', zIndex: isMobile ? 200 : 100 }">
|
||||
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" @click="handleMenuClick">
|
||||
<a-menu-item key="dash">
|
||||
<DashboardOutlined />
|
||||
<span>状态概览</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="history">
|
||||
<RocketOutlined />
|
||||
<span>请求模型</span>
|
||||
</a-menu-item>
|
||||
<a-sub-menu key="settings">
|
||||
<template #title>
|
||||
<span>
|
||||
@@ -444,7 +461,7 @@ onMounted(async () => {
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<a-layout
|
||||
:style="{ marginLeft: collapsed ? '80px' : '200px', padding: '16px', transition: 'margin-left 0.2s' }">
|
||||
:style="{ marginLeft: isMobile ? '0' : (collapsed ? '80px' : '200px'), padding: isMobile ? '12px' : '16px', transition: 'margin-left 0.2s' }">
|
||||
<a-layout-content style="min-height: 280px">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
@@ -463,7 +480,7 @@ onMounted(async () => {
|
||||
</a-layout>
|
||||
|
||||
<!-- 接口测试抽屉 -->
|
||||
<a-drawer v-model:open="apiTestDrawer" title="接口测试" placement="right" :width="500">
|
||||
<a-drawer v-model:open="apiTestDrawer" title="接口测试" placement="right" :width="isMobile ? '100%' : 500">
|
||||
<a-space direction="vertical" style="width: 100%" size="large">
|
||||
<!-- Models 接口 -->
|
||||
<a-card title="GET /v1/models" size="small">
|
||||
@@ -654,4 +671,14 @@ onMounted(async () => {
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.sider-mask {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 199;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -121,7 +121,7 @@ const openBatchProxy = () => {
|
||||
|
||||
const handleBatchProxySave = async () => {
|
||||
const newList = (instanceData.value || []).map(inst => {
|
||||
if (!selectedRowKeys.value.includes(inst.id)) return inst;
|
||||
if (!selectedRowKeys.value.includes(inst.name)) return inst;
|
||||
return {
|
||||
...inst,
|
||||
proxy: batchProxyForm.value.proxy ? {
|
||||
@@ -152,7 +152,7 @@ const handleBatchDelete = () => {
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
const newList = (instanceData.value || []).filter(
|
||||
inst => !selectedRowKeys.value.includes(inst.id)
|
||||
inst => !selectedRowKeys.value.includes(inst.name)
|
||||
);
|
||||
const success = await settingsStore.saveWorkerConfig(newList);
|
||||
if (success) {
|
||||
@@ -225,7 +225,7 @@ const handleEdit = (record) => {
|
||||
|
||||
// 删除实例
|
||||
const handleDelete = async (record) => {
|
||||
const newList = instanceData.value.filter(item => item.id !== record.id);
|
||||
const newList = instanceData.value.filter(item => item.name !== record.name);
|
||||
await settingsStore.saveWorkerConfig(newList);
|
||||
};
|
||||
|
||||
@@ -233,7 +233,6 @@ const handleDelete = async (record) => {
|
||||
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,
|
||||
@@ -254,8 +253,8 @@ const handleSaveEdit = async () => {
|
||||
// 创建
|
||||
newList.push(instanceToSave);
|
||||
} else {
|
||||
// 更新
|
||||
const index = newList.findIndex(item => item.id === editingInstance.value.id);
|
||||
// 更新 - 用原始 name 查找
|
||||
const index = newList.findIndex(item => item.name === editingInstance.value.name);
|
||||
if (index > -1) {
|
||||
newList[index] = instanceToSave;
|
||||
}
|
||||
@@ -371,34 +370,7 @@ const handleRemoveWorker = (index) => {
|
||||
故障转移时最大重试次数,范围 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>
|
||||
|
||||
<a-divider style="margin: 12px 0;" />
|
||||
|
||||
<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.imgDlRetry" />
|
||||
</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.imgDlRetryMaxRetries" :min="1"
|
||||
:max="10" :disabled="!poolConfig.failover.imgDlRetry" style="width: 100%"
|
||||
placeholder="请输入下载重试次数" />
|
||||
:disabled="!poolConfig.failover.enabled" style="width: 100%" placeholder="请输入重试次数" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -435,8 +407,8 @@ const handleRemoveWorker = (index) => {
|
||||
</template>
|
||||
|
||||
<!-- 实例表格 -->
|
||||
<a-table :columns="columns" :data-source="instanceData" :pagination="false" :row-selection="rowSelection"
|
||||
row-key="id">
|
||||
<a-table :columns="columns" :data-source="instanceData" :pagination="false"
|
||||
:row-selection="rowSelection" row-key="name">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 实例名称 -->
|
||||
<template v-if="column.key === 'name'">
|
||||
@@ -634,23 +606,18 @@ const handleRemoveWorker = (index) => {
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 批量代理设置弹窗 -->
|
||||
<a-modal v-model:open="batchProxyVisible" title="批量设置代理" okText="应用" cancelText="取消" @ok="handleBatchProxySave">
|
||||
<!-- 批量代理设置模态框 -->
|
||||
<a-modal v-model:open="batchProxyVisible" title="批量设置代理" okText="确定" cancelText="取消"
|
||||
@ok="handleBatchProxySave">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 16px;">
|
||||
将对选中的 {{ selectedRowKeys.length }} 个实例统一设置代理
|
||||
</div>
|
||||
<a-switch v-model:checked="batchProxyForm.proxy" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ batchProxyForm.proxy ? '启用代理' : '禁用代理' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 是否启用代理 -->
|
||||
<template v-if="batchProxyForm.proxy">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-switch v-model:checked="batchProxyForm.proxy" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ batchProxyForm.proxy ? '启用代理' : '关闭代理(将清除选中实例的代理配置)' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 代理类型 -->
|
||||
<div style="margin-bottom: 16px;" v-if="batchProxyForm.proxy">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">代理类型</div>
|
||||
<a-segmented v-model:value="batchProxyForm.proxyType" block :options="[
|
||||
{ label: 'SOCKS5', value: 'socks5' },
|
||||
@@ -658,21 +625,18 @@ const handleRemoveWorker = (index) => {
|
||||
]" style="width: 100%" />
|
||||
</div>
|
||||
|
||||
<!-- 服务器地址 -->
|
||||
<div style="margin-bottom: 16px;" v-if="batchProxyForm.proxy">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">服务器地址</div>
|
||||
<a-input v-model:value="batchProxyForm.proxyHost" placeholder="例如: 127.0.0.1" />
|
||||
</div>
|
||||
|
||||
<!-- 端口 -->
|
||||
<div style="margin-bottom: 16px;" v-if="batchProxyForm.proxy">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">端口</div>
|
||||
<a-input-number v-model:value="batchProxyForm.proxyPort" :min="1" :max="65535" style="width: 100%"
|
||||
placeholder="例如: 1080" />
|
||||
<a-input-number v-model:value="batchProxyForm.proxyPort" :min="1" :max="65535"
|
||||
style="width: 100%" placeholder="例如: 1080" />
|
||||
</div>
|
||||
|
||||
<!-- 身份验证 -->
|
||||
<div style="margin-bottom: 16px;" v-if="batchProxyForm.proxy">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">身份验证</div>
|
||||
<a-switch v-model:checked="batchProxyForm.proxyAuth" />
|
||||
<span style="margin-left: 8px;">
|
||||
@@ -680,18 +644,16 @@ const handleRemoveWorker = (index) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 用户名 -->
|
||||
<div style="margin-bottom: 16px;" v-if="batchProxyForm.proxy && batchProxyForm.proxyAuth">
|
||||
<div style="margin-bottom: 16px;" v-if="batchProxyForm.proxyAuth">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">用户名</div>
|
||||
<a-input v-model:value="batchProxyForm.proxyUsername" placeholder="请输入用户名" />
|
||||
</div>
|
||||
|
||||
<!-- 密码 -->
|
||||
<div style="margin-bottom: 16px;" v-if="batchProxyForm.proxy && batchProxyForm.proxyAuth">
|
||||
<div style="margin-bottom: 16px;" v-if="batchProxyForm.proxyAuth">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">密码</div>
|
||||
<a-input-password v-model:value="batchProxyForm.proxyPassword" placeholder="请输入密码" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
@@ -228,7 +228,7 @@ onUnmounted(() => {
|
||||
|
||||
<div class="stats-content">
|
||||
<a-range-picker v-model:value="dateRange" :format="'YYYY-MM-DD'" :placeholder="['开始日期', '结束日期']"
|
||||
size="small" style="width: 240px" @change="fetchRangeStats" />
|
||||
size="small" class="stats-date-picker" @change="fetchRangeStats" />
|
||||
|
||||
<a-divider type="vertical" style="height: 32px; margin: 0 16px" />
|
||||
|
||||
@@ -309,11 +309,13 @@ onUnmounted(() => {
|
||||
<!-- 日志列表 -->
|
||||
<div class="log-container">
|
||||
<div v-for="log in filteredLogs" :key="log.id" class="log-line" :class="'level-' + log.level.toLowerCase()">
|
||||
<span class="log-time">{{ log.time }}</span>
|
||||
<a-tag :color="levelConfig[log.level]?.color || '#8c8c8c'" size="small" style="margin: 0 8px;">
|
||||
{{ log.level }}
|
||||
</a-tag>
|
||||
<span class="log-module">[{{ log.module }}]</span>
|
||||
<div class="log-meta">
|
||||
<a-tag :color="levelConfig[log.level]?.color || '#8c8c8c'" size="small" style="margin: 0;">
|
||||
{{ log.level }}
|
||||
</a-tag>
|
||||
<span class="log-module">[{{ log.module }}]</span>
|
||||
<span class="log-time">{{ log.time }}</span>
|
||||
</div>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
</div>
|
||||
<a-empty v-if="filteredLogs.length === 0" description="暂无日志" />
|
||||
@@ -325,6 +327,7 @@ onUnmounted(() => {
|
||||
.log-container {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
background: #fafafa;
|
||||
@@ -335,6 +338,9 @@ onUnmounted(() => {
|
||||
.log-line {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -346,17 +352,26 @@ onUnmounted(() => {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.log-module {
|
||||
color: #1890ff;
|
||||
margin-right: 8px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.level-erro .log-message {
|
||||
@@ -459,6 +474,11 @@ onUnmounted(() => {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 日期选择器 */
|
||||
.stats-date-picker {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
/* 响应式:小屏幕统计面板垂直布局 */
|
||||
@media (max-width: 576px) {
|
||||
.stats-content {
|
||||
@@ -474,4 +494,43 @@ onUnmounted(() => {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式:移动端日志堆叠布局 */
|
||||
@media (max-width: 768px) {
|
||||
.stats-date-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
padding: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.log-line:hover {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ const routes = [
|
||||
{ path: '/tools/display', component: () => import('@/components/tools/display.vue') },
|
||||
{ path: '/tools/cache', component: () => import('@/components/tools/cache.vue') },
|
||||
{ path: '/tools/logs', component: () => import('@/components/tools/logs.vue') },
|
||||
{ path: '/tools/request', component: () => import('@/components/tools/request.vue') },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
Reference in New Issue
Block a user