feat: 前台匿名浏览、登录引导、客服入口与返水增强
前台: - 未登录可浏览首页/赛事/赔率,下注等操作弹出登录引导(去登录/继续浏览) - 顶部新增客服入口与 iframe 弹窗 - 登录页支持暂不登录返回浏览 API: - 首页/赛事/冠军盘接口改为公开访问,支持 X-Locale 头 - JWT 守卫支持可选认证 返水: - 注单新增 is_cashbacked 字段,发放时自动标记 - 预览展示玩家余额,明确平台直发不从代理扣款 - 后台注单列表与玩家历史展示回水状态 其他: - 串关禁止同场重复选号(SAME_MATCH) - 补充结算资金流分析文档 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import LoginPromptModal from './components/LoginPromptModal.vue';
|
||||
</script>
|
||||
<template>
|
||||
<RouterView />
|
||||
<LoginPromptModal />
|
||||
</template>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
145
apps/player/src/components/CustomerServiceModal.vue
Normal file
145
apps/player/src/components/CustomerServiceModal.vue
Normal 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>
|
||||
154
apps/player/src/components/LoginPromptModal.vue
Normal file
154
apps/player/src/components/LoginPromptModal.vue
Normal 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>
|
||||
@@ -48,7 +48,7 @@ function goEdit() {
|
||||
function logout() {
|
||||
close();
|
||||
auth.logout();
|
||||
router.push('/login');
|
||||
router.push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
3
apps/player/src/config/customerService.ts
Normal file
3
apps/player/src/config/customerService.ts
Normal 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() || '';
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '选择 2–5 场赛前赛事组合串关(2 串 1 至 5 串 1)。赔率相乘,不含滚球、冠军盘与四分盘让球/大小。',
|
||||
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
|
||||
parlay_guide_2: '须选 2–5 项(可同场多项);冠军盘与四分盘让球/大小不可选',
|
||||
parlay_guide_2: '须选 2–5 项,且须为不同赛事;冠军盘与四分盘让球/大小不可选',
|
||||
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 2–5 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 2–5 legs (same match allowed). No outright or quarter-ball HDP/O-U',
|
||||
parlay_guide_2: 'Pick 2–5 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: 2–5 legs, same-match multi-select allowed. Outright and quarter-ball HDP/O-U are excluded.',
|
||||
rules_p2: 'Parlays: 2–5 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 2–5 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 2–5 pilihan (boleh perlawanan sama). Tiada outright atau suku bola HDP/O-U',
|
||||
parlay_guide_2: 'Pilih 2–5 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 2–5 pilihan, boleh pilih berbilang dari perlawanan sama. Outright dan suku bola HDP/O-U tidak boleh parlay.',
|
||||
rules_p2: 'Parlay 2–5 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.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
/** 详情页等同场多笔单关(串关模式不走此路径) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ async function changeLocale(code: string) {
|
||||
|
||||
function logout() {
|
||||
auth.logout();
|
||||
router.push('/login');
|
||||
router.push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user