mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 完善 WebUI 功能
This commit is contained in:
+348
-4
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
<!-- 系统信息卡片 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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({
|
||||
|
||||
@@ -16,6 +16,12 @@ export const useSystemStore = defineStore('system', {
|
||||
free: 0
|
||||
},
|
||||
|
||||
// 安全模式状态
|
||||
safeMode: {
|
||||
enabled: false,
|
||||
reason: null
|
||||
},
|
||||
|
||||
// 仪表盘统计信息
|
||||
stats: {
|
||||
totalRequests: 0,
|
||||
|
||||
Reference in New Issue
Block a user