feat: 前台匿名浏览、登录引导、客服入口与返水增强

前台:
- 未登录可浏览首页/赛事/赔率,下注等操作弹出登录引导(去登录/继续浏览)
- 顶部新增客服入口与 iframe 弹窗
- 登录页支持暂不登录返回浏览

API:
- 首页/赛事/冠军盘接口改为公开访问,支持 X-Locale 头
- JWT 守卫支持可选认证

返水:
- 注单新增 is_cashbacked 字段,发放时自动标记
- 预览展示玩家余额,明确平台直发不从代理扣款
- 后台注单列表与玩家历史展示回水状态

其他:
- 串关禁止同场重复选号(SAME_MATCH)
- 补充结算资金流分析文档

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 09:36:44 +08:00
parent 785fa4416d
commit 844727c82e
35 changed files with 1007 additions and 49 deletions

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import LoginPromptModal from './components/LoginPromptModal.vue';
</script>
<template>
<RouterView />
<LoginPromptModal />
</template>

View File

@@ -20,7 +20,7 @@ api.interceptors.response.use(
// Don't redirect on login/auth failures — let the caller handle the error
if (!url.includes('/auth/login')) {
localStorage.removeItem('token');
window.location.href = '/login';
// 不再强制跳转登录页,让调用方处理 401
}
}
return Promise.reject(err);

View File

