Files
thebet365/apps/player/src/views/BetDetailView.vue
Mars 4a93fcfce0 feat(player): 全站 UI 主题重构与布局优化 — 从暗金奢华风格迁移至 Pinnacle 风格蓝白专业主题
一、全局主题重构(52 文件)
  - 设计体系从深色/金色奢华调性全面迁移至 Pinnacle 风格的清爽专业调性
  - 主色:深海蓝 #003D6B,辅色:橙色 #F8971F / #E8870A(赔率强调色)
  - 背景从纯黑/深灰改为白色/浅灰 (#F5F7FA, #E8EDF2)
  - 更新 CSS 变量设计令牌体系:--primary, --border, --text-muted, --bg-body, --bg-hover, --radius-sm 等
  - styles.css 全量重写,所有组件 scoped CSS 同步适配

二、紧凑布局优化
  - 首页:BannerCarousel 轮播高度缩减 (clamp 148→120px),MatchBetCard 内边距/间距/字号全面收紧
  - 首页:赛事卡片 padding 14px→10px,队旗尺寸 72×48→56×38,VS 区域缩小
  - 个人中心:钱包横幅宽高比 2/1→1.75/1,设置单元格 min-height 48→42px
  - 钱包页:交易行 padding/字号收紧,金额字号 15→14px
  - MainLayout:顶栏高度 50→48px,底部导航间距微调

三、对比度与可读性增强
  - 赛事详情/投注页背景从透明加深至 #E8EDF2
  - 所有卡片边框从 #E5E7EB 加深至 #CBD5E1,阴影强度提升
  - 盘口选择面板背景 #F5F7FA→#EDF0F4,增加 border-top 分隔线
  - 赔率颜色从 #F8971F 加深至 #E8870A,选中态 border-width 加粗至 2px
  - 标签字号增大并加粗 (font-weight: 600),颜色从 #6B7280 加深至 #4B5563

四、首页快捷入口 & 个人中心网格布局
  - 首页新增 4 宫格快捷入口(投注/充值/账单/活动),彩色图标区分功能
  - 个人中心重构为「4 宫格快捷操作 + 列表」结构:钱包/充值/返水/记录
  - 移除原个人中心金色入口列表项,精简设置列表为修改资料/投注规则/语言

五、顶部导航栏重设计
  - Logo 从 img 标签替换为内联 SVG 纯文字:THE(灰) + BET(深蓝) + 365(橙)
  - 客服按钮改为纯图标圆形按钮 (32×32px),增加竖线分隔符
  - Chip 高度 34→32px,间距 8→6px
  - 底部导航激活态指示条加粗至 2.5px,左右缩进从 22% 调至 18%

六、SVG Favicon
  - 新增纯路径 SVG favicon(无 <text> 元素,确保浏览器 favicon 沙箱兼容)
  - 深蓝圆角方块 + 白色 "B" 字母路径 + 橙色圆点 + 白色 "365" 数字路径
  - index.html 更新 favicon 引用为 /favicon.svg

🤖 Generated with [Qoder][https://qoder.com]
2026-06-16 12:24:41 +08:00

630 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 -->
<div class="top-bar">
<button class="back-btn" @click="router.back()"> {{ t('history.back') }}</button>
</div>
<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: #003D6B;
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: #6B7280;
font-size: 14px;
font-weight: 600;
}
/* ── hero ── */
.hero {
border-radius: 12px;
padding: 20px 20px 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin-bottom: 12px;
text-align: center;
}
.hero.won { background: rgba(5, 150, 105, 0.08); border: 1px solid rgba(5, 150, 105, 0.2); }
.hero.lost { background: rgba(220, 38, 38, 0.06); border: 1px solid rgba(220, 38, 38, 0.15); }
.hero.pending { background: rgba(248, 151, 31, 0.06); border: 1px solid rgba(248, 151, 31, 0.18); }
.hero.push { background: #FFFFFF; border: 1px solid #E5E7EB; }
.hero-status {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.85;
}
.hero.won .hero-status { color: #059669; }
.hero.lost .hero-status { color: #DC2626; }
.hero.pending .hero-status { color: #F8971F; }
.hero.push .hero-status { color: #6B7280; }
.hero-return {
font-size: 36px;
font-weight: 900;
letter-spacing: -0.01em;
line-height: 1.1;
}
.hero.won .hero-return { color: #059669; }
.hero.lost .hero-return { color: #DC2626; }
.hero.pending .hero-return { color: #F8971F; }
.hero.push .hero-return { color: #6B7280; }
.hero-return-label {
font-size: 10.5px;
color: #6B7280;
font-weight: 600;
letter-spacing: 0.04em;
}
/* ── sections ── */
.section {
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.league-tag {
font-size: 11px;
color: #6B7280;
font-weight: 700;
}
.placed-time {
font-size: 11px;
color: #6B7280;
font-weight: 600;
}
.match-name {
font-size: 17px;
font-weight: 900;
color: #1A1A2E;
line-height: 1.3;
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: #6B7280;
font-weight: 600;
}
.row-val {
font-size: 13px;
font-weight: 700;
color: #1A1A2E;
}
.row-val.pick {
color: #003D6B;
}
.score-chips {
display: flex;
gap: 8px;
}
.score-item {
display: flex;
align-items: center;
gap: 5px;
background: #F5F7FA;
border: 1px solid #E5E7EB;
border-radius: 7px;
padding: 5px 10px;
}
.score-period {
font-size: 10px;
color: #6B7280;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.score-value {
font-size: 16px;
font-weight: 900;
color: #1A1A2E;
}
.score-value.muted {
color: #6B7280;
font-size: 13px;
}
.no-score {
font-size: 11.5px;
color: #6B7280;
font-style: italic;
}
/* section title */
.section-title {
font-size: 10.5px;
color: #6B7280;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
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: #F5F7FA;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 10px 12px;
}
.leg-row.won { border-color: rgba(5, 150, 105, 0.25); }
.leg-row.lost { border-color: rgba(220, 38, 38, 0.2); }
.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: #E8EDF2;
color: #6B7280;
font-size: 10px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.leg-num.won { background: rgba(5, 150, 105, 0.12); color: #059669; }
.leg-num.lost { background: rgba(220, 38, 38, 0.1); color: #DC2626; }
.leg-body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.leg-match {
font-size: 12.5px;
font-weight: 800;
color: #1A1A2E;
}
.leg-pick-line {
font-size: 11.5px;
color: #6B7280;
font-weight: 600;
}
.leg-scores {
display: flex;
gap: 8px;
margin-top: 2px;
}
.leg-scores span {
font-size: 11px;
font-weight: 700;
color: #1A1A2E;
}
.leg-scores .muted-score {
color: #6B7280;
}
.leg-right {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.leg-odds-val {
font-size: 15px;
font-weight: 900;
color: #003D6B;
}
.leg-result-badge {
font-size: 9px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 10px;
}
.leg-result-badge.won { color: #059669; background: rgba(5, 150, 105, 0.1); }
.leg-result-badge.lost { color: #DC2626; background: rgba(220, 38, 38, 0.08); }
.leg-result-badge.push { color: #6B7280; background: #E8EDF2; }
.leg-result-badge.pending { color: #6B7280; background: #F5F7FA; }
/* 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 #E5E7EB;
font-size: 13px;
color: #1A1A2E;
font-weight: 600;
}
.sum-row:last-child {
border-bottom: none;
}
.sum-row.muted {
color: #6B7280;
font-size: 11px;
}
.odds-val {
color: #F8971F;
font-weight: 800;
}
.amt-won {
color: #059669;
font-weight: 900;
font-size: 15px;
}
.amt-pending {
color: #F8971F;
font-weight: 900;
}
.amt-lost {
color: #DC2626;
font-weight: 900;
font-size: 15px;
}
.bet-no-val {
font-family: monospace;
letter-spacing: 0.04em;
}
</style>