feat(player): theme-3 移动端/PC 双端拆分与桌面投注工作台

新增 DesktopShell 及桌面端首页、赛事、钱包、记录等页面,原 H5 视图迁移至 Mobile* 并由路由 wrapper 按视口切换。补齐右侧投注单栏、赔率快捷浮层、联赛/赛事侧栏、串关与定位能力;桌面下注成功改为全局 Toast,修复侧栏成功遮罩被裁剪与底部汇总黑底。同步悬浮客服、公告卡片、三语 i18n、桌面样式体系,以及 API 赛事与 shared 包相关调整。
This commit is contained in:
mars
2026-07-07 17:07:38 +08:00
parent fee34feee8
commit e7e2b77906
125 changed files with 26393 additions and 8687 deletions

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const route = useRoute();
const isWalletSection = computed(
() => route.path.startsWith('/wallet') || route.path === '/profile/cashbacks',
);
const hubTitleKey = computed(() => (isWalletSection.value ? 'nav.wallet' : 'nav.bet_history'));
const menuItems = computed(() => {
if (isWalletSection.value) {
return [
{
path: '/wallet',
labelKey: 'nav.wallet',
match: (p: string) =>
p === '/wallet' ||
p === '/wallet/detail' ||
p === '/wallet/recharge' ||
p.startsWith('/wallet/transactions'),
},
{
path: '/wallet/recharge/history',
labelKey: 'wallet.recharge_history',
match: (p: string) => p.startsWith('/wallet/recharge/history'),
},
{
path: '/wallet/cashbacks',
labelKey: 'wallet.cashbacks_tab',
match: (p: string) => p === '/wallet/cashbacks' || p === '/profile/cashbacks',
},
];
}
return [
{
path: '/bets',
labelKey: 'nav.bet_history',
match: (p: string) => p === '/bets' || p.startsWith('/bets/'),
},
];
});
</script>
<template>
<nav class="account-side-nav">
<div class="hub-nav-title">{{ t(hubTitleKey) }}</div>
<RouterLink
v-for="item in menuItems"
:key="item.path"
:to="item.path"
class="nav-item hover-bg"
:class="{ active: item.match(route.path) }"
>
<span class="dot"></span>
<span class="label">{{ t(item.labelKey) || item.path }}</span>
</RouterLink>
</nav>
</template>
<style scoped>
.account-side-nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
}
.hub-nav-title {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 6px 14px 10px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 4px;
color: var(--text-muted);
font-size: 13px;
font-weight: 700;
transition: all 0.2s;
position: relative;
}
.nav-item:hover {
color: #fff;
}
.nav-item.active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.08);
}
.nav-item.active .dot {
background: var(--primary);
box-shadow: 0 0 8px var(--primary);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--tertiary);
transition: all 0.2s;
}
.label {
line-height: 1;
}
</style>

View File

