feat: 管理端 RBAC 权限体系与员工管理
新增多角色权限控制(赛事/财务/客服管理员),支持员工 CRUD、路由菜单按权限显隐、审计日志范围过滤;登录返回角色与权限列表。玩家端赛事列表增加静默刷新避免图片闪烁。Seed 补充演示员工账号与充值相关权限。附带 RBAC/审计范围单元测试及 UAT 文档更新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user