Files
thebet365/apps/player/src/views/BetDetailView.vue
Mars d3ca8498fb feat(player/admin/api): 玩家端暗色极简主题重构与盘口单串关修复
## 管理端 / API(Bug 修复)
- matches.service:getAdminMatchDetail 返回 markets 时补充 allowSingle、allowParlay 字段
- MatchMarketsPanel:mapMarkets 从接口读取单关/串关开关,不再硬编码为 true
- match-form.ts:AdminMarket 类型补充 allowSingle、allowParlay

## 玩家端 — 全局主题(styles.css / index.html / site.webmanifest)
- 海军暗色 + 白色强调 token,卡片高光渐变(--gradient-card)
- 下拉/弹窗实底不透明(--dropdown-bg: #0A2540)
- 统一 sub-toolbar、status-ribbon、filter-tab 等全局样式
- theme-color 同步为 #001A33

## 玩家端 — 布局与壳层
- MainLayout:详情子页去顶栏/公告,sub-toolbar 全宽铺底
- WalletBalanceCard(新):个人页钱包卡片(反水 + 未结算)
- walletStats.ts(新):钱包统计逻辑抽取

## 玩家端 — 赛事 / 投注
- MatchDetailView:顶栏 8px 顶距、卡片全宽(--detail-gutter-x: 0)
- 盘口状态标签(暂停/已关闭)、去掉 MarketTypeTile 右侧箭头
- VsBadge(新):纯文字 VS,删除 vs.png
- isMarketLocked:仅关单关但允许串关时仍可点击,支持串关流程
- BetSlipDrawer:仅串关盘口禁用单关提交并提示 slip_parlay_only_hint
- betSlip:SlipItem 增加 allowSingle 字段
- MatchBetCard / LeagueAccordionItem:45° 待开赛角标,去掉展开左侧白边
- OutrightEventSection:去掉展开时左侧白色选中条
- FootballView:加大左右边距、筛选栏去 sticky、选中态增强
- BannerCarousel:恢复原始全宽轮播

## 玩家端 — 钱包 / 个人 / 认证 / 其他页面
- WalletView:移除顶部钱包卡片,保留账单列表
- ProfileView:保留 WalletBalanceCard
- 充值/账单/注单/登录注册等页面配色与间距统一
- 公告标签、余额面板、语言切换、区号选择等弹层改为实底

## 国际化
- zh-CN / en-US / ms-MY:新增 slip_parlay_only_hint(仅串关盘口提示)

## 资产
- 更新 banner.svg、empty-matches.svg 配色
- 删除 vs.png

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:00:25 +08:00

641 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import StatusWatermark from '../components/StatusWatermark.vue';
import { matchPhaseLabel, matchPhaseVariant, type MatchPhase } from '../utils/matchPhase';
import type { BetHistoryItem } from '../components/BetHistoryCard.vue';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const bet = ref<BetHistoryItem | null>(null);
const loading = ref(true);
const notFound = ref(false);
async function loadBet() {
loading.value = true;
try {
const { data } = await api.get(`/player/bets/${route.params.betNo}`);
if (!data.data) { notFound.value = true; return; }
bet.value = data.data;
} catch {
notFound.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadBet);
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: loadBet,
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
const statusKey = computed(() => {
const s = (bet.value?.status ?? '').toUpperCase();
if (s === 'WON' || s === 'WIN') return 'won';
if (s === 'LOST' || s === 'LOSE') return 'lost';
if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') return 'push';
return 'pending';
});
const statusLabel = computed(() => t(`history.status_${statusKey.value}`));
const placedDateTime = computed(() => {
if (!bet.value) return '';
const d = new Date(bet.value.placedAt);
const date = d.toLocaleDateString(locale.value, { year: 'numeric', month: 'short', day: 'numeric' });
const time = d.toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit' });
return `${date} ${time}`;
});
const returnAmount = computed(() => {
if (!bet.value) return '';
if (statusKey.value === 'won') return formatMoney(bet.value.actualReturn, locale.value);
if (statusKey.value === 'pending') return formatMoney(bet.value.potentialReturn, locale.value);
if (statusKey.value === 'lost') return formatMoney(-parseFloat(String(bet.value.stake ?? 0)), locale.value);
return formatMoney(bet.value.actualReturn ?? bet.value.potentialReturn, locale.value);
});
function formatOdds(v: unknown): string {
const n = parseFloat(String(v));
return isNaN(n) || n <= 0 ? '-' : n.toFixed(2);
}
const SEL_TRANS: Record<string, Record<string, string>> = {
'主胜': { 'en-US': 'Home Win', 'ms-MY': 'Rumah Menang' },
'客胜': { 'en-US': 'Away Win', 'ms-MY': 'Tandang Menang' },
'和局': { 'en-US': 'Draw', 'ms-MY': 'Seri' },
'主': { 'en-US': 'Home', 'ms-MY': 'Rumah' },
'客': { 'en-US': 'Away', 'ms-MY': 'Tandang' },
'大': { 'en-US': 'Over', 'ms-MY': 'Atas' },
'小': { 'en-US': 'Under', 'ms-MY': 'Bawah' },
'单': { 'en-US': 'Odd', 'ms-MY': 'Ganjil' },
'双': { 'en-US': 'Even', 'ms-MY': 'Genap' },
'冠军': { 'en-US': 'Winner', 'ms-MY': 'Juara' },
};
function translateSel(name: string): string {
if (locale.value === 'zh-CN') return name;
const exact = SEL_TRANS[name];
if (exact) return exact[locale.value] ?? exact['en-US'] ?? name;
const sp = name.indexOf(' ');
if (sp > 0) {
const head = name.slice(0, sp);
const m = SEL_TRANS[head];
if (m) return (m[locale.value] ?? m['en-US'] ?? head) + name.slice(sp);
}
return name;
}
function legStatusKey(rs: string | null | undefined) {
if (!rs) return 'pending';
const s = rs.toUpperCase();
if (s === 'WON' || s === 'WIN') return 'won';
if (s === 'LOST' || s === 'LOSE') return 'lost';
return 'push';
}
const matchTitle = computed(() => {
if (!bet.value) return '';
if (bet.value.isParlay) {
const n = bet.value.legCount ?? bet.value.legs?.length ?? 0;
return t('history.parlay_title', { n });
}
return bet.value.matchTitle;
});
const myPick = computed(() => {
if (!bet.value || bet.value.isParlay) return '';
const raw = bet.value.pickLabel ?? '';
if (locale.value === 'zh-CN' || !raw) return raw;
const ci = raw.indexOf(': ');
if (ci < 0) return raw;
return raw.slice(0, ci + 2) + translateSel(raw.slice(ci + 2));
});
const matchPhase = computed(
(): MatchPhase | null => bet.value?.matchPhase ?? null,
);
const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
</script>
<template>
<div class="detail-page">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<!-- back bar -->
<header class="top-bar sub-toolbar">
<button class="back-btn" @click="router.back()"> {{ t('history.back') }}</button>
</header>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="notFound || !bet" class="state">{{ t('history.not_found') }}</div>
<template v-else>
<!-- status hero -->
<div class="hero" :class="statusKey">
<span class="hero-status">{{ statusLabel }}</span>
<span class="hero-return">{{ returnAmount }}</span>
<span class="hero-return-label">
{{ statusKey === 'pending' ? t('history.est_return') : t('history.return') }}
</span>
</div>
<!-- match / parlay title -->
<section class="section">
<div class="section-head">
<span class="league-tag">
{{ bet.isParlay ? t('history.parlay_league') : (bet.leagueName || t('history.league_default')) }}
</span>
<span class="placed-time">{{ placedDateTime }}</span>
</div>
<div class="match-name">{{ matchTitle }}</div>
<!-- single bet: score comparison -->
<div v-if="!bet.isParlay" class="score-block" :class="{ 'score-block--phase': matchPhase && matchPhase !== 'open' }">
<StatusWatermark
v-if="matchPhase && matchPhase !== 'open'"
:label="matchPhaseText"
:variant="matchPhaseVariant(matchPhase)"
size="md"
/>
<!-- my pick row -->
<div class="row-label-val">
<span class="row-label">{{ t('history.my_pick') }}</span>
<span class="row-val pick">{{ myPick }}</span>
</div>
<!-- scores -->
<div v-if="bet.matchScore?.ft || bet.matchScore?.ht" class="score-chips">
<div v-if="bet.matchScore.ft" class="score-item">
<span class="score-period">{{ t('history.ft') }}</span>
<span class="score-value">{{ bet.matchScore.ft }}</span>
</div>
<div v-if="bet.matchScore.ht" class="score-item">
<span class="score-period">{{ t('history.ht') }}</span>
<span class="score-value muted">{{ bet.matchScore.ht }}</span>
</div>
</div>
<div v-else-if="statusKey === 'pending'" class="no-score">{{ t('history.awaiting_result') }}</div>
</div>
</section>
<!-- parlay legs -->
<section v-if="bet.isParlay && bet.legs?.length" class="section">
<div class="section-title">{{ t('history.legs') }}</div>
<div class="legs-list">
<div v-for="(leg, i) in bet.legs" :key="i" class="leg-row" :class="legStatusKey(leg.resultStatus)">
<div class="leg-left">
<span class="leg-num" :class="legStatusKey(leg.resultStatus)">{{ i + 1 }}</span>
<div class="leg-body">
<span class="leg-match">{{ leg.matchTitle }}</span>
<span class="leg-pick-line">
{{ leg.marketLabel }}: {{ translateSel(leg.selectionName) }}
</span>
<div v-if="leg.score?.ft || leg.score?.ht" class="leg-scores">
<span v-if="leg.score.ft">{{ t('history.ft') }} {{ leg.score.ft }}</span>
<span v-if="leg.score.ht" class="muted-score">{{ t('history.ht') }} {{ leg.score.ht }}</span>
</div>
</div>
</div>
<div class="leg-right">
<span class="leg-odds-val">{{ formatOdds(leg.odds) }}</span>
<span v-if="leg.resultStatus" class="leg-result-badge" :class="legStatusKey(leg.resultStatus)">
{{ t(`history.status_${legStatusKey(leg.resultStatus)}`) }}
</span>
</div>
</div>
</div>
</section>
<!-- bet summary -->
<section class="section">
<div class="section-title">{{ t('history.summary') }}</div>
<div class="summary-rows">
<div class="sum-row">
<span>{{ t('history.stake') }}</span>
<span>{{ formatMoney(bet.stake, locale) }}</span>
</div>
<div v-if="bet.totalOdds" class="sum-row">
<span>{{ t('history.odds') }}</span>
<span class="odds-val">{{ formatOdds(bet.totalOdds) }}</span>
</div>
<div class="sum-row">
<span>{{ statusKey === 'pending' ? t('history.est_return') : t('history.return') }}</span>
<span :class="{ 'amt-won': statusKey === 'won', 'amt-pending': statusKey === 'pending', 'amt-lost': statusKey === 'lost' }">
{{ returnAmount }}
</span>
</div>
<div class="sum-row muted">
<span>{{ t('history.bet_no') }}</span>
<span class="bet-no-val">{{ bet.betNo }}</span>
</div>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.detail-page {
padding-bottom: 32px;
}
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.top-bar {
margin-bottom: 4px;
}
.back-btn {
background: none;
border: none;
color: var(--primary-light);
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
}
.state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
/* ── hero ── */
.hero {
border-radius: var(--radius);
padding: 20px 20px 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin-bottom: 12px;
text-align: center;
}
.hero.won {
background: linear-gradient(135deg, rgba(46, 204, 113, 0.18), rgba(46, 204, 113, 0.06));
border: 1px solid rgba(46, 204, 113, 0.35);
}
.hero.lost {
background: linear-gradient(135deg, var(--surface-accent), rgba(255, 255, 255, 0.06));
border: 1px solid rgba(255, 255, 255, 0.35);
}
.hero.pending {
background: var(--gradient-card);
border: 1px solid var(--border-gold-soft);
}
.hero.push {
background: var(--bg-card);
border: 1px solid var(--border);
}
.hero-status {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: none;
opacity: 0.85;
}
.hero.won .hero-status { color: var(--success); }
.hero.lost .hero-status { color: var(--primary); }
.hero.pending .hero-status { color: var(--primary-light); }
.hero.push .hero-status { color: var(--text-muted); }
.hero-return {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.1;
}
.hero.won .hero-return { color: var(--success); text-shadow: none; }
.hero.lost .hero-return { color: var(--primary); }
.hero.pending .hero-return { color: var(--primary-light); }
.hero.push .hero-return { color: var(--text-muted); }
.hero-return-label {
font-size: 10.5px;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.04em;
}
/* ── sections ── */
.section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-card) 16px;
margin-bottom: var(--space-section);
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.league-tag {
font-size: 11px;
color: var(--text-muted);
font-weight: 700;
}
.placed-time {
font-size: 11px;
color: var(--neutral);
font-weight: 600;
}
.match-name {
font-size: 16px;
font-weight: 700;
color: var(--text);
line-height: 1.35;
margin-bottom: 10px;
}
/* score block */
.score-block {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 8px;
}
.score-block--phase {
padding: 8px 0 4px;
}
.row-label-val {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.row-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
}
.row-val {
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.row-val.pick {
color: var(--primary);
}
.score-chips {
display: flex;
gap: 8px;
}
.score-item {
display: flex;
align-items: center;
gap: 5px;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 7px;
padding: 5px 10px;
}
.score-period {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;
}
.score-value {
font-size: 15px;
font-weight: 700;
color: var(--text);
}
.score-value.muted {
color: var(--neutral);
font-size: 13px;
}
.no-score {
font-size: 11.5px;
color: var(--neutral);
font-style: italic;
}
/* section title */
.section-title {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;
margin-bottom: 10px;
}
/* parlay legs */
.legs-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.leg-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 9px;
padding: 10px 12px;
}
.leg-row.won { border-color: rgba(46, 204, 113, 0.35); }
.leg-row.lost { border-color: rgba(255, 255, 255, 0.35); }
.leg-left {
display: flex;
align-items: flex-start;
gap: 8px;
flex: 1;
min-width: 0;
}
.leg-num {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-card);
color: var(--text-muted);
font-size: 10px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.leg-num.won { background: rgba(46, 204, 113, 0.18); color: var(--success); }
.leg-num.lost { background: rgba(255, 255, 255, 0.18); color: var(--primary); }
.leg-body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.leg-match {
font-size: 12.5px;
font-weight: 800;
color: var(--text);
}
.leg-pick-line {
font-size: 11.5px;
color: var(--text-muted);
font-weight: 600;
}
.leg-scores {
display: flex;
gap: 8px;
margin-top: 2px;
}
.leg-scores span {
font-size: 11px;
font-weight: 700;
color: var(--text);
}
.leg-scores .muted-score {
color: var(--neutral);
}
.leg-right {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.leg-odds-val {
font-size: 15px;
font-weight: 700;
color: var(--text);
}
.leg-result-badge {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;
padding: 2px 8px;
border-radius: 10px;
}
.leg-result-badge.won { color: var(--success); background: rgba(46, 204, 113, 0.12); }
.leg-result-badge.lost { color: var(--primary); background: rgba(255, 255, 255, 0.1); }
.leg-result-badge.push { color: var(--text-muted); background: var(--bg-card); }
.leg-result-badge.pending { color: var(--neutral); background: var(--bg-body); }
/* summary */
.summary-rows {
display: flex;
flex-direction: column;
gap: 0;
}
.sum-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
color: var(--text-muted);
font-weight: 600;
}
.sum-row:last-child {
border-bottom: none;
}
.sum-row.muted {
color: var(--neutral);
font-size: 11px;
}
.odds-val {
color: var(--text);
font-weight: 700;
}
.amt-won {
color: var(--success);
font-weight: 700;
font-size: 15px;
}
.amt-pending {
color: var(--text-muted);
font-weight: 700;
}
.amt-lost {
color: var(--primary);
font-weight: 700;
font-size: 15px;
}
.bet-no-val {
font-family: monospace;
letter-spacing: 0.04em;
}
</style>