feat(player): 完善 H5 投注端与 API 演示数据

- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑
- 固定顶栏、公告与底栏,仅内容区滚动
- 底部导航与站点 favicon 使用 logo,登录页精简
- API 种子、冠军盘与历史注单增强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 17:18:11 +08:00
parent 7af2e418c3
commit b5dca1bfb1
75 changed files with 7077 additions and 384 deletions

View File

@@ -1,66 +1,293 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { useBetSlipStore } from '../stores/betSlip';
import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
import OutrightPanel from '../components/outright/OutrightPanel.vue';
import ParlayPanel from '../components/parlay/ParlayPanel.vue';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
const router = useRouter();
const matches = ref<Match[]>([]);
type MainTab = 'matches' | 'outright' | 'parlay';
type TimeTab = 'today' | 'early';
interface Match {
id: string;
leagueId?: string;
homeTeamName: string;
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
startTime: string;
leagueName: string;
markets?: Array<{ marketType: string; selections: Selection[] }>;
}
interface Selection {
id: string;
selectionCode: string;
selectionName: string;
odds: string;
oddsVersion: string;
interface LeagueGroup {
leagueId: string;
leagueName: string;
matches: Match[];
}
const { t } = useI18n();
const router = useRouter();
const slip = useBetSlipStore();
const mainTab = ref<MainTab>('matches');
const timeTab = ref<TimeTab>('early');
const matches = ref<Match[]>([]);
const loading = ref(true);
const expandedLeagues = ref<Set<string>>(new Set());
onMounted(async () => {
const { data } = await api.get('/player/matches');
matches.value = data.data;
loading.value = true;
try {
const { data } = await api.get('/player/matches');
matches.value = data.data ?? [];
} finally {
loading.value = false;
}
});
function dayStart(d: Date) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function isKickoffToday(startTime: string) {
const kick = new Date(startTime);
const now = new Date();
const start = dayStart(now);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return kick >= start && kick < end;
}
const filteredMatches = computed(() => {
if (mainTab.value !== 'matches') return [];
return matches.value.filter((m) => {
const today = isKickoffToday(m.startTime);
return timeTab.value === 'today' ? today : !today;
});
});
const leagueGroups = computed<LeagueGroup[]>(() => {
const map = new Map<string, LeagueGroup>();
for (const m of filteredMatches.value) {
const id = m.leagueId ?? m.leagueName;
if (!map.has(id)) {
map.set(id, { leagueId: id, leagueName: m.leagueName, matches: [] });
}
map.get(id)!.matches.push(m);
}
return [...map.values()];
});
watch(leagueGroups, (groups) => {
const ids = new Set(expandedLeagues.value);
for (const id of [...ids]) {
if (!groups.some((g) => g.leagueId === id)) ids.delete(id);
}
if (ids.size !== expandedLeagues.value.size) expandedLeagues.value = ids;
});
function isLeagueExpanded(leagueId: string) {
return expandedLeagues.value.has(leagueId);
}
function toggleLeague(leagueId: string) {
const next = new Set(expandedLeagues.value);
if (next.has(leagueId)) next.delete(leagueId);
else next.add(leagueId);
expandedLeagues.value = next;
}
function selectMainTab(tab: MainTab) {
mainTab.value = tab;
}
function goMatch(id: string) {
router.push(`/match/${id}`);
}
</script>
<template>
<div>
<h2 class="title">足球赛事</h2>
<div v-for="match in matches" :key="match.id" class="card">
<div class="league">{{ match.leagueName }}</div>
<div class="teams" @click="goMatch(match.id)">
{{ match.homeTeamName }} vs {{ match.awayTeamName }}
</div>
<div class="time">{{ new Date(match.startTime).toLocaleString() }}</div>
<div v-if="match.markets?.length" class="odds-row">
<template v-for="market in match.markets.filter(m => m.marketType === 'FT_1X2')" :key="market.marketType">
<div v-for="sel in market.selections" :key="sel.id" class="odds-btn" @click="goMatch(match.id)">
<div class="label">{{ sel.selectionName }}</div>
<div class="value">{{ sel.odds }}</div>
</div>
</template>
</div>
<div class="bet-page">
<div class="main-tabs">
<button
type="button"
class="main-tab"
:class="{ active: mainTab === 'matches', 'tab-gold-active': mainTab === 'matches' }"
@click="selectMainTab('matches')"
>
<span class="tab-icon"></span>
{{ t('bet.tab_matches') }}
</button>
<button
type="button"
class="main-tab"
:class="{ active: mainTab === 'outright', 'tab-gold-active': mainTab === 'outright' }"
@click="selectMainTab('outright')"
>
<span class="tab-icon">🏆</span>
{{ t('bet.tab_outright') }}
</button>
<button
type="button"
class="main-tab parlay-tab"
:class="{ active: mainTab === 'parlay', 'tab-gold-active': mainTab === 'parlay' }"
@click="selectMainTab('parlay')"
>
<span class="tab-icon">+</span>
{{ t('bet.tab_parlay') }}
<span v-if="slip.count" class="tab-badge">{{ slip.count }}</span>
</button>
</div>
<div v-if="!matches.length" class="empty">暂无赛事</div>
<template v-if="mainTab === 'matches'">
<div class="time-tabs">
<button
type="button"
class="time-tab"
:class="{ active: timeTab === 'today', 'tab-gold-active': timeTab === 'today' }"
@click="timeTab = 'today'"
>
{{ t('bet.tab_today') }}
</button>
<button
type="button"
class="time-tab"
:class="{ active: timeTab === 'early', 'tab-gold-active': timeTab === 'early' }"
@click="timeTab = 'early'"
>
{{ t('bet.tab_early') }}
</button>
</div>
<div v-if="loading" class="state">{{ t('bet.loading') }}</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"
:matches="group.matches"
:expanded="isLeagueExpanded(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>
</template>
<OutrightPanel v-else-if="mainTab === 'outright'" />
<ParlayPanel v-else-if="mainTab === 'parlay'" />
</div>
</template>
<style scoped>
.title { font-size: 18px; margin-bottom: 16px; }
.league { font-size: 11px; color: var(--primary); margin-bottom: 4px; }
.teams { font-weight: 600; cursor: pointer; margin-bottom: 4px; }
.time { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; }
.odds-row { display: flex; gap: 8px; }
.empty { text-align: center; color: var(--text-muted); padding: 40px; }
.bet-page {
margin: 0 -16px;
padding-bottom: 8px;
}
.main-tabs {
display: flex;
gap: 8px;
padding: 0 12px 12px;
}
.main-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 8px;
border-radius: 10px;
background: #1a1a1a;
border: 1px solid #2a2a2a;
color: var(--text-muted);
font-size: 13px;
font-weight: 700;
position: relative;
}
.main-tab.active {
font-weight: 800;
}
.tab-icon {
font-size: 16px;
line-height: 1;
}
.tab-badge {
position: absolute;
top: 4px;
right: 6px;
min-width: 16px;
padding: 0 4px;
border-radius: 8px;
background: #1a1000;
color: var(--primary-light);
font-size: 10px;
font-weight: 800;
line-height: 16px;
}
.time-tabs {
display: flex;
gap: 10px;
padding: 0 12px 14px;
}
.time-tab {
flex: 1;
padding: 10px 16px;
border-radius: 999px;
font-size: 14px;
font-weight: 800;
color: var(--primary-light);
background: #141414;
border: 1px solid var(--primary);
}
.time-tab.active {
font-weight: 800;
}
.league-list {
padding: 4px 12px 0;
}
.state,
.placeholder,
.empty {
text-align: center;
color: var(--text-muted);
padding: 48px 20px;
font-weight: 600;
}
.empty-icon {
width: 96px;
height: 96px;
margin-bottom: 14px;
}
.placeholder {
padding: 80px 20px;
}
.parlay-tab.tab-gold-active {
flex: 1.15;
}
</style>

