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

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import OutrightEventSection, {
@@ -12,6 +12,10 @@ import emptyMatchesImg from '../../assets/images/empty-matches.svg';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
const props = defineProps<{
activated?: boolean;
}>();
const { t } = useI18n();
const auth = useAuthStore();
@@ -94,6 +98,14 @@ function mergeOddsOnly(fresh: OutrightEvent[]) {
useOnLocaleChange(load);
// 每次切回优胜冠军 Tab 时静默刷新赔率
watch(
() => props.activated,
(active) => {
if (active && events.value.length > 0) void load();
},
);
function toggle(id: string) {
const next = new Set(expanded.value);
if (next.has(id)) next.delete(id);

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 {

View File

@@ -144,7 +144,7 @@ watch(
<main ref="mainRef" :class="['main', { 'has-nav': showBottomNav }]">
<RouterView v-slot="{ Component, route: viewRoute }">
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="3">
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="10">
<component :is="Component" :key="viewRoute.path" />
</KeepAlive>
<component v-else :is="Component" :key="viewRoute.fullPath" />

View File

@@ -64,6 +64,7 @@ const router = useRouter();
const mainTab = ref<MainTab>('matches');
const timeTab = ref<TimeTab>('today');
const showAll = ref(false);
const outrightActivated = ref(false);
const filterNow = ref(new Date());
const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches();
const matches = summaryMatches;
@@ -162,11 +163,13 @@ function toggleLeague(leagueId: string) {
function selectMainTab(tab: MainTab) {
mainTab.value = tab;
if (tab === 'outright') outrightActivated.value = true;
}
onActivated(() => {
filterNow.value = new Date();
timeTab.value = 'today';
void loadSummary(true, true);
});
function goMatch(id: string) {
@@ -204,7 +207,7 @@ function goMatch(id: string) {
</button>
</div>
<div v-show="mainTab === 'matches'">
<div :class="['tab-panel', { 'tab-panel--hidden': mainTab !== 'matches' }]">
<div class="time-tabs">
<button
type="button"
@@ -283,7 +286,11 @@ function goMatch(id: string) {
</template>
</div>
<OutrightPanel v-if="mainTab === 'outright'" class="outright-tab" />
<OutrightPanel
v-if="outrightActivated"
:class="['outright-tab', 'tab-panel', { 'tab-panel--hidden': mainTab !== 'outright' }]"
:activated="mainTab === 'outright'"
/>
</div>
</template>
@@ -302,6 +309,24 @@ function goMatch(id: string) {
padding-bottom: 8px;
}
/* Tab 面板切换:不用 display:none避免浏览器释放图片资源导致重新加载 */
.tab-panel {
display: block;
}
.tab-panel--hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
opacity: 0;
pointer-events: none;
}
.main-tabs {
display: flex;
gap: 8px;

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { onActivated } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
@@ -20,6 +21,8 @@ const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await load(true); },
});
onActivated(() => { void load(true); });
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { ref, onActivated, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../api';
import BetHistoryCard, { type BetHistoryItem } from '../components/BetHistoryCard.vue';
@@ -73,6 +73,8 @@ const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await loadPage(1); },
});
onActivated(() => { void loadPage(1); });
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onActivated, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
@@ -73,6 +73,7 @@ const { pullDistance, spinning, progress } = usePullToRefresh({
});
onMounted(fetchData);
onActivated(fetchData);
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,