在 MiGu 侧落地日志定时清理与磁盘空间告警。

系统配置可开关清理策略;日志管理页支持查看/保存清理选项。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
黄兆尉
2026-08-26 17:48:25 +08:00
co-authored by Cursor
parent 4180140ae4
commit 79c9e36d89
12 changed files with 856 additions and 150 deletions
@@ -3,7 +3,7 @@ import http from './http'
/**
* 「日志管理」前端胶水:对应 MiGu.Server `LogsController``/api/logs/*`)。
*
* 数据是 SimpleLite 内核 `Diagnosis.Post / Diagnosis.Log` 写到工作目录 `log/{日期}/xxx.log` 的落盘日志
* 数据是 Simple3 内核 `Diagnosis.Post / Diagnosis.Log` 写到工作目录 `log/{日期}/xxx.log` 的落盘日志
* (俗称 DLog)。后端直接读文件并解析为结构化条目,提供:概览 / 文件列表 / 条目分页 /
* 「按标签合订」/ 原文 / 下载。
*
@@ -14,10 +14,10 @@ import http from './http'
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
/** SimpleLite projection 投影 API 统一信封(同 reflection.ts)。 */
/** Simple3 projection 投影 API 统一信封(同 reflection.ts)。 */
interface SlEnvelope<T> { success: boolean; code: number; data: T | null; message: string }
/** 经 YARP 反代到 SimpleLite EmbedIO 的诊断投影端点(/api/sl/projection/diagnosis)。 */
/** 经 YARP 反代到 Simple3 EmbedIO 的诊断投影端点(/api/sl/projection/diagnosis)。 */
const SL_DIAG = '/sl/projection/diagnosis'
export interface LogEntry {
@@ -47,6 +47,31 @@ export interface LogDayStat {
bytes: number
}
export interface LogDiskStatus {
known: boolean
drive: string
freeGB: number
alertEnabled: boolean
alertGB: number
belowThreshold: boolean
}
export interface LogCleanupConfig {
enabled: boolean
retentionDays: number
checkIntervalHours: number
runOnStartup: boolean
diskAlertEnabled: boolean
diskFreeAlertGB: number
diskCheckIntervalMinutes: number
}
export interface LogCleanupResult {
deletedFiles: number
freedBytes: number
freedMB: number
}
export interface LogOverview {
exists: boolean
root: string | null
@@ -56,6 +81,7 @@ export interface LogOverview {
latestFileTime?: string | null
days?: LogDayStat[]
message?: string
disk?: LogDiskStatus
}
export interface LogFilesResult {
@@ -116,7 +142,7 @@ export interface DigestQuery {
maxPerTag?: number
}
/** 实时诊断单条:对应 SimpleLite 内核 Diagnosis 的内存态 Post/Toast。 */
/** 实时诊断单条:对应 Simple3 内核 Diagnosis 的内存态 Post/Toast。 */
export interface LiveDiagItem {
index: number
time: string
@@ -225,12 +251,25 @@ export interface AnalyzeQuery {
function mockOverview(): LogOverview {
return {
exists: true,
root: 'E:\\...\\SimpleLite\\bin\\Debug\\log',
workingDirectory: 'E:\\...\\SimpleLite\\bin\\Debug',
root: 'E:\\...\\Simple3\\bin\\Debug\\log',
workingDirectory: 'E:\\...\\Simple3\\bin\\Debug',
totalFiles: 3,
totalBytes: 5_233_649,
latestFileTime: new Date().toISOString(),
days: [{ day: '2026-06-01', files: 3, bytes: 5_233_649 }]
days: [{ day: '2026-06-01', files: 3, bytes: 5_233_649 }],
disk: { known: true, drive: 'E:\\', freeGB: 118.7, alertEnabled: true, alertGB: 5, belowThreshold: false }
}
}
function mockCleanupConfig(): LogCleanupConfig {
return {
enabled: true,
retentionDays: 30,
checkIntervalHours: 24,
runOnStartup: true,
diskAlertEnabled: true,
diskFreeAlertGB: 5,
diskCheckIntervalMinutes: 30
}
}
@@ -285,7 +324,7 @@ function mockLiveDiagnosis(): LiveDiagnosis {
{ index: 0, time: iso(2), tag: 'Persistence', tagged: true, content: 'flush 4 entities ok' },
{ index: 1, time: iso(4), tag: 'Dispatch', tagged: true, content: 'dispatch loop 50Hz online' },
{ index: 2, time: iso(6), tag: 'InternalAuth', tagged: true, content: '仅放行本机回环(无 internal token 配置)' },
{ index: 3, time: iso(1), tag: '', tagged: false, content: '[SimpleLite] Projection API listening on http://127.0.0.1:8222/projection/' },
{ index: 3, time: iso(1), tag: '', tagged: false, content: '[Simple3] Projection API listening on http://127.0.0.1:8222/projection/' },
{ index: 4, time: iso(8), tag: '', tagged: false, content: 'loaded 12 sites, 18 tracks' }
]
const taggedCount = items.filter((i) => i.tagged).length
@@ -373,7 +412,7 @@ export const logsApi = {
? Promise.resolve(mockDigest())
: http.get<LogDigest>('/logs/digest', { params: q }).then((r) => r.data),
/** 实时内存诊断(SimpleLite Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 SimpleLite 在运行,否则 502。 */
/** 实时内存诊断(Simple3 Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 Simple3 在运行,否则 502。 */
liveDiagnosis: (): Promise<LiveDiagnosis> => MOCK
? Promise.resolve(mockLiveDiagnosis())
: http.get<SlEnvelope<LiveDiagnosis>>(`${SL_DIAG}/all`).then((r) => {
@@ -398,5 +437,17 @@ export const logsApi = {
/** 浏览器直链下载(GET,靠 httpOnly Cookie 鉴权)。 */
downloadUrl: (file: string): string =>
`${API_BASE}/logs/download?file=${encodeURIComponent(file)}`
`${API_BASE}/logs/download?file=${encodeURIComponent(file)}`,
cleanupConfig: (): Promise<LogCleanupConfig> => MOCK
? Promise.resolve(mockCleanupConfig())
: http.get<LogCleanupConfig>('/logs/cleanup-config').then((r) => r.data),
saveCleanupConfig: (body: LogCleanupConfig): Promise<LogCleanupConfig> => MOCK
? Promise.resolve({ ...body })
: http.put<LogCleanupConfig>('/logs/cleanup-config', body).then((r) => r.data),
cleanupNow: (): Promise<LogCleanupResult> => MOCK
? Promise.resolve({ deletedFiles: 0, freedBytes: 0, freedMB: 0 })
: http.post<LogCleanupResult>('/logs/cleanup-now').then((r) => r.data)
}
@@ -2,22 +2,7 @@
* 配置中心 13+1 维度强类型(与 Platform.Server/Configs 一一对齐,字段命名追随 ARCHITECTURE.md §9)。
*/
export interface LogPolicy {
level: 'trace' | 'debug' | 'info' | 'warn' | 'error'
rollDays: number
maxSizeMB: number
}
export interface SecurityPolicy {
jwtExpireMin: number
enableSwagger: boolean
corsWhitelist: string[]
}
export interface SystemConfig {
dispatchLoopHz: number
log: LogPolicy
security: SecurityPolicy
}
export interface MesEndpoint { id: string; name: string; url: string; enabled: boolean }
@@ -9,17 +9,32 @@
<span>日志管理</span>
<el-tag size="small" effect="plain" type="info">Diagnosis · Post / Toast / DLog</el-tag>
</div>
<el-button size="small" :icon="Refresh" :loading="overviewLoading" @click="refreshAll">刷新</el-button>
<div class="ov-actions">
<el-button v-if="canOpenCleanup" size="small" :icon="Setting" @click="goCleanup">日志清理配置</el-button>
<el-button size="small" :loading="cleaning" @click="cleanNow">立即清理</el-button>
<el-button size="small" :icon="Refresh" :loading="overviewLoading" @click="refreshAll">刷新</el-button>
</div>
</div>
<p class="ov-desc">
<b>实时诊断</b>直连 SimpleLite 内核 <code>Diagnosis</code> 的内存态 <code>Post</code> / <code>Toast</code>
<b>实时诊断</b>直连 Simple3 内核 <code>Diagnosis</code> 的内存态 <code>Post</code> / <code>Toast</code>
有标签按标签合订无标签滚动记录<b>日志文件</b>浏览工作目录 <code>log/</code> 下的落盘日志DLog
</p>
<el-alert
v-if="overview?.disk?.belowThreshold"
type="warning"
:closable="false"
show-icon
:title="diskAlertTitle"
style="margin: 0 0 12px" />
<div v-if="overview" class="ov-stats">
<div class="ov-stat"><span class="k">日志根目录</span><span class="v path" :title="overview.root ?? ''">{{ overview.root ?? '—' }}</span></div>
<div class="ov-stat"><span class="k">文件数</span><span class="v">{{ overview.totalFiles ?? 0 }}</span></div>
<div class="ov-stat"><span class="k">总大小</span><span class="v">{{ formatBytes(overview.totalBytes ?? 0) }}</span></div>
<div class="ov-stat"><span class="k">最近写入</span><span class="v">{{ overview.latestFileTime ? formatTime(overview.latestFileTime) : '—' }}</span></div>
<div class="ov-stat">
<span class="k">日志所在盘剩余</span>
<span class="v" :class="{ warn: overview.disk?.belowThreshold }">{{ diskText }}</span>
</div>
</div>
</el-card>
@@ -74,7 +89,7 @@
</template>
</el-table-column>
<template #empty>
<span class="muted">{{ liveError ? 'SimpleLite 未连接' : '暂无诊断消息' }}</span>
<span class="muted">{{ liveError ? 'Simple3 未连接' : '暂无诊断消息' }}</span>
</template>
</el-table>
</el-tab-pane>
@@ -303,10 +318,11 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Refresh, Document, Search, View, Download, CopyDocument,
Folder, FolderOpened, Back, DataLine, TrendCharts
Folder, FolderOpened, Back, DataLine, TrendCharts, Setting
} from '@element-plus/icons-vue'
// 按需引入 echarts:仅 bar/line 图 + grid/tooltip/legend/dataZoom 组件 + canvas 渲染器,
// 避免全量 echarts(~1MB) 打进 bundle。
@@ -315,6 +331,7 @@ import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import PermissionGuard from '@/components/PermissionGuard.vue'
import { useAuthStore } from '@/stores/auth'
import {
logsApi,
type LogOverview, type LiveDiagnosis, type LiveDiagItem,
@@ -341,6 +358,46 @@ const activeTab = ref<Tab>('live')
const overview = ref<LogOverview | null>(null)
const overviewLoading = ref(false)
const cleaning = ref(false)
const router = useRouter()
const auth = useAuthStore()
const canOpenCleanup = computed(() => auth.hasPage('admin-config-system'))
const diskText = computed(() => {
const d = overview.value?.disk
if (!d?.known) return '未知'
const th = d.alertEnabled ? `(阈值 ${d.alertGB}G` : ''
const warn = d.belowThreshold ? ' 低于阈值' : ''
return `${d.drive} 剩余 ${d.freeGB.toFixed(1)} GB${th}${warn}`
})
const diskAlertTitle = computed(() => {
const d = overview.value?.disk
if (!d?.known) return ''
return `磁盘剩余空间不足:${d.drive} 仅剩 ${d.freeGB.toFixed(1)} GB(低于阈值 ${d.alertGB} GB`
})
function goCleanup() {
void router.push({ name: 'admin-config-system', query: { tab: 'logs' } })
}
async function cleanNow() {
try {
await ElMessageBox.confirm('将按当前保留天数删除过期日志文件,是否继续?', '立即清理', {
type: 'warning',
confirmButtonText: '清理',
cancelButtonText: '取消'
})
} catch {
return
}
cleaning.value = true
try {
const r = await logsApi.cleanupNow()
ElMessage.success(`已清理 ${r.deletedFiles} 个文件,释放 ${r.freedMB} MB`)
await loadOverview()
} catch (e) {
ElMessage.error(`立即清理失败:${(e as Error).message}`)
} finally {
cleaning.value = false
}
}
// ── 实时诊断 ──
const live = ref<LiveDiagnosis | null>(null)
@@ -740,17 +797,20 @@ onUnmounted(() => {
gap: 14px;
flex: 1;
min-height: 0;
padding: 16px;
}
.ov-card :deep(.el-card__body) { padding: 16px 18px; }
.ov-head { display: flex; align-items: center; justify-content: space-between; }
.ov-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.ov-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.ov-title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 600; color: var(--mg-text-light); }
.ov-desc { color: var(--mg-text-muted); font-size: 12.5px; line-height: 1.6; margin: 10px 0 12px; }
.ov-desc code { font-family: var(--mg-font-mono, monospace); color: var(--mg-accent); }
.ov-stats { display: grid; grid-template-columns: 2.2fr 0.8fr 0.8fr 1.4fr; gap: 12px; }
.ov-stats { display: grid; grid-template-columns: 1.8fr 0.6fr 0.7fr 1.1fr 1.6fr; gap: 12px; }
.ov-stat { display: flex; flex-direction: column; gap: 3px; }
.ov-stat .k { font-size: 11px; color: var(--mg-text-muted); }
.ov-stat .v { font-size: 13px; color: var(--mg-text-light); font-variant-numeric: tabular-nums; }
.ov-stat .v.path { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--mg-font-mono, monospace); }
.ov-stat .v.warn { color: var(--el-color-danger); }
.body-card { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.body-card :deep(.el-card__body) { flex: 1; min-height: 0; display: flex; flex-direction: column; }
@@ -2,13 +2,20 @@
<ConfigPageBase
section="ops"
title="运营维护"
description="调度回放、日志管理、版本维护(OpsConfig"
description="回放保留、日志、版本,以及地图监控里显示哪些动作。页面权限不在这里配。"
:defaults="DEFAULT_OPS"
:normalize-payload="normalizeOpsConfig"
:after-load="onOpsConfigLoaded"
:before-save="onBeforePlatformSave">
<template #default="{ payload, update }">
<el-form label-width="200px" :model="payload">
<el-alert class="acl-hint" type="info" :closable="false" show-icon>
<template #title>
本页只配回放日志和地图监控动作显示谁能打开哪些页面
<router-link class="acl-link" to="/admin/config/auth">权限与角色</router-link>
给角色勾选
</template>
</el-alert>
<el-divider content-position="left">调度回放</el-divider>
<el-form-item label="保留时长 (天)">
<el-input-number :model-value="payload.playback.retentionDays" :min="1" :max="3650" @update:model-value="(v: number | undefined) => update({ ...payload, playback: { ...payload.playback, retentionDays: v ?? 0 } })" />
@@ -59,7 +66,7 @@
</el-collapse-item>
</el-collapse>
<p v-if="!monitorSectionLoading && !carTypesLoading && !carTypeActions.length" class="muted empty-hint">
暂未扫描到带 MethodMember 的车型(请确认 SimpleLite 已连接并已加载插件)。
暂未扫描到带 MethodMember 的车型(请确认 Simple3 已连接并已加载插件)。
</p>
</el-form-item>
<el-form-item label="站点动作显示项">
@@ -329,4 +336,11 @@ function updateCarTypeActions(
.muted {
color: var(--mg-text-muted);
}
.acl-hint { margin-bottom: 14px; }
.acl-link {
color: var(--mg-primary);
font-weight: 600;
text-decoration: none;
}
.acl-link:hover { text-decoration: underline; }
</style>
@@ -1,92 +1,144 @@
<template>
<ConfigPageBase
section="system"
title="系统级配置"
description="运行参数、日志策略、安全策略(对齐 SystemConfig"
:defaults="DEFAULT_SYSTEM">
<template #default="{ payload, update }">
<el-form label-width="160px" :model="payload">
<el-form-item label="调度循环 (Hz)">
<el-input-number :model-value="payload.dispatchLoopHz" :min="1" :max="200" @update:model-value="(v: number | undefined) => update({ ...payload, dispatchLoopHz: v ?? 0 })" />
</el-form-item>
<el-divider content-position="left">日志策略</el-divider>
<el-form-item label="日志级别">
<el-select :model-value="payload.log.level" @update:model-value="(v: any) => update({ ...payload, log: { ...payload.log, level: v } })">
<el-option v-for="lv in ['trace','debug','info','warn','error']" :key="lv" :label="lv" :value="lv" />
</el-select>
</el-form-item>
<el-form-item label="日志保留 ()">
<el-input-number :model-value="payload.log.rollDays" :min="1" :max="365" @update:model-value="(v: number | undefined) => update({ ...payload, log: { ...payload.log, rollDays: v ?? 0 } })" />
</el-form-item>
<el-form-item label="单文件上限 (MB)">
<el-input-number :model-value="payload.log.maxSizeMB" :min="1" :max="4096" @update:model-value="(v: number | undefined) => update({ ...payload, log: { ...payload.log, maxSizeMB: v ?? 0 } })" />
</el-form-item>
<el-divider content-position="left">安全策略</el-divider>
<el-form-item label="JWT 过期 (分钟)">
<el-input-number :model-value="payload.security.jwtExpireMin" :min="5" :max="43200" @update:model-value="(v: number | undefined) => update({ ...payload, security: { ...payload.security, jwtExpireMin: v ?? 0 } })" />
</el-form-item>
<el-form-item label="启用 Swagger">
<el-switch :model-value="payload.security.enableSwagger" @update:model-value="(v: string | number | boolean) => update({ ...payload, security: { ...payload.security, enableSwagger: Boolean(v) } })" />
</el-form-item>
<el-form-item label="CORS 白名单">
<el-input
type="textarea"
:rows="3"
:model-value="payload.security.corsWhitelist.join('\n')"
@update:model-value="(v: string) => update({ ...payload, security: { ...payload.security, corsWhitelist: v.split(/\s+/).filter(Boolean) } })" />
</el-form-item>
<el-divider content-position="left">界面主题配色</el-divider>
<el-form-item label="主题取色盘">
<ThemeCustomizer />
</el-form-item>
</el-form>
</template>
</ConfigPageBase>
<PermissionGuard widget-id="ConfigCenter">
<div class="sys-page">
<el-card shadow="never" class="sys-card">
<template #header>
<div class="sys-header">
<div class="sys-title-wrap">
<span class="sys-title">系统配置</span>
<span class="sys-desc">{{ headerDesc }}</span>
</div>
<div class="sys-actions">
<el-button @click="onReset">重置</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
</div>
</div>
</template>
<el-card class="ai-service-card" shadow="never">
<template #header>
<span>AI 服务(地图编辑器「AI 生图」依赖)</span>
<el-tag v-if="aiCfg.configured" type="success" size="small" effect="dark" class="ml-8">已配置</el-tag>
<el-tag v-else type="warning" size="small" effect="dark" class="ml-8">未配置</el-tag>
</template>
<el-form label-width="140px" size="default">
<el-form-item label="Endpoint">
<el-input v-model="aiCfg.endpoint" placeholder="https://api.openai.com/v1/chat/completions" />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="aiCfg.apiKey" type="password" show-password placeholder="sk-..." />
</el-form-item>
<el-form-item label="模型">
<el-input v-model="aiCfg.model" placeholder="gpt-4o-mini" />
</el-form-item>
<el-form-item label="System Prompt">
<el-input v-model="aiCfg.systemPrompt" type="textarea" :rows="4" />
</el-form-item>
<el-form-item label="Temperature">
<el-input-number v-model="aiCfg.temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="Max Tokens">
<el-input-number v-model="aiCfg.maxTokens" :min="64" :max="32000" :step="64" />
</el-form-item>
<el-form-item label="超时 (秒)">
<el-input-number v-model="aiCfg.timeoutSec" :min="5" :max="600" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="aiSaving" @click="onSaveAi">保存 AI 配置</el-button>
<el-button :loading="aiTesting" @click="onTestAi">测试连通</el-button>
</el-form-item>
</el-form>
</el-card>
<el-tabs v-model="group" class="sys-tabs">
<el-tab-pane label="界面主题" name="theme">
<p class="tab-hint">为各主题单独调色修改后立即预览当前主题会同步到全站</p>
<ThemeCustomizer />
</el-tab-pane>
<el-tab-pane name="ai">
<template #label>
<span>AI 服务</span>
<el-tag
size="small"
:type="aiCfg.configured ? 'success' : 'warning'"
effect="plain"
class="ai-tab-tag">
{{ aiCfg.configured ? '已配置' : '未配置' }}
</el-tag>
</template>
<p class="tab-hint">地图编辑器AI 生图所依赖的对话接口</p>
<div class="sys-fields">
<label class="sys-field sys-field--wide">
<span>Endpoint</span>
<el-input v-model="aiCfg.endpoint" placeholder="https://api.openai.com/v1/chat/completions" />
</label>
<label class="sys-field sys-field--wide">
<span>API Key</span>
<el-input v-model="aiCfg.apiKey" type="password" show-password placeholder="sk-..." />
</label>
<label class="sys-field sys-field--wide">
<span>模型</span>
<el-input v-model="aiCfg.model" placeholder="gpt-4o-mini" />
</label>
<label class="sys-field sys-field--wide">
<span>System Prompt</span>
<el-input v-model="aiCfg.systemPrompt" type="textarea" :rows="4" />
</label>
<label class="sys-field">
<span>Temperature</span>
<el-input-number v-model="aiCfg.temperature" :min="0" :max="2" :step="0.1" />
</label>
<label class="sys-field">
<span>Max Tokens</span>
<el-input-number v-model="aiCfg.maxTokens" :min="64" :max="32000" :step="64" />
</label>
<label class="sys-field">
<span>超时 ()</span>
<el-input-number v-model="aiCfg.timeoutSec" :min="5" :max="600" />
</label>
</div>
<div class="tab-toolbar">
<el-button :loading="aiTesting" @click="onTestAi">测试连通</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="日志清理" name="logs">
<p class="tab-hint">清理 Simple3 工作目录 log/ 下过期的 *.log并监测日志所在盘剩余空间保存后立即生效</p>
<div class="sys-fields">
<label class="sys-field">
<span>启用自动清理</span>
<el-switch v-model="logCfg.enabled" />
</label>
<label class="sys-field">
<span>启动时先清一次</span>
<el-switch v-model="logCfg.runOnStartup" />
</label>
<label class="sys-field">
<span>保留天数</span>
<el-input-number v-model="logCfg.retentionDays" :min="1" :max="3650" :step="1" />
</label>
<label class="sys-field">
<span>清理间隔小时</span>
<el-input-number v-model="logCfg.checkIntervalHours" :min="1" :max="168" :step="1" />
</label>
<label class="sys-field">
<span>启用磁盘告警</span>
<el-switch v-model="logCfg.diskAlertEnabled" />
</label>
<label class="sys-field">
<span>告警阈值GB</span>
<el-input-number v-model="logCfg.diskFreeAlertGB" :min="0.5" :max="1024" :step="0.5" />
</label>
<label class="sys-field">
<span>磁盘检测间隔分钟</span>
<el-input-number v-model="logCfg.diskCheckIntervalMinutes" :min="1" :max="1440" :step="1" />
</label>
</div>
<div class="tab-toolbar">
<el-button :loading="logCleaning" @click="onCleanNow">立即清理</el-button>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</PermissionGuard>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import PermissionGuard from '@/components/PermissionGuard.vue'
import ThemeCustomizer from '@/components/ThemeCustomizer.vue'
import { DEFAULT_SYSTEM } from '@/mock/data/configs'
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { aiConfigApi, mapEditApi, type AiConfig } from '@/api/mapEdit'
import { logsApi, type LogCleanupConfig } from '@/api/logs'
import { ElMessage } from 'element-plus'
type GroupId = 'theme' | 'ai' | 'logs'
const TAB_NAMES: GroupId[] = ['theme', 'ai', 'logs']
const route = useRoute()
const router = useRouter()
function readGroup(): GroupId {
const q = route.query.tab
if (q === 'log') return 'logs'
return typeof q === 'string' && (TAB_NAMES as string[]).includes(q) ? (q as GroupId) : 'theme'
}
const group = ref<GroupId>(readGroup())
const headerDesc = computed(() => {
if (group.value === 'ai') return 'AI 服务需点保存后写入。'
if (group.value === 'logs') return '日志清理保存后立即重排后台任务。'
return '界面主题立即生效。'
})
const saving = computed(() => aiSaving.value || logSaving.value)
const aiCfg = reactive<AiConfig>({
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: '',
@@ -98,31 +150,82 @@ const aiCfg = reactive<AiConfig>({
configured: false
})
const logCfg = reactive<LogCleanupConfig>({
enabled: true,
retentionDays: 30,
checkIntervalHours: 24,
runOnStartup: true,
diskAlertEnabled: true,
diskFreeAlertGB: 5,
diskCheckIntervalMinutes: 30
})
const aiSaving = ref(false)
const aiTesting = ref(false)
const logSaving = ref(false)
const logCleaning = ref(false)
watch(group, (t) => {
if (route.query.tab !== t) router.replace({ query: { ...route.query, tab: t } })
})
watch(() => route.query.tab, () => { group.value = readGroup() })
onMounted(async () => {
group.value = readGroup()
await Promise.all([loadAi(), loadLogs()])
})
async function loadAi() {
try {
const r = await aiConfigApi.get()
Object.assign(aiCfg, r)
} catch (err) {
ElMessage.warning(`无法读取 AI 配置:${(err as Error).message}`)
}
})
}
async function onSaveAi() {
async function loadLogs() {
try {
aiSaving.value = true
await aiConfigApi.save({ ...aiCfg })
ElMessage.success('AI 配置已保存')
aiCfg.configured = !!aiCfg.apiKey
Object.assign(logCfg, await logsApi.cleanupConfig())
} catch (err) {
ElMessage.error(`保存失败${(err as Error).message}`)
} finally {
aiSaving.value = false
ElMessage.warning(`无法读取日志清理配置${(err as Error).message}`)
}
}
async function onSave() {
if (group.value === 'theme') {
ElMessage.info('主题已即时生效,无需保存')
return
}
if (group.value === 'ai') {
try {
aiSaving.value = true
await aiConfigApi.save({ ...aiCfg })
aiCfg.configured = !!aiCfg.apiKey
ElMessage.success('已保存')
} catch (err) {
ElMessage.error(`AI 配置保存失败:${(err as Error).message}`)
} finally {
aiSaving.value = false
}
return
}
try {
logSaving.value = true
Object.assign(logCfg, await logsApi.saveCleanupConfig({ ...logCfg }))
ElMessage.success('日志清理配置已保存')
} catch (err) {
ElMessage.error(`日志清理配置保存失败:${(err as Error).message}`)
} finally {
logSaving.value = false
}
}
async function onReset() {
if (group.value === 'logs') await loadLogs()
else await loadAi()
}
async function onTestAi() {
try {
aiTesting.value = true
@@ -138,9 +241,92 @@ async function onTestAi() {
aiTesting.value = false
}
}
async function onCleanNow() {
try {
logCleaning.value = true
const r = await logsApi.cleanupNow()
ElMessage.success(`已清理 ${r.deletedFiles} 个文件,释放 ${r.freedMB} MB`)
} catch (err) {
ElMessage.error(`立即清理失败:${(err as Error).message}`)
} finally {
logCleaning.value = false
}
}
</script>
<style scoped>
.ai-service-card { margin: 12px 0; }
.ml-8 { margin-left: 8px; }
.sys-page {
padding: 16px;
height: 100%;
min-height: 0;
overflow: auto;
}
.sys-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.sys-title-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.sys-title {
font-weight: 600;
font-size: 16px;
color: var(--mg-text-light);
}
.sys-desc {
font-size: 12.5px;
color: var(--mg-text-muted);
}
.sys-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.tab-hint {
margin: 0 0 14px;
font-size: 13px;
color: var(--mg-text-muted);
line-height: 1.5;
}
.tab-toolbar {
margin-top: 16px;
}
.ai-tab-tag {
margin-left: 6px;
vertical-align: middle;
}
.sys-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 280px));
gap: 16px 28px;
}
.sys-field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin: 0;
}
.sys-field > span {
font-size: 12px;
font-weight: 500;
color: var(--mg-text-muted);
}
.sys-field--wide {
grid-column: 1 / -1;
}
@media (max-width: 860px) {
.sys-header {
flex-direction: column;
}
.sys-fields {
grid-template-columns: 1fr;
}
}
</style>