@@ -0,0 +1,2005 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter, RouterLink } from 'vue-router';
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
import { useBetSlipStore, type ParlaySlipError, type SlipItem, type SlipMode } from '../../stores/betSlip';
import { useAuthStore } from '../../stores/auth';
import { formatMoney, parseAmount } from '../../utils/localeDisplay';
import api from '../../api';
import { usePlayerProfile } from '../../composables/usePlayerProfile';
import ConfirmDialog from '../ConfirmDialog.vue';
import { useAppToast } from '../../composables/useAppToast';
import GoldSpinner from '../GoldSpinner.vue';
import { type BetHistoryItem } from '../BetHistoryCard.vue';
import { buildBetPlaceConfirmMessage } from '../../utils/betPlaceConfirmMessage';
import { useDesktopBetLocate } from '../../composables/useDesktopBetLocate';
type PanelTab = SlipMode | 'history';
const { t, locale } = useI18n();
const route = useRoute();
const router = useRouter();
const slip = useBetSlipStore();
const auth = useAuthStore();
const { refreshProfile } = usePlayerProfile();
const { requestLocate } = useDesktopBetLocate();
const { showToast } = useAppToast();
const activeTab = ref<PanelTab>('single');
const transitionName = ref('list-slide');
const historyScope = ref<'all' | 'match'>('all');
const historyItems = ref<BetHistoryItem[]>([]);
const historyTotal = ref(0);
const historyPage = ref(1);
const historyPageSize = 15;
const historyLoading = ref(false);
const historyInitialLoading = ref(false);
const loading = ref(false);
const balanceLoading = ref(false);
const balance = ref<number | null>(null);
const error = ref('');
const showPlaceConfirm = ref(false);
const placeConfirmMessage = ref('');
const pendingSingleItem = ref<SlipItem | null>(null);
const MIN_STAKE = 5;
const MAX_STAKE_INTEGER_LENGTH = 9;
const stakeInput = ref('');
const itemStakeInputs = ref<Record<string, string>>({});
const ODDS_POLL_MS = 5000;
type OddsDelta = {
oldOdds: number;
newOdds: number;
newVersion: string;
suspended: boolean;
};
interface SelectionOddsRow {
id: string;
odds: string;
oddsVersion: string;
status: string;
marketStatus: string;
marketShowOnPlayer: boolean;
matchStatus: string;
}
const oddsDeltas = ref<Record<string, OddsDelta>>({});
let oddsPollTimer: ReturnType<typeof setInterval> | null = null;
const currentMatchId = computed(() => {
const match = route.path.match(/^\/match\/([^/]+)/);
if (match) return match[1];
const outright = route.path.match(/^\/outright\/([^/]+)/);
return outright ? outright[1] : '';
});
const showMatchHistoryFilter = computed(() => Boolean(currentMatchId.value));
const singleSlipItems = computed<SlipItem[]>(() => {
if (slip.singleCartItems.length) return slip.singleCartItems;
return slip.singleItem ? [slip.singleItem] : [];
});
const activeItems = computed<SlipItem[]>(() => {
if (activeTab.value === 'history') return [];
if (activeTab.value === 'parlay') return slip.parlayItems;
return singleSlipItems.value;
});
const historyHasMore = computed(
() => historyItems.value.length < historyTotal.value,
);
const activeCount = computed(() => activeItems.value.length);
function effectiveOdds(item: SlipItem) {
return oddsDeltas.value[item.selectionId]?.newOdds ?? item.odds;
}
const activeTotalOdds = computed(() =>
activeItems.value.reduce((acc, item) => acc * effectiveOdds(item), 1),
);
const activeEstimatedReturn = computed(() => {
if (!activeItems.value.length) return 0;
if (activeTab.value === 'parlay') {
if (!Number.isFinite(slip.stake) || slip.stake <= 0) return 0;
return slip.stake * activeTotalOdds.value;
}
return activeItems.value.reduce((acc, item) => {
const s = slip.getItemStake(item.selectionId);
if (!Number.isFinite(s) || s <= 0) return acc;
return acc + s * effectiveOdds(item);
}, 0);
});
const activeTotalStake = computed(() => {
if (activeTab.value === 'parlay') return Number(slip.stake) || 0;
if (activeTab.value !== 'single') return 0;
return singleSlipItems.value.reduce((acc, item) => acc + slip.getItemStake(item.selectionId), 0);
});
const hasSuspendedSelections = computed(() =>
Object.values(oddsDeltas.value).some((delta) => delta.suspended),
);
const hasPendingOddsChanges = computed(() =>
Object.values(oddsDeltas.value).some((delta) => !delta.suspended),
);
const oddsWarningText = computed(() => {
if (hasSuspendedSelections.value) return t('bet.odds_suspended');
if (hasPendingOddsChanges.value) return t('bet.odds_changed');
return '';
});
const submitButtonLabel = computed(() => {
if (loading.value) return t('bet.placing');
if (hasPendingOddsChanges.value) return t('bet.accept_changes_place');
if (activeTab.value === 'single') return t('bet.slip_place_all');
return t('bet.place_bet_short');
});
const canSubmitWithOdds = computed(() => canSubmitActive.value && !hasSuspendedSelections.value);
const canSubmitActive = computed(() => {
if (activeTab.value === 'parlay') {
return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS;
}
if (activeTab.value !== 'single') return false;
return singleSlipItems.value.length > 0 && singleSlipItems.value.every((item) => item.allowSingle !== false);
});
const singleParlayOnlyHint = computed(
() =>
slip.mode === 'single' &&
Boolean(slip.singleItem) &&
slip.singleItem!.allowSingle === false &&
slip.singleItem!.allowParlay !== false,
);
const balanceText = computed(() => {
if (balanceLoading.value) return t('bet.loading');
if (balance.value == null) return '--';
return formatMoney(balance.value, locale.value);
});
const stakeText = computed(() => formatMoney(activeTotalStake.value, locale.value));
const estimatedReturnText = computed(() => formatMoney(activeEstimatedReturn.value, locale.value));
const totalOddsText = computed(() => activeTotalOdds.value.toFixed(4).replace(/0+$/, '').replace(/\.$/, ''));
const parlayWarning = computed(() => {
if (activeTab.value !== 'parlay') return '';
if (slip.lastParlayError) return parlayErrorMessage(slip.lastParlayError);
if (slip.parlayItems.length > 0 && slip.parlayItems.length < PARLAY_MIN_LEGS) {
return t('bet.parlay_need_more');
}
return '';
});
const singleInParlay = computed(() => {
const item = slip.singleItem;
return Boolean(item && slip.parlayItems.some((leg) => leg.selectionId === item.selectionId));
});
const showFooterParlayAction = computed(() => slip.mode === 'single' && Boolean(slip.singleItem));
function genId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function selectTab(tab: PanelTab) {
transitionName.value = '';
activeTab.value = tab;
error.value = '';
if (tab === 'history') {
if (!auth.token) {
auth.showLoginPrompt(route.fullPath);
activeTab.value = slip.mode;
nextTick(() => {
transitionName.value = 'list-slide';
});
return;
}
if (showMatchHistoryFilter.value) {
historyScope.value = 'match';
}
void loadHistory(true);
nextTick(() => {
transitionName.value = 'list-slide';
});
return;
}
slip.setMode(tab);
syncStakeInputFromSlip();
nextTick(() => {
transitionName.value = 'list-slide';
});
}
function selectHistoryScope(scope: 'all' | 'match') {
if (historyScope.value === scope) return;
historyScope.value = scope;
void loadHistory(true);
}
async function loadHistory(reset = false) {
if (!auth.token || activeTab.value !== 'history') return;
if (historyLoading.value) return;
if (reset) {
historyPage.value = 1;
historyInitialLoading.value = true;
}
historyLoading.value = true;
try {
const params: Record<string, unknown> = {
page: historyPage.value,
pageSize: historyPageSize,
};
if (historyScope.value === 'match' && currentMatchId.value) {
params.matchId = currentMatchId.value;
}
const { data } = await api.get('/player/bets', { params });
const result = data.data ?? { items: [], total: 0 };
const items = (result.items ?? []) as BetHistoryItem[];
historyTotal.value = result.total ?? 0;
historyItems.value = reset ? items : [...historyItems.value, ...items];
} catch {
if (reset) {
historyItems.value = [];
historyTotal.value = 0;
}
} finally {
historyLoading.value = false;
historyInitialLoading.value = false;
}
}
function loadMoreHistory() {
if (!historyHasMore.value || historyLoading.value) return;
historyPage.value += 1;
void loadHistory(false);
}
function historyStatusLabel(status: string) {
const s = status.toUpperCase();
if (s === 'WON' || s === 'WIN') return t('history.status_won');
if (s === 'LOST' || s === 'LOSE') return t('history.status_lost');
if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') return t('history.status_push');
return t('history.status_pending');
}
function historyStatusClass(status: string) {
const s = status.toUpperCase();
if (s === 'WON' || s === 'WIN') return 'hist-won';
if (s === 'LOST' || s === 'LOSE') return 'hist-lost';
if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') return 'hist-push';
return 'hist-pending';
}
function historyPickLabel(bet: BetHistoryItem) {
if (bet.betType === 'PARLAY' || bet.isParlay) {
const n = bet.legCount ?? bet.legs?.length ?? 0;
return n > 0 ? t('history.parlay_title', { n }) : t('history.parlay_league');
}
return bet.pickLabel || bet.matchTitle || '-';
}
function removeItem(id: string) {
slip.removeItem(id);
error.value = '';
}
function locateSlipItem(item: SlipItem) {
const isOutright = item.marketType === 'OUTRIGHT_WINNER';
const targetPath = isOutright ? `/outright/${item.matchId}` : `/match/${item.matchId}`;
requestLocate({
matchId: item.matchId,
selectionId: item.selectionId,
marketId: item.marketId,
marketType: item.marketType,
});
if (route.path !== targetPath) {
void router.push(targetPath);
}
}
function clearActiveSlip() {
error.value = '';
oddsDeltas.value = {};
if (activeTab.value === 'parlay') {
slip.clearParlay();
} else if (activeTab.value === 'single') {
slip.clearSingle();
}
syncStakeInputFromSlip();
}
function stakeAmountToInput(amount: number) {
if (!Number.isFinite(amount) || amount <= 0) return '';
const rounded = Math.round(amount * 100) / 100;
return Number.isInteger(rounded)
? String(rounded)
: rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}
function sanitizeStakeInput(raw: string) {
const onlyAmountChars = raw.replace(/[^\d.]/g, '');
const [integerRaw, ...fractionParts] = onlyAmountChars.split('.');
const integerPart = integerRaw.replace(/^0+(?=\d)/, '').slice(0, MAX_STAKE_INTEGER_LENGTH);
if (!fractionParts.length) return integerPart;
const fractionPart = fractionParts.join('').slice(0, 2);
return `${integerPart || '0'}.${fractionPart}`;
}
function commitStakeInput(raw: string) {
const clean = sanitizeStakeInput(raw);
stakeInput.value = clean;
const amount = Number.parseFloat(clean);
slip.stake = Number.isFinite(amount) ? amount : 0;
}
function syncItemStakeInputs(items: SlipItem[]) {
const next: Record<string, string> = {};
for (const item of items) {
slip.initItemStake(item.selectionId);
next[item.selectionId] = stakeAmountToInput(slip.getItemStake(item.selectionId));
}
itemStakeInputs.value = next;
}
function itemStakeInputValue(selectionId: string) {
if (!(selectionId in itemStakeInputs.value)) {
slip.initItemStake(selectionId);
itemStakeInputs.value = {
...itemStakeInputs.value,
[selectionId]: stakeAmountToInput(slip.getItemStake(selectionId)),
};
}
return itemStakeInputs.value[selectionId];
}
function onItemStakeInput(selectionId: string, event: Event) {
const clean = sanitizeStakeInput((event.target as HTMLInputElement).value);
itemStakeInputs.value = { ...itemStakeInputs.value, [selectionId]: clean };
const amount = Number.parseFloat(clean);
slip.setItemStake(selectionId, Number.isFinite(amount) ? amount : 0);
}
function clearItemStakeInput(selectionId: string) {
itemStakeInputs.value = { ...itemStakeInputs.value, [selectionId]: '' };
slip.setItemStake(selectionId, 0);
}
function addItemStake(selectionId: string, amount: number) {
const next = (slip.getItemStake(selectionId) || 0) + amount;
const normalized = Math.round(Math.max(MIN_STAKE, next) * 100) / 100;
slip.setItemStake(selectionId, normalized);
itemStakeInputs.value = {
...itemStakeInputs.value,
[selectionId]: stakeAmountToInput(normalized),
};
}
function setItemMaxStake(selectionId: string) {
if (balance.value == null || balance.value <= 0) return;
const normalized = Math.round(balance.value * 100) / 100;
slip.setItemStake(selectionId, normalized);
itemStakeInputs.value = {
...itemStakeInputs.value,
[selectionId]: stakeAmountToInput(normalized),
};
}
function itemEstReturn(item: SlipItem) {
const s = slip.getItemStake(item.selectionId);
if (!Number.isFinite(s) || s <= 0) return 0;
return s * effectiveOdds(item);
}
function syncStakeInputFromSlip() {
stakeInput.value = stakeAmountToInput(Number(slip.stake) || 0);
if (activeTab.value === 'single') {
syncItemStakeInputs(singleSlipItems.value);
}
}
function onStakeInput(event: Event) {
commitStakeInput((event.target as HTMLInputElement).value);
}
function clearStakeInput() {
commitStakeInput('');
}
function setStake(amount: number, enforceMinimum = true) {
if (!Number.isFinite(amount)) return;
const normalized = Math.round((enforceMinimum ? Math.max(MIN_STAKE, amount) : amount) * 100) / 100;
slip.stake = normalized;
stakeInput.value = stakeAmountToInput(normalized);
}
function addStake(amount: number) {
setStake((Number(slip.stake) || 0) + amount);
}
function setMaxStake() {
if (balance.value != null && balance.value > 0) setStake(balance.value, false);
}
function stopOddsPolling() {
if (oddsPollTimer) {
clearInterval(oddsPollTimer);
oddsPollTimer = null;
}
oddsDeltas.value = {};
}
function acceptPendingOdds() {
for (const [selectionId, delta] of Object.entries(oddsDeltas.value)) {
if (!delta.suspended) {
slip.updateSelectionOdds(selectionId, delta.newOdds, delta.newVersion);
}
}
oddsDeltas.value = {};
}
function acceptPendingOddsForSelection(selectionId: string) {
const delta = oddsDeltas.value[selectionId];
if (!delta || delta.suspended) return;
slip.updateSelectionOdds(selectionId, delta.newOdds, delta.newVersion);
const next = { ...oddsDeltas.value };
delete next[selectionId];
oddsDeltas.value = next;
}
function isItemSuspended(item: SlipItem) {
return Boolean(oddsDeltaFor(item.selectionId)?.suspended);
}
function canPlaceSingleItem(item: SlipItem) {
if (item.allowSingle === false) return false;
if (isItemSuspended(item)) return false;
return slip.getItemStake(item.selectionId) >= MIN_STAKE;
}
function validateSingleItemPlace(item: SlipItem): boolean {
if (!auth.token) {
auth.showLoginPrompt();
return false;
}
if (item.allowSingle === false) {
error.value = t('bet.slip_parlay_only_hint');
return false;
}
const itemStake = slip.getItemStake(item.selectionId);
if (itemStake < MIN_STAKE) {
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
return false;
}
if (balance.value != null && itemStake > balance.value) {
error.value = t('bet.outright_insufficient');
return false;
}
if (isItemSuspended(item)) {
error.value = t('bet.odds_suspended');
return false;
}
return true;
}
function onPlaceSingleItemClick(item: SlipItem) {
error.value = '';
if (!validateSingleItemPlace(item)) return;
if (oddsDeltaFor(item.selectionId) && !isItemSuspended(item)) {
acceptPendingOddsForSelection(item.selectionId);
}
pendingSingleItem.value = item;
const stake = slip.getItemStake(item.selectionId);
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
mode: 'single',
items: [item],
totalStake: stake,
totalReturn: stake * effectiveOdds(item),
formatMoney: (amount) => formatMoney(amount, locale.value),
getStake: () => stake,
getOdds: () => effectiveOdds(item),
});
showPlaceConfirm.value = true;
}
async function pollSelectionsOdds() {
const items = activeItems.value;
if (!items.length) return;
try {
const ids = items.map((item) => item.selectionId).join(',');
const { data } = await api.get('/player/selections/odds', { params: { ids } });
const rows: SelectionOddsRow[] = data.data?.items ?? [];
const rowMap = new Map(rows.map((row) => [row.id, row]));
const next: Record<string, OddsDelta> = {};
for (const item of items) {
const row = rowMap.get(item.selectionId);
if (!row) continue;
const suspended =
row.status !== 'OPEN' ||
row.marketStatus !== 'OPEN' ||
row.marketShowOnPlayer === false ||
row.matchStatus !== 'PUBLISHED';
const newOdds = parseFloat(row.odds);
const versionChanged = row.oddsVersion !== item.oddsVersion;
const oddsChanged = Number.isFinite(newOdds) && Math.abs(newOdds - item.odds) > 0.0001;
if (suspended || versionChanged || oddsChanged) {
const existing = oddsDeltas.value[item.selectionId];
next[item.selectionId] = {
oldOdds: existing?.oldOdds ?? item.odds,
newOdds: Number.isFinite(newOdds) ? newOdds : item.odds,
newVersion: row.oddsVersion,
suspended,
};
}
}
oddsDeltas.value = next;
} catch {
/* silent retry */
}
}
function startOddsPolling() {
stopOddsPolling();
void pollSelectionsOdds();
oddsPollTimer = setInterval(() => {
void pollSelectionsOdds();
}, ODDS_POLL_MS);
}
function oddsDeltaFor(selectionId: string) {
return oddsDeltas.value[selectionId];
}
function oddsTrendClass(delta: OddsDelta) {
if (delta.suspended) return 'odds-suspended';
return delta.newOdds >= delta.oldOdds ? 'odds-up' : 'odds-down';
}
function singlesForSubmit() {
return [...singleSlipItems.value];
}
function validatePlaceBet(): boolean {
if (!activeItems.value.length) return false;
if (!auth.token) {
auth.showLoginPrompt();
return false;
}
if (activeTab.value === 'parlay') {
if (slip.stake < MIN_STAKE) {
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
return false;
}
if (balance.value != null && slip.stake > balance.value) {
error.value = t('bet.outright_insufficient');
return false;
}
} else {
const singles = singlesForSubmit();
let totalStake = 0;
for (const item of singles) {
const itemStake = slip.getItemStake(item.selectionId);
if (itemStake < MIN_STAKE) {
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
return false;
}
totalStake += itemStake;
}
if (balance.value != null && totalStake > balance.value) {
error.value = t('bet.outright_insufficient');
return false;
}
}
if (activeTab.value === 'parlay' && !canSubmitActive.value) {
error.value = slip.parlayItems.length > PARLAY_MAX_LEGS
? t('bet.parlay_max_legs')
: t('bet.parlay_need_more');
return false;
}
if (hasSuspendedSelections.value) {
error.value = t('bet.odds_suspended');
return false;
}
return true;
}
function onPlaceBetClick() {
error.value = '';
if (!validatePlaceBet()) return;
if (hasPendingOddsChanges.value) {
acceptPendingOdds();
}
pendingSingleItem.value = null;
const singles = singlesForSubmit();
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
mode: activeTab.value === 'parlay' ? 'parlay' : 'single',
items: activeTab.value === 'parlay' ? [...slip.parlayItems] : singles,
totalStake: activeTotalStake.value,
totalReturn: activeEstimatedReturn.value,
totalOdds: activeTab.value === 'parlay' ? activeTotalOdds.value : undefined,
formatMoney: (amount) => formatMoney(amount, locale.value),
getStake: (item) => slip.getItemStake(item.selectionId),
getOdds: (item) => effectiveOdds(item),
});
showPlaceConfirm.value = true;
}
async function executePlaceSingleItem(item: SlipItem) {
loading.value = true;
error.value = '';
try {
await api.post('/player/bets/single', {
selectionId: item.selectionId,
oddsVersion: item.oddsVersion,
stake: slip.getItemStake(item.selectionId),
requestId: genId(),
});
slip.removeItem(item.selectionId);
await Promise.all([loadBalance(), refreshProfile()]);
showToast(t('bet.place_success'));
syncStakeInputFromSlip();
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
t('bet.place_failed');
} finally {
loading.value = false;
}
}
async function executePlaceBet() {
loading.value = true;
error.value = '';
try {
if (activeTab.value === 'parlay') {
await api.post('/player/bets/parlay', {
legs: slip.parlayItems.map((item) => ({
selectionId: item.selectionId,
oddsVersion: item.oddsVersion,
})),
stake: slip.stake,
requestId: genId(),
});
slip.clearParlay();
} else {
for (const item of singlesForSubmit()) {
await api.post('/player/bets/single', {
selectionId: item.selectionId,
oddsVersion: item.oddsVersion,
stake: slip.getItemStake(item.selectionId),
requestId: genId(),
});
}
slip.clearSingle();
}
if (activeTab.value === 'history') {
void loadHistory(true);
}
await Promise.all([loadBalance(), refreshProfile()]);
showToast(t('bet.place_success'));
syncStakeInputFromSlip();
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
t('bet.place_failed');
} finally {
loading.value = false;
}
}
async function confirmPlaceBet() {
showPlaceConfirm.value = false;
const singleItem = pendingSingleItem.value;
pendingSingleItem.value = null;
if (singleItem) {
await executePlaceSingleItem(singleItem);
return;
}
await executePlaceBet();
}
async function loadBalance() {
if (!auth.token) {
balance.value = null;
return;
}
balanceLoading.value = true;
try {
const { data } = await api.get('/player/profile');
balance.value = parseAmount(data.data?.wallet?.availableBalance);
} catch {
balance.value = null;
} finally {
balanceLoading.value = false;
}
}
function parlayErrorMessage(reason: ParlaySlipError) {
if (reason === 'MAX_LEGS') return t('bet.parlay_max_legs');
if (reason === 'QUARTER_LINE') return t('bet.parlay_block_quarter');
if (reason === 'OUTRIGHT') return t('bet.parlay_block_outright');
if (reason === 'NOT_ALLOWED') return t('bet.parlay_block_not_allowed');
if (reason === 'SAME_MATCH') return t('bet.slip_parlay_same_match');
return t('bet.parlay_block_not_allowed');
}
function addItemToParlay(item: SlipItem) {
if (!auth.token) {
auth.showLoginPrompt(route.fullPath);
return;
}
if (slip.parlayItems.some((leg) => leg.selectionId === item.selectionId)) {
activeTab.value = 'parlay';
slip.setMode('parlay');
error.value = '';
return;
}
const err = slip.addParlayLeg(item);
activeTab.value = 'parlay';
slip.setMode('parlay');
if (err) {
error.value = parlayErrorMessage(err);
return;
}
error.value = '';
}
function isItemInParlay(item: SlipItem) {
return slip.parlayItems.some((leg) => leg.selectionId === item.selectionId);
}
function parlayActionLabel(item: SlipItem) {
return isItemInParlay(item) ? t('bet.slip_parlay_added_short') : t('bet.add_to_parlay');
}
function addCurrentToParlay() {
if (singleInParlay.value) {
activeTab.value = 'parlay';
slip.setMode('parlay');
error.value = '';
return;
}
const err = slip.addSingleToParlay();
activeTab.value = 'parlay';
slip.setMode('parlay');
if (err) {
error.value = parlayErrorMessage(err);
return;
}
error.value = '';
}
watch(
() => activeItems.value.map((item) => item.selectionId).join(','),
(ids) => {
if (ids) {
if (activeTab.value === 'single') syncItemStakeInputs(singleSlipItems.value);
startOddsPolling();
} else {
itemStakeInputs.value = {};
stopOddsPolling();
}
},
{ immediate: true },
);
watch(
() => slip.mode,
(mode) => {
if (activeTab.value !== 'history') {
transitionName.value = '';
activeTab.value = mode;
nextTick(() => {
transitionName.value = 'list-slide';
});
}
},
);
watch(showMatchHistoryFilter, (show) => {
if (!show && historyScope.value === 'match') {
historyScope.value = 'all';
if (activeTab.value === 'history') void loadHistory(true);
}
});
watch(currentMatchId, (id, prev) => {
if (activeTab.value !== 'history') return;
if (id && id !== prev && historyScope.value === 'match') {
void loadHistory(true);
}
if (!id && historyScope.value === 'match') {
historyScope.value = 'all';
void loadHistory(true);
}
});
watch(
() => auth.token,
(token) => {
if (!token && activeTab.value === 'history') {
activeTab.value = slip.mode;
historyItems.value = [];
historyTotal.value = 0;
}
},
);
onMounted(() => {
activeTab.value = slip.mode;
if (activeTab.value === 'single' && !slip.singleItem && slip.parlayItems.length) {
activeTab.value = 'parlay';
slip.setMode('parlay');
}
syncStakeInputFromSlip();
void loadBalance();
});
onUnmounted(() => {
stopOddsPolling();
});
</script>
<template>
<div class="betslip-panel">
<div class="panel-head">
<h3>{{ t('bet.bet_slip') || '投注单' }}</h3>
<button
type="button"
class="panel-close-btn"
@click="slip.collapsePanel()"
:aria-label="t('common.collapse') || '折叠'"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M13 17l5-5-5-5M6 17l5-5-5-5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="panel-tabs">
<button
type="button"
class="tab-btn"
:class="{ active: activeTab === 'single' }"
@click="selectTab('single')"
>
{{ t('bet.slip_tab_single') }}
<span v-if="slip.singleCount" class="badge">{{ slip.singleCount }}</span>
</button>
<button
type="button"
class="tab-btn"
:class="{ active: activeTab === 'parlay' }"
@click="selectTab('parlay')"
>
{{ t('bet.slip_tab_parlay') }}
<span v-if="slip.parlayCount" class="badge">{{ slip.parlayCount }}</span>
</button>
<button
type="button"
class="tab-btn"
:class="{ active: activeTab === 'history' }"
@click="selectTab('history')"
>
{{ t('bet.slip_tab_history') }}
</button>
</div>
<div v-if="activeTab === 'history' && showMatchHistoryFilter" class="history-scope-bar">
<button
type="button"
class="scope-chip"
:class="{ active: historyScope === 'all' }"
@click="selectHistoryScope('all')"
>
{{ t('bet.slip_history_all') }}
</button>
<button
type="button"
class="scope-chip"
:class="{ active: historyScope === 'match' }"
@click="selectHistoryScope('match')"
>
{{ t('bet.slip_history_match') }}
</button>
</div>
<!-- Warnings -->
<p v-if="activeTab !== 'history' && oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
<!-- Body -->
<div class="panel-body">
<template v-if="activeTab === 'history'">
<div v-if="historyInitialLoading" class="history-loading">
<GoldSpinner :size="28" />
</div>
<div v-else-if="!historyItems.length" class="empty-state">
<svg class="empty-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10H7v-2h10v2zm0-4H7V7h10v2zm0 8H7v-2h10v2z" fill="currentColor"/>
</svg>
<p>{{ t('history.empty') }}</p>
</div>
<div v-else class="history-list">
<RouterLink
v-for="bet in historyItems"
:key="bet.betNo"
:to="`/bets/${bet.betNo}`"
class="history-card"
>
<div class="history-card-top">
<span class="history-pick">{{ historyPickLabel(bet) }}</span>
<span class="history-status" :class="historyStatusClass(bet.status)">
{{ historyStatusLabel(bet.status) }}
</span>
</div>
<div v-if="historyScope === 'all' && bet.matchTitle" class="history-match">
{{ bet.matchTitle }}
</div>
<div class="history-card-foot">
<span class="history-stake">{{ t('history.stake') }} {{ formatMoney(bet.stake, locale) }}</span>
<span class="history-return" :class="historyStatusClass(bet.status)">
{{ formatMoney(bet.actualReturn || bet.potentialReturn, locale) }}
</span>
</div>
<div class="history-meta">
<span>{{ bet.betNo }}</span>
<span>{{ bet.placedAt ? new Date(bet.placedAt).toLocaleString(locale) : '' }}</span>
</div>
</RouterLink>
</div>
<button
v-if="historyHasMore && !historyInitialLoading"
type="button"
class="history-load-more"
:disabled="historyLoading"
@click="loadMoreHistory"
>
{{ historyLoading ? t('bet.loading') : t('bet.slip_history_load_more') }}
</button>
</template>
<template v-else>
<div v-if="!activeItems.length" class="empty-state">
<svg class="empty-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10H7v-2h10v2zm0-4H7V7h10v2zm0 8H7v-2h10v2z" fill="currentColor"/>
</svg>
<p>{{ activeTab === 'parlay' ? t('bet.slip_parlay_empty_hint') : t('bet.slip_empty_hint') }}</p>
</div>
<TransitionGroup v-else :name="transitionName" tag="div" class="items-list">
<p v-if="activeTab === 'parlay' && parlayWarning" class="panel-warning" key="warning">{{ parlayWarning }}</p>
<div
v-for="item in activeItems"
:key="item.selectionId"
class="slip-card"
>
<div class="card-top">
<div class="card-meta">
<span class="match-name">{{ item.matchName }}</span>
<span class="card-market">{{ item.marketName }}</span>
</div>
<div class="card-actions">
<button type="button" class="del-btn" @click="removeItem(item.selectionId)"></button>
<button
type="button"
class="locate-btn"
:title="t('bet.slip_locate')"
:aria-label="t('bet.slip_locate_aria')"
@click="locateSlipItem(item)"
>
<svg class="locate-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5A2.5 2.5 0 1 1 12 6a2.5 2.5 0 0 1 0 5.5z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div class="card-selection-row">
<span class="selection-name">{{ item.selectionName }}</span>
<span class="odds-value">
<template v-if="oddsDeltaFor(item.selectionId)">
<span class="odds-change" :class="oddsTrendClass(oddsDeltaFor(item.selectionId)!)">
{{ oddsDeltaFor(item.selectionId)!.oldOdds.toFixed(2) }}
{{ oddsDeltaFor(item.selectionId)!.newOdds.toFixed(2) }}
</span>
</template>
<template v-else>{{ item.odds.toFixed(2) }}</template>
</span>
</div>
<div v-if="activeTab === 'single'" class="card-stake-block">
<div class="card-stake-inline">
<div class="card-stake-input-box">
<input
:value="itemStakeInputValue(item.selectionId)"
type="number"
class="card-stake-input"
:placeholder="t('bet.stake_placeholder') || '金额'"
@input="onItemStakeInput(item.selectionId, $event)"
/>
<button
v-if="itemStakeInputValue(item.selectionId)"
type="button"
class="clear-input-btn"
@click="clearItemStakeInput(item.selectionId)"
>
</button>
</div>
<button type="button" class="chip-btn mini" @click="addItemStake(item.selectionId, 50)">+50</button>
<button type="button" class="chip-btn mini" @click="addItemStake(item.selectionId, 100)">+100</button>
<button type="button" class="chip-btn mini" @click="setItemMaxStake(item.selectionId)">
{{ t('bet.stake_max') || '' }}
</button>
</div>
<div
class="card-action-row"
:class="{
'card-action-row--single': item.allowParlay === false || item.allowSingle === false,
}"
>
<button
v-if="item.allowSingle !== false"
type="button"
class="card-action-btn card-single-btn"
:disabled="loading || !canPlaceSingleItem(item)"
@click="onPlaceSingleItemClick(item)"
>
{{ t('bet.slip_place_single') }}
</button>
<button
v-if="item.allowParlay !== false"
type="button"
class="card-action-btn card-parlay-btn"
:class="{ active: isItemInParlay(item) }"
@click="addItemToParlay(item)"
>
<span class="card-parlay-btn-icon" aria-hidden="true">{{ isItemInParlay(item) ? '✓' : '+' }}</span>
<span>{{ isItemInParlay(item) ? t('bet.slip_parlay_added_btn') : t('bet.add_to_parlay') }}</span>
</button>
</div>
<div class="card-est-row">
<span class="est-lbl">{{ t('bet.slip_est_return') }}</span>
<span class="est-val">{{ formatMoney(itemEstReturn(item), locale) }}</span>
</div>
</div>
</div>
<div v-if="activeTab === 'parlay' && slip.parlayItems.length" class="parlay-summary" key="summary">
<span>{{ t('bet.slip_parlay_count', { n: slip.parlayItems.length }) }}</span>
<span class="parlay-odds">{{ t('bet.slip_parlay_odds', { odds: totalOddsText }) }}</span>
</div>
</TransitionGroup>
<!-- Stake input (parlay only) -->
<div v-if="activeTab === 'parlay' && activeItems.length" class="stake-control">
<div class="stake-input-box">
<span class="currency">{{ t('bet.slip_currency') || 'CNY' }}</span>
<input
v-model="stakeInput"
type="number"
class="stake-input"
:placeholder="t('bet.stake') || '输入金额'"
@input="onStakeInput"
/>
<button v-if="stakeInput" type="button" class="clear-input-btn" @click="clearStakeInput"></button>
</div>
<div class="quick-chips">
<button type="button" class="chip-btn" @click="addStake(50)">+50</button>
<button type="button" class="chip-btn" @click="addStake(100)">+100</button>
<button type="button" class="chip-btn" @click="addStake(500)">+500</button>
<button type="button" class="chip-btn" @click="setMaxStake">{{ t('bet.stake_max') || '最大' }}</button>
</div>
</div>
<p v-if="error" class="panel-error-msg">{{ error }}</p>
</template>
</div>
<div v-if="activeTab === 'history'" class="panel-bottom-fixed panel-bottom-history">
<RouterLink to="/bets" class="history-view-all-btn">
{{ t('bet.slip_history_view_all') }}
</RouterLink>
</div>
<div v-else-if="activeItems.length" class="panel-bottom-fixed">
<div class="panel-summary">
<div class="summary-row">
<span class="lbl">{{ t('bet.slip_total_stake') }}</span>
<span class="val">{{ stakeText }}</span>
</div>
<div class="summary-row est-return">
<span class="lbl">{{ t('bet.slip_est_return') }}</span>
<span class="val">{{ estimatedReturnText }}</span>
</div>
</div>
<div class="panel-foot">
<button type="button" class="clear-slip-btn foot-clear" @click="clearActiveSlip">
{{ t('bet.slip_clear') }}
</button>
<button
type="button"
class="submit-btn"
:disabled="loading || !canSubmitWithOdds"
@click="onPlaceBetClick"
>
{{ submitButtonLabel }}
</button>
</div>
</div>
<ConfirmDialog
v-model:visible="showPlaceConfirm"
:title="t('bet.place_confirm_title')"
:message="placeConfirmMessage"
:confirm-text="t('bet.place_bet')"
:cancel-text="t('common.cancel')"
:loading="loading"
@confirm="confirmPlaceBet"
/>
</div>
</template>
<style scoped>
.betslip-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--desktop-sidebar-panel-bg);
}
.panel-head {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.panel-head h3 {
margin: 0;
font-size: 14px;
font-weight: 800;
color: var(--primary-light);
}
.panel-close-btn {
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.panel-close-btn:hover {
background: var(--bg-hover);
color: var(--primary);
}
.clear-slip-btn {
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
padding: 3px 8px;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.clear-slip-btn.foot-clear {
flex: 0 0 20%;
min-width: 0;
padding: 10px 4px;
font-size: 12px;
cursor: pointer;
}
.clear-slip-btn:hover {
color: var(--primary-light);
border-color: var(--border-gold-soft);
background: rgba(244, 162, 97, 0.06);
}
.balance-info {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
}
.balance-info .lbl {
color: var(--text-muted);
}
.balance-info .val {
color: var(--text);
font-weight: 700;
}
.refresh-btn {
background: transparent;
color: var(--primary-light);
font-size: 12px;
line-height: 1;
padding: 0 2px;
}
.panel-tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.tab-btn {
flex: 1;
background: transparent;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
padding: 10px 4px;
border-bottom: 2px solid transparent;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.tab-btn:hover {
color: var(--text);
}
.tab-btn.active {
color: var(--primary-light);
border-bottom-color: var(--primary);
}
.badge {
background: var(--primary);
color: #fff;
font-size: 9px;
font-weight: 800;
padding: 1px 4px;
border-radius: 6px;
line-height: 1;
}
.odds-warning {
background: rgba(244, 162, 97, 0.12);
border-bottom: 1px solid var(--border-gold-soft);
color: var(--primary-light);
font-size: 11px;
font-weight: 600;
padding: 8px 16px;
margin: 0;
text-align: center;
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
min-height: 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60%;
color: var(--text-muted);
gap: 12px;
text-align: center;
font-size: 12px;
}
.empty-icon {
width: 32px;
height: 32px;
opacity: 0.3;
}
.items-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
.slip-card {
background: rgba(22, 45, 54, 0.4);
border: 1px solid var(--border);
border-radius: 4px;
padding: 7px 8px;
position: relative;
transition: border-color 0.2s;
}
.slip-card:hover {
border-color: var(--border-gold-soft);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 6px;
margin-bottom: 4px;
}
.card-meta {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
.match-name {
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-market {
font-size: 10px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.del-btn {
background: transparent;
color: var(--text-muted);
font-size: 10px;
padding: 0 2px;
line-height: 1;
}
.card-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.locate-btn {
background: transparent;
color: var(--text-muted);
padding: 0 2px;
line-height: 1;
display: flex;
}
.locate-btn:hover {
color: var(--primary-light);
}
.locate-icon {
width: 12px;
height: 12px;
}
.del-btn:hover {
color: var(--danger);
}
.card-selection-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.selection-name {
font-size: 11px;
font-weight: 700;
color: #fff;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.odds-value {
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
flex-shrink: 0;
}
.odds-change {
font-size: 11px;
padding: 1px 4px;
border-radius: 2px;
}
.odds-up {
background: rgba(46, 204, 113, 0.15);
color: #2ecc71;
}
.odds-down {
background: rgba(231, 76, 60, 0.15);
color: #e74c3c;
}
.odds-suspended {
background: rgba(255, 255, 255, 0.05);
color: var(--text-muted);
text-decoration: line-through;
}
.card-stake-block {
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
gap: 4px;
}
.card-stake-inline {
display: flex;
align-items: center;
gap: 4px;
}
.card-stake-input-box {
flex: 1;
display: flex;
align-items: center;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 6px;
height: 26px;
min-width: 0;
}
.card-stake-input {
flex: 1;
background: transparent;
border: none !important;
color: #fff;
padding: 0;
font-size: 11px;
font-weight: 700;
min-width: 0;
}
.card-stake-input::-webkit-outer-spin-button,
.card-stake-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.chip-btn.mini {
flex: 0 0 auto;
padding: 0 6px;
height: 26px;
font-size: 9px;
min-width: 32px;
}
.card-action-row {
display: flex;
gap: 6px;
margin-top: 6px;
}
.card-action-row--single .card-action-btn {
flex: 1 1 100%;
}
.card-action-btn {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
}
.card-action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.card-single-btn {
border: 1px solid rgba(244, 162, 97, 0.55);
background: rgba(244, 162, 97, 0.14);
color: var(--primary-light);
}
.card-single-btn:hover:not(:disabled) {
background: rgba(244, 162, 97, 0.22);
border-color: var(--border-gold);
color: #fff;
}
.card-parlay-btn {
border: 1px dashed rgba(244, 162, 97, 0.45);
background: rgba(244, 162, 97, 0.04);
color: var(--primary-light);
}
.card-parlay-btn:hover:not(:disabled) {
background: rgba(244, 162, 97, 0.1);
border-color: var(--border-gold-soft);
color: #fff;
}
.card-parlay-btn.active {
border-style: solid;
border-color: rgba(244, 162, 97, 0.35);
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
}
.card-parlay-btn.active:hover {
color: var(--primary-light);
border-color: var(--border-gold-soft);
}
.card-parlay-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(244, 162, 97, 0.16);
font-size: 11px;
line-height: 1;
font-weight: 800;
}
.card-parlay-btn.active .card-parlay-btn-icon {
background: rgba(46, 204, 113, 0.18);
color: #2ecc71;
}
.card-est-row {
display: flex;
justify-content: space-between;
font-size: 9px;
line-height: 1.2;
}
.card-est-row .est-lbl {
color: var(--text-muted);
}
.card-est-row .est-val {
color: var(--primary-light);
font-weight: 800;
}
.panel-warning {
font-size: 11px;
color: var(--primary-light);
background: rgba(244, 162, 97, 0.08);
border: 1px solid var(--border-gold-soft);
border-radius: 4px;
padding: 8px;
margin: 6px 0;
}
.parlay-summary {
display: flex;
justify-content: space-between;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
padding: 4px 6px;
background: var(--bg-elevated);
border-radius: 4px;
}
.parlay-odds {
color: var(--primary-light);
}
.stake-control {
background: var(--bg-elevated);
border-radius: 4px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.stake-input-box {
display: flex;
align-items: center;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 10px;
height: 36px;
}
.currency {
font-size: 11px;
font-weight: 700;
color: var(--primary-light);
margin-right: 6px;
}
.stake-input {
flex: 1;
background: transparent;
border: none !important;
color: #fff;
padding: 0;
font-size: 14px;
font-weight: 700;
}
.stake-input::-webkit-outer-spin-button,
.stake-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.clear-input-btn {
background: transparent;
color: var(--text-muted);
font-size: 12px;
padding: 0 4px;
}
.quick-chips {
display: flex;
gap: 6px;
}
.chip-btn {
flex: 1;
background: rgba(22, 45, 54, 0.45); border: 1px solid var(--border);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
padding: 5px 0;
border-radius: 4px;
transition: all 0.2s;
}
.chip-btn:hover {
border-color: var(--border-gold-soft);
color: #fff;
}
.panel-error-msg {
color: var(--danger);
font-size: 11px;
font-weight: 600;
margin-top: 8px;
}
.panel-bottom-fixed {
flex-shrink: 0;
border-top: 1px solid var(--border);
background: transparent;
}
.panel-summary {
padding: 10px 16px 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.summary-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
}
.summary-row .lbl {
color: var(--text-muted);
}
.summary-row .val {
color: var(--text);
font-weight: 700;
}
.summary-row.est-return .val {
color: var(--primary-light);
font-size: 13px;
font-weight: 800;
}
.panel-foot {
padding: 10px 16px 12px;
display: flex;
align-items: stretch;
gap: 8px;
}
.add-parlay-btn {
width: 100%;
padding: 8px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: transparent;
color: var(--primary-light);
font-size: 11px;
font-weight: 700;
transition: all 0.2s;
}
.add-parlay-btn:hover {
background: rgba(244, 162, 97, 0.05);
border-color: var(--primary);
}
.submit-btn {
flex: 1;
min-width: 0;
padding: 10px;
border-radius: 4px;
background: var(--gradient-primary);
color: #fff;
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
transition: opacity 0.2s;
border: none;
cursor: pointer;
}
.submit-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
filter: saturate(0.5);
}
.history-view-all-btn {
display: block;
width: 100%;
padding: 10px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: rgba(244, 162, 97, 0.08);
color: var(--primary-light);
font-size: 13px;
font-weight: 800;
text-align: center;
text-decoration: none;
transition: background 0.15s, border-color 0.15s;
}
.history-view-all-btn:hover {
background: rgba(244, 162, 97, 0.14);
border-color: var(--primary);
}
.panel-bottom-history {
padding: 10px 16px 12px;
}
.history-scope-bar {
display: flex;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.scope-chip {
flex: 1;
padding: 5px 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
transition: all 0.15s;
}
.scope-chip.active {
color: var(--primary-light);
border-color: var(--border-gold-soft);
background: rgba(244, 162, 97, 0.08);
}
.history-loading {
display: flex;
justify-content: center;
padding: 32px 0;
}
.history-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.history-card {
display: block;
padding: 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: rgba(22, 45, 54, 0.45); border: 1px solid var(--border);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, background 0.15s;
}
.history-card:hover {
border-color: var(--border-gold-soft);
background: rgba(244, 162, 97, 0.04);
}
.history-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.history-pick {
font-size: 11px;
font-weight: 700;
color: #fff;
line-height: 1.35;
min-width: 0;
}
.history-status {
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
padding: 1px 5px;
border-radius: 3px;
}
.history-status.hist-won {
color: #2ecc71;
background: rgba(46, 204, 113, 0.12);
}
.history-status.hist-lost {
color: #e74c3c;
background: rgba(231, 76, 60, 0.12);
}
.history-status.hist-push {
color: #95a5a6;
background: rgba(149, 165, 166, 0.12);
}
.history-status.hist-pending {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.1);
}
.history-match {
font-size: 10px;
color: var(--text-muted);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-card-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
font-size: 11px;
}
.history-stake {
color: var(--text-muted);
}
.history-return {
font-weight: 800;
}
.history-return.hist-won {
color: #2ecc71;
}
.history-return.hist-lost {
color: #e74c3c;
}
.history-return.hist-push,
.history-return.hist-pending {
color: var(--primary-light);
}
.history-meta {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
font-size: 9px;
color: var(--text-muted);
opacity: 0.85;
}
.history-load-more {
width: 100%;
margin-top: 8px;
padding: 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: transparent;
color: var(--primary-light);
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.history-load-more:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Transition group list animation */
.list-slide-enter-active,
.list-slide-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.list-slide-enter-from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
.list-slide-leave-to {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
/* Ensure smooth moving when list changes */
.list-slide-move {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
defineProps<{
headers: { key: string; label: string; align?: 'left' | 'right' | 'center' }[];
items: any[];
loading?: boolean;
}>();
const { t } = useI18n();
</script>
<template>
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th
v-for="h in headers"
:key="h.key"
:class="[`align-${h.align || 'left'}`]"
>
{{ h.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading && !items.length">
<td :colspan="headers.length" class="loading-cell">
<div class="skeleton-row desktop-skeleton" v-for="i in 3" :key="i"></div>
</td>
</tr>
<tr v-else-if="!items.length">
<td :colspan="headers.length" class="empty-cell">
{{ t('common.no_data') || '暂无数据' }}
</td>
</tr>
<tr
v-else
v-for="(item, idx) in items"
:key="item.id || idx"
class="table-row hover-bg"
>
<td
v-for="h in headers"
:key="h.key"
:class="[`align-${h.align || 'left'}`]"
>
<slot :name="h.key" :item="item" :index="idx">
{{ item[h.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.data-table-container {
width: 100%;
overflow-x: auto;
background: var(--bg-card); backdrop-filter: blur(10px);
border: 1px solid var(--border);
border-radius: 6px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
color: #ddd;
}
th {
background: var(--bg-elevated);
color: var(--text-muted);
font-weight: 800;
text-transform: uppercase;
font-size: 11px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
letter-spacing: 0.04em;
}
td {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.table-row:last-child td {
border-bottom: none;
}
.table-row {
transition: background-color 0.15s;
}
.align-left { text-align: left; }
.align-right { text-align: right; }
.align-center { text-align: center; }
.loading-cell {
padding: 20px;
}
.skeleton-row {
height: 28px;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-row:last-child {
margin-bottom: 0;
}
.empty-cell {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,199 @@
<script setup lang="ts">
/**
* PC auth shell — admin-style card: large logo left, form slot right.
*/
import LocaleSwitcher from '../LocaleSwitcher.vue';
withDefaults(
defineProps<{
/** Wider card for register / forgot-password forms */
wide?: boolean;
}>(),
{ wide: false },
);
</script>
<template>
<div class="desktop-auth-page">
<div class="ambient-glow ambient-glow--1" aria-hidden="true"></div>
<div class="ambient-glow ambient-glow--2" aria-hidden="true"></div>
<div class="auth-wrap">
<div class="auth-card" :class="{ 'auth-card--wide': wide }">
<div class="form-lang">
<LocaleSwitcher compact />
</div>
<div class="form-body">
<aside class="form-brand">
<img src="/logo.png" alt="TheBet365" class="logo-large" />
</aside>
<div class="form-fields">
<slot />
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.desktop-auth-page {
position: relative;
min-height: 100dvh;
background: linear-gradient(135deg, #0f2027 0%, #203a43 50%, #0f2027 100%);
overflow: hidden;
}
.desktop-auth-page::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(244, 162, 97, 0.06) 0%, transparent 70%);
pointer-events: none;
}
.ambient-glow {
position: absolute;
border-radius: 50%;
filter: blur(60px);
pointer-events: none;
opacity: 0.15;
z-index: 0;
}
.ambient-glow--1 {
width: 320px;
height: 320px;
top: 10%;
left: 12%;
background: radial-gradient(circle, rgba(244, 162, 97, 0.5) 0%, transparent 70%);
animation: ambient-drift-1 12s ease-in-out infinite alternate;
}
.ambient-glow--2 {
width: 280px;
height: 280px;
bottom: 12%;
right: 12%;
background: radial-gradient(circle, rgba(231, 111, 81, 0.35) 0%, transparent 70%);
animation: ambient-drift-2 16s ease-in-out infinite alternate;
}
@keyframes ambient-drift-1 {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(30px, -20px) scale(1.1);
}
}
@keyframes ambient-drift-2 {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(-25px, 15px) scale(0.95);
}
}
.auth-wrap {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: 24px 20px;
overflow-y: auto;
}
.auth-card {
width: 100%;
max-width: 720px;
background: rgba(22, 45, 54, 0.82);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.38);
overflow: hidden;
}
.auth-card--wide {
max-width: 820px;
}
.form-lang {
display: flex;
justify-content: flex-end;
padding: 14px 18px 0;
}
.form-body {
display: grid;
grid-template-columns: minmax(200px, 260px) 1fr;
align-items: stretch;
}
.form-brand {
display: flex;
align-items: center;
justify-content: center;
padding: 28px 20px;
background: rgba(0, 0, 0, 0.25);
border-right: 1px solid rgba(244, 162, 97, 0.15);
}
.logo-large {
width: 100%;
max-width: 220px;
height: auto;
object-fit: contain;
filter: drop-shadow(0 4px 24px rgba(244, 162, 97, 0.25));
}
.form-fields {
display: flex;
flex-direction: column;
min-width: 0;
padding: 22px 24px 24px;
}
@media (max-width: 640px) {
.auth-wrap {
align-items: flex-start;
padding: 18px;
}
.auth-card,
.auth-card--wide {
max-width: 420px;
}
.form-body {
grid-template-columns: 1fr;
}
.form-brand {
padding: 20px 24px 12px;
border-right: none;
border-bottom: 1px solid rgba(244, 162, 97, 0.15);
}
.logo-large {
max-width: 180px;
}
.form-fields {
padding: 18px 20px 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.ambient-glow--1,
.ambient-glow--2 {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,471 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import type { BannerItem } from '../BannerCarousel.vue';
import defaultBannerImg from '../../assets/images/banner.webp';
const props = defineProps<{
banners: BannerItem[];
fallbackTo?: string;
}>();
const { t } = useI18n();
const router = useRouter();
const active = ref(0);
const timer = ref<ReturnType<typeof setInterval> | null>(null);
const FALLBACK_IMG = '/uploads/banners/welcome.svg';
const slideCount = computed(() => props.banners.length);
const canLoop = computed(() => slideCount.value > 1);
function imageUrl(banner: BannerItem) {
return banner.translation?.imageUrl || defaultBannerImg || FALLBACK_IMG;
}
function onImgError(e: Event) {
const img = e.target as HTMLImageElement;
if (img.dataset.fallbackApplied) return;
img.dataset.fallbackApplied = '1';
img.src = defaultBannerImg || FALLBACK_IMG;
}
function title(banner: BannerItem) {
return banner.translation?.title || t('home.banner_fallback');
}
function normalizeOffset(index: number) {
const len = slideCount.value;
if (!len) return 0;
let offset = index - active.value;
if (offset > len / 2) offset -= len;
if (offset < -len / 2) offset += len;
return offset;
}
/** Coverflow略重叠中间卡片置顶 */
function slideLayout(index: number) {
const offset = normalizeOffset(index);
const abs = Math.abs(offset);
if (abs > 1) {
return { hidden: true, offset, active: false };
}
const slotX = 50 + offset * 26;
const scale = offset === 0 ? 1 : 0.9;
const rotateY = offset === 0 ? 0 : offset < 0 ? 12 : -12;
const zIndex = offset === 0 ? 10 : 5;
return {
hidden: false,
offset,
active: offset === 0,
slotX,
scale,
rotateY,
zIndex,
};
}
function slideStyle(index: number) {
const layout = slideLayout(index);
if (layout.hidden) {
return {
left: '50%',
transform: 'translate(-50%, -50%) scale(0.7)',
opacity: '0',
zIndex: '0',
pointerEvents: 'none' as const,
};
}
return {
left: `${layout.slotX}%`,
transform: `translate(-50%, -50%) scale(${layout.scale}) rotateY(${layout.rotateY}deg)`,
opacity: '1',
zIndex: String(layout.zIndex),
pointerEvents: 'auto' as const,
};
}
function goTo(index: number) {
if (!slideCount.value) return;
active.value = (index + slideCount.value) % slideCount.value;
}
function next() {
goTo(active.value + 1);
}
function prev() {
goTo(active.value - 1);
}
function onBannerClick(banner: BannerItem, index: number) {
if (normalizeOffset(index) !== 0) {
goTo(index);
return;
}
if (banner.id) {
void router.push(`/announcements/${banner.id}`);
return;
}
if (props.fallbackTo) {
void router.push(props.fallbackTo);
}
}
function startAutoPlay() {
stopAutoPlay();
if (!canLoop.value) return;
timer.value = setInterval(next, 5000);
}
function stopAutoPlay() {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
}
watch(
() => props.banners.length,
() => {
active.value = 0;
startAutoPlay();
},
);
onMounted(startAutoPlay);
onUnmounted(stopAutoPlay);
</script>
<template>
<section
v-if="banners.length"
class="banner-coverflow"
@mouseenter="stopAutoPlay"
@mouseleave="startAutoPlay"
>
<div class="coverflow-stage">
<button
v-for="(banner, i) in banners"
:key="banner.id ?? i"
type="button"
class="coverflow-slide"
:class="{ 'is-active': slideLayout(i).active, 'is-side': Math.abs(slideLayout(i).offset) === 1 }"
:style="slideStyle(i)"
:aria-label="title(banner)"
@click="onBannerClick(banner, i)"
>
<div class="slide-card">
<img
v-if="imageUrl(banner)"
:src="imageUrl(banner)"
:alt="''"
class="slide-img"
:loading="i === 0 ? 'eager' : 'lazy'"
@error="onImgError"
/>
<div v-else class="slide-fallback" aria-hidden="true">
<svg class="fallback-icon" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
</div>
<div v-if="slideLayout(i).active" class="click-hint" aria-hidden="true">
<span class="click-ring" />
<svg class="click-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M9 11.24V7.5a2.5 2.5 0 0 1 5 0v3.74c1.21-.81 2-2.18 2-3.74C16 5.01 13.99 3 11.5 3S7 5.01 7 7.5c0 1.56.79 2.93 2 3.74zm9.84 4.63-4.54-2.26a1.5 1.5 0 0 0-.54-.11H13v-6c0-.83-.67-1.5-1.5-1.5S10 6.67 10 7.5v10.74l-3.43-.72a1 1 0 0 0-.24-.03c-.31 0-.59.13-.79.33l-.79.8 4.94 4.94c.27.27.65.44 1.06.44h6.79c.75 0 1.33-.55 1.44-1.28l.75-5.27a1.5 1.5 0 0 0-.91-1.38z"
/>
</svg>
</div>
</div>
</button>
<button
v-if="canLoop"
type="button"
class="nav-btn prev"
:aria-label="t('home.banner_prev')"
@click.stop="prev"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<button
v-if="canLoop"
type="button"
class="nav-btn next"
:aria-label="t('home.banner_next')"
@click.stop="next"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
<div v-if="canLoop" class="coverflow-dots">
<button
v-for="(_, i) in banners"
:key="i"
type="button"
class="dot"
:class="{ active: i === active }"
:aria-label="t('home.banner_slide', { n: i + 1 })"
@click.stop="goTo(i)"
/>
</div>
</section>
</template>
<style scoped>
.banner-coverflow {
width: 100%;
padding: 4px 0 0;
}
.coverflow-stage {
position: relative;
height: clamp(260px, 28vw, 360px);
perspective: 1000px;
}
.coverflow-slide {
position: absolute;
top: 50%;
width: 42%;
max-width: 560px;
height: 90%;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
transition:
left 0.55s cubic-bezier(0.25, 0.8, 0.25, 1),
transform 0.55s cubic-bezier(0.25, 0.8, 0.25, 1),
opacity 0.4s ease;
transform-style: preserve-3d;
will-change: left, transform;
}
.slide-card {
position: relative;
width: 100%;
height: 100%;
border-radius: 14px;
overflow: hidden;
background: linear-gradient(180deg, var(--secondary) 0%, var(--bg-body) 100%);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.45);
transition: box-shadow 0.35s ease, filter 0.35s ease, transform 0.25s ease;
display: flex;
align-items: center;
justify-content: center;
}
.coverflow-slide.is-side .slide-card {
filter: brightness(0.72) saturate(0.9);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
.coverflow-slide.is-side:hover .slide-card {
filter: brightness(0.88) saturate(1);
}
.coverflow-slide.is-active .slide-card {
filter: none;
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.5),
0 0 28px rgba(244, 162, 97, 0.22),
0 0 56px rgba(244, 162, 97, 0.1);
}
.coverflow-slide.is-active:hover .slide-card {
transform: scale(1.018);
}
@keyframes banner-breathe {
0%,
100% {
transform: scale(1);
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.5),
0 0 22px rgba(244, 162, 97, 0.16),
0 0 48px rgba(244, 162, 97, 0.08);
}
50% {
transform: scale(1.008);
box-shadow:
0 18px 44px rgba(0, 0, 0, 0.52),
0 0 32px rgba(244, 162, 97, 0.26),
0 0 60px rgba(244, 162, 97, 0.12);
}
}
.slide-img {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
display: block;
}
.slide-fallback {
width: 100%;
height: 100%;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(244, 162, 97, 0.12), var(--secondary));
}
.fallback-icon {
width: 40px;
height: 40px;
color: rgba(244, 162, 97, 0.35);
}
.click-hint {
position: absolute;
right: 10px;
bottom: 10px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 2;
background: rgba(0, 0, 0, 0.35);
border-radius: 50%;
}
.click-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(244, 162, 97, 0.45);
animation: click-ring-pulse 2.8s ease-out infinite;
}
.click-icon {
position: relative;
width: 15px;
height: 15px;
color: rgba(244, 162, 97, 0.88);
animation: click-icon-bob 2.8s ease-in-out infinite;
}
@keyframes click-ring-pulse {
0% {
transform: scale(0.9);
opacity: 0.55;
}
70% {
transform: scale(1.35);
opacity: 0;
}
100% {
transform: scale(1.35);
opacity: 0;
}
}
@keyframes click-icon-bob {
0%,
100% {
transform: translateY(1px) scale(1);
opacity: 0.65;
}
50% {
transform: translateY(-1px) scale(1.06);
opacity: 1;
}
}
.coverflow-slide.is-active:hover .click-hint {
opacity: 0;
transition: opacity 0.2s ease;
}
@media (prefers-reduced-motion: reduce) {
.coverflow-slide.is-active .slide-card,
.click-ring,
.click-icon {
animation: none;
}
}
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(8px);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
cursor: pointer;
}
.nav-btn svg {
width: 24px;
height: 24px;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.25);
color: #ffffff;
transform: translateY(-50%) scale(1.06);
}
.nav-btn.prev {
left: 12px;
}
.nav-btn.next {
right: 12px;
}
.coverflow-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 14px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
border: none;
padding: 0;
cursor: pointer;
transition: all 0.25s ease;
}
.dot.active {
width: 22px;
border-radius: 4px;
background: var(--gradient-gold);
}
.dot:hover:not(.active) {
background: rgba(255, 255, 255, 0.45);
}
</style>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import { watch } from 'vue';
import { useI18n } from 'vue-i18n';
import BetSlipPanel from './BetSlipPanel.vue';
import { useBetSlipStore } from '../../stores/betSlip';
const { t } = useI18n();
const slip = useBetSlipStore();
watch(
() => slip.totalCount,
(next, prev) => {
if (next > prev) slip.expandPanel();
},
);
</script>
<template>
<aside
class="desktop-betslip-rail"
:class="{ 'is-collapsed': !slip.panelExpanded }"
:aria-expanded="slip.panelExpanded"
>
<button
v-if="!slip.panelExpanded"
type="button"
class="rail-collapsed-hint"
:aria-label="t('bet.bet_slip')"
@click="slip.expandPanel()"
>
<span v-if="slip.totalCount" class="rail-badge">{{ slip.totalCount > 99 ? '99+' : slip.totalCount }}</span>
<span class="rail-label">{{ t('bet.bet_slip') }}</span>
</button>
<div class="rail-content" :hidden="!slip.panelExpanded">
<BetSlipPanel />
</div>
</aside>
</template>
<style scoped>
.desktop-betslip-rail {
position: relative;
flex-shrink: 0;
width: var(--desktop-right-betslip-w);
min-width: 0;
border-left: 1px solid var(--border);
background: var(--desktop-sidebar-panel-bg);
display: flex;
flex-direction: column;
min-height: 0;
transition: width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
overflow: visible;
}
.rail-toggle {
position: absolute;
left: -18px;
top: 50%;
z-index: 30;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-card);
color: var(--primary-light);
cursor: pointer;
display: grid;
place-items: center;
transform: translateY(-50%);
box-shadow:
0 2px 10px rgba(0, 0, 0, 0.5),
0 0 0 3px rgba(22, 45, 54, 0.92);
transition:
transform 0.22s cubic-bezier(0.4, 0, 0.2, 1),
background 0.2s,
color 0.2s,
border-color 0.2s,
box-shadow 0.2s;
}
.rail-toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
.rail-toggle.is-collapsed .rail-toggle-icon {
transform: rotate(180deg);
}
.rail-toggle:hover {
border-color: var(--border-gold);
background: var(--primary);
color: #fff;
box-shadow:
0 4px 14px rgba(0, 0, 0, 0.6),
0 0 0 3px rgba(244, 162, 97, 0.15);
}
.rail-toggle:active {
transform: translateY(-50%) scale(0.94);
}
.rail-toggle:focus-visible {
outline: 2px solid var(--border-gold);
outline-offset: 2px;
}
.rail-content {
flex: 1;
min-height: 0;
width: var(--desktop-right-betslip-expanded-w, 288px);
overflow: hidden;
}
.desktop-betslip-rail.is-collapsed .rail-content {
visibility: hidden;
}
.rail-collapsed-hint {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px 0;
border: none;
background: transparent;
cursor: pointer;
transition: background 0.2s;
}
.rail-collapsed-hint:hover {
background: var(--bg-hover);
}
.rail-label {
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 12px;
font-weight: 800;
color: var(--primary);
letter-spacing: 0.12em;
}
.rail-badge {
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: var(--primary, #F4A261);
color: var(--bg-body);
font-size: 10px;
font-weight: 800;
line-height: 20px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,458 @@
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
import { useBetSlipStore, type ParlaySlipError } from '../../stores/betSlip';
import { useAuthStore } from '../../stores/auth';
import { formatMoney } from '../../utils/localeDisplay';
import api from '../../api';
import { usePlayerProfile } from '../../composables/usePlayerProfile';
import ConfirmDialog from '../ConfirmDialog.vue';
import { buildBetPlaceConfirmMessage } from '../../utils/betPlaceConfirmMessage';
import { useAppToast } from '../../composables/useAppToast';
const { t, locale } = useI18n();
const { visible, anchorX, anchorY, pendingItem, placement, close, cancelClose, scheduleClose } = useDesktopBetPopover();
const slip = useBetSlipStore();
const auth = useAuthStore();
const { refreshProfile } = usePlayerProfile();
const { showToast } = useAppToast();
const popRef = ref<HTMLElement | null>(null);
const stakeInput = ref('5');
const loading = ref(false);
const error = ref('');
const showPlaceConfirm = ref(false);
const placeConfirmMessage = ref('');
const MIN_STAKE = 5;
const confirmingItem = ref<any>(null);
watch(showPlaceConfirm, (val) => {
if (!val) {
confirmingItem.value = null;
}
});
function onPopoverMouseLeave() {
if (showPlaceConfirm.value) return;
scheduleClose(200);
}
let outsideClickTimer = 0;
const stake = computed(() => {
const n = Number.parseFloat(stakeInput.value);
return Number.isFinite(n) ? n : 0;
});
const estReturn = computed(() => {
if (!pendingItem.value || stake.value <= 0) return 0;
return stake.value * pendingItem.value.odds;
});
watch(visible, (open) => {
if (open) {
error.value = '';
stakeInput.value = String(Math.max(MIN_STAKE, slip.stake || MIN_STAKE));
window.clearTimeout(outsideClickTimer);
outsideClickTimer = window.setTimeout(() => {
document.addEventListener('click', onDocumentClick, true);
}, 0);
} else {
document.removeEventListener('click', onDocumentClick, true);
}
});
onUnmounted(() => {
window.clearTimeout(outsideClickTimer);
document.removeEventListener('click', onDocumentClick, true);
});
function onDocumentClick(event: MouseEvent) {
if (!visible.value || !popRef.value || showPlaceConfirm.value) return;
const target = event.target as Node;
if (target instanceof Element && target.closest('.confirm-overlay')) return;
if (!popRef.value.contains(target)) {
close();
}
}
function parlayErrorMessage(reason: ParlaySlipError) {
if (reason === 'MAX_LEGS') return t('bet.parlay_max_legs');
if (reason === 'QUARTER_LINE') return t('bet.parlay_block_quarter');
if (reason === 'OUTRIGHT') return t('bet.parlay_block_outright');
if (reason === 'NOT_ALLOWED') return t('bet.parlay_block_not_allowed');
if (reason === 'SAME_MATCH') return t('bet.slip_parlay_same_match');
return t('bet.parlay_block_not_allowed');
}
function validatePlaceNow(): boolean {
const item = pendingItem.value;
if (!item) return false;
if (!auth.token) {
auth.showLoginPrompt();
return false;
}
if (item.allowSingle === false) {
error.value = t('bet.slip_parlay_only_hint');
return false;
}
if (stake.value < MIN_STAKE) {
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
return false;
}
return true;
}
function onPlaceNowClick() {
if (!validatePlaceNow()) return;
const item = pendingItem.value!;
confirmingItem.value = item;
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
mode: 'single',
items: [item],
totalStake: stake.value,
totalReturn: estReturn.value,
formatMoney: (amount) => formatMoney(amount, locale.value),
getStake: () => stake.value,
getOdds: (row) => row.odds,
});
showPlaceConfirm.value = true;
}
async function executePlaceNow(item = confirmingItem.value || pendingItem.value) {
if (!item) return;
loading.value = true;
error.value = '';
try {
await api.post('/player/bets/single', {
selectionId: item.selectionId,
oddsVersion: item.oddsVersion,
stake: stake.value,
requestId: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
});
slip.stake = stake.value;
await refreshProfile();
showPlaceConfirm.value = false;
close();
showToast(t('bet.place_success'));
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
t('bet.place_failed');
showToast(error.value);
} finally {
loading.value = false;
}
}
async function confirmPlaceNow() {
const item = confirmingItem.value;
if (!item) return;
showPlaceConfirm.value = false;
await executePlaceNow(item);
}
function addToSingleList() {
const item = pendingItem.value;
if (!item) return;
if (!auth.token) {
auth.showLoginPrompt();
return;
}
if (item.allowSingle === false) {
error.value = t('bet.slip_parlay_only_hint');
return;
}
slip.stake = Math.max(MIN_STAKE, stake.value || MIN_STAKE);
const before = slip.singleCartItems.length;
slip.addToSingleCart(item);
close();
showToast(t(before < slip.singleCartItems.length ? 'bet.added_to_single' : 'bet.pick_added'));
}
function addToParlayList() {
const item = pendingItem.value;
if (!item) return;
if (!auth.token) {
auth.showLoginPrompt();
return;
}
slip.stake = Math.max(MIN_STAKE, stake.value || MIN_STAKE);
const before = slip.parlayItems.length;
const err = slip.addParlayLeg(item);
if (err) {
error.value = parlayErrorMessage(err);
showToast(error.value);
return;
}
close();
showToast(t(before < slip.parlayItems.length ? 'bet.added_to_parlay' : 'bet.pick_added'));
}
</script>
<template>
<Teleport to="body">
<div
v-if="visible && pendingItem"
ref="popRef"
class="bet-popover"
:class="`placement-${placement}`"
tabindex="-1"
:style="{ left: `${anchorX}px`, top: `${anchorY}px` }"
@click.stop
@mouseenter="cancelClose"
@mouseleave="onPopoverMouseLeave"
>
<div class="pop-match">{{ pendingItem.matchName }}</div>
<div class="pop-market">{{ pendingItem.marketName }}</div>
<div class="pop-pick-row">
<span class="pick">{{ pendingItem.selectionName }}</span>
<span class="odds">@ {{ pendingItem.odds.toFixed(2) }}</span>
</div>
<label class="stake-label">{{ t('bet.stake') }}</label>
<input v-model="stakeInput" type="number" class="stake-input" min="5" step="1" />
<div class="est-row">
<span>{{ t('bet.slip_est_return') }}</span>
<span class="est-val">{{ formatMoney(estReturn, locale) }}</span>
</div>
<p v-if="error" class="pop-error">{{ error }}</p>
<div class="pop-actions">
<button type="button" class="btn-primary-gold" :disabled="loading" @click="onPlaceNowClick">
{{ loading ? t('bet.placing') : t('bet.place_now') }}
</button>
<div class="pop-actions-row">
<button type="button" class="btn-outline" @click="addToSingleList">
{{ t('bet.add_to_single') }}
</button>
<button type="button" class="btn-outline" @click="addToParlayList">
{{ t('bet.add_to_parlay') }}
</button>
</div>
</div>
</div>
</Teleport>
<ConfirmDialog
v-model:visible="showPlaceConfirm"
:title="t('bet.place_confirm_title')"
:message="placeConfirmMessage"
:confirm-text="t('bet.place_bet')"
:cancel-text="t('common.cancel')"
:loading="loading"
@confirm="confirmPlaceNow"
/>
</template>
<style scoped>
.bet-popover {
position: fixed;
z-index: 1000;
width: 172px;
padding: 6px 7px;
border-radius: 6px;
border: 1px solid var(--border-active);
background: var(--bg-card-elevated);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: var(--shadow-lg);
}
.bet-popover::after,
.bet-popover::before {
content: '';
position: absolute;
width: 0;
height: 0;
border-style: solid;
pointer-events: none;
}
.bet-popover.placement-bottom::after {
top: -6px;
left: 50%;
transform: translateX(-50%);
border-width: 0 6px 6px 6px;
border-color: transparent transparent var(--bg-card-elevated) transparent;
z-index: 2;
}
.bet-popover.placement-bottom::before {
top: -7px;
left: 50%;
transform: translateX(-50%);
border-width: 0 7px 7px 7px;
border-color: transparent transparent var(--border-active) transparent;
z-index: 1;
}
.bet-popover.placement-top::after {
bottom: -6px;
left: 50%;
transform: translateX(-50%);
border-width: 6px 6px 0 6px;
border-color: var(--bg-card-elevated) transparent transparent transparent;
z-index: 2;
}
.bet-popover.placement-top::before {
bottom: -7px;
left: 50%;
transform: translateX(-50%);
border-width: 7px 7px 0 7px;
border-color: var(--border-active) transparent transparent transparent;
z-index: 1;
}
.pop-match {
font-size: 9px;
color: var(--text-muted);
line-height: 1.3;
margin-bottom: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pop-market {
font-size: 8px;
color: var(--text-muted);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pop-pick-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
padding: 4px 6px;
margin-bottom: 6px;
border-radius: 3px;
background: rgba(244, 162, 97, 0.04);
border: 1px solid rgba(244, 162, 97, 0.15);
}
.pick {
font-size: 10px;
font-weight: 700;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.odds {
font-size: 10px;
font-weight: 800;
color: var(--primary-light);
}
.stake-label {
display: block;
font-size: 8px;
color: var(--text-muted);
margin-bottom: 2px;
}
.stake-input {
width: 100%;
box-sizing: border-box;
padding: 4px 6px;
margin-bottom: 5px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--bg-body);
color: #fff;
font-size: 10px;
}
.stake-input:focus {
border-color: rgba(244, 162, 97, 0.3);
outline: none;
}
.est-row {
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--text-muted);
margin-bottom: 6px;
}
.est-val {
color: var(--primary-light);
font-weight: 700;
}
.pop-error {
font-size: 8px;
color: var(--danger);
margin: 0 0 5px;
}
.pop-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.pop-actions-row {
display: flex;
gap: 4px;
}
.pop-actions-row .btn-outline {
flex: 1;
width: auto;
min-width: 0;
}
.btn-primary-gold {
width: 100%;
padding: 5px;
border: none;
border-radius: 3px;
background: var(--gradient-primary);
color: #ffffff;
font-size: 10px;
font-weight: 800;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary-gold:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary-gold:disabled {
opacity: 0.5;
cursor: default;
}
.btn-outline {
width: 100%;
padding: 4px;
border-radius: 3px;
border: 1px solid rgba(244, 162, 97, 0.25);
background: rgba(244, 162, 97, 0.03);
color: var(--primary-light);
font-size: 9px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
}
.btn-outline:hover {
background: rgba(244, 162, 97, 0.08);
}
</style>

View File

@@ -0,0 +1,332 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import type { OutrightEvent, OutrightSelection } from '../outright/OutrightEventSection.vue';
import saishiImg from '../../assets/images/saishi.webp';
const props = defineProps<{
event: OutrightEvent;
}>();
const emit = defineEmits<{
open: [];
pick: [selection: OutrightSelection];
}>();
const { t } = useI18n();
const headTitle = computed(() => {
const raw = props.event.title.replace(/^\*+/, '').trim();
return raw || props.event.leagueName || t('bet.tab_outright');
});
const teamCount = computed(() => props.event.selectionCount ?? props.event.selections.length);
const isSettled = computed(
() => props.event.bettingOpen === false || props.event.status === 'SETTLED',
);
// Dynamically limit visible buttons based on card width
const MIN_BTN_W = 52; // px
const GAP = 4; // px
const cardRef = ref<HTMLElement | null>(null);
const cardWidth = ref(400);
let ro: ResizeObserver | null = null;
onMounted(() => {
if (!cardRef.value) return;
ro = new ResizeObserver(([entry]) => {
// panel uses left/right: 12px, so panel width = card - 24
cardWidth.value = entry.contentRect.width;
});
ro.observe(cardRef.value);
});
onUnmounted(() => ro?.disconnect());
const maxVisible = computed(() => {
const panelW = cardWidth.value - 24; // account for left/right: 12px
// n items need: n * MIN_BTN_W + (n-1) * GAP <= panelW
// n <= (panelW + GAP) / (MIN_BTN_W + GAP)
return Math.max(1, Math.min(5, Math.floor((panelW + GAP) / (MIN_BTN_W + GAP))));
});
// Sort by odds asc, show up to 5 but limited by available space
const topSelections = computed(() => {
if (!props.event.selections) return [];
return [...props.event.selections]
.sort((a, b) => {
const oa = parseFloat(a.odds) || 9999;
const ob = parseFloat(b.odds) || 9999;
return oa - ob;
})
.slice(0, maxVisible.value);
});
</script>
<template>
<article ref="cardRef" class="outright-event-card" :class="{ settled: isSettled }">
<!-- Clickable upper area to open detail -->
<div class="card-clickable-area" @click="emit('open')">
<div class="card-main">
<div class="title-row">
<span class="title">{{ headTitle }}</span>
<span v-if="isSettled" class="settled-tag">{{ t('bet.outright_settled') }}</span>
</div>
<p v-if="event.leagueName && event.leagueName !== headTitle" class="league">{{ event.leagueName }}</p>
<p class="meta">{{ t('bet.outright_teams_count', { n: teamCount }) }}</p>
</div>
<img :src="saishiImg" alt="" class="saishi" />
</div>
<!-- Quick bet popular teams panel -->
<div v-if="!isSettled && topSelections.length" class="quick-bet-panel">
<div class="quick-bet-title">{{ t('bet.quick_bet') || '快速下注' }}<span class="quick-bet-hint">{{ t('bet.popular_teams') || '热门队伍' }}</span></div>
<div class="odds-row">
<button
v-for="sel in topSelections"
:key="sel.id"
type="button"
class="odds-btn"
@click.stop="emit('pick', sel)"
>
<span class="selection-name" :title="sel.teamName">{{ sel.teamName }}</span>
<span class="selection-odds">{{ sel.odds }}</span>
</button>
</div>
<button type="button" class="view-all-btn" @click.stop="emit('open')">
{{ t('bet.outright_view_all') || '查看全部队伍' }}
</button>
</div>
</article>
</template>
<style scoped>
.outright-event-card {
position: relative;
width: 100%;
min-height: 140px;
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow);
transition: border-color 0.25s ease, box-shadow 0.25s ease;
}
.outright-event-card:hover {
border-color: var(--border-active);
box-shadow: var(--shadow-lg);
}
.card-clickable-area {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
cursor: pointer;
flex: 1;
transition: transform 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.outright-event-card:hover .card-clickable-area {
transform: translateY(-10px);
}
.card-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.title {
font-size: 14px;
font-weight: 800;
color: #fff;
line-height: 1.35;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
transition: color 0.2s;
}
.outright-event-card:hover .title {
color: var(--primary-light);
}
.settled-tag {
font-size: 10px;
font-weight: 800;
color: var(--primary);
border: 1px solid rgba(201, 162, 39, 0.45);
border-radius: 999px;
padding: 1px 7px;
background: rgba(201, 162, 39, 0.1);
}
.league {
margin: 0;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
opacity: 1;
transition: opacity 0.15s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
margin: 0;
font-size: 11px;
font-weight: 600;
color: #888;
opacity: 1;
transition: opacity 0.15s ease;
}
.outright-event-card:hover .league,
.outright-event-card:hover .meta {
opacity: 0;
}
.saishi {
flex-shrink: 0;
height: 48px;
width: auto;
max-width: 44px;
object-fit: contain;
opacity: 0.85;
transition: transform 0.25s ease, opacity 0.2s ease;
}
.outright-event-card:hover .saishi {
transform: scale(0.9) rotate(5deg);
opacity: 0;
}
/* Quick Bet Panel — absolute, card height stays fixed */
.quick-bet-panel {
position: absolute;
left: 12px;
right: 12px;
bottom: 10px;
display: flex;
flex-direction: column;
gap: 5px;
overflow: hidden;
opacity: 0;
pointer-events: none;
transform: translateY(12px);
transition: opacity 0.2s ease, transform 0.22s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 5;
}
.outright-event-card:hover .quick-bet-panel {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.quick-bet-title {
font-size: 9px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.quick-bet-hint {
font-size: 9px;
font-weight: 600;
color: #666;
text-transform: none;
letter-spacing: 0;
margin-left: 1px;
}
.odds-row {
display: flex;
gap: 4px;
width: 100%;
overflow: hidden;
}
.odds-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1; /* equal share of available width */
gap: 1px;
padding: 2px 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
min-height: 32px;
transition: all 0.2s ease;
}
.odds-btn:hover {
background: var(--border-gold-soft);
border-color: var(--border-gold);
box-shadow: 0 0 8px rgba(244, 162, 97, 0.25);
}
.selection-name {
font-size: 9px;
font-weight: 700;
color: #bbb;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.odds-btn:hover .selection-name {
color: #fff;
}
.selection-odds {
font-size: 11px;
font-weight: 900;
color: var(--primary-light);
line-height: 1.1;
}
.odds-btn:hover .selection-odds {
color: #fff;
}
.view-all-btn {
align-self: flex-end;
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
background: none;
border: none;
cursor: pointer;
padding: 2px 0;
opacity: 0.7;
transition: opacity 0.15s ease;
letter-spacing: 0.02em;
}
.view-all-btn:hover {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { useRoute, RouterView } from 'vue-router';
import { useI18n } from 'vue-i18n';
import DesktopTopNav from './DesktopTopNav.vue';
import DesktopOddsBetPopover from './DesktopOddsBetPopover.vue';
import SportsCategoryBar from './SportsCategoryBar.vue';
import LeagueSidebar from './LeagueSidebar.vue';
import MatchSidebar from './MatchSidebar.vue';
import OutrightSidebar from './OutrightSidebar.vue';
import DesktopBetSlipRail from './DesktopBetSlipRail.vue';
import FloatingMailbox from '../FloatingMailbox.vue';
import { usePlayerHome } from '../../composables/usePlayerHome';
import { useAuthStore } from '../../stores/auth';
import { useBetSlipStore } from '../../stores/betSlip';
const route = useRoute();
const { locale } = useI18n();
const auth = useAuthStore();
const slip = useBetSlipStore();
const { load: loadPlayerHome } = usePlayerHome();
const showBetSlipRail = computed(() => {
const p = route.path;
if (p === '/bets' || p.startsWith('/bets/')) return false;
if (p.startsWith('/wallet') || p.startsWith('/profile')) return false;
if (route.params.id && (p.startsWith('/announcements/') || p.startsWith('/messages/'))) {
return false;
}
return true;
});
const layoutMode = computed<'betting' | 'account' | 'hub' | 'marketing'>(() => {
const p = route.path;
if (p === '/bet' || p.startsWith('/match/') || p.startsWith('/outright/')) {
return 'betting';
}
if (
p === '/bets' ||
p.startsWith('/bets/') ||
p.startsWith('/wallet') ||
p === '/profile/cashbacks'
) {
return 'account';
}
if (p.startsWith('/announcements')) {
return 'hub';
}
if (p.startsWith('/messages')) {
return 'hub';
}
return 'marketing';
});
const isMatchDetail = computed(() => route.path.startsWith('/match/'));
const isDetailPage = computed(
() => route.path.startsWith('/match/') || route.path.startsWith('/outright/'),
);
const isBettingDetail = computed(
() => route.path.startsWith('/match/') || route.path.startsWith('/outright/'),
);
onMounted(() => {
void loadPlayerHome(true);
});
watch(locale, (next, prev) => {
if (prev && next !== prev) void loadPlayerHome(true);
});
watch(
() => auth.token,
() => {
void loadPlayerHome(true);
},
);
</script>
<template>
<div
class="desktop-shell"
:class="{
'betslip-collapsed': showBetSlipRail && !slip.panelExpanded,
'betslip-hidden': !showBetSlipRail,
}"
>
<header class="desktop-header-wrap">
<DesktopTopNav />
</header>
<div class="desktop-body">
<div class="desktop-main-container">
<!-- Betting Layout -->
<div v-if="layoutMode === 'betting'" class="desktop-layout-betting-wrap">
<div class="desktop-betting-sports-row">
<SportsCategoryBar />
</div>
<div class="desktop-layout-betting">
<aside class="desktop-betting-left">
<LeagueSidebar v-if="!isDetailPage" />
<MatchSidebar v-else-if="isMatchDetail" />
<OutrightSidebar v-else />
</aside>
<main class="desktop-betting-center" :class="{ 'is-betting-detail': isBettingDetail }">
<RouterView v-slot="{ Component, route: viewRoute }">
<Transition name="page-fade" mode="out-in">
<component :is="Component" :key="viewRoute.path" />
</Transition>
</RouterView>
</main>
</div>
</div>
<!-- Account Layout -->
<div v-else-if="layoutMode === 'account'" class="desktop-layout-account">
<main class="desktop-account-center">
<RouterView v-slot="{ Component, route: viewRoute }">
<Transition name="page-fade" mode="out-in">
<component :is="Component" :key="viewRoute.path" />
</Transition>
</RouterView>
</main>
</div>
<!-- Hub Layout: Messages / Announcements split pane -->
<div v-else-if="layoutMode === 'hub'" class="desktop-layout-hub-outer">
<RouterView v-slot="{ Component, route: viewRoute }">
<Transition name="page-fade" mode="out-in">
<component :is="Component" :key="viewRoute.path" />
</Transition>
</RouterView>
</div>
<!-- Marketing/Single Column Layout -->
<div v-else class="desktop-layout-marketing no-scrollbar">
<main class="desktop-marketing-content">
<RouterView v-slot="{ Component, route: viewRoute }">
<Transition name="page-fade" mode="out-in">
<component :is="Component" :key="viewRoute.path" />
</Transition>
</RouterView>
</main>
</div>
</div>
<DesktopBetSlipRail v-if="showBetSlipRail" />
</div>
<FloatingMailbox />
<DesktopOddsBetPopover />
</div>
</template>
<style>
/* Page transition — must be global (not scoped) so Vue can match
the dynamically-applied .page-fade-* classes on child roots */
.page-fade-enter-active {
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
}
.page-fade-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.page-fade-enter-from {
opacity: 0;
transform: translateY(12px);
}
.page-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../../stores/auth';
import LocaleSwitcher from '../LocaleSwitcher.vue';
import CashBalanceChip from '../CashBalanceChip.vue';
import UserAvatarMenu from '../UserAvatarMenu.vue';
const { t } = useI18n();
const route = useRoute();
const auth = useAuthStore();
const activeTab = computed(() => {
const p = route.path;
if (p === '/') return 'home';
if (p === '/bet' || p.startsWith('/match/') || p.startsWith('/outright/')) return 'sports';
if (p === '/bets' || p.startsWith('/bets/')) return 'bet_history';
if (p.startsWith('/wallet') || p === '/profile/cashbacks') return 'wallet';
if (p.startsWith('/profile')) return 'profile';
if (p.startsWith('/messages')) return 'messages';
return '';
});
</script>
<template>
<div class="desktop-top-nav">
<RouterLink to="/" class="logo-wrap">
<img src="/logo.png" alt="TheBet365" class="logo" />
<span class="site-name">TheBet365</span>
</RouterLink>
<nav class="nav-menu">
<RouterLink
to="/"
class="nav-link"
:class="{ active: activeTab === 'home' }"
>
{{ t('nav.home') }}
</RouterLink>
<RouterLink
to="/bet"
class="nav-link"
:class="{ active: activeTab === 'sports' }"
>
{{ t('nav.sports') }}
</RouterLink>
<RouterLink
to="/bets"
class="nav-link"
:class="{ active: activeTab === 'bet_history' }"
>
{{ t('nav.bet_history') }}
</RouterLink>
<RouterLink
to="/wallet"
class="nav-link"
:class="{ active: activeTab === 'wallet' }"
>
{{ t('nav.wallet') }}
</RouterLink>
<RouterLink
to="/profile"
class="nav-link"
:class="{ active: activeTab === 'profile' }"
>
{{ t('nav.profile') }}
</RouterLink>
</nav>
<div class="nav-right">
<LocaleSwitcher />
<template v-if="auth.user">
<CashBalanceChip />
<UserAvatarMenu />
</template>
<div v-else class="auth-buttons">
<button
type="button"
class="login-btn"
@click="auth.showLoginPrompt(route.fullPath)"
>
{{ t('auth.login') }}
</button>
<RouterLink to="/register" class="register-btn">
{{ t('auth.register') || '注册' }}
</RouterLink>
</div>
</div>
</div>
</template>
<style scoped>
.desktop-top-nav {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
height: 100%;
}
.logo-wrap {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
flex: 1;
}
.logo {
height: 38px;
width: auto;
display: block;
filter: drop-shadow(0 0 4px rgba(244, 162, 97, 0.2));
}
.site-name {
font-size: 20px;
font-weight: 800;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
letter-spacing: 0.5px;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 8px rgba(244, 162, 97, 0.25);
transition: opacity 0.2s;
}
.logo-wrap:hover .site-name {
opacity: 0.85;
}
.nav-menu {
display: flex;
align-items: center;
gap: 24px;
justify-content: center;
}
.nav-link {
position: relative;
font-size: 14px;
font-weight: 700;
color: var(--text-muted);
padding: 18px 4px;
transition: color 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.nav-link:hover {
color: var(--primary-light);
}
.nav-link.active {
color: var(--primary-light);
}
.nav-link.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: var(--primary);
border-radius: 3px 3px 0 0;
box-shadow: 0 -1px 8px rgba(244, 162, 97, 0.5);
}
.badge {
display: inline-block;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: #e74c3c;
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 16px;
text-align: center;
}
.nav-right {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
justify-content: flex-end;
}
.auth-buttons {
display: flex;
align-items: center;
gap: 12px;
}
.login-btn {
padding: 6px 18px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: rgba(244, 162, 97, 0.05);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
transition: all 0.2s;
}
.login-btn:hover {
background: rgba(244, 162, 97, 0.12);
border-color: var(--primary);
}
.register-btn {
padding: 7px 18px;
border-radius: 4px;
background: var(--gradient-primary);
color: #fff;
font-size: 13px;
font-weight: 800;
transition: opacity 0.2s;
}
.register-btn:hover {
opacity: 0.9;
}
/* Override locale switcher and other header action height */
:deep(.locale-switch:not(.compact)),
:deep(.cash-chip),
:deep(.avatar-btn) {
height: 34px !important;
box-sizing: border-box;
}
:deep(.avatar-btn) {
width: 34px !important;
}
</style>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const route = useRoute();
const tabs = [
{
path: '/wallet',
labelKey: 'nav.wallet',
match: (p: string) =>
p === '/wallet' ||
p === '/wallet/detail' ||
p === '/wallet/recharge' ||
p.startsWith('/wallet/transactions'),
},
{
path: '/wallet/recharge/history',
labelKey: 'wallet.recharge_history',
match: (p: string) => p.startsWith('/wallet/recharge/history'),
},
{
path: '/wallet/cashbacks',
labelKey: 'wallet.cashbacks_tab',
match: (p: string) => p === '/wallet/cashbacks' || p === '/profile/cashbacks',
},
];
const activePath = computed(() => route.path);
</script>
<template>
<nav class="desktop-wallet-subnav" :aria-label="t('nav.wallet')">
<RouterLink
v-for="tab in tabs"
:key="tab.path"
:to="tab.path"
class="desktop-wallet-subnav-link"
:class="{ active: tab.match(activePath) }"
>
{{ t(tab.labelKey) }}
</RouterLink>
</nav>
</template>

View File

@@ -0,0 +1,363 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMatchFilters } from '../../composables/useMatchFilters';
import { useDesktopParlayMatches } from '../../composables/useDesktopParlayMatches';
import { useOutrightEvents } from '../../composables/useOutrightEvents';
import {
isAfterLocalTodayMatchWindow as isAfterTodayMatchWindow,
isInLocalTodayMatchWindow as isInTodayMatchWindow,
} from '@thebet365/shared';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const { parlayMatches, loadParlayMatches } = useDesktopParlayMatches();
const { searchQuery, filterState, toggleLeague, clearFilters } = useMatchFilters();
const { events: outrightEvents, load: loadOutrightEvents } = useOutrightEvents();
const isOutrightMode = computed(() => {
return route.query.tab === 'outright';
});
watch(
isOutrightMode,
(outright) => {
if (outright) {
void loadOutrightEvents({ silent: outrightEvents.value.length > 0 });
} else {
void loadParlayMatches();
}
},
{ immediate: true },
);
function normalizeLeagueName(name: string): string {
return name
.replace(/\.unit$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const availableLeagues = computed(() => {
const map = new Map<string, { id: string; name: string; count: number }>();
const keyword = searchQuery.value.trim().toLowerCase();
if (isOutrightMode.value) {
for (const e of outrightEvents.value) {
if (keyword) {
const haystack = `${e.title} ${e.leagueName || ''}`.toLowerCase();
if (!haystack.includes(keyword)) continue;
}
const id = e.leagueId ?? e.leagueName ?? 'unknown';
const existing = map.get(id);
if (existing) {
existing.count += 1;
} else {
map.set(id, {
id,
name: normalizeLeagueName(e.leagueName || e.title),
count: 1,
});
}
}
} else {
const now = new Date();
for (const m of parlayMatches.value) {
if (keyword) {
const haystack = `${m.homeTeamName} ${m.awayTeamName} ${m.leagueName}`.toLowerCase();
if (!haystack.includes(keyword)) continue;
}
let timeMatch = true;
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, now);
} else if (filterState.value.time === 'early') {
timeMatch = isAfterTodayMatchWindow(m.startTime, now);
}
if (!timeMatch) continue;
if (filterState.value.status === 'open' && m.matchPhase !== 'open' && m.matchPhase !== undefined) continue;
if (filterState.value.status === 'settled' && m.matchPhase !== 'settled') continue;
const id = m.leagueId ?? m.leagueName;
const existing = map.get(id);
if (existing) {
existing.count += 1;
} else {
map.set(id, {
id,
name: normalizeLeagueName(m.leagueName),
count: 1,
});
}
}
}
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
});
const isSelected = (leagueId: string) => {
return filterState.value.leagueIds.length === 0 || filterState.value.leagueIds.includes(leagueId);
};
const handleLeagueClick = (leagueId: string) => {
toggleLeague(leagueId);
};
</script>
<template>
<div class="league-sidebar">
<div class="search-box">
<input
v-model="searchQuery"
type="text"
:placeholder="t('nav.search')"
class="sidebar-search-input"
/>
</div>
<div v-if="!isOutrightMode" class="sidebar-section">
<div class="section-hdr">{{ t('bet.filter_time') }}</div>
<div class="time-filters">
<button
type="button"
class="time-btn"
:class="{ active: filterState.time === 'all' }"
@click="filterState.time = 'all'"
>
{{ t('bet.time_all') }}
</button>
<button
type="button"
class="time-btn"
:class="{ active: filterState.time === 'today' }"
@click="filterState.time = 'today'"
>
{{ t('bet.time_today') }}
</button>
<button
type="button"
class="time-btn"
:class="{ active: filterState.time === 'early' }"
@click="filterState.time = 'early'"
>
{{ t('bet.time_early') }}
</button>
</div>
</div>
<div class="sidebar-section leagues-section">
<div class="section-hdr leagues-hdr">
<span>{{ t('bet.filter_league') }}</span>
<button
v-if="filterState.leagueIds.length > 0"
type="button"
class="clear-btn"
@click="clearFilters"
>
{{ t('common.clear') }}
</button>
</div>
<div class="leagues-list">
<div
v-for="lg in availableLeagues"
:key="lg.id"
class="league-item"
:class="{ active: isSelected(lg.id) }"
@click="handleLeagueClick(lg.id)"
>
<div class="checkbox" :class="{ checked: isSelected(lg.id) }"></div>
<span class="league-name" :title="lg.name">{{ lg.name }}</span>
<span class="league-count">{{ lg.count }}</span>
</div>
<div v-if="availableLeagues.length === 0" class="empty-leagues">
{{ isOutrightMode ? t('bet.no_outright') : t('bet.no_matches') }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.league-sidebar {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px 0;
}
.search-box {
padding: 0 12px 10px;
border-bottom: 1px solid var(--border);
}
.sidebar-search-input {
width: 100%;
background: var(--bg-body) !important;
border: 1px solid var(--border) !important;
color: var(--text);
padding: 6px 10px;
font-size: 11px;
border-radius: 4px;
}
.sidebar-search-input:focus {
border-color: var(--border-gold-soft) !important;
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.1) !important;
}
.sidebar-section {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.section-hdr {
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.time-filters {
display: flex;
gap: 4px;
}
.time-btn {
flex: 1;
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 5px 2px;
font-size: 10px;
font-weight: 700;
border-radius: 4px;
text-align: center;
transition: all 0.2s;
}
.time-btn:hover {
border-color: var(--border-gold-soft);
color: var(--text);
}
.time-btn.active {
background: rgba(244, 162, 97, 0.08);
border-color: var(--primary);
color: var(--primary-light);
}
.leagues-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-bottom: none;
padding-bottom: 0;
}
.leagues-hdr {
display: flex;
justify-content: space-between;
align-items: center;
}
.clear-btn {
background: transparent;
color: var(--primary-light);
font-size: 10px;
font-weight: 700;
padding: 0;
text-transform: none;
}
.leagues-list {
flex: 1;
overflow-y: auto;
margin-right: -4px;
padding-right: 4px;
}
.league-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin-bottom: 2px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s;
}
.league-item:hover {
background: var(--bg-hover);
}
.league-item.active {
background: rgba(244, 162, 97, 0.04);
}
.checkbox {
width: 12px;
height: 12px;
border: 1px solid var(--border);
border-radius: 2px;
position: relative;
flex-shrink: 0;
background: var(--bg-body);
}
.checkbox.checked {
border-color: var(--primary);
background: var(--primary);
}
.checkbox.checked::after {
content: '';
position: absolute;
top: 1px;
left: 4px;
width: 3px;
height: 6px;
border: solid var(--bg-body);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.league-name {
flex: 1;
font-size: 11px;
font-weight: 600;
color: #ddd;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.league-item.active .league-name {
color: var(--primary-light);
font-weight: 700;
}
.league-count {
font-size: 9px;
color: var(--text-muted);
font-weight: 700;
background: rgba(22, 45, 54, 0.4); border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 8px;
}
.empty-leagues {
padding: 16px 0;
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,343 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMatchFilters } from '../../composables/useMatchFilters';
import { useDesktopParlayMatches } from '../../composables/useDesktopParlayMatches';
import MatchBetCard from '../MatchBetCard.vue';
import GoldSpinner from '../GoldSpinner.vue';
import {
isAfterLocalTodayMatchWindow as isAfterTodayMatchWindow,
isInLocalTodayMatchWindow as isInTodayMatchWindow,
} from '@thebet365/shared';
import { ref, watch, nextTick } from 'vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const { searchQuery, filterState } = useMatchFilters();
const { parlayMatches, parlayLoading, loadParlayMatches } = useDesktopParlayMatches();
const listRef = ref<HTMLElement | null>(null);
onMounted(() => {
void loadParlayMatches().then(() => {
scrollToActive();
});
});
const currentMatchId = computed(() => {
const id = route.params.id;
return Array.isArray(id) ? id[0] : id;
});
function matchesSearchKeyword(m: any, keyword: string) {
if (!keyword) return true;
const haystack = `${m.homeTeamName} ${m.awayTeamName} ${m.leagueName}`.toLowerCase();
return haystack.includes(keyword);
}
const filteredMatches = computed(() => {
const now = new Date();
const keyword = searchQuery.value.trim().toLowerCase();
return parlayMatches.value.filter((m) => {
const isCurrent = String(m.id) === String(currentMatchId.value);
// 封盘或已结算的不展示,除非是当前正在查看的赛事
if (!isCurrent && (m.bettingOpen === false || m.matchPhase === 'settled' || m.matchPhase === 'closed_pending')) {
return false;
}
if (!isCurrent && !matchesSearchKeyword(m, keyword)) return false;
let timeMatch = true;
if (!isCurrent) {
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, now);
} else if (filterState.value.time === 'early') {
timeMatch = isAfterTodayMatchWindow(m.startTime, now);
}
}
return isCurrent || timeMatch;
});
});
function goMatch(id: string) {
router.replace(`/match/${id}`);
}
function scrollToActive() {
nextTick(() => {
if (!listRef.value) return;
const activeEl = listRef.value.querySelector('.active') as HTMLElement;
if (activeEl) {
activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}
watch(currentMatchId, scrollToActive);
</script>
<template>
<div class="match-sidebar">
<div class="search-box">
<input
v-model="searchQuery"
type="text"
:placeholder="t('nav.search')"
class="sidebar-search-input"
/>
</div>
<div class="sidebar-section">
<div class="section-hdr">{{ t('bet.filter_time') }}</div>
<div class="time-filters">
<button
type="button"
class="time-btn"
:class="{ active: filterState.time === 'all' }"
@click="filterState.time = 'all'"
>
{{ t('bet.time_all') }}
</button>
<button
type="button"
class="time-btn"
:class="{ active: filterState.time === 'today' }"
@click="filterState.time = 'today'"
>
{{ t('bet.time_today') }}
</button>
<button
type="button"
class="time-btn"
:class="{ active: filterState.time === 'early' }"
@click="filterState.time = 'early'"
>
{{ t('bet.time_early') }}
</button>
</div>
</div>
<div class="sidebar-section matches-section">
<div class="section-hdr leagues-hdr">
<span>{{ t('bet.tab_matches') || '赛事' }}</span>
</div>
<div class="matches-list" ref="listRef">
<div v-if="parlayLoading && !filteredMatches.length" class="sidebar-loading">
<GoldSpinner :size="24" />
</div>
<template v-else>
<MatchBetCard
v-for="m in filteredMatches"
:key="m.id"
:match="m"
:no-quick-bet="true"
:class="{ active: String(m.id) === String(currentMatchId) }"
@bet="goMatch"
/>
<div v-if="filteredMatches.length === 0 && !parlayLoading" class="empty-matches">
{{ t('bet.no_matches') }}
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.match-sidebar {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px 0;
}
.search-box {
padding: 0 12px 10px;
border-bottom: 1px solid var(--border);
}
.sidebar-search-input {
width: 100%;
background: var(--bg-body) !important;
border: 1px solid var(--border) !important;
color: var(--text);
padding: 6px 10px;
font-size: 11px;
border-radius: 4px;
}
.sidebar-search-input:focus {
border-color: var(--border-gold-soft) !important;
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.1) !important;
}
.sidebar-section {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.section-hdr {
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.leagues-hdr {
display: flex;
justify-content: space-between;
align-items: center;
}
.time-filters {
display: flex;
gap: 4px;
}
.time-btn {
flex: 1;
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 5px 2px;
font-size: 10px;
font-weight: 700;
border-radius: 4px;
text-align: center;
transition: all 0.2s;
}
.time-btn:hover {
border-color: var(--border-gold-soft);
color: var(--text);
}
.time-btn.active {
background: rgba(244, 162, 97, 0.08);
border-color: var(--primary);
color: var(--primary-light);
}
.matches-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-bottom: none;
padding-bottom: 0;
}
.matches-list {
flex: 1;
overflow-y: auto;
margin-right: -4px;
padding-right: 4px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar-loading {
display: flex;
justify-content: center;
align-items: center;
padding: 30px 0;
}
.empty-matches {
padding: 16px 0;
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
/* Compact overrides for MatchBetCard inside sidebar */
.matches-list :deep(.bet-btn),
.matches-list :deep(.team-flag) {
display: none !important;
}
.matches-list :deep(.team) {
transform: none !important;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.matches-list :deep(.team-name) {
font-size: 12px;
font-weight: 700;
color: #ffffff;
text-align: center;
padding: 0 4px;
max-width: 80px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: none;
display: block;
}
.matches-list :deep(.center-col) {
min-width: 60px;
gap: 2px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.matches-list :deep(.kickoff) {
font-size: 9px;
color: var(--text-muted);
text-shadow: none;
white-space: nowrap;
}
.matches-list :deep(.vs) {
font-size: 11px;
font-weight: 800;
color: var(--primary-light);
text-shadow: none;
}
.matches-list :deep(.live-score) {
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
}
.matches-list :deep(.status-tag) {
font-size: 8px;
padding: 2px 6px;
border-radius: 0 4px 0 6px;
}
.matches-list :deep(.match-card) {
padding: 10px 8px;
min-height: unset;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 6px;
gap: 6px;
}
.matches-list :deep(.match-card:hover) {
border-color: var(--border-gold-soft);
background: var(--bg-hover) !important;
}
.matches-list :deep(.match-card.active) {
border-color: var(--primary) !important;
background: rgba(244, 162, 97, 0.08) !important;
box-shadow: 0 0 8px rgba(244, 162, 97, 0.25);
}
</style>

View File

@@ -0,0 +1,265 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMatchFilters } from '../../composables/useMatchFilters';
import { useOutrightEvents } from '../../composables/useOutrightEvents';
import GoldSpinner from '../GoldSpinner.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const { searchQuery } = useMatchFilters();
const { events: outrightEvents, loading, load: loadOutrightEvents } = useOutrightEvents();
const listRef = ref<HTMLElement | null>(null);
onMounted(() => {
void loadOutrightEvents({ silent: outrightEvents.value.length > 0 }).then(() => {
scrollToActive();
});
});
const currentOutrightId = computed(() => {
const id = route.params.id;
return Array.isArray(id) ? id[0] : id;
});
function normalizeLeagueName(name: string): string {
return name
.replace(/\.unit$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const filteredEvents = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase();
return outrightEvents.value.filter((e) => {
// If it's the active event, always show it
const isCurrent = String(e.id) === String(currentOutrightId.value);
if (isCurrent) return true;
if (keyword) {
const haystack = `${e.title} ${e.leagueName || ''}`.toLowerCase();
if (!haystack.includes(keyword)) return false;
}
return true;
});
});
function goOutright(id: string) {
router.replace(`/outright/${id}`);
}
function scrollToActive() {
nextTick(() => {
if (!listRef.value) return;
const activeEl = listRef.value.querySelector('.active') as HTMLElement;
if (activeEl) {
activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}
watch(currentOutrightId, scrollToActive);
</script>
<template>
<div class="outright-sidebar">
<div class="search-box">
<input
v-model="searchQuery"
type="text"
:placeholder="t('nav.search')"
class="sidebar-search-input"
/>
</div>
<div class="sidebar-section outrights-section">
<div class="section-hdr">
<span>{{ t('bet.tab_outright') || '优胜冠军' }}</span>
</div>
<div class="outrights-list" ref="listRef">
<div v-if="loading && !filteredEvents.length" class="sidebar-loading">
<GoldSpinner :size="24" />
</div>
<template v-else>
<div
v-for="e in filteredEvents"
:key="e.id"
class="outright-card"
:class="{ active: String(e.id) === String(currentOutrightId) }"
@click="goOutright(e.id)"
>
<div class="card-header">
<span class="league-tag" :title="e.leagueName">{{ normalizeLeagueName(e.leagueName || e.title) }}</span>
</div>
<div class="event-title" :title="e.title">{{ e.title }}</div>
<div class="card-footer">
<span class="selections-badge">
{{ e.selections?.length || 0 }} {{ t('bet.selections') || '项' }}
</span>
</div>
</div>
<div v-if="filteredEvents.length === 0 && !loading" class="empty-outrights">
{{ t('bet.no_outright') }}
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.outright-sidebar {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px 0;
}
.search-box {
padding: 0 12px 10px;
border-bottom: 1px solid var(--border);
}
.sidebar-search-input {
width: 100%;
background: var(--bg-body) !important;
border: 1px solid var(--border) !important;
color: var(--text);
padding: 6px 10px;
font-size: 11px;
border-radius: 4px;
}
.sidebar-search-input:focus {
border-color: var(--border-gold-soft) !important;
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.1) !important;
}
.sidebar-section {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.section-hdr {
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.outrights-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-bottom: none;
padding-bottom: 0;
}
.outrights-list {
flex: 1;
overflow-y: auto;
margin-right: -4px;
padding-right: 4px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar-loading {
display: flex;
justify-content: center;
align-items: center;
padding: 30px 0;
}
.empty-outrights {
padding: 16px 0;
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
.outright-card {
padding: 12px 10px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.outright-card:hover {
border-color: var(--border-gold-soft);
background: var(--bg-hover) !important;
}
.outright-card.active {
border-color: var(--primary) !important;
background: rgba(244, 162, 97, 0.08) !important;
box-shadow: 0 0 8px rgba(244, 162, 97, 0.25);
}
.card-header {
display: flex;
align-items: center;
}
.league-tag {
font-size: 9px;
color: var(--primary-light);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.event-title {
font-size: 12px;
font-weight: 700;
color: #ffffff;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.outright-card.active .event-title {
color: var(--primary-light);
}
.card-footer {
display: flex;
justify-content: flex-start;
}
.selections-badge {
font-size: 9px;
color: var(--text-muted);
font-weight: 700;
background: rgba(22, 45, 54, 0.45); border: 1px solid var(--border);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.03);
}
.outright-card.active .selections-badge {
background: rgba(244, 162, 97, 0.15);
color: var(--primary-light);
border-color: rgba(244, 162, 97, 0.3);
}
</style>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
modelValue: { type: Number, required: true },
total: { type: Number, required: true },
pageSize: { type: Number, default: 20 },
});
const emit = defineEmits(['update:modelValue', 'update:pageSize', 'change']);
const { t } = useI18n();
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
const pages = computed(() => {
const current = props.modelValue;
const max = totalPages.value;
if (max <= 7) {
return Array.from({ length: max }, (_, i) => i + 1);
}
if (current <= 4) {
return [1, 2, 3, 4, 5, '...', max];
}
if (current >= max - 3) {
return [1, '...', max - 4, max - 3, max - 2, max - 1, max];
}
return [1, '...', current - 1, current, current + 1, '...', max];
});
function goTo(page: number | string) {
if (typeof page === 'string') return;
if (page < 1 || page > totalPages.value) return;
if (page !== props.modelValue) {
emit('update:modelValue', page);
emit('change', page);
}
}
function onPageSizeChange(e: Event) {
const size = Number((e.target as HTMLSelectElement).value);
emit('update:pageSize', size);
emit('update:modelValue', 1);
emit('change', 1);
}
</script>
<template>
<div v-if="total > 0" class="pagination">
<span class="page-total">{{ t('pagination.total', { total }) }}</span>
<div class="page-nav">
<button
type="button"
class="page-btn nav-btn"
:disabled="modelValue === 1"
@click="goTo(modelValue - 1)"
>
&lt;
</button>
<button
v-for="(p, idx) in pages"
:key="idx"
type="button"
class="page-btn"
:class="{ active: p === modelValue, ellipsis: typeof p === 'string' }"
:disabled="typeof p === 'string'"
@click="goTo(p)"
>
{{ p }}
</button>
<button
type="button"
class="page-btn nav-btn"
:disabled="modelValue === totalPages"
@click="goTo(modelValue + 1)"
>
&gt;
</button>
</div>
<div class="page-size-wrap">
<select :value="pageSize" class="page-size-select" @change="onPageSizeChange">
<option :value="10">{{ t('pagination.per_page', { size: 10 }) }}</option>
<option :value="20">{{ t('pagination.per_page', { size: 20 }) }}</option>
<option :value="50">{{ t('pagination.per_page', { size: 50 }) }}</option>
</select>
</div>
</div>
</template>
<style scoped>
.pagination {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.page-total {
margin-right: auto;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.page-nav {
display: flex;
align-items: center;
gap: 8px;
}
.page-btn {
min-width: 32px;
height: 32px;
padding: 0 8px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.page-btn:hover:not(:disabled) {
border-color: var(--border-gold-soft);
color: var(--text);
background: rgba(244, 162, 97, 0.05);
}
.page-btn.active {
background: var(--primary);
border-color: var(--primary);
color: var(--bg-body);
font-weight: 800;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-btn.ellipsis {
border: none;
background: transparent;
cursor: default;
}
.page-size-select {
height: 32px;
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
outline: none;
cursor: pointer;
transition: all 0.2s;
}
.page-size-select:hover {
border-color: var(--border-gold-soft);
color: var(--text);
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
type SportCategory = 'football' | 'basketball' | 'tennis';
const SPORT_CATEGORIES = [
{ id: 'football' as const, icon: '', labelKey: 'bet.sport_football', enabled: true },
{ id: 'basketball' as const, icon: '', labelKey: 'bet.sport_basketball', enabled: false },
{ id: 'tennis' as const, icon: '', labelKey: 'bet.sport_tennis', enabled: false },
] as const;
const { t } = useI18n();
const activeSport = ref<SportCategory>('football');
function selectSport(id: SportCategory, enabled: boolean) {
if (!enabled) return;
activeSport.value = id;
}
</script>
<template>
<div class="sports-category-bar">
<button
v-for="sport in SPORT_CATEGORIES"
:key="sport.id"
type="button"
class="sport-category"
:class="{
active: sport.enabled && activeSport === sport.id,
disabled: !sport.enabled,
}"
:disabled="!sport.enabled"
:aria-current="sport.enabled && activeSport === sport.id ? 'page' : undefined"
@click="selectSport(sport.id, sport.enabled)"
>
<span v-if="sport.icon" class="sport-category-icon" aria-hidden="true">{{ sport.icon }}</span>
<span class="sport-category-label">{{ t(sport.labelKey) }}</span>
<span v-if="!sport.enabled" class="sport-category-soon">{{ t('bet.sport_coming_soon') }}</span>
</button>
</div>
</template>
<style scoped>
.sports-category-bar {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 0;
overflow-x: auto;
scrollbar-width: none;
}
.sports-category-bar::-webkit-scrollbar {
display: none;
}
.sport-category {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
transition: background 0.18s, border-color 0.18s, color 0.18s;
}
.sport-category:hover:not(:disabled) {
color: #fff;
background: rgba(255, 255, 255, 0.06);
}
.sport-category.active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.1);
border-color: var(--border-gold-soft);
box-shadow: inset 0 0 0 1px rgba(244, 162, 97, 0.08);
}
.sport-category.disabled {
cursor: not-allowed;
opacity: 0.55;
}
.sport-category-icon {
font-size: 14px;
line-height: 1;
}
.sport-category-label {
line-height: 1.2;
}
.sport-category-soon {
padding: 1px 5px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.45);
}
</style>