feat(player): 完善 H5 投注端与 API 演示数据
- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑 - 固定顶栏、公告与底栏,仅内容区滚动 - 底部导航与站点 favicon 使用 logo,登录页精简 - API 种子、冠军盘与历史注单增强 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
271
apps/player/src/views/ProfileEditView.vue
Normal file
271
apps/player/src/views/ProfileEditView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
54
apps/player/src/views/WalletView.vue
Normal file
54
apps/player/src/views/WalletView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user