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

@@ -288,6 +288,7 @@ export const adminPagesMs: Record<string, string> = {
'bet.col.odds': 'Odds',
'bet.col.payout': 'Bayaran',
'bet.col.placed_at': 'Masa pertaruhan',
'bet.col.cashbacked': 'Rebat dibayar',
'bet.dialog.detail': 'Butiran pertaruhan',
'bet.field.total_odds': 'Jumlah odds',
'bet.field.currency': 'Mata wang',
@@ -358,20 +359,22 @@ export const adminPagesMs: Record<string, string> = {
'cashback.col.index': '#',
'cashback.col.player': 'Pemain',
'cashback.col.agent': 'Ejen',
'cashback.col.balance': 'Baki semasa',
'cashback.col.effective_stake': 'Stake berkesan',
'cashback.col.rate': 'Kadar',
'cashback.col.amount': 'Rebat',
'cashback.confirm_issue': 'Sahkan bayaran',
'cashback.cancel_issue': 'Batalkan',
'cashback.confirm_prompt': 'Bayar rebat kelompok ini ke dompet pemain? Tindakan ini tidak boleh dibatalkan.',
'cashback.confirm_prompt': 'Bayar rebat kelompok ini ke dompet pemain? Rebat dikreditkan terus oleh platform dan tidak ditolak daripada ejen. Tindakan ini tidak boleh dibatalkan.',
'cashback.cancel_prompt': 'Batalkan kelompok menunggu ini? Tiada kredit dompet; boleh pratonton semula.',
'cashback.status.CANCELLED': 'Dibatalkan',
'cashback.rules_title': 'Peraturan rebat',
'cashback.rule_period': 'Pilih julat tarikh. Taruhan dikira mengikut masa penyelesaian dalam tempoh tersebut.',
'cashback.rule_eligible': 'Termasuk: taruhan selesai WON/LOST (tunggal ikut stake; parlay sekali ikut stake parlay). Tidak termasuk: belum selesai, dibatalkan, batal, push, dan kadar 0.',
'cashback.rule_eligible': 'Termasuk: taruhan selesai WON/LOST (tunggal ikut stake; parlay sekali ikut stake parlay). Tidak termasuk: belum selesai, dibatalkan, batal, push, kadar 0, dan taruhan yang sudah dibayar rebat.',
'cashback.rule_formula': 'Setiap taruhan: stake × kadar rebat. Jumlah diagregat mengikut pemain.',
'cashback.rule_rate': 'Keutamaan kadar: pemain > ejen > global > kadar lalai ejen (cth. 0.01 = 1%).',
'cashback.rule_flow': 'Aliran: pratonton (satu menunggu setiap tempoh) → semak → sahkan bayaran; batalkan jika tidak perlu. Tempoh dibayar tidak boleh pratonton semula.',
'cashback.rule_platform': 'Bayaran: rebat dikreditkan ke baki tunai pemain oleh platform; tidak ditolak daripada kredit atau baki ejen.',
'cashback.rule_note_zero': 'Jika 0, semak taruhan WON/LOST dalam tempoh dan kadar rebat > 0.',
'user.field.player_id': 'ID pemain',

View File

@@ -303,6 +303,7 @@ export const adminPagesZh: Record<string, string> = {
'bet.col.odds': '赔率',
'bet.col.payout': '派彩',
'bet.col.placed_at': '投注时间',
'bet.col.cashbacked': '已回水',
'bet.dialog.detail': '注单详情',
'bet.field.total_odds': '总赔率',
'bet.field.currency': '币种',
@@ -373,20 +374,22 @@ export const adminPagesZh: Record<string, string> = {
'cashback.col.index': '#',
'cashback.col.player': '玩家',
'cashback.col.agent': '所属代理',
'cashback.col.balance': '当前余额',
'cashback.col.effective_stake': '有效投注',
'cashback.col.rate': '返水比例',
'cashback.col.amount': '返水金额',
'cashback.confirm_issue': '确认发放',
'cashback.cancel_issue': '作废',
'cashback.confirm_prompt': '确认向玩家钱包发放本批次返水?此操作不可撤销。',
'cashback.confirm_prompt': '确认向玩家钱包发放本批次返水?返水由平台直接入账,不从代理扣款。此操作不可撤销。',
'cashback.cancel_prompt': '确认作废该待发放批次?作废后不会入账,可重新生成预览。',
'cashback.status.CANCELLED': '已作废',
'cashback.rules_title': '返水规则说明',
'cashback.rule_period': '选择开始/结束日期,统计该周期内、按注单结算时间落在区间内的有效投注。',
'cashback.rule_eligible': '计入:已结算且结果为「赢」或「输」的注单(单关按本金,串关按整单本金计一次)。不计入:未结算、已取消、作废、走水,以及返水比例为 0 的注单。',
'cashback.rule_eligible': '计入:已结算且结果为「赢」或「输」的注单(单关按本金,串关按整单本金计一次)。不计入:未结算、已取消、作废、走水,以及返水比例为 0 的注单;已返水过的注单不会重复计入。',
'cashback.rule_formula': '单笔返水 = 投注本金 × 适用返水比例;同一玩家多笔注单汇总后生成一条返水明细。',
'cashback.rule_rate': '返水比例优先级:玩家专属规则 > 代理线规则 > 全局规则 > 所属代理默认返水率(在代理/玩家管理中配置,如 0.01 表示 1%)。',
'cashback.rule_flow': '操作流程:生成预览(同周期仅保留一条待发放)→ 核对明细 → 确认发放;不需要的可作废。已发放周期不可重复预览。',
'cashback.rule_platform': '发放方式:返水由平台直接打入玩家现金余额,不从代理信用或余额中扣除。',
'cashback.rule_note_zero': '预览为 0 时,请检查:周期内是否有已结算输赢注单、代理/玩家是否配置了大于 0 的返水率。',
'user.field.player_id': '玩家 ID',
@@ -1174,6 +1177,7 @@ export const adminPagesEn: Record<string, string> = {
'bet.col.odds': 'Odds',
'bet.col.payout': 'Payout',
'bet.col.placed_at': 'Placed at',
'bet.col.cashbacked': 'Cashbacked',
'bet.dialog.detail': 'Bet details',
'bet.field.total_odds': 'Total odds',
'bet.field.currency': 'Currency',
@@ -1244,20 +1248,22 @@ export const adminPagesEn: Record<string, string> = {
'cashback.col.index': '#',
'cashback.col.player': 'Player',
'cashback.col.agent': 'Agent',
'cashback.col.balance': 'Balance',
'cashback.col.effective_stake': 'Effective stake',
'cashback.col.rate': 'Rate',
'cashback.col.amount': 'Cashback',
'cashback.confirm_issue': 'Confirm payout',
'cashback.cancel_issue': 'Void',
'cashback.confirm_prompt': 'Pay out this cashback batch to player wallets? This cannot be undone.',
'cashback.confirm_prompt': 'Pay out this cashback batch to player wallets? Cashback is credited by the platform directly and is not deducted from agents. This cannot be undone.',
'cashback.cancel_prompt': 'Void this pending batch? No wallet credit will be made; you can preview again.',
'cashback.status.CANCELLED': 'Voided',
'cashback.rules_title': 'Cashback rules',
'cashback.rule_period': 'Pick a date range. Bets are included by settlement time within that period.',
'cashback.rule_eligible': 'Included: settled bets with result WON or LOST (singles by stake; parlays counted once by parlay stake). Excluded: pending, cancelled, void, push, and zero-rate bets.',
'cashback.rule_eligible': 'Included: settled bets with result WON or LOST (singles by stake; parlays counted once by parlay stake). Excluded: pending, cancelled, void, push, zero-rate bets, and bets already paid cashback.',
'cashback.rule_formula': 'Per bet: stake × applicable cashback rate. Amounts are summed per player into one line item.',
'cashback.rule_rate': 'Rate priority: player rule > agent rule > global rule > agent default rate (set under Agents/Players, e.g. 0.01 = 1%).',
'cashback.rule_flow': 'Flow: preview (one pending batch per period) → review → confirm payout; void if not needed. Paid periods cannot be previewed again.',
'cashback.rule_platform': 'Payout: cashback is credited to player cash balance by the platform; it is not deducted from agent credit or balance.',
'cashback.rule_note_zero': 'If preview is 0, check for settled WON/LOST bets in the period and a cashback rate above 0.',
'user.field.player_id': 'Player ID',

View File

@@ -229,6 +229,12 @@ async function openDetail(row: BetListRow) {
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.cashbacked')" width="88" align="center">
<template #default="{ row }">
<el-tag v-if="row.isCashbacked" type="success" size="small" effect="plain"></el-tag>
<span v-else class="bet-content-empty"></span>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.placed_at')" min-width="168">
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
</el-table-column>
@@ -284,6 +290,9 @@ async function openDetail(row: BetListRow) {
</el-descriptions-item>
<el-descriptions-item :label="t('bet.col.placed_at')">{{ formatTime(detail.placedAt) }}</el-descriptions-item>
<el-descriptions-item :label="t('bet.field.settled_at')">{{ formatTime(detail.settledAt) }}</el-descriptions-item>
<el-descriptions-item :label="t('bet.col.cashbacked')">
{{ detail.isCashbacked ? t('common.yes') : t('common.no') }}
</el-descriptions-item>
<el-descriptions-item :label="t('bet.field.request_id')" :span="2">{{ detail.requestId }}</el-descriptions-item>
</el-descriptions>

View File

@@ -27,6 +27,7 @@ interface CashbackPreviewItem {
userId: string;
username: string;
agentUsername: string | null;
availableBalance: string;
effectiveStake: string;
betCount: number;
rate: string;
@@ -288,6 +289,7 @@ onMounted(loadHistory);
<li>{{ t('cashback.rule_formula') }}</li>
<li>{{ t('cashback.rule_rate') }}</li>
<li>{{ t('cashback.rule_flow') }}</li>
<li>{{ t('cashback.rule_platform') }}</li>
<li class="rules-note">{{ t('cashback.rule_note_zero') }}</li>
</ul>
</el-dialog>
@@ -374,6 +376,18 @@ onMounted(loadHistory);
>
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
</el-table-column>
<el-table-column
prop="availableBalance"
:label="t('cashback.col.balance')"
min-width="110"
align="right"
>
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.availableBalance)" placement="top">
<span>{{ formatAmount(row.availableBalance) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column
prop="betCount"
:label="t('cashback.col.bet_count')"
@@ -547,6 +561,9 @@ onMounted(loadHistory);
<el-table-column prop="agentUsername" :label="t('cashback.col.agent')" min-width="100">
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
</el-table-column>
<el-table-column prop="availableBalance" :label="t('cashback.col.balance')" min-width="100" align="right">
<template #default="{ row }">{{ formatAmount(row.availableBalance) }}</template>
</el-table-column>
<el-table-column prop="betCount" :label="t('cashback.col.bet_count')" width="88" align="right" />
<el-table-column prop="effectiveStake" :label="t('cashback.col.effective_stake')" min-width="110" align="right">
<template #default="{ row }">{{ formatAmount(row.effectiveStake) }}</template>

View File

@@ -25,6 +25,7 @@ export interface BetListRow {
currency: string;
placedAt: string;
settledAt: string | null;
isCashbacked: boolean;
selectionCount: number;
selectionSummary: string;
selectionPreviews: BetSelectionPreview[];

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "bets" ADD COLUMN "is_cashbacked" BOOLEAN NOT NULL DEFAULT false;
-- Backfill: mark bets already paid out via confirmed cashback batches
UPDATE "bets" b
SET "is_cashbacked" = true
FROM "cashback_bets" cb
INNER JOIN "cashback_batches" batch ON batch.id = cb.batch_id
WHERE cb.bet_id = b.id AND batch.status = 'CONFIRMED';

View File

@@ -396,6 +396,7 @@ model Bet {
requestId String @map("request_id") @db.VarChar(128)
placedAt DateTime @default(now()) @map("placed_at")
settledAt DateTime? @map("settled_at")
isCashbacked Boolean @default(false) @map("is_cashbacked")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -6,11 +6,12 @@ import {
Body,
Param,
Query,
Headers,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, PlayerGuard } from '../../domains/identity/guards';
import { CurrentUser } from '../../shared/common/decorators';
import { CurrentUser, Public } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { UsersService } from '../../domains/identity/users.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
@@ -144,8 +145,13 @@ export class PlayerController {
return jsonResponse(await this.formatPlayerProfile(user));
}
@Public()
@Get('home')
async home(@CurrentUser('locale') locale: string) {
async home(
@CurrentUser('locale') userLocale: string | undefined,
@Headers('x-locale') headerLocale?: string,
) {
const locale = userLocale || headerLocale || 'zh-CN';
const [banners, announcements, hotMatches, todayMatches] = await Promise.all([
this.content.listActive('BANNER', locale),
this.content.listActiveAnnouncements(locale),
@@ -164,23 +170,37 @@ export class PlayerController {
});
}
@Public()
@Get('matches')
async listMatches(
@CurrentUser('locale') locale: string,
@CurrentUser('locale') userLocale: string | undefined,
@Headers('x-locale') headerLocale: string | undefined,
@Query('leagueId') leagueId?: string,
) {
const locale = userLocale || headerLocale || 'zh-CN';
const items = await this.matches.listPublished(locale, leagueId ? BigInt(leagueId) : undefined);
return jsonResponse(items);
}
@Public()
@Get('outrights')
async listOutrights(@CurrentUser('locale') locale: string) {
async listOutrights(
@CurrentUser('locale') userLocale: string | undefined,
@Headers('x-locale') headerLocale: string | undefined,
) {
const locale = userLocale || headerLocale || 'zh-CN';
const items = await this.outright.listForPlayer(locale);
return jsonResponse(items);
}
@Public()
@Get('matches/:id')
async matchDetail(@Param('id') id: string, @CurrentUser('locale') locale: string) {
async matchDetail(
@Param('id') id: string,
@CurrentUser('locale') userLocale: string | undefined,
@Headers('x-locale') headerLocale: string | undefined,
) {
const locale = userLocale || headerLocale || 'zh-CN';
const match = await this.matches.getMatchDetail(BigInt(id), locale);
return jsonResponse(match);
}

View File

@@ -10,6 +10,7 @@ import {
PARLAY_MIN_LEGS,
PARLAY_MAX_LEGS,
canSelectForParlay,
hasDuplicateParlayMatch,
isPreMatchKickoff,
isSupportedSport,
resolveTranslationFallback,
@@ -180,6 +181,11 @@ export class BetsService {
selections.push(sel);
}
const matchIds = selections.map((s) => s.market.matchId);
if (hasDuplicateParlayMatch(matchIds)) {
throw appBadRequest('PARLAY_SAME_MATCH_FORBIDDEN');
}
let totalOdds = new Decimal(1);
for (const sel of selections) {
totalOdds = totalOdds.mul(sel.odds.toString());
@@ -403,6 +409,7 @@ export class BetsService {
currency: string;
placedAt: Date;
settledAt: Date | null;
isCashbacked?: boolean;
user: { id: bigint; username: string; parent: { username: string } | null };
_count: { selections: number };
},
@@ -424,6 +431,7 @@ export class BetsService {
currency: b.currency,
placedAt: b.placedAt,
settledAt: b.settledAt,
isCashbacked: b.isCashbacked ?? false,
selectionCount: b._count.selections,
};
}

View File

@@ -1413,6 +1413,7 @@ export class MatchesService {
actualReturn: unknown;
status: string;
placedAt: Date;
isCashbacked?: boolean;
selections: Array<{
matchId: bigint | null;
marketType: string;
@@ -1505,6 +1506,7 @@ export class MatchesService {
actualReturn: bet.actualReturn,
status: bet.status,
placedAt: bet.placedAt,
isCashbacked: bet.isCashbacked ?? false,
leagueName: isParlay
? 'Parlay'
: meta?.leagueName ?? legs[0]?.leagueName ?? '',

View File

@@ -68,7 +68,14 @@ export function UserTypeGuard(...types: string[]) {
@Injectable()
export class PlayerGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const { user } = context.switchToHttp().getRequest();
if (user?.userType !== 'PLAYER') throw appForbidden('PLAYER_ACCESS_ONLY');
return true;

View File

@@ -18,6 +18,7 @@ type AggregatedItem = {
amount: Decimal;
username: string;
agentUsername: string | null;
availableBalance: Decimal;
};
type BetCashbackLine = {
@@ -158,12 +159,25 @@ export class CashbackService {
: [];
const userById = new Map(users.map((u) => [u.id.toString(), u]));
const wallets =
userIds.length > 0
? await this.prisma.wallet.findMany({
where: { userId: { in: userIds } },
select: { userId: true, availableBalance: true },
})
: [];
const balanceByUserId = new Map(
wallets.map((w) => [w.userId.toString(), w.availableBalance]),
);
const items: AggregatedItem[] = rawItems.map((item) => {
const user = userById.get(item.userId.toString());
return {
...item,
username: user?.username ?? '',
agentUsername: user?.parent?.username ?? null,
availableBalance:
balanceByUserId.get(item.userId.toString()) ?? new Decimal(0),
};
});
@@ -362,6 +376,17 @@ export class CashbackService {
: [];
const userById = new Map(users.map((u) => [u.id.toString(), u]));
const wallets =
userIds.length > 0
? await this.prisma.wallet.findMany({
where: { userId: { in: userIds } },
select: { userId: true, availableBalance: true },
})
: [];
const balanceByUserId = new Map(
wallets.map((w) => [w.userId.toString(), w.availableBalance]),
);
let operatorUsername: string | null = null;
if (batch.operatorId) {
const op = await this.prisma.user.findUnique({
@@ -378,6 +403,8 @@ export class CashbackService {
userId: item.userId,
username: user?.username ?? '',
agentUsername: user?.parent?.username ?? null,
availableBalance:
balanceByUserId.get(item.userId.toString()) ?? new Decimal(0),
effectiveStake: item.effectiveStake,
betCount: item.betCount,
rate: item.rate,
@@ -448,6 +475,13 @@ export class CashbackService {
}
}
if (betIds.length > 0) {
await this.prisma.bet.updateMany({
where: { id: { in: betIds } },
data: { isCashbacked: true },
});
}
await this.prisma.cashbackBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },

View File

@@ -1,6 +1,7 @@
import { Decimal } from '@prisma/client/runtime/library';
import {
canSelectForParlay,
hasDuplicateParlayMatch,
isQuarterHandicapOrTotal,
PARLAY_MAX_LEGS,
PARLAY_MIN_LEGS,
@@ -456,6 +457,18 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
expectTrue('1 leg blocked', 1 < PARLAY_MIN_LEGS);
},
},
{
id: 'B007',
suite: 'betting',
name: '同场串关应拒绝',
uatRef: 'B007',
run: () => {
expectTrue('same match blocked', hasDuplicateParlayMatch(['1', '1']), { matchIds: ['1', '1'] });
expectFalse('different matches ok', hasDuplicateParlayMatch(['1', '2', '3']), {
matchIds: ['1', '2', '3'],
});
},
},
{
id: 'B008',
suite: 'betting',

View File

@@ -3,6 +3,7 @@ import {
calculatePayout,
isQuarterHandicapOrTotal,
} from './domains/settlement/domain/settlement-calculator';
import { hasDuplicateParlayMatch } from '@thebet365/shared';
/**
* Agent credit & wallet integration scenarios (A001-A007)
@@ -82,13 +83,9 @@ describe('Bet Validation Rules (B001-B010)', () => {
expect(submitted === current).toBe(false);
});
it('B007: same match legs allowed in parlay (25 legs)', () => {
const legs = [
{ matchId: '1', selectionId: 'a' },
{ matchId: '1', selectionId: 'b' },
];
expect(legs.length).toBeGreaterThanOrEqual(2);
expect(legs.length).toBeLessThanOrEqual(5);
it('B007: same match legs rejected in parlay', () => {
expect(hasDuplicateParlayMatch(['1', '1'])).toBe(true);
expect(hasDuplicateParlayMatch(['1', '2', '3'])).toBe(false);
});
it('B008: quarter line in parlay rejected', () => {

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>