feat: 赛事管理操作日志与审计页体验优化

- API 为赛事/联赛/盘口/赔率/冠军盘写操作写入 CATALOG 审计日志,并新增 catalog-audit-logs 接口

- 管理端将赛事日志独立至赛事管理子页,通用操作日志排除 CATALOG 模块

- 统一赛事子页 list-chrome 布局与面包屑;修复操作日志页表格滚动与分页不可见问题

- 补充中英文/马来语文案及 UAT 验收项 SEC013/SEC014

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 10:00:24 +08:00
parent 567ec9ec8a
commit 2a8b8415e7
17 changed files with 850 additions and 216 deletions

View File

@@ -1,169 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import { useAuditLabels } from '../utils/audit-labels';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
const { t, locale, localeTag } = useAdminLocale();
const { auditActionLabel, auditModuleLabel } = useAuditLabels();
interface AuditRow {
action: string;
module: string;
targetId: string | null;
operatorId: string | null;
operatorUsername: string | null;
operatorRole: string | null;
operatorUserType: string | null;
operatorType: string;
ipAddress: string | null;
createdAt: string;
}
const logs = ref<AuditRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const filterModule = ref('');
onMounted(load);
async function load() {
const { data } = await api.get('/admin/audit-logs', {
params: {
page: page.value,
pageSize: pageSize.value,
module: filterModule.value.trim() || undefined,
},
});
logs.value = (data.data.items ?? []) as AuditRow[];
total.value = data.data.total ?? 0;
}
function onPageChange(p: number) {
page.value = p;
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function operatorRoleLabel(row: AuditRow): string | null {
const code = row.operatorRole;
if (code === 'SUPER_ADMIN') return t('role.super_admin');
if (code === 'MATCH_ADMIN') return t('role.match_admin');
if (code === 'FINANCE_ADMIN') return t('role.finance_admin');
if (code === 'SUPPORT') return t('role.support');
if (row.operatorUserType === 'ADMIN' || row.operatorType === 'ADMIN') return t('role.admin');
if (row.operatorUserType === 'AGENT' || row.operatorType === 'AGENT') return t('role.agent');
if (row.operatorUserType === 'PLAYER' || row.operatorType === 'PLAYER') return t('audit.operator_player');
return null;
}
function operatorDisplay(row: AuditRow): string {
if (row.operatorUsername) return row.operatorUsername;
if (row.operatorType === 'SYSTEM') return t('audit.operator_system');
return '—';
}
import AuditLogTable from '../components/AuditLogTable.vue';
</script>
<template>
<div class="admin-list-page">
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item :label="t('common.module')">
<el-input
v-model="filterModule"
:placeholder="t('audit.module_ph')"
clearable
style="width: 160px"
@keyup.enter="load"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="locale" :data="logs" stripe>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column :label="t('audit.col.time')" min-width="168" fixed="left">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('audit.col.operator')" min-width="160">
<template #default="{ row }">
<div class="operator-cell">
<span class="operator-name">{{ operatorDisplay(row) }}</span>
<span v-if="operatorRoleLabel(row)" class="operator-role">{{ operatorRoleLabel(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="t('audit.col.action')" min-width="140">
<template #default="{ row }">{{ auditActionLabel(row.action) }}</template>
</el-table-column>
<el-table-column :label="t('audit.col.module')" width="120">
<template #default="{ row }">{{ auditModuleLabel(row.module) }}</template>
</el-table-column>
<el-table-column prop="targetId" :label="t('audit.col.target_id')" min-width="100" show-overflow-tooltip />
<el-table-column :label="t('audit.col.ip')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.ipAddress ?? '—' }}</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</el-card>
<AuditLogTable />
</div>
</template>
<style scoped>
.filter-card { border-radius: 12px; }
.data-card { border-radius: 12px; }
.operator-cell {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.35;
}
.operator-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
.operator-role {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -477,8 +477,14 @@ load();
</script>
<template>
<div v-loading="loading" class="template-page">
<MatchesSubNav />
<div v-loading="loading" class="admin-list-page template-page">
<div class="list-chrome">
<div class="list-chrome__row">
<div class="list-chrome__left">
<MatchesSubNav embedded />
</div>
</div>
</div>
<div class="template-toolbar">
<el-select v-model="selectedId" class="template-select" :placeholder="ui('selectTemplate')">
<el-option

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import MatchesSubNav from '../components/MatchesSubNav.vue';
import AuditLogTable from '../components/AuditLogTable.vue';
</script>
<template>
<div class="admin-list-page matches-page">
<div class="list-chrome">
<div class="list-chrome__row">
<div class="list-chrome__left">
<MatchesSubNav embedded />
</div>
</div>
</div>
<AuditLogTable
endpoint="/admin/catalog-audit-logs"
:show-module-column="false"
:show-module-filter="false"
/>
</div>
</template>