View File

@@ -1,10 +1,29 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
import BannerCarousel from '../components/BannerCarousel.vue';
import { resolveBanners } from '../constants/defaultBanner';
const router = useRouter();
const home = ref<{ banners: unknown[]; hotMatches: Match[]; ticker: unknown[] } | null>(null);
const home = ref<{
banners: Banner[];
hotMatches: Match[];
ticker: ContentItem[];
notices: ContentItem[];
} | null>(null);
interface ContentItem {
translation?: { title?: string; body?: string; imageUrl?: string };
}
interface Banner {
id?: string;
linkType?: string | null;
linkTarget?: string | null;
translation?: { title?: string; body?: string; imageUrl?: string };
}
interface Match {
id: string;
@@ -12,9 +31,10 @@ interface Match {
awayTeamName: string;
startTime: string;
isHot: boolean;
markets?: Array<{ marketType: string; selections: Array<{ id: string; selectionName: string; odds: string; oddsVersion: string }> }>;
}
const displayBanners = computed(() => resolveBanners(home.value?.banners));
onMounted(async () => {
const { data } = await api.get('/player/home');
home.value = data.data;
@@ -27,13 +47,7 @@ function goMatch(id: string) {
<template>
<div>
<div v-if="home?.banners?.length" class="banner card">
{{ (home.banners[0] as { translation?: { title?: string } })?.translation?.title || 'Welcome' }}
</div>
<div v-if="home?.ticker?.length" class="ticker">
{{ (home.ticker[0] as { translation?: { body?: string } })?.translation?.body }}
</div>
<BannerCarousel :banners="displayBanners" />
<h2 class="section-title">热门赛事</h2>
<div v-for="match in home?.hotMatches || []" :key="match.id" class="card match-card" @click="goMatch(match.id)">
@@ -41,17 +55,18 @@ function goMatch(id: string) {
<div class="match-time">{{ new Date(match.startTime).toLocaleString() }}</div>
</div>
<div v-if="!home?.hotMatches?.length" class="empty">暂无赛事</div>
<div v-if="home && !home.hotMatches?.length" class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>暂无赛事</p>
</div>
</div>
</template>
<style scoped>
.banner { background: linear-gradient(135deg, #1a472a, #0f1419); padding: 24px; font-size: 18px; font-weight: 600; }
.ticker { background: #243044; padding: 8px 12px; font-size: 12px; margin-bottom: 12px; border-radius: 4px; overflow: hidden; white-space: nowrap; }
.section-title { font-size: 16px; margin-bottom: 12px; }
.match-card { cursor: pointer; }
.match-card:hover { background: var(--bg-hover); }
.match-teams { font-weight: 600; margin-bottom: 4px; }
.match-time { font-size: 12px; color: var(--text-muted); }
.empty { text-align: center; color: var(--text-muted); padding: 40px; }
.match-card { cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; }
.match-card:active { border-color: var(--border-gold-soft); }
.match-teams { font-weight: 800; margin-bottom: 8px; font-size: 16px; }
.match-time { font-size: 13px; color: var(--text-muted); font-weight: 500; }
.empty { text-align: center; color: var(--text-muted); padding: 40px 20px; font-weight: 600; }
.empty-icon { width: 96px; height: 96px; margin-bottom: 14px; }
</style>

View File

@@ -3,16 +3,24 @@ import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import RobotVerify from '../components/RobotVerify.vue';
import loginBg from '../assets/images/h5bg.png';
const { t } = useI18n();
const auth = useAuthStore();
const router = useRouter();
const captchaRef = ref<InstanceType<typeof RobotVerify> | null>(null);
const username = ref('player1');
const password = ref('Player@123');
const error = ref('');
const loading = ref(false);
async function submit() {
if (!captchaRef.value?.validate()) {
error.value = t('auth.captcha_wrong');
captchaRef.value?.refresh();
return;
}
loading.value = true;
error.value = '';
try {
@@ -27,15 +35,15 @@ async function submit() {
</script>
<template>
<div class="login-page">
<img src="/logo.png" alt="TheBet365" class="logo" />
<form @submit.prevent="submit" class="login-form">
<div class="login-page" :style="{ backgroundImage: `url(${loginBg})` }">
<form @submit.prevent="submit" class="login-form ps-gold-frame">
<label>{{ t('auth.username') }}</label>
<input v-model="username" required />
<input v-model="username" class="ps-gold-input" required />
<label>{{ t('auth.password') }}</label>
<input v-model="password" type="password" required />
<input v-model="password" class="ps-gold-input" type="password" required />
<RobotVerify ref="captchaRef" />
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="btn-primary" :disabled="loading">
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
{{ t('auth.login') }}
</button>
</form>
@@ -44,11 +52,53 @@ async function submit() {
<style scoped>
.login-page {
min-height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center; padding: 24px;
height: 100%;
min-height: 100dvh;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 34vh 20px max(28px, env(safe-area-inset-bottom));
background-color: var(--tertiary);
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
}
.login-form {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
}
label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.04em;
}
.btn-login {
margin-top: 4px;
padding: 10px 14px;
border-radius: 6px;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.06em;
}
.btn-login:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.error {
color: var(--danger);
font-size: 13px;
font-weight: 600;
}
.logo { height: 80px; width: auto; margin-bottom: 32px; }
.login-form { width: 100%; max-width: 320px; display: flex; flex-direction: column; gap: 12px; }
label { font-size: 13px; color: var(--text-muted); }
.error { color: var(--danger); font-size: 13px; }
</style>

View File

@@ -1,20 +1,25 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { useBetSlipStore } from '../stores/betSlip';
import { teamFlagUrl } from '../utils/teamFlag';
import {
FEATURED_MARKET_TYPES,
GRID_MARKET_TYPES,
MARKET_I18N_KEY,
} from '../utils/marketCatalog';
import FeaturedMarketRow from '../components/match-detail/FeaturedMarketRow.vue';
import MarketTypeTile from '../components/match-detail/MarketTypeTile.vue';
import MarketSelectionsPanel from '../components/match-detail/MarketSelectionsPanel.vue';
import CorrectScorePanel from '../components/match-detail/CorrectScorePanel.vue';
import { isCorrectScoreMarket } from '../utils/correctScoreLayout';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const slip = useBetSlipStore();
const match = ref<MatchDetail | null>(null);
interface MatchDetail {
id: string;
homeTeamName: string;
awayTeamName: string;
startTime: string;
markets: Market[];
}
interface Market {
id: string;
@@ -32,20 +37,137 @@ interface Selection {
oddsVersion: string;
}
const marketLabels: Record<string, string> = {
FT_1X2: '全场独赢',
FT_HANDICAP: '全场让球',
FT_OVER_UNDER: '全场大小',
FT_ODD_EVEN: '全场单双',
HT_1X2: '半场独赢',
FT_CORRECT_SCORE: '波胆',
};
interface MatchDetail {
id: string;
homeTeamName: string;
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
startTime: string;
markets: Market[];
}
onMounted(async () => {
const { data } = await api.get(`/player/matches/${route.params.id}`);
match.value = data.data;
const match = ref<MatchDetail | null>(null);
const loading = ref(true);
const expandedKey = ref<string | null>(null);
const correctScoreStakes = ref<Record<string, number>>({});
const placingCs = ref(false);
const csMessage = ref('');
const marketsByType = computed(() => {
const map = new Map<string, Market>();
for (const m of match.value?.markets ?? []) {
map.set(m.marketType, m);
}
return map;
});
const homeFlag = computed(() =>
teamFlagUrl(match.value?.homeTeamCode, match.value?.homeTeamName),
);
const awayFlag = computed(() =>
teamFlagUrl(match.value?.awayTeamCode, match.value?.awayTeamName),
);
const kickoff = computed(() => {
if (!match.value) return '';
return new Date(match.value.startTime).toLocaleString(locale.value, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
});
function marketLabel(marketType: string) {
const key = MARKET_I18N_KEY[marketType];
return key ? t(key) : marketType;
}
function expandKey(marketType: string) {
return marketType;
}
function isExpanded(marketType: string) {
return expandedKey.value === expandKey(marketType);
}
function openMarket(marketType: string) {
if (!marketsByType.value.has(marketType)) return;
expandedKey.value = expandKey(marketType);
}
function closeMarket() {
expandedKey.value = null;
}
function clearMarketSlip(marketType: string) {
if (!match.value) return;
const toRemove = slip.items.filter(
(i) => i.matchId === match.value!.id && i.marketType === marketType,
);
for (const item of toRemove) slip.removeItem(item.selectionId);
if (isCorrectScoreMarket(marketType)) {
const market = marketsByType.value.get(marketType);
if (market) {
const next = { ...correctScoreStakes.value };
for (const s of market.selections) delete next[s.id];
correctScoreStakes.value = next;
}
}
if (expandedKey.value === expandKey(marketType)) expandedKey.value = null;
}
function genRequestId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async function placeCorrectScoreBets(marketType: string) {
const market = marketsByType.value.get(marketType);
if (!market || !match.value) return;
const entries = market.selections.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0);
if (!entries.length) {
csMessage.value = t('bet.cs_stake_required');
return;
}
placingCs.value = true;
csMessage.value = '';
try {
for (const sel of entries) {
await api.post('/player/bets/single', {
selectionId: sel.id,
oddsVersion: String(sel.oddsVersion),
stake: correctScoreStakes.value[sel.id],
requestId: genRequestId(),
});
}
csMessage.value = t('bet.cs_place_success');
const next = { ...correctScoreStakes.value };
for (const sel of entries) delete next[sel.id];
correctScoreStakes.value = next;
} catch (e: unknown) {
csMessage.value =
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
t('bet.cs_place_failed');
} finally {
placingCs.value = false;
}
}
async function loadMatch() {
loading.value = true;
try {
const { data } = await api.get(`/player/matches/${route.params.id}`);
match.value = data.data;
} finally {
loading.value = false;
}
}
onMounted(loadMatch);
function isSelected(id: string) {
return slip.items.some((i) => i.selectionId === id);
}
@@ -54,7 +176,7 @@ function toggleSelection(sel: Selection, market: Market) {
if (!match.value) return;
slip.addItem({
selectionId: sel.id,
oddsVersion: sel.oddsVersion,
oddsVersion: String(sel.oddsVersion),
matchId: match.value.id,
matchName: `${match.value.homeTeamName} vs ${match.value.awayTeamName}`,
selectionName: sel.selectionName,
@@ -63,40 +185,226 @@ function toggleSelection(sel: Selection, market: Market) {
});
}
const groupedMarkets = computed(() => {
if (!match.value) return [];
return match.value.markets;
});
function onPickSelection(selId: string, marketType: string) {
const market = marketsByType.value.get(marketType);
const sel = market?.selections.find((s) => s.id === selId);
if (market && sel) toggleSelection(sel, market);
}
</script>
<template>
<div v-if="match">
<div class="match-header card">
<h2>{{ match.homeTeamName }} vs {{ match.awayTeamName }}</h2>
<p class="time">{{ new Date(match.startTime).toLocaleString() }}</p>
</div>
<div v-for="market in groupedMarkets" :key="market.id" class="card market-group">
<h3>{{ marketLabels[market.marketType] || market.marketType }}</h3>
<div class="selections">
<button
v-for="sel in market.selections"
:key="sel.id"
class="odds-btn"
:class="{ selected: isSelected(sel.id) }"
@click="toggleSelection(sel, market)"
>
<div class="label">{{ sel.selectionName }}</div>
<div class="value">{{ sel.odds }}</div>
<div class="detail-page">
<header class="toolbar">
<button type="button" class="btn-back btn-gold-outline" @click="router.back()">{{ t('bet.back') }}</button>
<div class="toolbar-right">
<button type="button" class="btn-tool btn-gold-outline" :aria-label="t('bet.refresh')" @click="loadMatch">
</button>
<button type="button" class="btn-tool btn-gold-outline" :aria-label="t('bet.download')"></button>
<span class="fifa-badge">FIFA</span>
</div>
</div>
</header>
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
<template v-else-if="match">
<section class="match-hero">
<p class="kickoff">{{ kickoff }}</p>
<div class="flags-row">
<img v-if="homeFlag" :src="homeFlag" alt="" class="flag-lg" />
<span class="vs-pill">vs</span>
<img v-if="awayFlag" :src="awayFlag" alt="" class="flag-lg" />
</div>
<div class="teams-row">
<span class="team-name">{{ match.homeTeamName }}</span>
<span class="vs-text">VS</span>
<span class="team-name">{{ match.awayTeamName }}</span>
</div>
</section>
<section class="markets-section">
<FeaturedMarketRow
v-for="marketType in FEATURED_MARKET_TYPES"
:key="marketType"
:label="marketLabel(marketType)"
:has-market="marketsByType.has(marketType)"
:expanded="isExpanded(marketType)"
:reward-active="marketsByType.has(marketType)"
@bet="isCorrectScoreMarket(marketType) ? placeCorrectScoreBets(marketType) : openMarket(marketType)"
@expand="openMarket(marketType)"
@collapse="clearMarketSlip(marketType)"
>
<CorrectScorePanel
v-if="marketsByType.get(marketType) && isCorrectScoreMarket(marketType)"
:market-type="marketType"
:home-team-name="match.homeTeamName"
:away-team-name="match.awayTeamName"
:selections="marketsByType.get(marketType)!.selections"
v-model:stakes="correctScoreStakes"
/>
<MarketSelectionsPanel
v-else-if="marketsByType.get(marketType)"
:selections="marketsByType.get(marketType)!.selections"
:is-selected="isSelected"
@pick="onPickSelection($event, marketType)"
/>
</FeaturedMarketRow>
<p v-if="csMessage" class="cs-toast">{{ csMessage }}</p>
<div class="market-grid">
<template v-for="marketType in GRID_MARKET_TYPES" :key="marketType">
<MarketTypeTile
:label="marketLabel(marketType)"
:has-market="marketsByType.has(marketType)"
:active="isExpanded(marketType)"
@expand="openMarket(marketType)"
@collapse="clearMarketSlip(marketType)"
/>
<MarketSelectionsPanel
v-if="isExpanded(marketType) && marketsByType.get(marketType)"
class="grid-panel"
:selections="marketsByType.get(marketType)!.selections"
:is-selected="isSelected"
@pick="onPickSelection($event, marketType)"
/>
</template>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.match-header h2 { font-size: 18px; margin-bottom: 4px; }
.time { color: var(--text-muted); font-size: 13px; }
.market-group h3 { font-size: 14px; margin-bottom: 12px; color: var(--text-muted); }
.selections { display: flex; flex-wrap: wrap; gap: 8px; }
.detail-page {
margin: 0 -16px;
padding-bottom: 24px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px 12px;
gap: 12px;
}
.btn-back {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.btn-tool {
width: 36px;
height: 36px;
border-radius: 6px;
font-size: 18px;
line-height: 1;
}
.fifa-badge {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-left: 4px;
}
.match-hero {
text-align: center;
padding: 8px 16px 20px;
}
.kickoff {
font-size: 13px;
font-weight: 600;
color: var(--primary-light);
margin-bottom: 14px;
}
.flags-row {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-bottom: 12px;
}
.flag-lg {
width: 72px;
height: 48px;
object-fit: cover;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.vs-pill {
font-size: 12px;
font-weight: 800;
color: var(--text-muted);
text-transform: lowercase;
}
.teams-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.team-name {
font-size: 22px;
font-weight: 900;
color: var(--primary-light);
letter-spacing: 0.04em;
}
.vs-text {
font-size: 14px;
font-weight: 800;
color: var(--text-muted);
}
.markets-section {
padding: 0 12px;
}
.market-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 6px;
}
.grid-panel {
grid-column: 1 / -1;
margin-top: -4px;
margin-bottom: 4px;
padding: 0 4px 8px;
background: #101010;
border-radius: 6px;
border: 1px solid #2a2a2a;
}
.state {
text-align: center;
padding: 48px;
color: var(--text-muted);
}
.cs-toast {
text-align: center;
font-size: 13px;
font-weight: 700;
color: var(--primary-light);
padding: 4px 12px 8px;
}
</style>

View File

@@ -1,62 +1,59 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../api';
import BetHistoryCard, { type BetHistoryItem } from '../components/BetHistoryCard.vue';
const bets = ref<{ items: Bet[]; total: number }>({ items: [], total: 0 });
const { t } = useI18n();
interface Bet {
betNo: string;
betType: string;
stake: string;
totalOdds: string;
potentialReturn: string;
actualReturn: string;
status: string;
placedAt: string;
selections: Array<{ selectionNameSnapshot: string; odds: string; resultStatus?: string }>;
}
const bets = ref<{ items: BetHistoryItem[]; total: number }>({ items: [], total: 0 });
const loading = ref(true);
onMounted(load);
async function load() {
const { data } = await api.get('/player/bets');
bets.value = data.data;
loading.value = true;
try {
const { data } = await api.get('/player/bets');
bets.value = data.data ?? { items: [], total: 0 };
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<h2>我的投注</h2>
<div v-for="bet in bets.items" :key="bet.betNo" class="card bet-card">
<div class="bet-header">
<span class="bet-no">{{ bet.betNo }}</span>
<span :class="['status', bet.status.toLowerCase()]">{{ bet.status }}</span>
</div>
<div v-for="(sel, i) in bet.selections" :key="i" class="sel">
{{ sel.selectionNameSnapshot }} @ {{ sel.odds }}
<span v-if="sel.resultStatus"> {{ sel.resultStatus }}</span>
</div>
<div class="bet-footer">
<span>{{ bet.betType }} · 投注 {{ bet.stake }}</span>
<span v-if="bet.status === 'WON'">返还 {{ bet.actualReturn }}</span>
<span v-else-if="bet.status === 'PENDING'">预计 {{ bet.potentialReturn }}</span>
</div>
<div class="time">{{ new Date(bet.placedAt).toLocaleString() }}</div>
</div>
<div v-if="!bets.items.length" class="empty">暂无投注</div>
<div class="history-page">
<h2 class="page-title">{{ t('nav.bet_history') }}</h2>
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
<template v-else-if="bets.items.length">
<BetHistoryCard v-for="bet in bets.items" :key="bet.betNo" :bet="bet" />
</template>
<div v-else class="state">{{ t('history.empty') }}</div>
</div>
</template>
<style scoped>
h2 { margin-bottom: 16px; }
.bet-header { display: flex; justify-content: space-between; margin-bottom: 8px; }
.bet-no { font-size: 12px; color: var(--text-muted); }
.status { font-size: 12px; font-weight: 600; }
.status.pending { color: #ff9800; }
.status.won { color: var(--primary); }
.status.lost { color: var(--danger); }
.sel { font-size: 13px; margin-bottom: 4px; }
.bet-footer { font-size: 13px; margin-top: 8px; display: flex; justify-content: space-between; }
.time { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.empty { text-align: center; color: var(--text-muted); padding: 40px; }
.history-page {
padding-bottom: 8px;
}
.page-title {
font-size: 20px;
font-weight: 800;
color: var(--text);
margin-bottom: 16px;
letter-spacing: 0.02em;
}
.state {
text-align: center;
color: var(--text-muted);
padding: 56px 20px;
font-weight: 600;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,271 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
const { t } = useI18n();
const router = useRouter();
const username = ref('');
const phone = ref('');
const email = ref('');
const oldPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const message = ref('');
const error = ref('');
const saving = ref(false);
onMounted(async () => {
const { data } = await api.get('/player/profile');
const user = data.data;
username.value = user?.username ?? '';
phone.value = user?.preferences?.phone ?? '';
email.value = user?.preferences?.email ?? '';
});
function wantsPasswordChange() {
return !!(oldPassword.value || newPassword.value || confirmPassword.value);
}
async function saveAll() {
error.value = '';
message.value = '';
if (wantsPasswordChange()) {
if (!oldPassword.value || !newPassword.value || !confirmPassword.value) {
error.value = t('profile.password_incomplete');
return;
}
if (newPassword.value !== confirmPassword.value) {
error.value = t('profile.password_mismatch');
return;
}
}
saving.value = true;
const parts: string[] = [];
try {
await api.patch('/player/profile', {
phone: phone.value.trim() || undefined,
email: email.value.trim() || undefined,
});
parts.push(t('profile.saved'));
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
t('profile.save_failed');
saving.value = false;
return;
}
if (wantsPasswordChange()) {
try {
await api.post('/player/auth/change-password', {
oldPassword: oldPassword.value,
newPassword: newPassword.value,
});
oldPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
parts.push(t('profile.password_changed'));
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
t('profile.password_failed');
saving.value = false;
return;
}
}
message.value = parts.join(' · ');
saving.value = false;
}
function back() {
router.push('/profile');
}
</script>
<template>
<div class="edit-page">
<header class="page-head">
<button type="button" class="back-btn" @click="back"> {{ t('profile.back') }}</button>
<h2 class="page-title">{{ t('profile.edit') }}</h2>
</header>
<form class="form-card" @submit.prevent="saveAll">
<div class="field">
<label>{{ t('auth.username') }}</label>
<input :value="username" class="readonly" disabled />
</div>
<div class="field">
<label>{{ t('profile.phone') }}</label>
<input v-model="phone" type="tel" class="field-input" :placeholder="t('profile.phone_placeholder')" />
</div>
<div class="field">
<label>{{ t('profile.email') }}</label>
<input v-model="email" type="email" class="field-input" :placeholder="t('profile.email_placeholder')" />
</div>
<p class="field-hint">{{ t('profile.password_optional_hint') }}</p>
<div class="field">
<label>{{ t('profile.old_password') }}</label>
<input
v-model="oldPassword"
type="password"
class="field-input"
autocomplete="current-password"
:placeholder="t('profile.old_password_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('profile.new_password') }}</label>
<input
v-model="newPassword"
type="password"
class="field-input"
autocomplete="new-password"
:placeholder="t('profile.new_password_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('profile.confirm_password') }}</label>
<input
v-model="confirmPassword"
type="password"
class="field-input"
autocomplete="new-password"
:placeholder="t('profile.confirm_password_placeholder')"
/>
</div>
<button type="submit" class="btn-action btn-gold-outline" :disabled="saving">
{{ t('profile.save') }}
</button>
</form>
<p v-if="message" class="msg ok">{{ message }}</p>
<p v-if="error" class="msg err">{{ error }}</p>
</div>
</template>
<style scoped>
.edit-page {
padding: 8px 0 12px;
}
.page-head {
margin-bottom: 10px;
}
.back-btn {
background: none;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
padding: 0;
}
.page-title {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
letter-spacing: 0.04em;
}
.form-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
}
.field {
margin-bottom: 10px;
}
.field-hint {
font-size: 11px;
color: var(--text-muted);
margin: 4px 0 10px;
line-height: 1.4;
}
label {
display: block;
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 4px;
}
.field-input,
.readonly {
display: block;
width: 100%;
padding: 9px 11px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border: 1px solid var(--border);
background: #0a0a0a;
color: var(--text);
transition: border-color 0.2s;
}
.field-input:focus {
outline: none;
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.12);
}
.field-input:-webkit-autofill,
.field-input:-webkit-autofill:hover,
.field-input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--text);
box-shadow: 0 0 0 1000px #0a0a0a inset;
border: 1px solid var(--border);
}
.field-input::placeholder {
color: #555;
}
.readonly {
opacity: 0.55;
cursor: not-allowed;
}
.btn-action {
width: 100%;
margin-top: 4px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.04em;
}
.btn-action:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.msg {
margin-top: 10px;
font-size: 13px;
font-weight: 600;
}
.msg.ok {
color: var(--primary-light);
}
.msg.err {
color: var(--danger);
}
</style>

View File

@@ -1,23 +1,30 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import LocaleFlag from '../components/LocaleFlag.vue';
import { useAuthStore } from '../stores/auth';
const { t, locale } = useI18n();
const auth = useAuthStore();
const router = useRouter();
const profile = ref<{ wallet?: { availableBalance: string; frozenBalance: string } } | null>(null);
const transactions = ref<unknown[]>([]);
const auth = useAuthStore();
const locales = [
{ code: 'zh-CN', label: '中文' },
{ code: 'en-US', label: 'EN' },
{ code: 'ms-MY', label: 'BM' },
] as const;
const profile = ref<{
username?: string;
wallet?: { availableBalance: string; frozenBalance: string };
} | null>(null);
onMounted(async () => {
const [prof, txns] = await Promise.all([
api.get('/player/profile'),
api.get('/player/wallet/transactions'),
]);
profile.value = prof.data.data;
transactions.value = txns.data.data.items;
const { data } = await api.get('/player/profile');
profile.value = data.data;
});
async function changeLocale(code: string) {
@@ -33,54 +40,200 @@ function logout() {
</script>
<template>
<div>
<div class="card profile-card">
<div class="username">{{ auth.user?.username }}</div>
<div class="profile-page">
<div class="summary-card">
<p class="username">{{ profile?.username }}</p>
<div class="balance-row">
<span>{{ t('wallet.balance') }}</span>
<span class="amount">{{ profile?.wallet?.availableBalance ?? '0' }}</span>
<span class="balance-label">
<LocaleFlag :locale="locale" :size="16" />
{{ t('wallet.balance') }}
</span>
<span class="amount">{{ formatMoney(profile?.wallet?.availableBalance, locale) }}</span>
</div>
<div class="frozen">冻结: {{ profile?.wallet?.frozenBalance ?? '0' }}</div>
<p class="frozen">
{{ t('wallet.unsettled') }}: {{ formatMoney(profile?.wallet?.frozenBalance, locale) }}
</p>
</div>
<div class="card">
<h3>语言</h3>
<div class="lang-btns">
<button @click="changeLocale('zh-CN')" :class="{ active: locale === 'zh-CN' }">中文</button>
<button @click="changeLocale('en-US')" :class="{ active: locale === 'en-US' }">English</button>
<button @click="changeLocale('ms-MY')" :class="{ active: locale === 'ms-MY' }">BM</button>
</div>
</div>
<section class="settings-group">
<RouterLink to="/profile/edit" class="settings-cell">
<span class="cell-label">{{ t('profile.edit') }}</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<div class="card">
<h3>账变记录</h3>
<div v-for="tx in transactions as Array<{ transactionType: string; amount: string; createdAt: string }>" :key="(tx as { transactionId?: string }).transactionId" class="tx-row">
<span>{{ tx.transactionType }}</span>
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">{{ tx.amount }}</span>
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
<div class="settings-cell settings-cell--stack">
<div class="cell-head">
<span class="cell-label">{{ t('profile.language') }}</span>
</div>
<div class="lang-segment" role="group" :aria-label="t('profile.language')">
<button
v-for="item in locales"
:key="item.code"
type="button"
class="lang-opt"
:class="{ active: locale === item.code }"
@click="changeLocale(item.code)"
>
<LocaleFlag :locale="item.code" :size="14" />
<span>{{ item.label }}</span>
</button>
</div>
</div>
</div>
</section>
<button class="btn-logout" @click="logout">退出登录</button>
<button type="button" class="logout-btn" @click="logout">
{{ t('auth.logout') }}
</button>
</div>
</template>
<style scoped>
.profile-card { margin-bottom: 12px; }
.username { font-size: 18px; font-weight: 600; margin-bottom: 12px; }
.balance-row { display: flex; justify-content: space-between; font-size: 16px; }
.amount { color: #ffd700; font-weight: 700; }
.frozen { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
h3 { font-size: 14px; margin-bottom: 12px; }
.lang-btns { display: flex; gap: 8px; }
.lang-btns button {
flex: 1; padding: 8px; background: var(--bg-hover); color: #fff;
border-radius: 6px; border: 1px solid var(--border);
.profile-page {
padding: 8px 0 12px;
}
.summary-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 12px;
}
.username {
font-size: 15px;
font-weight: 800;
color: var(--primary-light);
margin-bottom: 10px;
}
.balance-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.balance-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-muted);
font-weight: 600;
}
.amount {
color: var(--primary-light);
font-weight: 800;
font-size: 20px;
white-space: nowrap;
}
.frozen {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
font-weight: 500;
}
.settings-group {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.settings-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 48px;
padding: 0 16px;
background: none;
color: var(--text);
font-size: 14px;
font-weight: 600;
text-decoration: none;
border-bottom: 1px solid var(--border);
}
.settings-cell:last-child {
border-bottom: none;
}
.settings-cell:active {
background: rgba(255, 255, 255, 0.03);
}
.settings-cell--stack {
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 10px;
padding: 12px 16px 14px;
min-height: auto;
}
.cell-head {
display: flex;
align-items: center;
}
.cell-label {
color: var(--text);
}
.cell-chevron {
color: var(--text-muted);
font-size: 20px;
line-height: 1;
font-weight: 300;
}
.lang-segment {
display: flex;
gap: 6px;
}
.lang-opt {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 34px;
padding: 0 6px;
border-radius: 6px;
border: 1px solid var(--border);
background: #0a0a0a;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
}
.lang-opt.active {
border-color: var(--border-gold-soft);
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
}
.logout-btn {
width: 100%;
margin-top: 12px;
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--danger);
font-size: 14px;
font-weight: 700;
}
.logout-btn:active {
background: rgba(255, 69, 58, 0.08);
}
.lang-btns button.active { border-color: var(--primary); color: var(--primary); }
.tx-row { display: flex; justify-content: space-between; font-size: 13px; padding: 8px 0; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.pos { color: var(--primary); }
.neg { color: var(--danger); }
.tx-time { width: 100%; font-size: 11px; color: var(--text-muted); }
.btn-logout { width: 100%; margin-top: 16px; padding: 12px; background: var(--bg-card); color: var(--danger); border-radius: 6px; border: 1px solid var(--border); }
</style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
const { t, locale } = useI18n();
const transactions = ref<
Array<{ transactionType: string; amount: string; createdAt: string; transactionId?: string }>
>([]);
onMounted(async () => {
const { data } = await api.get('/player/wallet/transactions');
transactions.value = data.data.items ?? [];
});
</script>
<template>
<div>
<h2 class="section-title">{{ t('nav.wallet') }}</h2>
<div v-if="transactions.length" class="card">
<div
v-for="tx in transactions"
:key="tx.transactionId ?? tx.createdAt"
class="tx-row"
>
<span>{{ tx.transactionType }}</span>
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
{{ formatMoney(tx.amount, locale) }}
</span>
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
</div>
</div>
<div v-else class="empty">{{ t('wallet.no_records') }}</div>
</div>
</template>
<style scoped>
.tx-row {
display: flex;
justify-content: space-between;
font-size: 14px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.tx-row:last-child {
border-bottom: none;
}
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
.neg { color: var(--danger); font-weight: 700; }
.tx-time { width: 100%; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.empty { text-align: center; color: var(--text-muted); padding: 40px 16px; font-weight: 600; }
</style>