feat: 赛事管理操作日志与审计页体验优化
- API 为赛事/联赛/盘口/赔率/冠军盘写操作写入 CATALOG 审计日志,并新增 catalog-audit-logs 接口 - 管理端将赛事日志独立至赛事管理子页,通用操作日志排除 CATALOG 模块 - 统一赛事子页 list-chrome 布局与面包屑;修复操作日志页表格滚动与分页不可见问题 - 补充中英文/马来语文案及 UAT 验收项 SEC013/SEC014 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -261,7 +261,6 @@ body::-webkit-scrollbar {
|
||||
}
|
||||
.list-chrome__left .matches-subnav--embedded {
|
||||
align-self: center;
|
||||
height: var(--list-chrome-control-h);
|
||||
}
|
||||
.list-chrome__actions {
|
||||
display: flex;
|
||||
|
||||
194
apps/admin/src/components/AuditLogTable.vue
Normal file
194
apps/admin/src/components/AuditLogTable.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { useAuditLabels } from '../utils/audit-labels';
|
||||
import api from '../api';
|
||||
import AdminTableEmpty from './AdminTableEmpty.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
endpoint?: string;
|
||||
showModuleColumn?: boolean;
|
||||
showModuleFilter?: boolean;
|
||||
}>(),
|
||||
{
|
||||
endpoint: '/admin/audit-logs',
|
||||
showModuleColumn: true,
|
||||
showModuleFilter: true,
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
watch(
|
||||
() => props.endpoint,
|
||||
() => {
|
||||
page.value = 1;
|
||||
load();
|
||||
},
|
||||
);
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get(props.endpoint, {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
module: props.showModuleFilter && filterModule.value.trim()
|
||||
? 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 '—';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card v-if="showModuleFilter" 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
|
||||
v-if="showModuleColumn"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card { border-radius: 12px; margin-bottom: 16px; }
|
||||
.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>
|
||||
@@ -16,6 +16,7 @@ const tabs = [
|
||||
{ path: '/matches', labelKey: 'nav.matches.fixtures' },
|
||||
{ path: '/matches/outrights', labelKey: 'nav.matches.outrights' },
|
||||
{ path: '/matches/market-templates', labelKey: 'nav.matches.market_templates' },
|
||||
{ path: '/matches/audit-logs', labelKey: 'nav.matches.audit_logs' },
|
||||
];
|
||||
|
||||
function isActive(path: string) {
|
||||
@@ -25,11 +26,15 @@ function isActive(path: string) {
|
||||
if (path === '/matches/market-templates') {
|
||||
return route.path === '/matches/market-templates';
|
||||
}
|
||||
if (path === '/matches/audit-logs') {
|
||||
return route.path === '/matches/audit-logs';
|
||||
}
|
||||
return (
|
||||
route.path === '/matches' ||
|
||||
(route.path.startsWith('/matches/') &&
|
||||
!route.path.startsWith('/matches/outrights') &&
|
||||
!route.path.startsWith('/matches/market-templates'))
|
||||
!route.path.startsWith('/matches/market-templates') &&
|
||||
!route.path.startsWith('/matches/audit-logs'))
|
||||
);
|
||||
}
|
||||
</script>
|
||||
@@ -73,9 +78,16 @@ function isActive(path: string) {
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
height: 44px;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
align-self: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.matches-subnav--embedded .matches-subnav__item {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.matches-subnav__item {
|
||||
padding: 0 14px;
|
||||
height: 34px;
|
||||
|
||||
@@ -247,6 +247,7 @@ const zh: Record<string, string> = {
|
||||
'nav.matches.fixtures': '赛事配置',
|
||||
'nav.matches.outrights': '优胜赛配置(盘口)',
|
||||
'nav.matches.market_templates': '盘口模板',
|
||||
'nav.matches.audit_logs': '赛事日志',
|
||||
'page.matches.title': '赛事管理',
|
||||
'page.matches.desc': '草稿可编辑、删除;已发布可改开赛时间与热门;未结算可下架',
|
||||
'page.bets.title': '注单管理',
|
||||
@@ -254,7 +255,9 @@ const zh: Record<string, string> = {
|
||||
'page.cashback.title': '返水管理',
|
||||
'page.cashback.desc': '按周期生成返水并发放',
|
||||
'page.audit.title': '操作日志',
|
||||
'page.audit.desc': '记录所有管理员操作行为',
|
||||
'page.audit.desc': '账务、用户、结算等非赛事类管理操作',
|
||||
'page.matches_audit.title': '赛事日志',
|
||||
'page.matches_audit.desc': '联赛、赛事、盘口、赔率、冠军盘等赛事管理操作',
|
||||
'page.settlement.title': '赛事结算',
|
||||
'page.agent_dash.title': '代理概览',
|
||||
'page.agent_dash.desc': '下线经营概况与分布',
|
||||
@@ -543,6 +546,7 @@ const en: Record<string, string> = {
|
||||
'nav.matches.fixtures': 'Fixtures',
|
||||
'nav.matches.outrights': 'Outright odds',
|
||||
'nav.matches.market_templates': 'Market templates',
|
||||
'nav.matches.audit_logs': 'Match logs',
|
||||
'page.matches.title': 'Matches',
|
||||
'page.matches.desc': 'Edit/delete drafts; adjust kickoff and featured when published; unpublish while unsettled',
|
||||
'page.bets.title': 'Bets',
|
||||
@@ -550,7 +554,9 @@ const en: Record<string, string> = {
|
||||
'page.cashback.title': 'Cashback',
|
||||
'page.cashback.desc': 'Generate and issue cashback by period',
|
||||
'page.audit.title': 'Audit Log',
|
||||
'page.audit.desc': 'Administrator action history',
|
||||
'page.audit.desc': 'Non-catalog admin actions: users, wallet, settlement, etc.',
|
||||
'page.matches_audit.title': 'Match Logs',
|
||||
'page.matches_audit.desc': 'Leagues, fixtures, markets, odds, and outright operations',
|
||||
'page.settlement.title': 'Settlement',
|
||||
'page.agent_dash.title': 'Agent overview',
|
||||
'page.agent_dash.desc': 'Downline performance at a glance',
|
||||
@@ -839,6 +845,7 @@ const ms: Record<string, string> = {
|
||||
'nav.matches.fixtures': 'Konfigurasi perlawanan',
|
||||
'nav.matches.outrights': 'Odds juara',
|
||||
'nav.matches.market_templates': 'Templat pasaran',
|
||||
'nav.matches.audit_logs': 'Log perlawanan',
|
||||
'page.matches.title': 'Perlawanan',
|
||||
'page.matches.desc': 'Edit/padam draf; laraskan masa mula bila diterbitkan; nyahterbit jika belum selesai',
|
||||
'page.bets.title': 'Pertaruhan',
|
||||
@@ -846,7 +853,9 @@ const ms: Record<string, string> = {
|
||||
'page.cashback.title': 'Rebat',
|
||||
'page.cashback.desc': 'Jana dan keluarkan rebat mengikut tempoh',
|
||||
'page.audit.title': 'Log audit',
|
||||
'page.audit.desc': 'Sejarah tindakan pentadbir',
|
||||
'page.audit.desc': 'Tindakan bukan katalog: pengguna, dompet, penyelesaian, dll.',
|
||||
'page.matches_audit.title': 'Log perlawanan',
|
||||
'page.matches_audit.desc': 'Liga, perlawanan, pasaran, odds, dan outright',
|
||||
'page.settlement.title': 'Penyelesaian',
|
||||
'page.agent_dash.title': 'Gambaran ejen',
|
||||
'page.agent_dash.desc': 'Prestasi downline sepintas lalu',
|
||||
|
||||
@@ -332,7 +332,7 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'bet.col.line': 'Garisan',
|
||||
'bet.col.result': 'Keputusan',
|
||||
|
||||
'audit.module_ph': 'cth. USERS, AGENTS',
|
||||
'audit.module_ph': 'cth. USERS, SETTLEMENT',
|
||||
'audit.col.time': 'Masa',
|
||||
'audit.col.operator': 'Pengendali',
|
||||
'audit.col.action': 'Tindakan',
|
||||
@@ -360,6 +360,39 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'audit.action.UPDATE_STAFF': 'Kemas kini kakitangan',
|
||||
'audit.action.PURGE_UNUSED_FILES': 'Padam media tidak digunakan',
|
||||
'audit.action.FORGOT_PASSWORD_RESET': 'Pemain set semula kata laluan',
|
||||
'audit.action.CREATE_LEAGUE': 'Cipta liga',
|
||||
'audit.action.UPDATE_LEAGUE': 'Kemas kini liga',
|
||||
'audit.action.ARCHIVE_LEAGUE': 'Arkib liga',
|
||||
'audit.action.CREATE_TEAM': 'Cipta pasukan',
|
||||
'audit.action.CREATE_MATCH': 'Cipta perlawanan',
|
||||
'audit.action.UPDATE_MATCH': 'Kemas kini perlawanan',
|
||||
'audit.action.DELETE_MATCH': 'Padam perlawanan',
|
||||
'audit.action.ARCHIVE_MATCH': 'Arkib perlawanan',
|
||||
'audit.action.IMPORT_MATCHES': 'Import perlawanan',
|
||||
'audit.action.PUBLISH_MATCH': 'Terbit perlawanan',
|
||||
'audit.action.UNPUBLISH_MATCH': 'Nyahterbit perlawanan',
|
||||
'audit.action.CLOSE_MATCH': 'Tutup perlawanan',
|
||||
'audit.action.REOPEN_MATCH': 'Buka semula perlawanan',
|
||||
'audit.action.CANCEL_MATCH': 'Batalkan perlawanan',
|
||||
'audit.action.CREATE_MARKET_TEMPLATE': 'Cipta templat pasaran',
|
||||
'audit.action.UPDATE_MARKET_TEMPLATE': 'Kemas kini templat pasaran',
|
||||
'audit.action.DUPLICATE_MARKET_TEMPLATE': 'Salin templat pasaran',
|
||||
'audit.action.SET_DEFAULT_MARKET_TEMPLATE': 'Tetapkan templat lalai',
|
||||
'audit.action.GENERATE_MATCH_MARKETS': 'Jana pasaran perlawanan',
|
||||
'audit.action.APPLY_MARKET_TEMPLATE': 'Guna templat pasaran',
|
||||
'audit.action.BULK_SAVE_MATCH_MARKETS': 'Simpan pasaran pukal',
|
||||
'audit.action.UPDATE_MATCH_ODDS': 'Kemas kini odds perlawanan',
|
||||
'audit.action.UPDATE_MARKET': 'Kemas kini pasaran',
|
||||
'audit.action.UPDATE_SELECTION': 'Kemas kini pilihan',
|
||||
'audit.action.CREATE_OUTRIGHT': 'Cipta outright',
|
||||
'audit.action.UPDATE_OUTRIGHT': 'Kemas kini outright',
|
||||
'audit.action.UPDATE_OUTRIGHT_ODDS': 'Kemas kini odds outright',
|
||||
'audit.action.ADD_OUTRIGHT_SELECTION': 'Tambah pilihan outright',
|
||||
'audit.action.BATCH_ADD_OUTRIGHT_SELECTIONS': 'Tambah pilihan outright pukal',
|
||||
'audit.action.UPDATE_OUTRIGHT_SELECTION': 'Kemas kini pilihan outright',
|
||||
'audit.action.REMOVE_OUTRIGHT_SELECTION': 'Buang pilihan outright',
|
||||
'audit.action.IMPORT_WC2026_OUTRIGHT': 'Import outright WC2026',
|
||||
'audit.module.CATALOG': 'Pengurusan perlawanan',
|
||||
'audit.module.USERS': 'Pemain',
|
||||
'audit.module.AGENTS': 'Ejen',
|
||||
'audit.module.SYSTEM': 'Sistem',
|
||||
|
||||
@@ -354,7 +354,7 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'bet.col.line': '盘口',
|
||||
'bet.col.result': '赛果',
|
||||
|
||||
'audit.module_ph': '如 USERS、AGENTS',
|
||||
'audit.module_ph': '如 USERS、SETTLEMENT',
|
||||
'audit.col.time': '时间',
|
||||
'audit.col.operator': '操作人',
|
||||
'audit.col.action': '操作',
|
||||
@@ -382,6 +382,39 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'audit.action.UPDATE_STAFF': '更新后台员工',
|
||||
'audit.action.PURGE_UNUSED_FILES': '清理未引用媒体',
|
||||
'audit.action.FORGOT_PASSWORD_RESET': '玩家找回密码',
|
||||
'audit.action.CREATE_LEAGUE': '新建联赛',
|
||||
'audit.action.UPDATE_LEAGUE': '更新联赛',
|
||||
'audit.action.ARCHIVE_LEAGUE': '归档联赛',
|
||||
'audit.action.CREATE_TEAM': '新建球队',
|
||||
'audit.action.CREATE_MATCH': '新建赛事',
|
||||
'audit.action.UPDATE_MATCH': '更新赛事',
|
||||
'audit.action.DELETE_MATCH': '删除赛事',
|
||||
'audit.action.ARCHIVE_MATCH': '归档赛事',
|
||||
'audit.action.IMPORT_MATCHES': '批量导入赛事',
|
||||
'audit.action.PUBLISH_MATCH': '发布赛事',
|
||||
'audit.action.UNPUBLISH_MATCH': '下架赛事',
|
||||
'audit.action.CLOSE_MATCH': '封盘赛事',
|
||||
'audit.action.REOPEN_MATCH': '重新开放赛事',
|
||||
'audit.action.CANCEL_MATCH': '取消赛事',
|
||||
'audit.action.CREATE_MARKET_TEMPLATE': '新建盘口模板',
|
||||
'audit.action.UPDATE_MARKET_TEMPLATE': '更新盘口模板',
|
||||
'audit.action.DUPLICATE_MARKET_TEMPLATE': '复制盘口模板',
|
||||
'audit.action.SET_DEFAULT_MARKET_TEMPLATE': '设为默认盘口模板',
|
||||
'audit.action.GENERATE_MATCH_MARKETS': '生成赛事盘口',
|
||||
'audit.action.APPLY_MARKET_TEMPLATE': '应用盘口模板',
|
||||
'audit.action.BULK_SAVE_MATCH_MARKETS': '批量保存赛事盘口',
|
||||
'audit.action.UPDATE_MATCH_ODDS': '更新赛事赔率',
|
||||
'audit.action.UPDATE_MARKET': '更新盘口',
|
||||
'audit.action.UPDATE_SELECTION': '更新选项',
|
||||
'audit.action.CREATE_OUTRIGHT': '新建冠军盘',
|
||||
'audit.action.UPDATE_OUTRIGHT': '更新冠军盘',
|
||||
'audit.action.UPDATE_OUTRIGHT_ODDS': '更新冠军盘赔率',
|
||||
'audit.action.ADD_OUTRIGHT_SELECTION': '添加冠军盘选项',
|
||||
'audit.action.BATCH_ADD_OUTRIGHT_SELECTIONS': '批量添加冠军盘选项',
|
||||
'audit.action.UPDATE_OUTRIGHT_SELECTION': '更新冠军盘选项',
|
||||
'audit.action.REMOVE_OUTRIGHT_SELECTION': '移除冠军盘选项',
|
||||
'audit.action.IMPORT_WC2026_OUTRIGHT': '导入世界杯冠军盘',
|
||||
'audit.module.CATALOG': '赛事管理',
|
||||
'audit.module.USERS': '玩家',
|
||||
'audit.module.AGENTS': '代理',
|
||||
'audit.module.SYSTEM': '系统',
|
||||
@@ -1393,7 +1426,7 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'bet.col.line': 'Line',
|
||||
'bet.col.result': 'Result',
|
||||
|
||||
'audit.module_ph': 'e.g. USERS, AGENTS',
|
||||
'audit.module_ph': 'e.g. USERS, SETTLEMENT',
|
||||
'audit.col.time': 'Time',
|
||||
'audit.col.operator': 'Operator',
|
||||
'audit.col.action': 'Action',
|
||||
@@ -1421,6 +1454,39 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'audit.action.UPDATE_STAFF': 'Update staff account',
|
||||
'audit.action.PURGE_UNUSED_FILES': 'Purge unused media',
|
||||
'audit.action.FORGOT_PASSWORD_RESET': 'Player forgot-password reset',
|
||||
'audit.action.CREATE_LEAGUE': 'Create league',
|
||||
'audit.action.UPDATE_LEAGUE': 'Update league',
|
||||
'audit.action.ARCHIVE_LEAGUE': 'Archive league',
|
||||
'audit.action.CREATE_TEAM': 'Create team',
|
||||
'audit.action.CREATE_MATCH': 'Create match',
|
||||
'audit.action.UPDATE_MATCH': 'Update match',
|
||||
'audit.action.DELETE_MATCH': 'Delete match',
|
||||
'audit.action.ARCHIVE_MATCH': 'Archive match',
|
||||
'audit.action.IMPORT_MATCHES': 'Import matches',
|
||||
'audit.action.PUBLISH_MATCH': 'Publish match',
|
||||
'audit.action.UNPUBLISH_MATCH': 'Unpublish match',
|
||||
'audit.action.CLOSE_MATCH': 'Close match',
|
||||
'audit.action.REOPEN_MATCH': 'Reopen match',
|
||||
'audit.action.CANCEL_MATCH': 'Cancel match',
|
||||
'audit.action.CREATE_MARKET_TEMPLATE': 'Create market template',
|
||||
'audit.action.UPDATE_MARKET_TEMPLATE': 'Update market template',
|
||||
'audit.action.DUPLICATE_MARKET_TEMPLATE': 'Duplicate market template',
|
||||
'audit.action.SET_DEFAULT_MARKET_TEMPLATE': 'Set default market template',
|
||||
'audit.action.GENERATE_MATCH_MARKETS': 'Generate match markets',
|
||||
'audit.action.APPLY_MARKET_TEMPLATE': 'Apply market template',
|
||||
'audit.action.BULK_SAVE_MATCH_MARKETS': 'Bulk save match markets',
|
||||
'audit.action.UPDATE_MATCH_ODDS': 'Update match odds',
|
||||
'audit.action.UPDATE_MARKET': 'Update market',
|
||||
'audit.action.UPDATE_SELECTION': 'Update selection',
|
||||
'audit.action.CREATE_OUTRIGHT': 'Create outright',
|
||||
'audit.action.UPDATE_OUTRIGHT': 'Update outright',
|
||||
'audit.action.UPDATE_OUTRIGHT_ODDS': 'Update outright odds',
|
||||
'audit.action.ADD_OUTRIGHT_SELECTION': 'Add outright selection',
|
||||
'audit.action.BATCH_ADD_OUTRIGHT_SELECTIONS': 'Batch add outright selections',
|
||||
'audit.action.UPDATE_OUTRIGHT_SELECTION': 'Update outright selection',
|
||||
'audit.action.REMOVE_OUTRIGHT_SELECTION': 'Remove outright selection',
|
||||
'audit.action.IMPORT_WC2026_OUTRIGHT': 'Import WC2026 outright',
|
||||
'audit.module.CATALOG': 'Catalog',
|
||||
'audit.module.USERS': 'Players',
|
||||
'audit.module.AGENTS': 'Agents',
|
||||
'audit.module.SYSTEM': 'System',
|
||||
|
||||
@@ -48,7 +48,7 @@ const adminMenus = computed(() => {
|
||||
{ path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] },
|
||||
{ path: '/contents', label: t('nav.contents'), icon: 'contents', permissions: [AdminPerm.content] },
|
||||
{ path: '/media', label: t('nav.media'), icon: 'media', permissions: [AdminPerm.content, AdminPerm.matches] },
|
||||
{ path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit] },
|
||||
{ path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit], excludeRoles: ['MATCH_ADMIN'] },
|
||||
{ path: '/staff', label: t('nav.staff'), icon: 'users', permissions: [AdminPerm.settings] },
|
||||
{ path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] },
|
||||
];
|
||||
|
||||
@@ -86,6 +86,11 @@ const router = createRouter({
|
||||
component: () => import('../views/MarketTemplates.vue'),
|
||||
meta: { adminOnly: true, permissions: [AdminPerm.matches] },
|
||||
},
|
||||
{
|
||||
path: 'matches/audit-logs',
|
||||
component: () => import('../views/MatchesAudit.vue'),
|
||||
meta: { adminOnly: true, permissions: [AdminPerm.matches] },
|
||||
},
|
||||
{
|
||||
path: 'matches/:matchId/edit',
|
||||
name: 'admin-match-edit',
|
||||
|
||||
@@ -14,12 +14,6 @@ export function resolveAdminBreadcrumb(
|
||||
{ label: t('breadcrumb.settlement') },
|
||||
];
|
||||
}
|
||||
if (path === '/matches/market-templates') {
|
||||
return [
|
||||
{ label: t('nav.matches'), to: '/matches' },
|
||||
{ label: t('nav.matches.market_templates') },
|
||||
];
|
||||
}
|
||||
if (/^\/matches\/[^/]+\/edit/.test(path)) {
|
||||
return [
|
||||
{ label: t('nav.matches'), to: '/matches' },
|
||||
@@ -32,12 +26,6 @@ export function resolveAdminBreadcrumb(
|
||||
{ label: t('breadcrumb.match_markets') },
|
||||
];
|
||||
}
|
||||
if (path === '/matches/outrights') {
|
||||
return [
|
||||
{ label: t('nav.matches'), to: '/matches' },
|
||||
{ label: t('nav.matches.outrights') },
|
||||
];
|
||||
}
|
||||
if (path === '/dashboard/players') {
|
||||
return [
|
||||
{ label: t('nav.dashboard'), to: '/' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
21
apps/admin/src/views/MatchesAudit.vue
Normal file
21
apps/admin/src/views/MatchesAudit.vue
Normal 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>
|
||||
Reference in New Issue
Block a user