feat: multi-tier agent hierarchy, wallet ledger, and player UX polish

Add configurable agent max level and default sub-agent credit ratio, per-agent block direct player login on suspend, admin/agent wallet transaction views, and match detail my-bets section with refreshed player card styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-10 16:15:34 +08:00
parent 641c92a5f5
commit ef6b15f119
39 changed files with 2398 additions and 410 deletions

View File

@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { useBetSlipStore } from '../stores/betSlip';
import TeamEmblem from '../components/TeamEmblem.vue';
import { DETAIL_MARKET_TYPES, MARKET_I18N_KEY } from '../utils/marketCatalog';
@@ -82,6 +83,53 @@ const csMessage = ref('');
const showCsSuccess = ref(false);
const csConfirmOpen = ref(false);
const csConfirmMarketType = ref<string | null>(null);
interface MyBet {
betNo: string;
betType: string;
stake: string;
totalOdds: string;
potentialReturn: string;
actualReturn: string;
status: string;
placedAt: string;
pickLabel: string;
matchTitle: string;
}
const myBets = ref<MyBet[]>([]);
const loadingMyBets = ref(false);
async function loadMyBets() {
if (!match.value) return;
loadingMyBets.value = true;
try {
const { data } = await api.get('/player/bets?page=1');
const items = (data.data?.items ?? data.data ?? []) as MyBet[];
const matchTitle = `${match.value.homeTeamName} vs ${match.value.awayTeamName}`;
myBets.value = items.filter(
(b) => b.matchTitle === matchTitle || b.matchTitle === `${match.value!.awayTeamName} vs ${match.value!.homeTeamName}`,
);
} catch {
myBets.value = [];
} finally {
loadingMyBets.value = false;
}
}
const statusLabel = (status: string) => {
const s = status.toUpperCase();
if (s === 'WON' || s === 'WIN') return t('history.status_won');
if (s === 'LOST' || s === 'LOSE') return t('history.status_lost');
if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') return t('history.status_push');
return t('history.status_pending');
};
const statusClass = (status: string) => {
const s = status.toUpperCase();
if (s === 'WON' || s === 'WIN') return 'bet-status-won';
if (s === 'LOST' || s === 'LOSE') return 'bet-status-lost';
return 'bet-status-pending';
};
const marketsByType = computed(() => {
const map = new Map<string, Market>();
for (const m of match.value?.markets ?? []) {
@@ -237,6 +285,7 @@ async function loadMatch() {
} finally {
loading.value = false;
}
loadMyBets();
}
useOnLocaleChange(loadMatch);
@@ -384,6 +433,25 @@ function hasSlipPickForMarket(marketType: string) {
<p v-if="liveScoreText" class="live-score">{{ liveScoreText }}</p>
</section>
<!-- 我的投注 -->
<section v-if="myBets.length" class="my-bets-section">
<h3 class="my-bets-title">{{ t('history.my_bets') || '我的投注' }}</h3>
<div class="my-bets-list">
<div v-for="bet in myBets" :key="bet.betNo" class="my-bet-card" @click="router.push(`/bets/${bet.betNo}`)">
<div class="bet-header">
<span class="bet-type">{{ bet.betType === 'PARLAY' ? t('history.parlay_league') : bet.pickLabel }}</span>
<span class="bet-status" :class="statusClass(bet.status)">{{ statusLabel(bet.status) }}</span>
</div>
<div class="bet-footer">
<span class="bet-stake">{{ t('history.stake') }} {{ formatMoney(bet.stake, locale) }}</span>
<span class="bet-return" :class="statusClass(bet.status)">
{{ statusClass(bet.status) === 'bet-status-won' ? '+' : '' }}{{ formatMoney(bet.actualReturn || bet.potentialReturn, locale) }}
</span>
</div>
</div>
</div>
</section>
<section class="markets-section">
<CorrectScoreConfirmModal
:open="csConfirmOpen"
@@ -777,4 +845,93 @@ function hasSlipPickForMarket(marketType: string) {
padding: 2px 0 6px;
}
/* ── 我的投注 ── */
.my-bets-section {
padding: 0 12px;
margin-bottom: 12px;
}
.my-bets-title {
font-size: 14px;
font-weight: 700;
color: #ccc;
margin: 0 0 8px;
}
.my-bets-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.my-bet-card {
background: #1c1c1c;
border: 1px solid #2e2e2e;
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.15s;
}
.my-bet-card:active {
background: #252525;
}
.bet-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.bet-type {
font-size: 12px;
font-weight: 700;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
margin-right: 8px;
}
.bet-status {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
.bet-status-won {
background: rgba(61, 184, 101, 0.15);
color: #3db865;
}
.bet-status-lost {
background: rgba(224, 80, 80, 0.15);
color: #e05050;
}
.bet-status-pending {
background: rgba(232, 200, 74, 0.15);
color: #e8c84a;
}
.bet-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.bet-stake {
font-size: 11px;
color: #888;
}
.bet-return {
font-size: 13px;
font-weight: 800;
}
</style>