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

@@ -55,7 +55,38 @@ export function usePlayerHome() {
loading.value = true;
try {
const { data } = await api.get('/player/home');
homeRaw.value = (data.data ?? null) as HomePayload | null;
const fresh = (data.data ?? null) as HomePayload | null;
if (fresh && homeRaw.value) {
// 已有数据 → 原地更新,保留对象引用,避免图片重新加载
const existing = homeRaw.value;
existing.banners = fresh.banners;
existing.announcements = fresh.announcements;
existing.ticker = fresh.ticker;
existing.notices = fresh.notices;
if (fresh.hotMatches && existing.hotMatches) {
const freshMap = new Map(fresh.hotMatches.map((m) => [m.id, m]));
for (const m of existing.hotMatches) {
const f = freshMap.get(m.id);
if (f) Object.assign(m, f);
}
// 处理新增或删除的比赛
const existingIds = new Set(existing.hotMatches.map((m) => m.id));
for (const fm of fresh.hotMatches) {
if (!existingIds.has(fm.id)) existing.hotMatches.push(fm);
}
for (let i = existing.hotMatches.length - 1; i >= 0; i--) {
if (!freshMap.has(existing.hotMatches[i].id)) {
existing.hotMatches.splice(i, 1);
}
}
} else {
existing.hotMatches = fresh.hotMatches;
}
} else {
homeRaw.value = fresh;
}
} catch {
homeRaw.value = null;
} finally {

View File

@@ -1,4 +1,4 @@
import { ref, shallowRef } from 'vue';
import { ref } from 'vue';
import api from '../api';
import type { MatchPhase } from '../utils/matchPhase';
@@ -35,21 +35,44 @@ export interface PlayerMatchSummary {
} | null;
}
const summaryMatches = shallowRef<PlayerMatchSummary[]>([]);
const summaryMatches = ref<PlayerMatchSummary[]>([]);
const summaryLoading = ref(false);
let summaryInflight: Promise<void> | null = null;
async function loadSummary(force = false): Promise<void> {
if (force) summaryMatches.value = [];
if (!force && summaryMatches.value.length > 0) return;
async function loadSummary(force = false, silent = false): Promise<void> {
if (force && !silent) summaryMatches.value = [];
if (!force && !silent && summaryMatches.value.length > 0) return;
if (summaryInflight) return summaryInflight;
summaryLoading.value = true;
if (!silent) summaryLoading.value = true;
summaryInflight = (async () => {
try {
const { data } = await api.get('/player/matches');
summaryMatches.value = (data.data ?? []) as PlayerMatchSummary[];
const fresh = (data.data ?? []) as PlayerMatchSummary[];
if (silent && summaryMatches.value.length > 0) {
// 静默模式:原地更新,保留对象引用,避免图片重新加载
const freshMap = new Map(fresh.map((m) => [m.id, m]));
const existingIds = new Set(summaryMatches.value.map((m) => m.id));
for (const fm of fresh) {
if (!existingIds.has(fm.id)) {
summaryMatches.value.push(fm);
}
}
for (let i = summaryMatches.value.length - 1; i >= 0; i--) {
const existing = summaryMatches.value[i];
const f = freshMap.get(existing.id);
if (f) {
Object.assign(existing, f);
} else {
summaryMatches.value.splice(i, 1);
}
}
} else {
summaryMatches.value = fresh;
}
} catch {
if (!summaryMatches.value.length) summaryMatches.value = [];
} finally {