feat: 前台匿名浏览、登录引导、客服入口与返水增强
前台: - 未登录可浏览首页/赛事/赔率,下注等操作弹出登录引导(去登录/继续浏览) - 顶部新增客服入口与 iframe 弹窗 - 登录页支持暂不登录返回浏览 API: - 首页/赛事/冠军盘接口改为公开访问,支持 X-Locale 头 - JWT 守卫支持可选认证 返水: - 注单新增 is_cashbacked 字段,发放时自动标记 - 预览展示玩家余额,明确平台直发不从代理扣款 - 后台注单列表与玩家历史展示回水状态 其他: - 串关禁止同场重复选号(SAME_MATCH) - 补充结算资金流分析文档 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user