feat: 完善 WebUI 功能

This commit is contained in:
foxhui
2025-12-20 18:35:53 +08:00
Unverified
parent c8c7aec0e1
commit 10c96e420f
32 changed files with 1395 additions and 507 deletions
+348 -4
View File
@@ -1,13 +1,19 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { Modal } from 'ant-design-vue';
import { Modal, message } from 'ant-design-vue';
import {
DashboardOutlined,
SettingOutlined,
ToolOutlined,
PoweroffOutlined,
GithubOutlined
GithubOutlined,
ApiOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
InboxOutlined,
PictureOutlined
} from '@ant-design/icons-vue';
import { useSettingsStore } from '@/stores/settings';
import LoginModal from '@/components/auth/LoginModal.vue';
@@ -29,6 +35,189 @@ const enterIconLoading = () => {
}, 500);
};
// 接口测试抽屉
const apiTestDrawer = ref(false);
const apiTestResults = ref({
models: { status: 'pending', data: null, error: null },
cookies: { status: 'pending', data: null, error: null },
chat: { status: 'pending', data: null, error: null }
});
const chatTestPrompt = ref('Say hello in one word');
const chatTestModel = ref('');
const chatModelList = ref([]);
const chatImageList = ref([]);
const chatStreamMode = ref(false);
const chatStreamContent = ref('');
// 获取模型列表
const fetchModelList = async () => {
try {
const res = await fetch('/v1/models', { headers: settingsStore.getHeaders() });
if (res.ok) {
const data = await res.json();
chatModelList.value = data.data || [];
if (chatModelList.value.length > 0 && !chatTestModel.value) {
chatTestModel.value = chatModelList.value[0].id;
}
}
} catch (e) {
console.error('获取模型列表失败', e);
}
};
// 图片转 base64
const fileToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
});
};
// 图片上传前检查
const beforeUpload = (file) => {
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
message.error('仅支持 PNG, JPEG, GIF, WebP 格式');
return false;
}
if (chatImageList.value.length >= 10) {
message.error('最多上传 10 张图片');
return false;
}
return false; // 阻止自动上传,手动处理
};
// 处理图片选择
const handleImageChange = async (info) => {
const file = info.file;
if (file.status === 'removed') {
chatImageList.value = chatImageList.value.filter(f => f.uid !== file.uid);
return;
}
try {
const base64 = await fileToBase64(file.originFileObj || file);
chatImageList.value.push({
uid: file.uid,
name: file.name,
base64
});
} catch (e) {
message.error('图片读取失败');
}
};
const testApi = async (type) => {
apiTestResults.value[type].status = 'loading';
apiTestResults.value[type].error = null;
apiTestResults.value[type].data = null;
chatStreamContent.value = '';
try {
let url, options;
if (type === 'models') {
url = '/v1/models';
options = { headers: settingsStore.getHeaders() };
} else if (type === 'cookies') {
url = '/v1/cookies';
options = { headers: settingsStore.getHeaders() };
} else if (type === 'chat') {
url = '/v1/chat/completions';
// 构建消息内容
let content;
if (chatImageList.value.length > 0) {
// 多模态请求
content = [
{ type: 'text', text: chatTestPrompt.value }
];
for (const img of chatImageList.value) {
content.push({
type: 'image_url',
image_url: { url: img.base64 }
});
}
} else {
content = chatTestPrompt.value;
}
options = {
method: 'POST',
headers: { ...settingsStore.getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
model: chatTestModel.value,
messages: [{ role: 'user', content }],
stream: chatStreamMode.value
})
};
// 流式请求处理
if (chatStreamMode.value) {
const res = await fetch(url, options);
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error?.message || `HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content || '';
chatStreamContent.value += delta;
} catch { /* 忽略解析错误 */ }
}
}
}
apiTestResults.value[type].status = 'success';
apiTestResults.value[type].data = { content: chatStreamContent.value };
return;
}
}
const res = await fetch(url, options);
const data = await res.json();
if (res.ok) {
apiTestResults.value[type].status = 'success';
apiTestResults.value[type].data = data;
} else {
apiTestResults.value[type].status = 'error';
apiTestResults.value[type].error = data.error?.message || `HTTP ${res.status}`;
}
} catch (e) {
apiTestResults.value[type].status = 'error';
apiTestResults.value[type].error = e.message;
}
};
const openApiTestDrawer = () => {
apiTestDrawer.value = true;
// 重置状态
Object.keys(apiTestResults.value).forEach(key => {
apiTestResults.value[key] = { status: 'pending', data: null, error: null };
});
chatImageList.value = [];
// 获取模型列表
fetchModelList();
};
// 菜单 key 到路由路径的映射
const menuRoutes = {
'dash': '/',
@@ -37,7 +226,8 @@ const menuRoutes = {
'settings-browser': '/settings/browser',
'settings-adapters': '/settings/adapters',
'tools-display': '/tools/display',
'tools-cache': '/tools/cache'
'tools-cache': '/tools/cache',
'tools-logs': '/tools/logs'
};
// 处理菜单点击
@@ -134,7 +324,13 @@ onMounted(async () => {
<div class="logo" style="font-size: 1.25rem; font-weight: bold; color: #1890ff; margin-right: 24px;">
WebAI2API
</div>
<a-flex justify="end" align="center" style="flex: 1;">
<a-flex justify="end" align="center" style="flex: 1;" :gap="12">
<a-button @click="openApiTestDrawer">
<template #icon>
<ApiOutlined />
</template>
接口测试
</a-button>
<a-button danger :loading="iconLoading" @click="enterIconLoading">
<template #icon>
<PoweroffOutlined />
@@ -172,6 +368,7 @@ onMounted(async () => {
</template>
<a-menu-item key="tools-display">虚拟显示器</a-menu-item>
<a-menu-item key="tools-cache">缓存与重启</a-menu-item>
<a-menu-item key="tools-logs">日志查看器</a-menu-item>
</a-sub-menu>
</a-menu>
</a-layout-sider>
@@ -193,6 +390,153 @@ onMounted(async () => {
</a-layout>
</a-layout>
</a-layout>
<!-- 接口测试抽屉 -->
<a-drawer v-model:open="apiTestDrawer" title="接口测试" placement="right" :width="500">
<a-space direction="vertical" style="width: 100%" size="large">
<!-- Models 接口 -->
<a-card title="GET /v1/models" size="small">
<template #extra>
<a-button size="small" type="primary" @click="testApi('models')"
:loading="apiTestResults.models.status === 'loading'">
测试
</a-button>
</template>
<div v-if="apiTestResults.models.status === 'success'">
<a-tag color="success">
<CheckCircleOutlined /> 成功
</a-tag>
<div style="margin-top: 8px; font-size: 12px; color: #8c8c8c;">
返回 {{ apiTestResults.models.data?.data?.length || 0 }} 个模型
</div>
</div>
<div v-else-if="apiTestResults.models.status === 'error'">
<a-tag color="error">
<CloseCircleOutlined /> 失败
</a-tag>
<div style="margin-top: 8px; font-size: 12px; color: #ff4d4f;">
{{ apiTestResults.models.error }}
</div>
</div>
<div v-else style="color: #8c8c8c; font-size: 12px;">点击测试按钮开始</div>
</a-card>
<!-- Cookies 接口 -->
<a-card title="GET /v1/cookies" size="small">
<template #extra>
<a-button size="small" type="primary" @click="testApi('cookies')"
:loading="apiTestResults.cookies.status === 'loading'">
测试
</a-button>
</template>
<div v-if="apiTestResults.cookies.status === 'success'">
<a-tag color="success">
<CheckCircleOutlined /> 成功
</a-tag>
<div style="margin-top: 8px; font-size: 12px; color: #8c8c8c;">
返回 {{ apiTestResults.cookies.data?.cookies?.length || 0 }} Cookie
</div>
</div>
<div v-else-if="apiTestResults.cookies.status === 'error'">
<a-tag color="error">
<CloseCircleOutlined /> 失败
</a-tag>
<div style="margin-top: 8px; font-size: 12px; color: #ff4d4f;">
{{ apiTestResults.cookies.error }}
</div>
</div>
<div v-else style="color: #8c8c8c; font-size: 12px;">点击测试按钮开始</div>
</a-card>
<!-- Chat 接口 -->
<a-card title="POST /v1/chat/completions" size="small">
<template #extra>
<a-button size="small" type="primary" @click="testApi('chat')"
:loading="apiTestResults.chat.status === 'loading'" :disabled="!chatTestModel">
测试
</a-button>
</template>
<!-- 模型选择 -->
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px;">模型</div>
<a-select v-model:value="chatTestModel" style="width: 100%" size="small" placeholder="选择模型" show-search>
<a-select-option v-for="model in chatModelList" :key="model.id" :value="model.id">
{{ model.id }}
</a-select-option>
</a-select>
</div>
<!-- 提示词 -->
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px;">提示词</div>
<a-textarea v-model:value="chatTestPrompt" placeholder="输入提示词" :rows="2" size="small" />
</div>
<!-- 图片上传 -->
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px;">
附加图片 ({{ chatImageList.length }}/10)
</div>
<a-upload-dragger :file-list="[]" :multiple="true" :before-upload="beforeUpload" @change="handleImageChange"
accept=".png,.jpg,.jpeg,.gif,.webp" :show-upload-list="false" style="padding: 8px;">
<p style="margin: 0;">
<InboxOutlined style="font-size: 24px; color: #1890ff;" />
</p>
<p style="font-size: 12px; margin: 4px 0 0 0; color: #8c8c8c;">
点击或拖拽上传图片 (PNG/JPEG/GIF/WebP)
</p>
</a-upload-dragger>
<div v-if="chatImageList.length > 0" style="margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px;">
<a-tag v-for="img in chatImageList" :key="img.uid" closable
@close="chatImageList = chatImageList.filter(i => i.uid !== img.uid)">
<PictureOutlined /> {{ img.name.slice(0, 15) }}{{ img.name.length > 15 ? '...' : '' }}
</a-tag>
</div>
</div>
<!-- 流式选项 -->
<div style="margin-bottom: 12px;">
<a-checkbox v-model:checked="chatStreamMode">流式响应</a-checkbox>
</div>
<!-- 测试结果 -->
<!-- 流式模式实时显示内容 -->
<div v-if="chatStreamMode && apiTestResults.chat.status === 'loading'"
style="background: #fafafa; padding: 12px; border-radius: 4px; font-size: 12px;">
<div style="color: #1890ff; margin-bottom: 8px;">
<LoadingOutlined /> 正在接收流式响应...
</div>
<pre style="white-space: pre-wrap; word-break: break-all; margin: 0; min-height: 50px;">{{ chatStreamContent ||
'等待内容...' }}</pre>
</div>
<div v-else-if="apiTestResults.chat.status === 'success'">
<a-tag color="success">
<CheckCircleOutlined /> 成功
</a-tag>
<div
style="margin-top: 8px; font-size: 12px; max-height: 200px; overflow-y: auto; background: #fafafa; padding: 8px; border-radius: 4px;">
<pre v-if="chatStreamMode" style="white-space: pre-wrap; word-break: break-all; margin: 0;">{{
apiTestResults.chat.data?.content || '' }}</pre>
<pre v-else style="white-space: pre-wrap; word-break: break-all; margin: 0;">{{
JSON.stringify(apiTestResults.chat.data, null, 2) }}</pre>
</div>
</div>
<div v-else-if="apiTestResults.chat.status === 'error'">
<a-tag color="error">
<CloseCircleOutlined /> 失败
</a-tag>
<div style="margin-top: 8px; font-size: 12px; color: #ff4d4f;">
{{ apiTestResults.chat.error }}
</div>
</div>
<div v-else-if="apiTestResults.chat.status === 'loading' && !chatStreamMode"
style="color: #1890ff; font-size: 12px;">
<LoadingOutlined /> 请求中可能需要较长时间...
</div>
</a-card>
</a-space>
</a-drawer>
</div>
</template>
+20
View File
@@ -95,6 +95,26 @@ onUnmounted(() => {
<template>
<a-layout style="width: 100%; background: transparent;">
<!-- 安全模式告警横幅 -->
<a-alert v-if="systemStore.safeMode?.enabled" type="error" show-icon style="margin-bottom: 16px;" closable>
<template #message>
<span style="font-weight: 600;"> 安全模式</span>
</template>
<template #description>
<div>
<p style="margin-bottom: 8px;">
服务因初始化失败进入安全模式OpenAI API 不可用
</p>
<p style="margin-bottom: 8px; color: #cf1322;">
<b>原因</b>{{ systemStore.safeMode.reason }}
</p>
<p style="margin: 0;">
请前往系统设置修改正确的配置后重启服务
</p>
</div>
</template>
</a-alert>
<!-- 响应式布局手机竖向电脑横向 -->
<a-row :gutter="[16, 16]" style="margin-bottom: 24px">
<!-- 系统信息卡片 -->
+11 -8
View File
@@ -69,13 +69,16 @@ const handleSave = async () => {
<a-list :grid="{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 3, xl: 4, xxl: 4 }" :data-source="adapters">
<template #renderItem="{ item }">
<a-list-item>
<a-card hoverable @click="handleEdit(item)" :bodyStyle="{ padding: '16px' }">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center;">
<AppstoreOutlined style="font-size: 20px; color: #1890ff; margin-right: 12px;" />
<span style="font-weight: 600; font-size: 15px;">{{ item.id }}</span>
<a-card hoverable @click="handleEdit(item)" :bodyStyle="{ padding: '12px 16px' }">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="display: flex; align-items: center; min-width: 0; flex: 1;">
<AppstoreOutlined
style="font-size: 18px; color: #1890ff; margin-right: 8px; flex-shrink: 0;" />
<span
style="font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{
item.id }}</span>
</div>
<SettingOutlined style="font-size: 16px; color: #8c8c8c;" />
<SettingOutlined style="font-size: 16px; color: #8c8c8c; flex-shrink: 0;" />
</div>
</a-card>
</a-list-item>
@@ -84,8 +87,8 @@ const handleSave = async () => {
</a-card>
<!-- 配置抽屉 -->
<a-drawer v-if="currentAdapter" v-model:open="drawerVisible" :title="`配置适配器 - ${currentAdapter.name}`"
width="500" placement="right">
<a-drawer v-if="currentAdapter" v-model:open="drawerVisible" :title="`配置适配器 - ${currentAdapter.id}`" width="500"
placement="right">
<div v-if="!currentAdapter.configSchema || currentAdapter.configSchema.length === 0">
<a-empty description="该适配器没有可配置项" />
</div>
+306
View File
@@ -0,0 +1,306 @@
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useSettingsStore } from '@/stores/settings';
import {
ReloadOutlined,
DeleteOutlined,
SearchOutlined,
DownloadOutlined,
WarningOutlined,
CloseCircleOutlined,
InfoCircleOutlined,
BugOutlined
} from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
const settingsStore = useSettingsStore();
const logs = ref([]);
const loading = ref(false);
const total = ref(0);
const autoRefresh = ref(false);
const refreshInterval = ref(null);
const searchText = ref('');
const levelFilter = ref('all');
// 日志级别配置
const levelConfig = {
'INFO': { color: '#1890ff', icon: InfoCircleOutlined },
'WARN': { color: '#faad14', icon: WarningOutlined },
'ERRO': { color: '#ff4d4f', icon: CloseCircleOutlined },
'DBUG': { color: '#722ed1', icon: BugOutlined }
};
// 获取日志
const fetchLogs = async () => {
loading.value = true;
try {
const res = await fetch('/admin/logs?lines=500', {
headers: settingsStore.getHeaders()
});
if (res.ok) {
const data = await res.json();
logs.value = parseLogs(data.logs || []);
total.value = data.total || 0;
}
} catch (e) {
message.error('获取日志失败');
} finally {
loading.value = false;
}
};
// 解析日志行
const parseLogs = (lines) => {
return lines.map((line, index) => {
// 格式: 2025-12-20 17:00:00.000 [INFO] [模块] 消息
const match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) \[(\w+)\] \[([^\]]+)\] (.*)$/);
if (match) {
return {
id: index,
time: match[1],
level: match[2],
module: match[3],
message: match[4],
raw: line
};
}
return { id: index, raw: line, level: 'INFO', time: '', module: '', message: line };
});
};
// 过滤后的日志
const filteredLogs = computed(() => {
return logs.value.filter(log => {
// 级别过滤
if (levelFilter.value !== 'all' && log.level !== levelFilter.value) {
return false;
}
// 搜索过滤
if (searchText.value) {
const search = searchText.value.toLowerCase();
return log.raw.toLowerCase().includes(search);
}
return true;
});
});
// 清除日志
const clearLogs = () => {
Modal.confirm({
title: '确认清除日志',
content: '此操作将删除所有系统日志文件,是否继续?',
okText: '确认清除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
const res = await fetch('/admin/logs', {
method: 'DELETE',
headers: settingsStore.getHeaders()
});
if (res.ok) {
message.success('日志已清除');
logs.value = [];
total.value = 0;
} else {
message.error('清除失败');
}
} catch (e) {
message.error('请求失败');
}
}
});
};
// 导出日志
const exportLogs = () => {
const content = logs.value.map(l => l.raw).join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `system-${new Date().toISOString().split('T')[0]}.log`;
a.click();
URL.revokeObjectURL(url);
};
// 切换自动刷新
const toggleAutoRefresh = (newState) => {
autoRefresh.value = newState;
if (newState) {
fetchLogs(); // 立即刷新一次
refreshInterval.value = setInterval(fetchLogs, 5000);
} else {
if (refreshInterval.value) {
clearInterval(refreshInterval.value);
refreshInterval.value = null;
}
}
};
onMounted(() => {
fetchLogs();
});
onUnmounted(() => {
if (refreshInterval.value) {
clearInterval(refreshInterval.value);
}
});
</script>
<template>
<a-card title="系统日志" :bordered="false">
<!-- 工具栏 -->
<div class="toolbar">
<!-- 第一行级别筛选和操作按钮 -->
<div class="toolbar-row">
<a-select v-model:value="levelFilter" style="width: 90px" size="small">
<a-select-option value="all">全部</a-select-option>
<a-select-option value="INFO">INFO</a-select-option>
<a-select-option value="WARN">WARN</a-select-option>
<a-select-option value="ERRO">ERROR</a-select-option>
<a-select-option value="DBUG">DEBUG</a-select-option>
</a-select>
<a-space :size="4">
<a-tooltip :title="autoRefresh ? '关闭自动刷新' : '开启自动刷新'">
<a-button size="small" :type="autoRefresh ? 'primary' : 'default'"
@click="toggleAutoRefresh(!autoRefresh)">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip title="导出日志">
<a-button size="small" @click="exportLogs">
<template #icon>
<DownloadOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip title="清除日志">
<a-button size="small" danger @click="clearLogs">
<template #icon>
<DeleteOutlined />
</template>
</a-button>
</a-tooltip>
</a-space>
</div>
<!-- 第二行搜索框 -->
<div class="toolbar-row">
<a-input-search v-model:value="searchText" placeholder="搜索日志" size="small" enter-button allow-clear
style="width: 100%;" />
</div>
</div>
<!-- 统计信息 -->
<div style="margin-bottom: 12px; color: #8c8c8c; font-size: 12px;">
{{ total }} 条日志当前显示 {{ filteredLogs.length }}
<span v-if="autoRefresh" style="color: #1890ff; margin-left: 8px;">
<ReloadOutlined :spin="true" /> 自动刷新中
</span>
</div>
<!-- 日志列表 -->
<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>
<span class="log-message">{{ log.message }}</span>
</div>
<a-empty v-if="filteredLogs.length === 0" description="暂无日志" />
</div>
</a-card>
</template>
<style scoped>
.log-container {
max-height: 600px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
background: #fafafa;
border-radius: 4px;
padding: 12px;
}
.log-line {
padding: 4px 0;
border-bottom: 1px solid #f0f0f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.log-line:hover {
background: #e6f7ff;
white-space: normal;
word-break: break-all;
}
.log-time {
color: #8c8c8c;
}
.log-module {
color: #1890ff;
margin-right: 8px;
}
.log-message {
color: #333;
}
.level-erro .log-message {
color: #ff4d4f;
}
.level-warn .log-message {
color: #faad14;
}
.level-dbug .log-message {
color: #722ed1;
}
/* 工具栏样式 */
.toolbar {
margin-bottom: 16px;
}
.toolbar-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.toolbar-row:last-child {
margin-bottom: 0;
}
/* 大屏幕:工具栏一行显示 */
@media (min-width: 768px) {
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.toolbar-row {
margin-bottom: 0;
}
.toolbar-row:last-child {
flex: 1;
max-width: 300px;
}
}
</style>
+1
View File
@@ -13,6 +13,7 @@ const routes = [
{ path: '/settings/adapters', component: () => import('@/components/settings/adapters.vue') },
{ path: '/tools/display', component: () => import('@/components/tools/display.vue') },
{ path: '/tools/cache', component: () => import('@/components/tools/cache.vue') },
{ path: '/tools/logs', component: () => import('@/components/tools/logs.vue') },
];
const router = createRouter({
+6
View File
@@ -16,6 +16,12 @@ export const useSystemStore = defineStore('system', {
free: 0
},
// 安全模式状态
safeMode: {
enabled: false,
reason: null
},
// 仪表盘统计信息
stats: {
totalRequests: 0,