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

@@ -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();
}