feat: 管理端 RBAC 权限体系与员工管理

新增多角色权限控制(赛事/财务/客服管理员),支持员工 CRUD、路由菜单按权限显隐、审计日志范围过滤;登录返回角色与权限列表。玩家端赛事列表增加静默刷新避免图片闪烁。Seed 补充演示员工账号与充值相关权限。附带 RBAC/审计范围单元测试及 UAT 文档更新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 17:52:39 +08:00
parent be5b4a4921
commit 567ec9ec8a
44 changed files with 1717 additions and 125 deletions

View File

@@ -12,13 +12,19 @@ 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(10);
const pageSize = ref(20);
const filterModule = ref('');
onMounted(load);
@@ -56,6 +62,24 @@ function formatTime(v: string) {
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>
@@ -79,19 +103,30 @@ function formatTime(v: string) {
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="logs" stripe>
<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" />
<el-table-column :label="t('audit.col.time')" min-width="160">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
<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>
@@ -114,4 +149,21 @@ function formatTime(v: string) {
<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>