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