feat(M08-D): 补审计日志与系统配置

This commit is contained in:
Codex
2026-08-10 13:28:54 +08:00
parent bd00aa6275
commit 7e170e8743
13 changed files with 794 additions and 3 deletions
+10 -1
View File
@@ -13,6 +13,7 @@
<button class="nav-item" :class="{ active: activeModule === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
<button class="nav-item" :class="{ active: activeModule === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
<button class="nav-item" :class="{ active: activeModule === 'franchise' }" type="button" @click="activeModule = 'franchise'"><Handshake :size="18" /><span>加盟跟进</span></button>
<button class="nav-item" :class="{ active: activeModule === 'system' }" type="button" @click="activeModule = 'system'"><ScrollText :size="18" /><span>日志配置</span></button>
<button
class="nav-item"
:class="{ active: activeModule === 'overview' }"
@@ -130,6 +131,11 @@
:session="session"
/>
<LogsSystemPanel
v-else-if="activeModule === 'system'"
:session="session"
/>
<StoresRoomsPanel
v-else-if="activeModule === 'stores'"
:session="session"
@@ -329,6 +335,7 @@ import {
PanelsTopLeft,
RadioTower,
RotateCcw,
ScrollText,
Save,
Send,
Sparkles,
@@ -352,6 +359,7 @@ import DevicesPanel from './components/DevicesPanel.vue';
import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
import ContentManagementPanel from './components/ContentManagementPanel.vue';
import FranchisePanel from './components/FranchisePanel.vue';
import LogsSystemPanel from './components/LogsSystemPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -388,7 +396,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value);
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'franchise' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'franchise' | 'system' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeTab = ref('tasks');
const lastError = ref('');
const lastMessage = ref('');
@@ -402,6 +410,7 @@ const activeModuleMeta = computed(() => ({
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
content: { stage: 'M08-D', title: '广告投放与门店装修' },
franchise: { stage: 'M08-D', title: '加盟申请与跟进运营' },
system: { stage: 'M08-D', title: '安全审计与系统配置' },
stores: { stage: 'M08-D', title: '门店与房间管理' },
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
+27
View File
@@ -1,6 +1,7 @@
import type {
Advertisement,
AdvertisementInput,
AuditLog,
CleaningSettlement,
CleaningSettlementDetail,
CleaningStatistics,
@@ -33,6 +34,7 @@ import type {
PayoutStateFilter,
StaffRole,
SettlementStatus,
SystemOverview,
TaskStatus,
TransferMode,
RoomConfigurationStatus,
@@ -532,6 +534,31 @@ export function addFranchiseFollowUp(session: ApiSession, applicationId: string,
{ method: 'POST', body: JSON.stringify(input) });
}
export function listAuditLogs(session: ApiSession, input: {
page: number; pageSize: number; action?: string; resourceType?: string; search?: string;
from?: string; to?: string;
}) {
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
if (input.action) params.set('action', input.action);
if (input.resourceType) params.set('resourceType', input.resourceType);
if (input.search) params.set('search', input.search);
if (input.from) params.set('from', input.from);
if (input.to) params.set('to', input.to);
return request<PageResult<AuditLog>>(session, `/audit-logs?${params}`);
}
export function getSystemOverview(session: ApiSession) {
return request<SystemOverview>(session, '/system/overview');
}
export function updateTenantSystemConfig(session: ApiSession, input: {
name: string; timezone: string; status?: 'ACTIVE' | 'DISABLED';
}) {
return request<{ tenantId: string; updated: boolean }>(session, '/system/tenant', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function createStaffUser(
session: ApiSession,
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
+108
View File
@@ -0,0 +1,108 @@
<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>
+103 -1
View File
@@ -239,6 +239,102 @@
gap: 16px;
}
.system-page,
.system-workspace {
display: grid;
gap: 16px;
}
.system-tabs {
padding: 0 18px 18px;
}
.audit-filters {
display: grid;
grid-template-columns: minmax(210px, 1fr) 210px 180px minmax(320px, 1.2fr) auto;
gap: 10px;
margin-bottom: 16px;
}
.system-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.system-metrics > span {
display: grid;
gap: 6px;
padding: 16px 18px;
border: 1px solid #dbe3ec;
border-radius: 14px;
background: #f8fafc;
}
.system-metrics small,
.field-help {
color: #66758a;
}
.system-metrics strong {
font-size: 26px;
}
.system-config-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, .8fr);
gap: 16px;
}
.config-card {
padding: 18px;
border: 1px solid #dbe3ec;
border-radius: 14px;
background: #fff;
}
.config-card > header,
.status-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.config-card h3 {
margin-top: 4px;
}
.status-control {
width: 100%;
}
.status-control .el-select {
width: 150px;
}
.field-help {
display: block;
margin-top: 7px;
font-size: 12px;
}
.migration-card .el-button {
margin-top: 16px;
}
.audit-metadata {
overflow: auto;
min-height: 160px;
padding: 14px;
border-radius: 10px;
background: #14202b;
color: #d9e7ef;
font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap;
word-break: break-word;
}
.franchise-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -321,7 +417,10 @@
@media (max-width: 980px) {
.franchise-metrics,
.franchise-filters {
.franchise-filters,
.system-metrics,
.audit-filters,
.system-config-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -329,6 +428,9 @@
@media (max-width: 560px) {
.franchise-metrics,
.franchise-filters,
.system-metrics,
.audit-filters,
.system-config-grid,
.follow-up-form .el-form {
grid-template-columns: 1fr;
}
+36
View File
@@ -539,6 +539,42 @@ export interface FranchiseApplicationDetail {
followUps: FranchiseFollowUp[];
}
export interface AuditLog {
id: string;
tenantId: string;
actorType: string;
actorId: string | null;
actorName: string | null;
action: string;
resourceType: string;
resourceId: string | null;
traceId: string;
ip: string;
userAgent: string;
metadata: unknown;
createdAt: string;
}
export interface SystemOverview {
tenant: {
id: string;
code: string;
name: string;
status: 'ACTIVE' | 'DISABLED';
timezone: string;
createdAt: string;
updatedAt: string;
};
counts: {
storeCount: number;
userCount: number;
activeSessionCount: number;
appBindingCount: number;
auditTodayCount: number;
};
latestMigration: null | { version: string; name: string; appliedAt: string };
}
export interface CleaningTask {
id: string;
taskNo: string;