Files
qipai/admin/src/components/LogsSystemPanel.vue
T
2026-08-10 13:28:54 +08:00

109 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="system-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<el-tabs v-model="activeTab" class="panel system-tabs">
<el-tab-pane label="安全审计" name="audit">
<header class="panel-toolbar">
<div><p class="section-kicker">M08-D · 可追溯运营</p><h3>安全审计日志</h3></div>
<div class="toolbar-actions">
<el-button :icon="Download" :disabled="page.items.length === 0" @click="exportCsv">导出当前页</el-button>
<el-button :icon="RefreshCw" :loading="auditLoading" @click="loadAudit">刷新</el-button>
</div>
</header>
<div class="audit-filters">
<el-input v-model="filters.search" clearable placeholder="操作、资源、追踪号或操作人" @keyup.enter="applyFilters" />
<el-input v-model="filters.action" clearable placeholder="操作代码,如 ORDER_CREATED" @keyup.enter="applyFilters" />
<el-input v-model="filters.resourceType" clearable placeholder="资源类型,如 ORDER" @keyup.enter="applyFilters" />
<el-date-picker v-model="dateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" />
<el-button type="primary" @click="applyFilters">筛选</el-button>
</div>
<el-table v-loading="auditLoading" :data="page.items" row-key="id" class="data-table">
<el-table-column label="时间" min-width="168"><template #default="{ row }">{{ shortDate(row.createdAt) }}</template></el-table-column>
<el-table-column label="操作" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.action }}</strong><span>{{ row.resourceType }}{{ row.resourceId ? ` #${row.resourceId}` : '' }}</span></div></template></el-table-column>
<el-table-column label="操作人" min-width="150"><template #default="{ row }"><div class="stack"><strong>{{ row.actorName || row.actorType }}</strong><span>{{ row.actorId ? `#${row.actorId}` : '系统' }}</span></div></template></el-table-column>
<el-table-column label="来源" min-width="150"><template #default="{ row }"><div class="stack"><span>{{ row.ip || '-' }}</span><small>{{ compactAgent(row.userAgent) }}</small></div></template></el-table-column>
<el-table-column label="追踪号" min-width="180" show-overflow-tooltip prop="traceId" />
<el-table-column label="详情" width="92" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Eye" @click="selected = row; drawer = true">查看</el-button></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="page.page" :page-size="page.pageSize" :total="page.total" layout="prev, pager, next, total" @current-change="loadAudit" />
</el-tab-pane>
<el-tab-pane label="系统配置" name="system">
<div v-loading="systemLoading" class="system-workspace">
<section class="system-metrics">
<span><small>门店</small><strong>{{ overview?.counts.storeCount ?? 0 }}</strong></span>
<span><small>用户</small><strong>{{ overview?.counts.userCount ?? 0 }}</strong></span>
<span><small>活跃会话</small><strong>{{ overview?.counts.activeSessionCount ?? 0 }}</strong></span>
<span><small>今日审计</small><strong>{{ overview?.counts.auditTodayCount ?? 0 }}</strong></span>
</section>
<section class="system-config-grid">
<article class="config-card">
<header><div><p class="section-kicker">租户配置</p><h3>{{ overview?.tenant.code || '当前租户' }}</h3></div><el-tag :type="tenantStatusType">{{ overview?.tenant.status || '-' }}</el-tag></header>
<el-form label-position="top">
<el-form-item label="租户名称"><el-input v-model="tenantForm.name" maxlength="128" /></el-form-item>
<el-form-item label="默认时区"><el-input v-model="tenantForm.timezone" placeholder="Asia/Shanghai" /></el-form-item>
<el-form-item label="平台状态操作">
<div class="status-control"><el-switch v-model="applyStatus" active-text="本次更新状态" /><el-select v-model="tenantForm.status" :disabled="!applyStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></div>
<small class="field-help">只有平台管理员可以变更租户启停状态普通租户管理员请保持关闭</small>
</el-form-item>
</el-form>
<el-button type="primary" :loading="saving" @click="saveConfig">保存系统配置</el-button>
</article>
<article class="config-card migration-card">
<p class="section-kicker">数据库状态</p><h3>Schema 迁移</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="最新版本">{{ overview?.latestMigration?.version || '-' }}</el-descriptions-item>
<el-descriptions-item label="迁移名称">{{ overview?.latestMigration?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="执行时间">{{ overview?.latestMigration?.appliedAt ? shortDate(overview.latestMigration.appliedAt) : '-' }}</el-descriptions-item>
<el-descriptions-item label="小程序绑定">{{ overview?.counts.appBindingCount ?? 0 }}</el-descriptions-item>
</el-descriptions>
<el-button :icon="RefreshCw" :loading="systemLoading" @click="loadSystem">刷新运行状态</el-button>
</article>
</section>
</div>
</el-tab-pane>
</el-tabs>
<el-drawer v-model="drawer" title="审计事件详情" size="min(640px, 96vw)">
<el-descriptions v-if="selected" :column="1" border>
<el-descriptions-item label="操作">{{ selected.action }}</el-descriptions-item>
<el-descriptions-item label="资源">{{ selected.resourceType }} {{ selected.resourceId || '' }}</el-descriptions-item>
<el-descriptions-item label="追踪号">{{ selected.traceId }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ selected.actorName || selected.actorType }} {{ selected.actorId || '' }}</el-descriptions-item>
<el-descriptions-item label="脱敏来源 IP">{{ selected.ip || '-' }}</el-descriptions-item>
</el-descriptions>
<h4>脱敏元数据</h4><pre class="audit-metadata">{{ prettyMetadata }}</pre>
</el-drawer>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Download, Eye, RefreshCw } from '@lucide/vue';
import { ApiError, getSystemOverview, listAuditLogs, updateTenantSystemConfig, type ApiSession } from '../api';
import { shortDate } from '../format';
import type { AuditLog, PageResult, SystemOverview } from '../types';
const props = defineProps<{ session: ApiSession }>();
const activeTab = ref('audit'); const auditLoading = ref(false); const systemLoading = ref(false); const saving = ref(false);
const lastError = ref(''); const drawer = ref(false); const selected = ref<AuditLog | null>(null);
const overview = ref<SystemOverview | null>(null); const dateRange = ref<[Date, Date] | null>(null);
const applyStatus = ref(false);
const filters = reactive({ search: '', action: '', resourceType: '' });
const tenantForm = reactive({ name: '', timezone: 'Asia/Shanghai', status: 'ACTIVE' as 'ACTIVE' | 'DISABLED' });
const page = reactive<PageResult<AuditLog>>({ items: [], total: 0, page: 1, pageSize: 20 });
const prettyMetadata = computed(() => JSON.stringify(selected.value?.metadata ?? {}, null, 2));
const tenantStatusType = computed(() => overview.value?.tenant.status === 'ACTIVE' ? 'success' : 'danger');
function captureError(error: unknown) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; }
async function loadAudit() { if (!props.session.token) return; auditLoading.value = true; lastError.value = ''; try { const result = await listAuditLogs(props.session, { page: page.page, pageSize: page.pageSize, search: filters.search.trim() || undefined, action: filters.action.trim() || undefined, resourceType: filters.resourceType.trim() || undefined, from: dateRange.value?.[0].toISOString(), to: dateRange.value?.[1].toISOString() }); Object.assign(page, result); } catch (error) { captureError(error); } finally { auditLoading.value = false; } }
async function loadSystem() { if (!props.session.token) return; systemLoading.value = true; lastError.value = ''; try { overview.value = await getSystemOverview(props.session); Object.assign(tenantForm, { name: overview.value.tenant.name, timezone: overview.value.tenant.timezone, status: overview.value.tenant.status }); applyStatus.value = false; } catch (error) { captureError(error); } finally { systemLoading.value = false; } }
function applyFilters() { page.page = 1; void loadAudit(); }
async function saveConfig() { if (!tenantForm.name.trim() || !tenantForm.timezone.trim()) return ElMessage.warning('请填写租户名称和有效时区'); saving.value = true; try { await updateTenantSystemConfig(props.session, { name: tenantForm.name.trim(), timezone: tenantForm.timezone.trim(), status: applyStatus.value ? tenantForm.status : undefined }); ElMessage.success('系统配置已保存'); await loadSystem(); } catch (error) { captureError(error); } finally { saving.value = false; } }
function compactAgent(value: string) { return value ? value.slice(0, 36) : '-'; }
function csvCell(value: unknown) { return `"${String(value ?? '').replace(/"/g, '""')}"`; }
function exportCsv() { const rows = [['时间', '操作', '资源类型', '资源ID', '操作人', '来源IP', '追踪号'], ...page.items.map((item) => [item.createdAt, item.action, item.resourceType, item.resourceId, item.actorName || item.actorType, item.ip, item.traceId])]; const blob = new Blob([`\ufeff${rows.map((row) => row.map(csvCell).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click(); URL.revokeObjectURL(url); }
watch(() => props.session.token, (token) => { if (token) void Promise.all([loadAudit(), loadSystem()]); }, { immediate: true });
</script>