feat(webui): 请求历史记录与请求模型页面

后端:
- 新增 SQLite 存储请求/响应历史 (src/utils/history.js)
- Admin API 历史查询、删除、媒体重试等端点
- PoolManager.downloadMedia 用于媒体下载

WebUI:
- 请求历史页面:筛选、分页、详情、媒体预览
- 请求历史升级为请求模型页面,支持直接发送请求
- 移动端适配与体验优化
- 批量代理设置与删除实例
- 实例配置编辑删除改用 name 匹配
This commit is contained in:
daidai
2026-03-27 19:46:16 +08:00
Unverified
parent c3163729f4
commit 50b09ed740
10 changed files with 2430 additions and 94 deletions
+24
View File
@@ -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
*/
+149
View File
@@ -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
View File
@@ -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 = `![generated](${result.image})`;
} 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
};
}
+8
View File
@@ -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('服务器', '登录模式已就绪,请在浏览器中完成登录操作');
+508
View File
@@ -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
View File
@@ -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>
+25 -63
View File
@@ -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>
+66 -7
View File
@@ -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
+1
View File
@@ -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({