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

@@ -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`,