feat(M08-D): 补审计日志与系统配置
This commit is contained in:
+10
-1
@@ -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: '设备资产、拓扑与控制' },
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user