109 lines
10 KiB
Vue
109 lines
10 KiB
Vue
<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>
|