feat: 充值订单审计与重新申请,优化赛事展示和余额刷新

- 新增 deposit_order_audit_logs 表,记录提交/审批/拒绝/撤销/重提全链路
- 管理端充值单页增加审计历史;玩家端充值历史支持时间线与重新申请
- 已拒绝订单可原单号重提;撤销入账使用 PLAYER_DEPOSIT_REVERSAL 并加强幂等
- 结算后清除热门标记,允许归档已结算赛事,完善今日赛事时区窗口
- 足球页今日/早盘独立折叠;资料与余额在进入钱包/个人页及下注后自动刷新
- 补充投注玩法、结算返水规则文档;新增 smoke/settlement CLI 脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 14:52:05 +08:00
parent 73a94e6be3
commit afb5c5437e
55 changed files with 3050 additions and 160 deletions

View File

@@ -68,7 +68,10 @@ const filterNow = ref(new Date());
const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches();
const matches = summaryMatches;
const loading = summaryLoading;
const expandedLeagues = ref<Set<string>>(new Set());
const expandedLeagues = ref<Record<TimeTab, Set<string>>>({
today: new Set(),
early: new Set(),
});
async function loadMatches() {
filterNow.value = new Date();
@@ -86,23 +89,23 @@ const pullIndicatorStyle = () => ({
opacity: Math.min(pullDistance.value / 48, 1),
});
const filteredMatches = computed(() => {
function filterMatchesForTab(tab: TimeTab) {
if (mainTab.value !== 'matches') return [];
const now = filterNow.value;
return matches.value.filter((m) => {
const timeMatch =
timeTab.value === 'today'
tab === 'today'
? isInTodayMatchWindow(m.startTime, now)
: isAfterTodayMatchWindow(m.startTime, now);
if (!timeMatch) return false;
if (!showAll.value && m.matchPhase !== 'open' && m.matchPhase !== undefined) return false;
return true;
});
});
}
const leagueGroups = computed<LeagueGroup[]>(() => {
function buildLeagueGroups(source: Match[]): LeagueGroup[] {
const map = new Map<string, LeagueGroup>();
for (const m of filteredMatches.value) {
for (const m of source) {
const id = m.leagueId ?? m.leagueName;
if (!map.has(id)) {
map.set(id, {
@@ -127,29 +130,34 @@ const leagueGroups = computed<LeagueGroup[]>(() => {
(a.matches[0]?.displayOrder ?? 0) - (b.matches[0]?.displayOrder ?? 0) ||
a.leagueName.localeCompare(b.leagueName),
);
});
}
watch(leagueGroups, (groups) => {
const ids = new Set(expandedLeagues.value);
const todayLeagueGroups = computed(() => buildLeagueGroups(filterMatchesForTab('today')));
const earlyLeagueGroups = computed(() => buildLeagueGroups(filterMatchesForTab('early')));
function syncExpandedLeagues(groups: LeagueGroup[], tab: TimeTab) {
const current = expandedLeagues.value[tab];
const ids = new Set(current);
for (const id of [...ids]) {
if (!groups.some((g) => g.leagueId === id)) ids.delete(id);
}
if (ids.size !== expandedLeagues.value.size) expandedLeagues.value = ids;
// 默认只展开第一个联赛,减少首屏 DOM
if (groups.length > 0 && expandedLeagues.value.size === 0) {
expandedLeagues.value = new Set([groups[0].leagueId]);
if (groups.length > 0 && ids.size === 0) {
ids.add(groups[0].leagueId);
}
if (ids.size !== current.size || [...ids].some((id) => !current.has(id))) {
expandedLeagues.value = { ...expandedLeagues.value, [tab]: ids };
}
});
function isLeagueExpanded(leagueId: string) {
return expandedLeagues.value.has(leagueId);
}
watch(todayLeagueGroups, (groups) => syncExpandedLeagues(groups, 'today'));
watch(earlyLeagueGroups, (groups) => syncExpandedLeagues(groups, 'early'));
function toggleLeague(leagueId: string) {
const next = new Set(expandedLeagues.value);
const tab = timeTab.value;
const next = new Set(expandedLeagues.value[tab]);
if (next.has(leagueId)) next.delete(leagueId);
else next.add(leagueId);
expandedLeagues.value = next;
expandedLeagues.value = { ...expandedLeagues.value, [tab]: next };
}
function selectMainTab(tab: MainTab) {
@@ -232,24 +240,47 @@ function goMatch(id: string) {
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="leagueGroups.length" class="league-list">
<LeagueAccordionItem
v-for="group in leagueGroups"
:key="group.leagueId"
:league-id="group.leagueId"
:league-name="group.leagueName"
:league-logo-url="group.leagueLogoUrl"
:matches="group.matches"
:expanded="isLeagueExpanded(group.leagueId)"
@toggle="toggleLeague(group.leagueId)"
@bet="goMatch"
/>
</div>
<template v-else>
<div v-show="timeTab === 'today'">
<div v-if="todayLeagueGroups.length" class="league-list">
<LeagueAccordionItem
v-for="group in todayLeagueGroups"
:key="`today-${group.leagueId}`"
:league-id="group.leagueId"
:league-name="group.leagueName"
:league-logo-url="group.leagueLogoUrl"
:matches="group.matches"
:expanded="expandedLeagues.today.has(group.leagueId)"
@toggle="toggleLeague(group.leagueId)"
@bet="goMatch"
/>
</div>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('bet.no_matches') }}</p>
</div>
</div>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('bet.no_matches') }}</p>
</div>
<div v-show="timeTab === 'early'">
<div v-if="earlyLeagueGroups.length" class="league-list">
<LeagueAccordionItem
v-for="group in earlyLeagueGroups"
:key="`early-${group.leagueId}`"
:league-id="group.leagueId"
:league-name="group.leagueName"
:league-logo-url="group.leagueLogoUrl"
:matches="group.matches"
:expanded="expandedLeagues.early.has(group.leagueId)"
@toggle="toggleLeague(group.leagueId)"
@bet="goMatch"
/>
</div>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('bet.no_matches') }}</p>
</div>
</div>
</template>
</div>
<OutrightPanel v-if="mainTab === 'outright'" class="outright-tab" />