点击赔率添加投注
{{ item.matchName }}
-
{{ item.selectionName }} @ {{ item.odds }}
+
{{ item.selectionName }} @ {{ item.odds }}
@@ -88,7 +88,7 @@ async function placeBet() {
{{ t('bet.parlay') }} · 赔率 {{ slip.totalOdds.toFixed(2) }}
-
预计返还: {{ slip.potentialReturn.toFixed(2) }}
+
预计返还: {{ slip.potentialReturn.toFixed(2) }}
{{ error }}
@@ -102,20 +102,49 @@ async function placeBet() {
diff --git a/apps/player/src/components/BottomNavIcon.vue b/apps/player/src/components/BottomNavIcon.vue
new file mode 100644
index 0000000..35084bd
--- /dev/null
+++ b/apps/player/src/components/BottomNavIcon.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/CashBalanceChip.vue b/apps/player/src/components/CashBalanceChip.vue
new file mode 100644
index 0000000..0f9c430
--- /dev/null
+++ b/apps/player/src/components/CashBalanceChip.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+
+ {{ t('wallet.cash_balance') }}
+ {{ total }}
+
+
+
+ {{ t('wallet.unsettled') }}
+ {{ frozen }}
+
+
+ {{ t('wallet.available') }}
+ {{ available }}
+
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/LeagueAccordionItem.vue b/apps/player/src/components/LeagueAccordionItem.vue
new file mode 100644
index 0000000..25b8c0e
--- /dev/null
+++ b/apps/player/src/components/LeagueAccordionItem.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/LocaleFlag.vue b/apps/player/src/components/LocaleFlag.vue
new file mode 100644
index 0000000..fd3ee81
--- /dev/null
+++ b/apps/player/src/components/LocaleFlag.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/MatchBetCard.vue b/apps/player/src/components/MatchBetCard.vue
new file mode 100644
index 0000000..743a05b
--- /dev/null
+++ b/apps/player/src/components/MatchBetCard.vue
@@ -0,0 +1,132 @@
+
+
+
+
+ {{ kickoff }}
+
+
+
![]()
+
{{ match.homeTeamName }}
+
+
VS
+
+
![]()
+
{{ match.awayTeamName }}
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/RobotVerify.vue b/apps/player/src/components/RobotVerify.vue
new file mode 100644
index 0000000..4df0882
--- /dev/null
+++ b/apps/player/src/components/RobotVerify.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/UserAvatarMenu.vue b/apps/player/src/components/UserAvatarMenu.vue
new file mode 100644
index 0000000..41c0f39
--- /dev/null
+++ b/apps/player/src/components/UserAvatarMenu.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/match-detail/CorrectScorePanel.vue b/apps/player/src/components/match-detail/CorrectScorePanel.vue
new file mode 100644
index 0000000..9b2cf62
--- /dev/null
+++ b/apps/player/src/components/match-detail/CorrectScorePanel.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
{{ homeTeamName }}
+
{{ awayTeamName }}
+
+
+
+ {{ t('bet.col_home') }}
+ {{ t('bet.col_draw') }}
+ {{ t('bet.col_away') }}
+
+
+
+
+
+
{{ sel.scoreDisplay }}
+
{{ formatOdds(sel.odds) }}
+
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/match-detail/FeaturedMarketRow.vue b/apps/player/src/components/match-detail/FeaturedMarketRow.vue
new file mode 100644
index 0000000..256fb54
--- /dev/null
+++ b/apps/player/src/components/match-detail/FeaturedMarketRow.vue
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+ {{ label }}
+
+ 🏆 {{ t('bet.reward_active') }}
+
+ {{ t('bet.market_closed') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/match-detail/MarketSelectionsPanel.vue b/apps/player/src/components/match-detail/MarketSelectionsPanel.vue
new file mode 100644
index 0000000..e6d04d0
--- /dev/null
+++ b/apps/player/src/components/match-detail/MarketSelectionsPanel.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/match-detail/MarketTypeTile.vue b/apps/player/src/components/match-detail/MarketTypeTile.vue
new file mode 100644
index 0000000..f57ec2a
--- /dev/null
+++ b/apps/player/src/components/match-detail/MarketTypeTile.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
{{ label }}
+
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/outright/OutrightBetModal.vue b/apps/player/src/components/outright/OutrightBetModal.vue
new file mode 100644
index 0000000..871ce77
--- /dev/null
+++ b/apps/player/src/components/outright/OutrightBetModal.vue
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+ {{ t('bet.outright_enter_stake') }}
+ {{ pick.teamName }}
+ {{ formatOdds(pick.odds) }}
+ {{ t('bet.outright_balance') }}:{{ balanceText }}
+ {{ pick.eventTitle }}
+
+ {{ error }}
+
+
+
+
+
+
+
+ ✓
+ {{ t('bet.outright_success') }}
+ {{ pick.teamName }}
+ {{ formatOdds(pick.odds) }}
+
+ {{ t('bet.outright_stake_amount') }} : {{ successStake.toFixed(2) }}
+
+
+ {{ t('bet.outright_balance') }} : {{ formatMoney(successBalance, locale) }}
+
+ {{ pick.eventTitle }}
+
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/outright/OutrightOptionCard.vue b/apps/player/src/components/outright/OutrightOptionCard.vue
new file mode 100644
index 0000000..4e21a20
--- /dev/null
+++ b/apps/player/src/components/outright/OutrightOptionCard.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/outright/OutrightPanel.vue b/apps/player/src/components/outright/OutrightPanel.vue
new file mode 100644
index 0000000..05561cb
--- /dev/null
+++ b/apps/player/src/components/outright/OutrightPanel.vue
@@ -0,0 +1,188 @@
+
+
+
+
+
{{ t('bet.loading') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
{{ t('bet.no_outright') }}
+
+
+
+
+
+
+
diff --git a/apps/player/src/components/parlay/ParlayPanel.vue b/apps/player/src/components/parlay/ParlayPanel.vue
new file mode 100644
index 0000000..48ffe31
--- /dev/null
+++ b/apps/player/src/components/parlay/ParlayPanel.vue
@@ -0,0 +1,403 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('bet.loading') }}
+
+
+
+
+
{{ match.leagueName }}
+
{{ match.homeTeamName }} vs {{ match.awayTeamName }}
+
{{ formatKickoff(match.startTime) }}
+
+
+
+
+
+
+ —
+
+
+
+
+
+
+
📅
+
{{ t('bet.parlay_empty') }}
+
+
+
+
+
diff --git a/apps/player/src/composables/useAnnouncements.ts b/apps/player/src/composables/useAnnouncements.ts
new file mode 100644
index 0000000..bed1ec9
--- /dev/null
+++ b/apps/player/src/composables/useAnnouncements.ts
@@ -0,0 +1,35 @@
+import { ref } from 'vue';
+import api from '../api';
+import { resolveAnnouncements } from '../constants/defaultAnnouncement';
+
+function collectAnnouncementLines(data: {
+ ticker?: Array<{ translation?: { title?: string; body?: string } }>;
+ notices?: Array<{ translation?: { title?: string; body?: string } }>;
+} | null): string[] {
+ const lines: string[] = [];
+ if (!data) return lines;
+ for (const item of data.ticker ?? []) {
+ const text = item.translation?.body || item.translation?.title;
+ if (text) lines.push(text);
+ }
+ for (const item of data.notices ?? []) {
+ const text = item.translation?.title || item.translation?.body;
+ if (text) lines.push(text);
+ }
+ return lines;
+}
+
+export function useAnnouncements() {
+ const items = ref
(resolveAnnouncements([]));
+
+ async function load() {
+ try {
+ const { data } = await api.get('/player/home');
+ items.value = resolveAnnouncements(collectAnnouncementLines(data.data));
+ } catch {
+ items.value = resolveAnnouncements([]);
+ }
+ }
+
+ return { items, load };
+}
diff --git a/apps/player/src/constants/defaultAnnouncement.ts b/apps/player/src/constants/defaultAnnouncement.ts
new file mode 100644
index 0000000..1d71d1f
--- /dev/null
+++ b/apps/player/src/constants/defaultAnnouncement.ts
@@ -0,0 +1,8 @@
+export const DEFAULT_ANNOUNCEMENTS = [
+ '欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
+];
+
+export function resolveAnnouncements(items: string[]): string[] {
+ const list = items.map((s) => s.trim()).filter(Boolean);
+ return list.length ? list : DEFAULT_ANNOUNCEMENTS;
+}
diff --git a/apps/player/src/constants/defaultBanner.ts b/apps/player/src/constants/defaultBanner.ts
new file mode 100644
index 0000000..dbcaef1
--- /dev/null
+++ b/apps/player/src/constants/defaultBanner.ts
@@ -0,0 +1,41 @@
+import type { BannerItem } from '../components/BannerCarousel.vue';
+
+/** 默认轮播首图:apps/player/src/assets/images/banner.png */
+import defaultBannerImg from '../assets/images/banner.png';
+
+const FALLBACK_BANNER_URL = '/uploads/banners/welcome.svg';
+
+export const DEFAULT_BANNER: BannerItem = {
+ id: 'default',
+ linkType: 'ROUTE',
+ linkTarget: '/bet',
+ translation: {
+ title: '',
+ imageUrl: defaultBannerImg,
+ },
+};
+
+function pickImageUrl(url?: string | null): string {
+ if (!url) return defaultBannerImg;
+ return url;
+}
+
+export function resolveBanners(banners: BannerItem[] | undefined | null): BannerItem[] {
+ const fromApi = (banners ?? []).map((banner) => ({
+ ...banner,
+ translation: {
+ ...banner.translation,
+ imageUrl: pickImageUrl(banner.translation?.imageUrl),
+ },
+ }));
+
+ const defaultSlide: BannerItem = {
+ ...DEFAULT_BANNER,
+ translation: {
+ ...DEFAULT_BANNER.translation,
+ imageUrl: defaultBannerImg || FALLBACK_BANNER_URL,
+ },
+ };
+
+ return [defaultSlide, ...fromApi];
+}
diff --git a/apps/player/src/layouts/MainLayout.vue b/apps/player/src/layouts/MainLayout.vue
index 8e27710..bf7d686 100644
--- a/apps/player/src/layouts/MainLayout.vue
+++ b/apps/player/src/layouts/MainLayout.vue
@@ -4,13 +4,19 @@ import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import { useBetSlipStore } from '../stores/betSlip';
import BetSlipDrawer from '../components/BetSlipDrawer.vue';
-import { ref } from 'vue';
+import CashBalanceChip from '../components/CashBalanceChip.vue';
+import UserAvatarMenu from '../components/UserAvatarMenu.vue';
+import LocaleFlag from '../components/LocaleFlag.vue';
+import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
+import BottomNavIcon from '../components/BottomNavIcon.vue';
+import { computed, onMounted } from 'vue';
+import { getLocaleDisplay } from '../utils/localeDisplay';
+import { useAnnouncements } from '../composables/useAnnouncements';
const { t, locale } = useI18n();
const auth = useAuthStore();
-const slip = useBetSlipStore();
const route = useRoute();
-const showSlip = ref(false);
+const slip = useBetSlipStore();
const locales = [
{ code: 'zh-CN', label: '中文' },
@@ -18,6 +24,11 @@ const locales = [
{ code: 'ms-MY', label: 'BM' },
];
+const showAnnouncement = computed(() => !route.path.startsWith('/profile'));
+const { items: announcements, load: loadAnnouncements } = useAnnouncements();
+
+onMounted(loadAnnouncements);
+
function setLocale(code: string) {
locale.value = code;
localStorage.setItem('locale', code);
@@ -29,56 +40,164 @@ function setLocale(code: string) {
+
+
-
diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts
index 010d1a7..b6043ee 100644
--- a/apps/player/src/main.ts
+++ b/apps/player/src/main.ts
@@ -2,7 +2,7 @@ import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { createI18n } from 'vue-i18n';
import App from './App.vue';
-import router from './router';
+import router from './router/index.ts';
import './styles.css';
const i18n = createI18n({
@@ -11,22 +11,331 @@ const i18n = createI18n({
fallbackLocale: 'en-US',
messages: {
'zh-CN': {
- nav: { home: '首页', football: '足球', my_bets: '我的投注', profile: '我的' },
- auth: { login: '登录', username: '账号', password: '密码' },
- wallet: { balance: '余额' },
- bet: { bet_slip: '投注单', stake: '投注金额', place_bet: '确认下注', parlay: '串关' },
+ nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
+ history: {
+ league_default: '足球',
+ stake: '投注 Stake',
+ return: '回报 Return',
+ est_return: '预计回报 Est. Return',
+ parlay_title: '串关 · {n} 场',
+ parlay_league: '串关 Parlay',
+ empty: '暂无投注记录',
+ status_won: 'WON 赢',
+ status_pending: 'PENDING 待定',
+ status_lost: 'LOST 输',
+ status_push: 'PUSH 走盘',
+ },
+ auth: {
+ login: '登录',
+ logout: '退出登录',
+ username: '账号',
+ password: '密码',
+ captcha_placeholder: 'Captcha',
+ captcha_refresh: '点击换一张',
+ captcha_wrong: '验证码错误',
+ },
+ wallet: {
+ balance: '余额',
+ cash_balance: '现金余额',
+ unsettled: '未结算',
+ available: '可用',
+ no_records: '暂无账单记录',
+ },
+ bet: {
+ bet_slip: '投注单',
+ stake: '投注金额',
+ place_bet: '确认下注',
+ place_bet_short: '下注',
+ parlay: '串关',
+ tab_matches: '球赛',
+ tab_outright: '优胜冠军',
+ tab_parlay: '串关投注',
+ tab_today: '今日',
+ tab_early: '早盘',
+ loading: '加载中…',
+ no_matches: '暂无赛事',
+ outright_coming: '优胜冠军玩法即将上线',
+ outright_enter_stake: '请输入投注金额',
+ outright_balance: '结余',
+ outright_stake_amount: '投注额度',
+ outright_success: '下注成功',
+ outright_done: '完毕',
+ outright_bet_failed: '下注失败',
+ no_outright: '暂无冠军盘口',
+ cancel: '取消',
+ parlay_title: '串关投注',
+ parlay_desc: '选择3-10场比赛来创建串关投注。组合赔率相乘可赢得更多!',
+ parlay_filter_all: '全部',
+ parlay_empty: '暂无可用串关赛事',
+ parlay_same_match: '同一场比赛不能串关',
+ parlay_need_more: '请至少选择 2 项进行串关',
+ back: '返回',
+ refresh: '刷新',
+ download: '下载',
+ reward_active: '奖励生效中!',
+ market_closed: '暂未开盘',
+ expand_market: '展开玩法',
+ collapse_market: '收起玩法',
+ market_cs: '波胆',
+ market_ht_cs: '上半场波胆',
+ market_sh_cs: '下半场波胆',
+ market_ft_handicap: '全场 让球',
+ market_ft_ou: '全场 大小',
+ market_ft_1x2: '全场 独赢盘',
+ market_ft_oe: '全场 单/双',
+ market_ht_handicap: '半场 让球',
+ market_ht_ou: '半场 大小',
+ market_ht_1x2: '半场 独赢盘',
+ col_home: '主场',
+ col_draw: '平',
+ col_away: '客场',
+ cs_stake_required: '请至少在一个比分输入投注金额',
+ cs_place_success: '下注成功',
+ cs_place_failed: '下注失败',
+ },
+ profile: {
+ edit: '修改资料',
+ language: '语言',
+ phone: '手机号',
+ email: '邮箱',
+ phone_placeholder: '请输入手机号',
+ email_placeholder: '请输入邮箱',
+ save: '保存',
+ password_optional_hint: '不修改密码可留空',
+ old_password_placeholder: '留空则不修改',
+ new_password_placeholder: '留空则不修改',
+ confirm_password_placeholder: '留空则不修改',
+ old_password: '当前密码',
+ new_password: '新密码',
+ confirm_password: '确认新密码',
+ back: '返回',
+ saved: '联系方式已保存',
+ save_failed: '保存失败',
+ password_changed: '密码已更新',
+ password_failed: '密码修改失败',
+ password_mismatch: '两次新密码不一致',
+ password_incomplete: '修改密码需填写当前密码、新密码及确认密码',
+ },
},
'en-US': {
- nav: { home: 'Home', football: 'Football', my_bets: 'My Bets', profile: 'Profile' },
- auth: { login: 'Login', username: 'Username', password: 'Password' },
- wallet: { balance: 'Balance' },
- bet: { bet_slip: 'Bet Slip', stake: 'Stake', place_bet: 'Place Bet', parlay: 'Parlay' },
+ nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
+ history: {
+ league_default: 'Football',
+ stake: 'Stake 投注',
+ return: 'Return 回报',
+ est_return: 'Est. Return 预计回报',
+ parlay_title: 'Parlay · {n} legs',
+ parlay_league: 'Parlay 串关',
+ empty: 'No bets yet',
+ status_won: 'WON 赢',
+ status_pending: 'PENDING 待定',
+ status_lost: 'LOST 输',
+ status_push: 'PUSH 走盘',
+ },
+ auth: {
+ login: 'Login',
+ logout: 'Log out',
+ username: 'Username',
+ password: 'Password',
+ captcha_placeholder: 'Captcha',
+ captcha_refresh: 'Click to refresh',
+ captcha_wrong: 'Invalid captcha',
+ },
+ wallet: {
+ balance: 'Balance',
+ cash_balance: 'Cash Balance',
+ unsettled: 'Unsettled',
+ available: 'Available',
+ no_records: 'No records',
+ },
+ bet: {
+ bet_slip: 'Bet Slip',
+ stake: 'Stake',
+ place_bet: 'Place Bet',
+ place_bet_short: 'Bet',
+ parlay: 'Parlay',
+ tab_matches: 'Matches',
+ tab_outright: 'Outright',
+ tab_parlay: 'Parlay',
+ tab_today: 'Today',
+ tab_early: 'Early',
+ loading: 'Loading…',
+ no_matches: 'No matches',
+ outright_coming: 'Outright markets coming soon',
+ outright_enter_stake: 'Enter stake',
+ outright_balance: 'Balance',
+ outright_stake_amount: 'Stake',
+ outright_success: 'Bet placed',
+ outright_done: 'Done',
+ outright_bet_failed: 'Bet failed',
+ no_outright: 'No outright markets',
+ cancel: 'Cancel',
+ parlay_title: 'Parlay',
+ parlay_desc: 'Pick 3-10 matches. Combined odds multiply your potential win!',
+ parlay_filter_all: 'All',
+ parlay_empty: 'No matches available for parlay betting',
+ parlay_same_match: 'Cannot parlay selections from the same match',
+ parlay_need_more: 'Select at least 2 legs for parlay',
+ back: 'Back',
+ refresh: 'Refresh',
+ download: 'Download',
+ reward_active: 'Reward active!',
+ market_closed: 'Not open',
+ expand_market: 'Expand',
+ collapse_market: 'Collapse',
+ market_cs: 'Correct Score',
+ market_ht_cs: '1H Correct Score',
+ market_sh_cs: '2H Correct Score',
+ market_ft_handicap: 'FT Handicap',
+ market_ft_ou: 'FT O/U',
+ market_ft_1x2: 'FT 1X2',
+ market_ft_oe: 'FT Odd/Even',
+ market_ht_handicap: 'HT Handicap',
+ market_ht_ou: 'HT O/U',
+ market_ht_1x2: 'HT 1X2',
+ col_home: 'Home',
+ col_draw: 'Draw',
+ col_away: 'Away',
+ cs_stake_required: 'Enter stake on at least one score',
+ cs_place_success: 'Bet placed',
+ cs_place_failed: 'Bet failed',
+ },
+ profile: {
+ edit: 'Edit Profile',
+ language: 'Language',
+ phone: 'Phone',
+ email: 'Email',
+ phone_placeholder: 'Phone number',
+ email_placeholder: 'Email address',
+ save: 'Save',
+ password_optional_hint: 'Leave password fields blank to keep current password',
+ old_password_placeholder: 'Leave blank to skip',
+ new_password_placeholder: 'Leave blank to skip',
+ confirm_password_placeholder: 'Leave blank to skip',
+ old_password: 'Current password',
+ new_password: 'New password',
+ confirm_password: 'Confirm password',
+ back: 'Back',
+ saved: 'Contact saved',
+ save_failed: 'Save failed',
+ password_changed: 'Password updated',
+ password_failed: 'Password change failed',
+ password_mismatch: 'Passwords do not match',
+ password_incomplete: 'Fill current, new and confirm password to change password',
+ },
},
'ms-MY': {
- nav: { home: 'Laman Utama', football: 'Bola Sepak', my_bets: 'Pertaruhan Saya', profile: 'Profil' },
- auth: { login: 'Log Masuk', username: 'Nama Pengguna', password: 'Kata Laluan' },
- wallet: { balance: 'Baki' },
- bet: { bet_slip: 'Slip Pertaruhan', stake: 'Jumlah', place_bet: 'Letak Pertaruhan', parlay: 'Berganda' },
+ nav: {
+ home: 'Laman Utama',
+ bet: 'Pertaruhan',
+ bet_history: 'Sejarah',
+ wallet: 'Bil',
+ profile: 'Profil',
+ },
+ history: {
+ league_default: 'Bola Sepak',
+ stake: 'Stake 投注',
+ return: 'Return 回报',
+ est_return: 'Est. Return 预计回报',
+ parlay_title: 'Parlay · {n} perlawanan',
+ parlay_league: 'Parlay 串关',
+ empty: 'Tiada rekod pertaruhan',
+ status_won: 'WON 赢',
+ status_pending: 'PENDING 待定',
+ status_lost: 'LOST 输',
+ status_push: 'PUSH 走盘',
+ },
+ auth: {
+ login: 'Log Masuk',
+ logout: 'Log Keluar',
+ username: 'Nama Pengguna',
+ password: 'Kata Laluan',
+ captcha_placeholder: 'Captcha',
+ captcha_refresh: 'Klik untuk muat semula',
+ captcha_wrong: 'Kod pengesahan salah',
+ },
+ wallet: {
+ balance: 'Baki',
+ cash_balance: 'Baki Tunai',
+ unsettled: 'Belum Selesai',
+ available: 'Tersedia',
+ no_records: 'Tiada rekod',
+ },
+ bet: {
+ bet_slip: 'Slip Pertaruhan',
+ stake: 'Jumlah',
+ place_bet: 'Letak Pertaruhan',
+ place_bet_short: 'Pertaruhan',
+ parlay: 'Berganda',
+ tab_matches: 'Perlawanan',
+ tab_outright: 'Juara',
+ tab_parlay: 'Berganda',
+ tab_today: 'Hari Ini',
+ tab_early: 'Awal',
+ loading: 'Memuatkan…',
+ no_matches: 'Tiada perlawanan',
+ outright_coming: 'Pasaran juara akan datang',
+ outright_enter_stake: 'Masukkan jumlah',
+ outright_balance: 'Baki',
+ outright_stake_amount: 'Jumlah pertaruhan',
+ outright_success: 'Pertaruhan berjaya',
+ outright_done: 'Selesai',
+ outright_bet_failed: 'Pertaruhan gagal',
+ no_outright: 'Tiada pasaran juara',
+ cancel: 'Batal',
+ parlay_title: 'Pertaruhan Berganda',
+ parlay_desc: 'Pilih 3-10 perlawanan. Gabungan odds didarab!',
+ parlay_filter_all: 'Semua',
+ parlay_empty: 'No matches available for parlay betting',
+ parlay_same_match: 'Perlawanan sama tidak boleh berganda',
+ parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
+ back: 'Kembali',
+ refresh: 'Muat semula',
+ download: 'Muat turun',
+ reward_active: 'Ganjaran aktif!',
+ market_closed: 'Belum dibuka',
+ expand_market: 'Kembang',
+ collapse_market: 'Tutup',
+ market_cs: 'Skor Tepat',
+ market_ht_cs: 'Skor Tepat PB1',
+ market_sh_cs: 'Skor Tepat PB2',
+ market_ft_handicap: 'Handicap Penuh',
+ market_ft_ou: 'Atas/Bawah Penuh',
+ market_ft_1x2: '1X2 Penuh',
+ market_ft_oe: 'Ganjil/Genap Penuh',
+ market_ht_handicap: 'Handicap Separuh',
+ market_ht_ou: 'Atas/Bawah Separuh',
+ market_ht_1x2: '1X2 Separuh',
+ col_home: 'Home',
+ col_draw: 'Seri',
+ col_away: 'Away',
+ cs_stake_required: 'Masukkan jumlah pada sekurang-kurangnya satu skor',
+ cs_place_success: 'Pertaruhan berjaya',
+ cs_place_failed: 'Pertaruhan gagal',
+ },
+ profile: {
+ edit: 'Edit Profil',
+ language: 'Bahasa',
+ phone: 'Telefon',
+ email: 'E-mel',
+ phone_placeholder: 'Nombor telefon',
+ email_placeholder: 'Alamat e-mel',
+ save: 'Simpan',
+ password_optional_hint: 'Biarkan kosong jika tidak mahu tukar kata laluan',
+ old_password_placeholder: 'Biarkan kosong untuk langkau',
+ new_password_placeholder: 'Biarkan kosong untuk langkau',
+ confirm_password_placeholder: 'Biarkan kosong untuk langkau',
+ old_password: 'Kata laluan semasa',
+ new_password: 'Kata laluan baharu',
+ confirm_password: 'Sahkan kata laluan',
+ back: 'Kembali',
+ saved: 'Hubungan disimpan',
+ save_failed: 'Gagal simpan',
+ password_changed: 'Kata laluan dikemas kini',
+ password_failed: 'Gagal tukar kata laluan',
+ password_mismatch: 'Kata laluan tidak sepadan',
+ password_incomplete: 'Isi kata laluan semasa, baharu dan pengesahan untuk menukar',
+ },
},
},
});
diff --git a/apps/player/src/router/index.ts b/apps/player/src/router/index.ts
index f1a357e..a2782e7 100644
--- a/apps/player/src/router/index.ts
+++ b/apps/player/src/router/index.ts
@@ -1,5 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router';
-import { useAuthStore } from '../stores/auth';
+import { useAuthStore } from '../stores/auth.ts';
const router = createRouter({
history: createWebHistory(),
@@ -11,10 +11,13 @@ const router = createRouter({
meta: { requiresAuth: true },
children: [
{ path: '', component: () => import('../views/HomeView.vue') },
- { path: 'football', component: () => import('../views/FootballView.vue') },
+ { path: 'bet', component: () => import('../views/FootballView.vue') },
+ { path: 'football', redirect: '/bet' },
{ path: 'match/:id', component: () => import('../views/MatchDetailView.vue') },
{ path: 'bets', component: () => import('../views/MyBetsView.vue') },
+ { path: 'wallet', component: () => import('../views/WalletView.vue') },
{ path: 'profile', component: () => import('../views/ProfileView.vue') },
+ { path: 'profile/edit', component: () => import('../views/ProfileEditView.vue') },
],
},
],
diff --git a/apps/player/src/stores/betSlip.ts b/apps/player/src/stores/betSlip.ts
index 2bdc078..872e9b3 100644
--- a/apps/player/src/stores/betSlip.ts
+++ b/apps/player/src/stores/betSlip.ts
@@ -25,12 +25,26 @@ export const useBetSlipStore = defineStore('betSlip', () => {
);
if (existing >= 0) {
items.value.splice(existing, 1);
+ if (items.value.length < 2) mode.value = 'single';
return;
}
items.value.push(item);
if (items.value.length >= 2) mode.value = 'parlay';
}
+ /** 串关:同场只保留一个选项;再次点击已选项则取消 */
+ function addParlayLeg(item: SlipItem) {
+ const samePick = items.value.findIndex((i) => i.selectionId === item.selectionId);
+ if (samePick >= 0) {
+ items.value.splice(samePick, 1);
+ if (items.value.length < 2) mode.value = 'single';
+ return;
+ }
+ items.value = items.value.filter((i) => i.matchId !== item.matchId);
+ items.value.push(item);
+ mode.value = items.value.length >= 2 ? 'parlay' : 'single';
+ }
+
function removeItem(selectionId: string) {
items.value = items.value.filter((i) => i.selectionId !== selectionId);
if (items.value.length < 2) mode.value = 'single';
@@ -58,6 +72,16 @@ export const useBetSlipStore = defineStore('betSlip', () => {
return new Set(matchIds).size !== matchIds.length;
});
+ const drawerOpen = ref(false);
+
+ function openDrawer() {
+ drawerOpen.value = true;
+ }
+
+ function closeDrawer() {
+ drawerOpen.value = false;
+ }
+
return {
items,
stake,
@@ -67,8 +91,12 @@ export const useBetSlipStore = defineStore('betSlip', () => {
totalOdds,
potentialReturn,
hasSameMatch,
+ drawerOpen,
addItem,
+ addParlayLeg,
removeItem,
clear,
+ openDrawer,
+ closeDrawer,
};
});
diff --git a/apps/player/src/styles.css b/apps/player/src/styles.css
index dc5a4e9..f428f30 100644
--- a/apps/player/src/styles.css
+++ b/apps/player/src/styles.css
@@ -1,58 +1,299 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --primary: #D4AF37;
+ --primary-dark: #A8841F;
+ --primary-light: #F0D875;
+ --secondary: #1A1A1A;
+ --tertiary: #000000;
+ --neutral: #8E8E93;
+ --bg-body: #000000;
+ --bg-card: #141414;
+ --bg-hover: #222222;
+ --bg-elevated: #2A2A2A;
+ --text: #FFFFFF;
+ --text-muted: #8E8E93;
+ --border: #333333;
+ --border-gold: #D4AF37;
+ --border-gold-soft: rgba(212, 175, 55, 0.28);
+ --border-w: 1px;
+ --border-w-thick: 1px;
+ --danger: #FF453A;
+ --radius: 12px;
+ --radius-sm: 8px;
+ --shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
+ --shadow-gold: 0 2px 12px rgba(212, 175, 55, 0.12);
+ --glow-gold: 0 0 8px rgba(212, 175, 55, 0.2);
+ --gradient-gold: linear-gradient(
+ 180deg,
+ #FFF4C8 0%,
+ #F0D875 18%,
+ #D4AF37 50%,
+ #B8942B 78%,
+ #8B6914 100%
+ );
+ --gradient-gold-border: linear-gradient(
+ 180deg,
+ #FFF8DC 0%,
+ #E8C96A 35%,
+ #A8841F 70%,
+ #5C4A12 100%
+ );
+ --gradient-card: linear-gradient(160deg, #1E1A12 0%, #141414 40%, #0A0A0A 100%);
+}
+
+/** 金色描边按钮(避免大面积实心填充) */
+.btn-gold-outline {
+ background: #141414;
+ border: 1px solid var(--border-gold-soft);
+ color: var(--primary-light);
+ font-weight: 800;
+}
+.btn-gold-outline:active:not(:disabled) {
+ background: rgba(212, 175, 55, 0.1);
+ border-color: var(--border-gold);
+}
+
+/** 选中态:浅底 + 金边,非整块金色 */
+.tab-gold-active {
+ background: rgba(212, 175, 55, 0.08) !important;
+ border-color: var(--border-gold) !important;
+ color: var(--primary-light) !important;
+}
+
+/* 登录等关键区域:轻量金边(非厚重 PS 描边) */
+.ps-gold-frame {
+ position: relative;
+ border: 1px solid var(--border-gold-soft) !important;
+ border-radius: var(--radius);
+ background: rgba(14, 14, 14, 0.92) !important;
+ box-shadow: var(--shadow);
+}
+
+.ps-gold-frame::before,
+.ps-gold-frame::after {
+ display: none;
+}
+
+.ps-gold-input {
+ background: #0d0d0d !important;
+ border: 1px solid var(--border) !important;
+ box-shadow: none;
+}
+
+.ps-gold-input:focus {
+ border-color: var(--border-gold-soft) !important;
+ box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.12) !important;
+}
+
+/* 主按钮:斜面浮雕 + 渐变描边 + 外发光 */
+.btn-primary {
+ position: relative;
+ isolation: isolate;
+ padding: 17px 24px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: #3D2800;
+ font-weight: 900;
+ font-size: 16px;
+ width: 100%;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ text-shadow:
+ 0 1px 0 rgba(255, 252, 235, 0.95),
+ 0 -1px 0 rgba(120, 85, 15, 0.35);
+ transition: transform 0.12s ease;
+ z-index: 0;
+}
+
+.btn-primary::before {
+ content: '';
+ position: absolute;
+ inset: -3px;
+ border-radius: calc(var(--radius-sm) + 3px);
+ background: linear-gradient(
+ 180deg,
+ #FFFCE8 0%,
+ #F7E08A 12%,
+ #D4AF37 35%,
+ #8B6914 55%,
+ #5C4A12 72%,
+ #E8C040 90%,
+ #FFF8DC 100%
+ );
+ z-index: -2;
+ box-shadow:
+ 0 0 14px rgba(255, 210, 70, 0.55),
+ 0 0 28px rgba(212, 175, 55, 0.28),
+ 0 6px 14px rgba(0, 0, 0, 0.55);
+}
+
+.btn-primary::after {
+ content: '';
+ position: absolute;
+ inset: 2px;
+ border-radius: calc(var(--radius-sm) - 1px);
+ background: linear-gradient(
+ 180deg,
+ #FFFBE8 0%,
+ #FFE566 6%,
+ #F0D050 22%,
+ #D4AF37 48%,
+ #B8860B 72%,
+ #8B6914 100%
+ );
+ z-index: -1;
+ box-shadow:
+ inset 0 2px 0 rgba(255, 255, 255, 0.95),
+ inset 0 4px 10px rgba(255, 240, 180, 0.45),
+ inset 0 -2px 0 rgba(90, 65, 12, 0.85),
+ inset 0 -6px 14px rgba(45, 32, 6, 0.5);
+}
+
+.btn-primary:active:not(:disabled) {
+ transform: translateY(2px) scale(0.985);
+}
+
+.btn-primary:active:not(:disabled)::after {
+ background: linear-gradient(
+ 180deg,
+ #E8C040 0%,
+ #C9962A 40%,
+ #8B6914 100%
+ );
+ box-shadow:
+ inset 0 4px 10px rgba(35, 24, 4, 0.65),
+ inset 0 1px 0 rgba(255, 255, 255, 0.35);
+}
+
+.btn-primary:active:not(:disabled)::before {
+ box-shadow:
+ 0 0 10px rgba(255, 200, 50, 0.35),
+ 0 2px 6px rgba(0, 0, 0, 0.5);
+}
+
+.btn-primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ filter: saturate(0.4);
+}
+
+html,
+body,
+#app {
+ height: 100%;
+}
+
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- background: #0f1419;
- color: #e8eaed;
- min-height: 100vh;
-}
-:root {
- --primary: #00a826;
- --primary-dark: #008a1f;
- --bg-card: #1a2332;
- --bg-hover: #243044;
- --text-muted: #8b95a5;
- --border: #2d3a4d;
- --danger: #ff4444;
+ background:
+ radial-gradient(ellipse 120% 60% at 50% -10%, rgba(212, 175, 55, 0.14), transparent 55%),
+ var(--bg-body);
+ color: var(--text);
+ overflow: hidden;
+ -webkit-font-smoothing: antialiased;
}
+
a { color: inherit; text-decoration: none; }
+
button {
cursor: pointer;
border: none;
font-family: inherit;
}
+
input {
font-family: inherit;
- background: var(--bg-card);
+ background: #0D0D0D;
border: 1px solid var(--border);
- color: #fff;
- padding: 10px 12px;
- border-radius: 6px;
+ color: var(--text);
+ padding: 14px 16px;
+ border-radius: var(--radius-sm);
width: 100%;
+ font-size: 15px;
+ font-weight: 500;
+ transition: border-color 0.2s, box-shadow 0.2s;
}
-.btn-primary {
- background: var(--primary);
- color: #fff;
- padding: 12px 24px;
- border-radius: 6px;
- font-weight: 600;
- width: 100%;
+
+input:focus {
+ outline: none;
+ border-color: var(--border-gold-soft);
}
-.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
+
+input:-webkit-autofill,
+input:-webkit-autofill:hover,
+input:-webkit-autofill:focus,
+input:-webkit-autofill:active {
+ -webkit-text-fill-color: var(--text);
+ caret-color: var(--text);
+ box-shadow: 0 0 0 1000px #0a0a0a inset;
+ border: 1px solid var(--border);
+ transition: background-color 99999s ease-out 0s;
+}
+
.odds-btn {
- background: var(--bg-card);
+ background: #161616;
border: 1px solid var(--border);
- color: #fff;
- padding: 8px 12px;
- border-radius: 6px;
+ color: var(--text);
+ padding: 12px 14px;
+ border-radius: var(--radius-sm);
text-align: center;
- min-width: 70px;
+ min-width: 78px;
+ transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
}
-.odds-btn.selected { background: var(--primary); border-color: var(--primary); }
-.odds-btn .label { font-size: 11px; color: var(--text-muted); }
-.odds-btn .value { font-size: 15px; font-weight: 700; color: #ffd700; }
+
+.odds-btn:active { transform: scale(0.96); }
+
+.odds-btn.selected {
+ border: 1px solid var(--border-gold-soft);
+ background: rgba(212, 175, 55, 0.12);
+ box-shadow: none;
+}
+
+.odds-btn.selected::before,
+.odds-btn.selected::after {
+ display: none;
+}
+
+.odds-btn.selected .label,
+.odds-btn.selected .value {
+ position: relative;
+ z-index: 1;
+}
+
+.odds-btn .label {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.odds-btn .value {
+ font-size: 18px;
+ font-weight: 800;
+ color: var(--primary-light);
+ text-shadow: 0 0 8px rgba(212, 175, 55, 0.5);
+ margin-top: 2px;
+}
+
.card {
background: var(--bg-card);
- border-radius: 8px;
- padding: 12px;
- margin-bottom: 8px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 16px;
+ margin-bottom: 12px;
+ box-shadow: var(--shadow);
+}
+
+.section-title {
+ font-size: 18px;
+ font-weight: 800;
+ margin-bottom: 14px;
+ padding: 4px 0 4px 12px;
+ border-left: 3px solid var(--border-gold);
+ color: var(--primary-light);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
}
diff --git a/apps/player/src/utils/correctScoreLayout.ts b/apps/player/src/utils/correctScoreLayout.ts
new file mode 100644
index 0000000..cf18ab1
--- /dev/null
+++ b/apps/player/src/utils/correctScoreLayout.ts
@@ -0,0 +1,86 @@
+/** 与后台 settlement 模板顺序一致,用于列内排序 */
+export const FT_CORRECT_SCORE_ORDER = [
+ 'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2',
+ 'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME',
+ 'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',
+ 'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3',
+ 'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY',
+];
+
+export const HT_CORRECT_SCORE_ORDER = [
+ 'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME',
+ 'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW',
+ 'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY',
+];
+
+export type CsColumn = 'home' | 'draw' | 'away';
+
+export interface CsSelection {
+ id: string;
+ selectionCode: string;
+ selectionName: string;
+ odds: string;
+ oddsVersion: string;
+ scoreDisplay: string;
+}
+
+export function isCorrectScoreMarket(marketType: string) {
+ return marketType.includes('CORRECT_SCORE');
+}
+
+function orderForMarket(marketType: string) {
+ if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_ORDER;
+ return HT_CORRECT_SCORE_ORDER;
+}
+
+export function parseScoreCode(code: string): { display: string; column: CsColumn } | null {
+ if (code === 'OTHER_HOME') return { display: '其它', column: 'home' };
+ if (code === 'OTHER_DRAW') return { display: '其它', column: 'draw' };
+ if (code === 'OTHER_AWAY') return { display: '其它', column: 'away' };
+ const m = code.match(/^SCORE_(\d+)_(\d+)$/);
+ if (!m) return null;
+ const h = Number(m[1]);
+ const a = Number(m[2]);
+ if (h > a) return { display: `${h}:${a}`, column: 'home' };
+ if (h < a) return { display: `${h}:${a}`, column: 'away' };
+ return { display: `${h}:${a}`, column: 'draw' };
+}
+
+function sortByTemplate(items: CsSelection[], template: string[]) {
+ return [...items].sort((a, b) => {
+ const ia = template.indexOf(a.selectionCode);
+ const ib = template.indexOf(b.selectionCode);
+ return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
+ });
+}
+
+export function groupCorrectScoreSelections(
+ selections: Array<{
+ id: string;
+ selectionCode: string;
+ selectionName: string;
+ odds: string;
+ oddsVersion: string;
+ }>,
+ marketType: string,
+) {
+ const template = orderForMarket(marketType);
+ const home: CsSelection[] = [];
+ const draw: CsSelection[] = [];
+ const away: CsSelection[] = [];
+
+ for (const sel of selections) {
+ const parsed = parseScoreCode(sel.selectionCode);
+ if (!parsed) continue;
+ const row: CsSelection = { ...sel, scoreDisplay: parsed.display };
+ if (parsed.column === 'home') home.push(row);
+ else if (parsed.column === 'draw') draw.push(row);
+ else away.push(row);
+ }
+
+ return {
+ home: sortByTemplate(home, template),
+ draw: sortByTemplate(draw, template),
+ away: sortByTemplate(away, template),
+ };
+}
diff --git a/apps/player/src/utils/localeDisplay.js b/apps/player/src/utils/localeDisplay.js
new file mode 100644
index 0000000..529b85a
--- /dev/null
+++ b/apps/player/src/utils/localeDisplay.js
@@ -0,0 +1,2 @@
+// 兼容旧缓存:转发到 TypeScript 源文件
+export { getLocaleDisplay, parseAmount, sumAmounts, formatMoney } from './localeDisplay.ts';
diff --git a/apps/player/src/utils/localeDisplay.ts b/apps/player/src/utils/localeDisplay.ts
new file mode 100644
index 0000000..0bbd73d
--- /dev/null
+++ b/apps/player/src/utils/localeDisplay.ts
@@ -0,0 +1,55 @@
+export interface LocaleDisplay {
+ countryCode: 'cn' | 'us' | 'my';
+ currency: string;
+ label: string;
+}
+
+const LOCALE_DISPLAY: Record