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

@@ -3,11 +3,14 @@ import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAdminDashboard } from '../../composables/useAdminDashboard';
import EChartPanel from '../../components/dashboard/EChartPanel.vue';
import { buildCombinedTrendOption, buildTriplePieOption } from '../../utils/dashboard-charts';
import { buildCombinedTrendOption, buildTriplePieOption, buildBarChartOption } from '../../utils/dashboard-charts';
import { betStatusLabel } from '../../utils/bet-labels';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { usePermissions } from '../../composables/usePermissions';
const { t } = useAdminLocale();
const { role } = usePermissions();
const isMatchOperatorView = computed(() => role.value === 'MATCH_ADMIN');
const router = useRouter();
const {
s,
@@ -32,8 +35,16 @@ function goKpiLink(link: KpiLink) {
router.push(link.query ? { path: link.path, query: link.query } : link.path);
}
const mainTrendOption = computed(() =>
buildCombinedTrendOption(
const mainTrendOption = computed(() => {
const counts = s.value?.trend7d?.map((d) => d.betCount) ?? [];
if (isMatchOperatorView.value) {
return buildBarChartOption(
trendLabels.value,
[{ name: t('dash.chart_bet_count'), color: '#956400', values: counts }],
{ amountAxis: false },
);
}
return buildCombinedTrendOption(
trendLabels.value,
[
{
@@ -52,10 +63,10 @@ const mainTrendOption = computed(() =>
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
},
],
s.value?.trend7d?.map((d) => d.betCount) ?? [],
counts,
chartI18n.value,
),
);
);
});
const distributionOption = computed(() => {
const m = s.value?.matches;
@@ -146,13 +157,15 @@ const kpiMatch = computed(() => {
<template v-else-if="s">
<el-card class="overview-board" shadow="never">
<div v-if="s.generatedAt" class="board-head">
<span class="board-hint">{{ t('dash.section_matches_hint') }}</span>
<span class="board-hint">{{
isMatchOperatorView ? t('dash.section_match_ops_hint') : t('dash.section_matches_hint')
}}</span>
<span class="dash-updated">
{{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
</span>
</div>
<div class="kpi-grid kpi-primary">
<div v-if="!isMatchOperatorView" class="kpi-grid kpi-primary">
<div v-for="item in kpiPrimary" :key="item.label" class="kpi-cell">
<span class="kpi-label">{{ item.label }}</span>
<span class="kpi-value">{{ item.value }}</span>
@@ -166,7 +179,7 @@ const kpiMatch = computed(() => {
</div>
</div>
<div class="kpi-grid kpi-secondary">
<div class="kpi-grid" :class="isMatchOperatorView ? 'kpi-primary' : 'kpi-secondary'">
<div
v-for="item in kpiMatch"
:key="item.label"
@@ -185,7 +198,9 @@ const kpiMatch = computed(() => {
<div class="charts-stack">
<EChartPanel title="" :option="mainTrendOption" height="300px" class="chart-main" />
<div class="chart-main-caption">{{ t('dash.trend_caption') }}</div>
<div class="chart-main-caption">{{
isMatchOperatorView ? t('dash.trend_bet_count_caption') : t('dash.trend_caption')
}}</div>
<EChartPanel title="" :option="distributionOption" height="200px" class="chart-dist" />
</div>
</el-card>