@@ -20,6 +20,7 @@ export interface BetHistoryItem {
actualReturn: unknown;
status: string;
placedAt: string;
isCashbacked?: boolean;
leagueName?: string;
matchTitle: string;
pickLabel: string;
@@ -122,6 +123,7 @@ function goDetail() {
<div class="card-body">
<div class="card-header">
<span v-if="betTypeLabel" class="bet-type-tag">{{ betTypeLabel }}</span>
<span v-if="bet.isCashbacked" class="cashback-tag">{{ t('history.cashbacked') }}</span>
<span class="card-date">{{ placedDate }}</span>
</div>
<span class="title">{{ title }}</span>
@@ -169,8 +171,18 @@ function goDetail() {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.cashback-tag {
font-size: 10px;
font-weight: 700;
color: #f0b90b;
background: rgba(240, 185, 11, 0.1);
border: 1px solid rgba(240, 185, 11, 0.25);
border-radius: 4px;
padding: 1px 6px;
}
.bet-type-tag {
@@ -189,6 +201,7 @@ function goDetail() {
color: #555;
font-weight: 600;
flex-shrink: 0;
margin-left: auto;
}
.title {

View File

@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
import { useBetSlipStore } from '../stores/betSlip';
import { useAuthStore } from '../stores/auth';
import BetSuccessOverlay from './BetSuccessOverlay.vue';
import api from '../api';
@@ -11,6 +12,7 @@ const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
const { t } = useI18n();
const slip = useBetSlipStore();
const auth = useAuthStore();
const show = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
@@ -33,6 +35,10 @@ function onSuccessDone() {
async function placeBet() {
if (!slip.items.length) return;
if (!auth.token) {
auth.showLoginPrompt();
return;
}
loading.value = true;
error.value = '';
success.value = '';

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { CUSTOMER_SERVICE_URL } from '../config/customerService';
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
const { t } = useI18n();
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
const hasUrl = computed(() => Boolean(CUSTOMER_SERVICE_URL));
function close() {
visible.value = false;
}
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="visible" class="cs-overlay" @click.self="close">
<div class="cs-modal" role="dialog" :aria-label="t('support.title')">
<header class="cs-header">
<h2 class="cs-title">{{ t('support.title') }}</h2>
<button type="button" class="close-btn" :aria-label="t('support.close')" @click="close">
</button>
</header>
<div class="cs-body">
<iframe
v-if="hasUrl"
class="cs-frame"
:src="CUSTOMER_SERVICE_URL"
:title="t('support.title')"
allow="clipboard-read; clipboard-write"
/>
<p v-else class="cs-empty">{{ t('support.url_pending') }}</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.cs-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.cs-modal {
display: flex;
flex-direction: column;
width: min(100%, 420px);
height: min(82vh, 680px);
background: #141414;
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
border-radius: 12px;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
}
.cs-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border, #2a2a2a);
background: rgba(26, 26, 26, 0.98);
}
.cs-title {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--primary-light, #c8a84e);
}
.close-btn {
background: none;
border: none;
color: #666;
font-size: 18px;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.close-btn:hover {
color: #aaa;
}
.cs-body {
flex: 1;
min-height: 0;
background: #0d0d0d;
}
.cs-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
.cs-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
margin: 0;
padding: 24px;
text-align: center;
font-size: 14px;
line-height: 1.6;
color: #888;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
const { t } = useI18n();
const auth = useAuthStore();
const router = useRouter();
function goLogin() {
const redirect = auth.loginReturnTo || undefined;
auth.hideLoginPrompt();
router.push({
path: '/login',
query: redirect ? { redirect } : {},
});
}
function continueBrowsing() {
auth.loginReturnTo = '';
auth.hideLoginPrompt();
}
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="auth.loginPromptVisible" class="login-overlay" @click.self="continueBrowsing">
<div class="login-modal">
<button type="button" class="close-btn" :aria-label="t('auth.continue_browsing')" @click="continueBrowsing">
</button>
<h2 class="login-title">{{ t('auth.login_required') }}</h2>
<p class="login-hint">{{ t('auth.login_hint') }}</p>
<div class="login-actions">
<button type="button" class="login-submit" @click="goLogin">
{{ t('auth.go_login') }}
</button>
<button type="button" class="login-secondary" @click="continueBrowsing">
{{ t('auth.continue_browsing') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.login-overlay {
position: fixed;
inset: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.login-modal {
position: relative;
width: 90%;
max-width: 360px;
background: #1a1a1a;
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
border-radius: 12px;
padding: 28px 24px 24px;
}
.close-btn {
position: absolute;
top: 12px;
right: 14px;
background: none;
border: none;
color: #666;
font-size: 18px;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.close-btn:hover {
color: #aaa;
}
.login-title {
font-size: 18px;
font-weight: 800;
color: var(--primary-light, #c8a84e);
margin: 0 0 6px;
text-align: center;
}
.login-hint {
font-size: 12px;
color: #888;
text-align: center;
margin: 0 0 24px;
}
.login-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.login-submit {
padding: 12px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #d4a017, #e8c84a);
color: #1a1a1a;
font-size: 15px;
font-weight: 800;
cursor: pointer;
min-height: 44px;
transition: opacity 0.2s;
}
.login-submit:active {
opacity: 0.85;
}
.login-secondary {
padding: 10px;
border-radius: 8px;
border: 1px solid #333;
background: transparent;
color: #888;
font-size: 14px;
font-weight: 600;
cursor: pointer;
min-height: 40px;
transition: color 0.2s, border-color 0.2s;
}
.login-secondary:active {
color: #aaa;
border-color: #444;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -48,7 +48,7 @@ function goEdit() {
function logout() {
close();
auth.logout();
router.push('/login');
router.push('/');
}
</script>

View File

@@ -7,11 +7,17 @@ import OutrightEventSection, {
type OutrightSelection,
} from './OutrightEventSection.vue';
import OutrightBetModal, { type OutrightPick } from './OutrightBetModal.vue';
import { useAuthStore } from '../../stores/auth';
import emptyMatchesImg from '../../assets/images/empty-matches.svg';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
const { t } = useI18n();
const auth = useAuthStore();
function goLogin() {
auth.showLoginPrompt('/bet');
}
const loading = ref(true);
const loadError = ref('');
@@ -96,6 +102,10 @@ function toggle(id: string) {
}
function openBet(event: OutrightEvent, sel: OutrightSelection) {
if (!auth.token) {
goLogin();
return;
}
activePick.value = {
selectionId: sel.id,
oddsVersion: sel.oddsVersion,

View File

@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import { useBetSlipStore } from '../../stores/betSlip';
import { useAuthStore } from '../../stores/auth';
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS, PARLAY_MARKET_GROUPS } from '../../utils/parlayColumns';
import BetGuideHelp from '../BetGuideHelp.vue';
@@ -56,6 +57,11 @@ interface ParlayMatch {
const { t, locale } = useI18n();
const slip = useBetSlipStore();
const auth = useAuthStore();
function goLogin() {
auth.showLoginPrompt('/bet');
}
const loading = ref(true);
const matches = ref<ParlayMatch[]>([]);
@@ -248,10 +254,15 @@ function pickSelection(match: ParlayMatch, market: Market, sel: Selection) {
else if (err === 'QUARTER_LINE') parlayHint.value = t('bet.parlay_block_quarter');
else if (err === 'OUTRIGHT') parlayHint.value = t('bet.parlay_block_outright');
else if (err === 'NOT_ALLOWED') parlayHint.value = t('bet.parlay_block_not_allowed');
else if (err === 'SAME_MATCH') parlayHint.value = t('bet.parlay_same_match');
else parlayHint.value = '';
}
function openSlip() {
if (!auth.token) {
goLogin();
return;
}
slip.openDrawer();
}

View File

@@ -0,0 +1,3 @@
/** 客服 iframe 地址,可通过环境变量 VITE_CUSTOMER_SERVICE_URL 覆盖 */
export const CUSTOMER_SERVICE_URL =
(import.meta.env.VITE_CUSTOMER_SERVICE_URL as string | undefined)?.trim() || '';

View File

@@ -10,6 +10,7 @@ import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import { useAppLocale } from '../composables/useAppLocale';
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
import BottomNavIcon from '../components/BottomNavIcon.vue';
import CustomerServiceModal from '../components/CustomerServiceModal.vue';
import { computed, onMounted, ref, watch } from 'vue';
import { usePlayerHome } from '../composables/usePlayerHome';
import { usePlayerProfile } from '../composables/usePlayerProfile';
@@ -44,6 +45,7 @@ const { announcements, load: loadPlayerHome } = usePlayerHome();
const { loadProfile } = usePlayerProfile();
const mainRef = ref<HTMLElement | null>(null);
const tabScrollTops = new Map<string, number>();
const customerServiceOpen = ref(false);
watch(locale, (next, prev) => {
if (prev && next !== prev) void loadPlayerHome(true);
@@ -66,8 +68,10 @@ onMounted(() => {
watch(
() => auth.token,
(token) => {
// 首页数据(公告、热门赛事)对所有人公开,始终加载
void loadPlayerHome();
// 个人资料仅登录用户需要
if (token) {
void loadPlayerHome();
void loadProfile();
}
},
@@ -80,9 +84,34 @@ watch(
<header v-if="showHeader" class="header">
<img src="/logo.png" alt="TheBet365" class="logo" />
<div class="header-actions">
<button
type="button"
class="support-btn"
:aria-label="t('support.open')"
@click="customerServiceOpen = true"
>
<svg class="support-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 3C7.03 3 3 6.58 3 11c0 2.02.9 3.86 2.38 5.24L4 21l4.2-1.02A10.8 10.8 0 0 0 12 19c4.97 0 9-3.58 9-8s-4.03-8-9-8Z"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linejoin="round"
/>
<circle cx="9" cy="11" r="1" fill="currentColor" />
<circle cx="12" cy="11" r="1" fill="currentColor" />
<circle cx="15" cy="11" r="1" fill="currentColor" />
</svg>
<span class="support-label">{{ t('support.short') }}</span>
</button>
<LocaleSwitcher />
<CashBalanceChip v-if="auth.user" />
<UserAvatarMenu v-if="auth.user" />
<template v-if="auth.user">
<CashBalanceChip />
<UserAvatarMenu />
</template>
<button v-else type="button" class="login-btn" @click="auth.showLoginPrompt(route.fullPath)">
{{ t('auth.login') }}
</button>
</div>
</header>
@@ -127,6 +156,7 @@ watch(
</nav>
<BetSlipDrawer v-model="slip.drawerOpen" />
<CustomerServiceModal v-model="customerServiceOpen" />
</div>
</template>
@@ -168,6 +198,55 @@ watch(
.header-actions :deep(.avatar-btn) {
width: var(--header-chip-h);
}
.support-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: var(--header-chip-h, 36px);
padding: 0 10px;
border-radius: 6px;
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
background: rgba(200, 168, 78, 0.08);
color: var(--primary-light, #c8a84e);
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
}
.support-btn:active {
background: rgba(200, 168, 78, 0.16);
}
.support-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.support-label {
max-width: 48px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login-btn {
padding: 6px 16px;
border-radius: 6px;
border: 1px solid var(--primary, #c8a84e);
background: transparent;
color: var(--primary-light, #c8a84e);
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.login-btn:active {
background: rgba(200, 168, 78, 0.15);
}
.announce-strip {
flex-shrink: 0;
z-index: 105;

View File

@@ -66,6 +66,7 @@ const i18n = createI18n({
stats_push: '走盘',
stats_stake: '总投注额',
stats_return: '总回报',
cashbacked: '已回水',
},
auth: {
login: '登录',
@@ -75,6 +76,21 @@ const i18n = createI18n({
captcha_placeholder: 'Captcha',
captcha_refresh: '点击换一张',
captcha_wrong: '验证码错误',
login_required: '请先登录',
login_hint: '登录后可下注及访问更多功能',
go_login: '去登录',
continue_browsing: '暂不登录,继续浏览',
username_placeholder: '请输入账号',
password_placeholder: '请输入密码',
login_btn: '登录',
login_failed: '登录失败,请重试',
},
support: {
short: '客服',
title: '在线客服',
open: '打开在线客服',
close: '关闭',
url_pending: '客服链接暂未配置,请联系管理员。',
},
wallet: {
balance: '余额',
@@ -181,7 +197,7 @@ const i18n = createI18n({
parlay_guide_help: '查看串关说明',
parlay_desc: '选择 25 场赛前赛事组合串关2 串 1 至 5 串 1。赔率相乘不含滚球、冠军盘与四分盘让球/大小。',
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
parlay_guide_2: '须选 25 项(可同场多项);冠军盘与四分盘让球/大小不可选',
parlay_guide_2: '须选 25 项,且须为不同赛事;冠军盘与四分盘让球/大小不可选',
parlay_guide_3: '选好后点底部「确认下单」打开投注单,填写金额并提交',
parlay_max_legs: '串关最多 5 项',
parlay_block_outright: '冠军盘不可串关',
@@ -316,7 +332,7 @@ const i18n = createI18n({
password_disabled: '当前账号不允许自行修改密码,请联系客服',
rules_title: '投注规则',
rules_p1: '本平台第一版仅支持足球赛前盘不含滚球、Cash Out、改单及系统串关。',
rules_p2: '串关为 2 串 1 至 5 串 1同场可多选;冠军盘、四分盘让球/大小不可进入串关。',
rules_p2: '串关为 2 串 1 至 5 串 1每场最多选 1 项;冠军盘、四分盘让球/大小不可进入串关。',
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
@@ -378,6 +394,7 @@ const i18n = createI18n({
stats_push: 'Push',
stats_stake: 'Total Stake',
stats_return: 'Total Return',
cashbacked: 'Cashbacked',
},
auth:
{ login: 'Login',
@@ -387,6 +404,21 @@ const i18n = createI18n({
captcha_placeholder: 'Captcha',
captcha_refresh: 'Click to refresh',
captcha_wrong: 'Invalid captcha',
login_required: 'Login Required',
login_hint: 'Log in to place bets and access more features',
go_login: 'Go to login',
continue_browsing: 'Continue browsing',
username_placeholder: 'Enter username',
password_placeholder: 'Enter password',
login_btn: 'Log In',
login_failed: 'Login failed, please try again',
},
support: {
short: 'Support',
title: 'Customer Support',
open: 'Open customer support',
close: 'Close',
url_pending: 'Support URL is not configured yet.',
},
wallet: {
balance: 'Balance',
@@ -493,7 +525,7 @@ const i18n = createI18n({
parlay_guide_help: 'Parlay help',
parlay_desc: 'Combine 25 pre-match legs (2-fold to 5-fold). No live, outright, or quarter-ball HDP/O-U in parlay.',
parlay_guide_1: 'Tap odds in the list; selected cells show a gold border. Tap again to remove',
parlay_guide_2: 'Pick 25 legs (same match allowed). No outright or quarter-ball HDP/O-U',
parlay_guide_2: 'Pick 25 legs from different matches. No outright or quarter-ball HDP/O-U',
parlay_guide_3: 'Tap Confirm order at the bottom, enter stake in the bet slip, and submit',
parlay_max_legs: 'Parlay allows up to 5 legs',
parlay_block_outright: 'Outright cannot be parlayed',
@@ -628,7 +660,7 @@ const i18n = createI18n({
password_disabled: 'Password change is disabled for this account; contact support',
rules_title: 'Betting Rules',
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
rules_p2: 'Parlays: 25 legs, same-match multi-select allowed. Outright and quarter-ball HDP/O-U are excluded.',
rules_p2: 'Parlays: 25 legs from different matches (one per match). Outright and quarter-ball HDP/O-U are excluded.',
rules_p3: 'Results use admin-entered half-time and full-time scores; payouts apply after settlement preview is confirmed.',
rules_p4: 'If this text conflicts with site notices, the latest notice and market rules prevail.',
rules_p5: 'How to bet: open any match, tap the ? icon on the top right.',
@@ -696,6 +728,7 @@ const i18n = createI18n({
stats_push: 'Seri',
stats_stake: 'Jumlah Taruhan',
stats_return: 'Jumlah Pulangan',
cashbacked: 'Rebat dibayar',
},
auth: {
login: 'Log Masuk',
@@ -705,6 +738,21 @@ const i18n = createI18n({
captcha_placeholder: 'Captcha',
captcha_refresh: 'Klik untuk muat semula',
captcha_wrong: 'Kod pengesahan salah',
login_required: 'Sila Log Masuk',
login_hint: 'Log masuk untuk bertaruh dan akses lebih banyak ciri',
go_login: 'Pergi log masuk',
continue_browsing: 'Teruskan melayari',
username_placeholder: 'Masukkan nama pengguna',
password_placeholder: 'Masukkan kata laluan',
login_btn: 'Log Masuk',
login_failed: 'Log masuk gagal, sila cuba lagi',
},
support: {
short: 'Sokongan',
title: 'Khidmat Pelanggan',
open: 'Buka khidmat pelanggan',
close: 'Tutup',
url_pending: 'Pautan khidmat pelanggan belum dikonfigurasi.',
},
wallet: {
balance: 'Baki',
@@ -811,7 +859,7 @@ const i18n = createI18n({
parlay_guide_help: 'Bantuan parlay',
parlay_desc: 'Gabung 25 perlawanan pra-perlawanan (2 hingga 5 liputan). Tiada live, outright atau suku bola HDP/O-U.',
parlay_guide_1: 'Ketik odds dalam senarai; pilihan dipilih ada sempadan emas. Ketik lagi untuk batal',
parlay_guide_2: 'Pilih 25 pilihan (boleh perlawanan sama). Tiada outright atau suku bola HDP/O-U',
parlay_guide_2: 'Pilih 25 pilihan dari perlawanan berbeza. Tiada outright atau suku bola HDP/O-U',
parlay_guide_3: 'Ketik Sahkan pesanan di bawah, isi pegangan dalam slip, dan hantar',
parlay_max_legs: 'Maksimum 5 pilihan parlay',
parlay_block_outright: 'Outright tidak boleh parlay',
@@ -946,7 +994,7 @@ const i18n = createI18n({
password_disabled: 'Akaun ini tidak dibenarkan tukar kata laluan; hubungi sokongan',
rules_title: 'Peraturan Pertaruhan',
rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.',
rules_p2: 'Parlay 25 pilihan, boleh pilih berbilang dari perlawanan sama. Outright dan suku bola HDP/O-U tidak boleh parlay.',
rules_p2: 'Parlay 25 pilihan, satu pilihan setiap perlawanan. Outright dan suku bola HDP/O-U tidak boleh parlay.',
rules_p3: 'Keputusan berdasarkan skor separuh masa/penuh yang dimasukkan admin; bayaran selepas pratonton disahkan.',
rules_p4: 'Jika bercanggah dengan notis laman, ikut notis terkini dan peraturan pasaran.',
rules_p5: 'Langkah operasi: buka butiran perlawanan, ketik ikon ? di atas kanan.',

View File

@@ -8,21 +8,22 @@ const router = createRouter({
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
// 公开页面 — 无需登录即可浏览
{ path: '', component: () => import('../views/HomeView.vue'), meta: { keepAlive: true } },
{ path: 'bet', component: () => import('../views/FootballView.vue'), meta: { keepAlive: true } },
{ path: 'football', redirect: '/bet' },
{ path: 'match/:id', component: () => import('../views/MatchDetailView.vue') },
{ path: 'bets', component: () => import('../views/MyBetsView.vue'), meta: { keepAlive: true } },
{ path: 'bets/:betNo', component: () => import('../views/BetDetailView.vue') },
{ path: 'wallet', component: () => import('../views/WalletView.vue'), meta: { keepAlive: true } },
{ path: 'wallet/detail', component: () => import('../views/WalletDetailView.vue') },
{ path: 'wallet/cashbacks', component: () => import('../views/CashbackRecordsView.vue') },
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue') },
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true } },
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue') },
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue') },
// 需要登录的页面
{ path: 'bets', component: () => import('../views/MyBetsView.vue'), meta: { keepAlive: true, requiresAuth: true } },
{ path: 'bets/:betNo', component: () => import('../views/BetDetailView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet', component: () => import('../views/WalletView.vue'), meta: { keepAlive: true, requiresAuth: true } },
{ path: 'wallet/detail', component: () => import('../views/WalletDetailView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue'), meta: { requiresAuth: true } },
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true, requiresAuth: true } },
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue'), meta: { requiresAuth: true } },
],
},
],
@@ -30,8 +31,12 @@ const router = createRouter({
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.requiresAuth && !auth.token) return '/login';
if (to.path === '/login' && auth.token) return '/';
// 需要登录的页面 — 未登录时弹出登录提示,留在当前页
if (to.meta.requiresAuth && !auth.token) {
auth.showLoginPrompt(to.fullPath);
return false;
}
});
export default router;

View File

@@ -8,12 +8,28 @@ export const useAuthStore = defineStore('auth', () => {
JSON.parse(localStorage.getItem('user') || 'null'),
);
const loginPromptVisible = ref(false);
const loginReturnTo = ref('');
function showLoginPrompt(returnTo?: string) {
loginReturnTo.value = returnTo || '';
loginPromptVisible.value = true;
}
function hideLoginPrompt() {
loginPromptVisible.value = false;
}
async function login(username: string, password: string) {
const { data } = await api.post('/player/auth/login', { username, password });
token.value = data.data.token;
user.value = data.data.user;
localStorage.setItem('token', token.value);
localStorage.setItem('user', JSON.stringify(user.value));
const returnTo = loginReturnTo.value;
loginReturnTo.value = '';
loginPromptVisible.value = false;
return returnTo;
}
function logout() {
@@ -23,5 +39,9 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem('user');
}
return { token, user, login, logout };
return {
token, user, login, logout,
loginPromptVisible, loginReturnTo,
showLoginPrompt, hideLoginPrompt,
};
});

View File

@@ -50,7 +50,7 @@ export const useBetSlipStore = defineStore('betSlip', () => {
items.value.push(item);
}
/** 串关页:同场/跨场均可多选,合成一张串关单 */
/** 串关页:须为不同赛事,每场最多 1 项 */
function addParlayLeg(item: SlipItem): ParlayRejectReason | 'MAX_LEGS' | null {
if (mode.value === 'single') items.value = [];
mode.value = 'parlay';
@@ -62,6 +62,11 @@ export const useBetSlipStore = defineStore('betSlip', () => {
return null;
}
if (items.value.some((i) => i.matchId === item.matchId)) {
lastParlayError.value = 'SAME_MATCH';
return 'SAME_MATCH';
}
const check = canSelectForParlay({
marketType: item.marketType,
lineValue: parseLineValue(item.lineValue),
@@ -115,7 +120,8 @@ export const useBetSlipStore = defineStore('betSlip', () => {
() =>
mode.value === 'parlay' &&
items.value.length >= PARLAY_MIN_LEGS &&
items.value.length <= PARLAY_MAX_LEGS,
items.value.length <= PARLAY_MAX_LEGS &&
!hasSameMatch.value,
);
/** 详情页等同场多笔单关(串关模式不走此路径) */

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
@@ -12,6 +12,7 @@ const { t } = useI18n();
const { initFromUser } = useAppLocale();
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const captchaRef = ref<InstanceType<typeof RobotVerify> | null>(null);
const username = ref('');
const password = ref('');
@@ -29,13 +30,19 @@ async function submit() {
try {
await auth.login(username.value, password.value);
initFromUser(auth.user?.locale);
router.push('/');
const redirectTo = (route.query.redirect as string) || '/';
router.push(redirectTo);
} catch (e: unknown) {
error.value = (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Login failed';
} finally {
loading.value = false;
}
}
function continueBrowsing() {
const redirect = (route.query.redirect as string) || '/';
router.push(redirect);
}
</script>
<template>
@@ -53,6 +60,9 @@ async function submit() {
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
{{ t('auth.login') }}
</button>
<button type="button" class="btn-skip" @click="continueBrowsing">
{{ t('auth.continue_browsing') }}
</button>
</form>
</div>
</template>
@@ -111,6 +121,21 @@ label {
cursor: not-allowed;
}
.btn-skip {
margin-top: 2px;
padding: 8px 14px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.55);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-skip:active {
color: rgba(255, 255, 255, 0.75);
}
.error {
color: var(--danger);
font-size: 13px;

View File

@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { useBetSlipStore } from '../stores/betSlip';
import { useAuthStore } from '../stores/auth';
import TeamEmblem from '../components/TeamEmblem.vue';
import { DETAIL_MARKET_TYPES, MARKET_I18N_KEY } from '../utils/marketCatalog';
import MatchBetGuide from '../components/match-detail/MatchBetGuide.vue';
@@ -26,6 +27,11 @@ const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const slip = useBetSlipStore();
const auth = useAuthStore();
function goLogin() {
auth.showLoginPrompt(route.fullPath);
}
interface Market {
id: string;
@@ -96,7 +102,7 @@ const myBets = ref<MyBet[]>([]);
const loadingMyBets = ref(false);
async function loadMyBets() {
if (!match.value) return;
if (!match.value || !auth.token) return;
loadingMyBets.value = true;
try {
const { data } = await api.get('/player/bets?page=1');
@@ -241,6 +247,10 @@ async function confirmCorrectScoreBets() {
async function placeCorrectScoreBets(marketType: string) {
if (!bettingOpen.value) return;
if (!auth.token) {
goLogin();
return;
}
const market = marketsByType.value.get(marketType);
if (!market || !match.value) return;
const entries = market.selections.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0);
@@ -325,6 +335,10 @@ function onPickSelection(selId: string, marketType: string) {
}
function openBetSlipDrawer() {
if (!auth.token) {
goLogin();
return;
}
slip.openDrawer();
}

View File

@@ -80,7 +80,7 @@ async function changeLocale(code: string) {
function logout() {
auth.logout();
router.push('/login');
router.push('/');
}
</script>