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
+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({