Files
thebet365/apps/player/src/views/desktop/DesktopMatchDetailView.vue
Mars 8a1ba0fd95 feat(player): 新增桌面端 UI 与响应式双端路由架构
- 将原移动端页面重命名为 Mobile*,新增 22 个 Desktop* 视图与 DesktopShell 布局体系
- 路由入口改为 Wrapper 视图,通过 useViewport(≥1024px)自动切换移动/桌面端
- 新增投注单面板、盘口弹层、联赛侧栏、数据表格等桌面组件及独立样式令牌
- 扩展 betSlip store、串关筛选、冠军盘、Toast/悬浮信箱等交互与三语 i18n
- 公告/消息 Hub 拆分移动与桌面实现;API 投注/钱包/充值记录支持 pageSize 分页
2026-06-29 18:01:39 +08:00

772 lines
20 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import { useBetSlipStore } from '../../stores/betSlip';
import { useAuthStore } from '../../stores/auth';
import TeamEmblem from '../../components/TeamEmblem.vue';
import MatchBetGuide from '../../components/match-detail/MatchBetGuide.vue';
import MarketSelectionsPanel from '../../components/match-detail/MarketSelectionsPanel.vue';
import CorrectScorePanel from '../../components/match-detail/CorrectScorePanel.vue';
import { isCorrectScoreMarket, parseScoreCode } from '../../utils/correctScoreLayout';
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { matchPhaseLabel } from '../../utils/matchPhase';
import { formatLocalMatchDateTime, defaultMarketName, DETAIL_MARKET_TYPES } from '@thebet365/shared';
import { resolveSelectionLabel } from '../../utils/selectionLabel';
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
import { useDesktopBetLocate, scrollToBetSelection } from '../../composables/useDesktopBetLocate';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const slip = useBetSlipStore();
const auth = useAuthStore();
const { openAt, visible: popoverVisible, pendingItem: popoverItem } = useDesktopBetPopover();
const { pendingLocate, clearLocate } = useDesktopBetLocate();
function goBack() {
if (window.history.state && window.history.state.back) {
router.back();
} else {
router.push('/bet');
}
}
interface Selection {
id: string;
selectionCode: string;
selectionName: string;
selectionDisplayName?: string;
odds: string;
oddsVersion: string;
}
interface Market {
id: string;
marketType: string;
marketDisplayName?: string;
lineValue?: string | number | null;
status: string;
allowSingle?: boolean;
allowParlay?: boolean;
promoLabel?: string | null;
selections: Selection[];
}
interface MatchDetail {
id: string;
leagueName?: string;
leagueLogoUrl?: string | null;
homeTeamName: string;
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
homeTeamLogoUrl?: string | null;
awayTeamLogoUrl?: string | null;
startTime: string;
status?: string;
bettingOpen?: boolean;
matchPhase?: string;
score?: {
htHome: number | null;
htAway: number | null;
ftHome: number | null;
ftAway: number | null;
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
} | null;
markets: Market[];
}
const match = ref<MatchDetail | null>(null);
const loading = ref(true);
const selectedMarketTypes = ref<Set<string>>(new Set());
async function loadMatch() {
loading.value = true;
try {
const { data } = await api.get(`/player/matches/${route.params.id}`);
match.value = data.data;
selectedMarketTypes.value = new Set();
} finally {
loading.value = false;
}
}
onMounted(() => {
void loadMatch();
});
useOnLocaleChange(loadMatch);
const kickoff = computed(() => {
if (!match.value) return '';
return formatLocalMatchDateTime(match.value.startTime, locale.value, { variant: 'full' });
});
const bettingOpen = computed(() => match.value?.bettingOpen !== false);
const matchPhase = computed<import('../../utils/matchPhase').MatchPhase | null>(() => (match.value?.matchPhase ?? (bettingOpen.value ? 'open' : 'closed_pending')) as import('../../utils/matchPhase').MatchPhase);
const phaseLabel = computed(() => matchPhaseLabel(t, matchPhase.value));
const liveScoreText = computed(() => {
const s = match.value?.score;
if (!s || s.ftHome == null || s.ftAway == null) return '';
return `${s.ftHome} - ${s.ftAway}`;
});
const resultStats = computed(() => {
const s = match.value?.score;
if (!s || matchPhase.value !== 'settled') return [];
const statVal = (val: number | null | undefined) => val == null ? '-' : String(val);
const pair = (h: number | null | undefined, a: number | null | undefined) => `${statVal(h)} - ${statVal(a)}`;
return [
{ key: 'ht', label: t('bet.result_ht'), value: pair(s.htHome, s.htAway) },
{ key: 'ft', label: t('bet.result_ft'), value: pair(s.ftHome, s.ftAway) },
{ key: 'corners', label: t('bet.result_corners'), value: pair(s.homeCorners, s.awayCorners) },
{ key: 'yellow', label: t('bet.result_yellow_cards'), value: pair(s.homeYellowCards, s.awayYellowCards) },
{ key: 'red', label: t('bet.result_red_cards'), value: pair(s.homeRedCards, s.awayRedCards) },
];
});
const showResultStats = computed(() => resultStats.value.length > 0);
const allMarkets = computed(() => match.value?.markets ?? []);
const compactMarkets = computed(() =>
allMarkets.value.filter((m) => !isCorrectScoreMarket(m.marketType)),
);
const scoreMarkets = computed(() =>
allMarkets.value.filter((m) => isCorrectScoreMarket(m.marketType)),
);
const marketFilterOptions = computed(() => {
const labels = new Map<string, string>();
for (const market of allMarkets.value) {
if (labels.has(market.marketType)) continue;
labels.set(
market.marketType,
market.marketDisplayName?.trim() || defaultMarketName(market.marketType, locale.value),
);
}
return Array.from(labels.entries())
.map(([type, label]) => ({ type, label }))
.sort((a, b) => marketTypeSortIndex(a.type) - marketTypeSortIndex(b.type));
});
const hasMarketFilter = computed(() => selectedMarketTypes.value.size > 0);
const filteredCompactMarkets = computed(() =>
compactMarkets.value.filter((market) => matchesMarketFilter(market.marketType)),
);
const filteredScoreMarkets = computed(() =>
scoreMarkets.value.filter((market) => matchesMarketFilter(market.marketType)),
);
function marketTypeSortIndex(marketType: string) {
const idx = (DETAIL_MARKET_TYPES as readonly string[]).indexOf(marketType);
return idx >= 0 ? idx : 999;
}
function matchesMarketFilter(marketType: string) {
if (!hasMarketFilter.value) return true;
return selectedMarketTypes.value.has(marketType);
}
function isMarketFilterActive(marketType: string) {
return selectedMarketTypes.value.has(marketType);
}
function isAllMarketFilterActive() {
return !hasMarketFilter.value;
}
function selectAllMarketFilters() {
selectedMarketTypes.value = new Set();
}
function toggleMarketFilter(marketType: string) {
const next = new Set(selectedMarketTypes.value);
if (next.has(marketType)) next.delete(marketType);
else next.add(marketType);
selectedMarketTypes.value = next;
}
function ensureMarketVisibleForLocate(target: { marketId?: string; selectionId: string }) {
const market =
allMarkets.value.find((m) => m.id === target.marketId) ??
allMarkets.value.find((m) => m.selections.some((s) => s.id === target.selectionId));
if (!market) return;
if (hasMarketFilter.value && !matchesMarketFilter(market.marketType)) {
selectedMarketTypes.value = new Set();
}
}
watch(
[pendingLocate, () => match.value?.id, loading],
async ([target, matchId, isLoading]) => {
if (!target || isLoading || !matchId) return;
if (String(matchId) !== String(target.matchId)) return;
ensureMarketVisibleForLocate(target);
await nextTick();
const found = await scrollToBetSelection(target.selectionId);
if (found) clearLocate();
},
{ flush: 'post' },
);
watch(
() => route.params.id,
() => {
selectedMarketTypes.value = new Set();
},
);
function marketLabel(market: Market) {
return market.marketDisplayName?.trim() || market.marketType || '';
}
function normalizeMarketStatus(status?: string) {
return (status ?? 'OPEN').toUpperCase();
}
function isMarketOpen(market: Market) {
return normalizeMarketStatus(market.status) === 'OPEN';
}
function marketStatusLabel(status?: string) {
const normalized = normalizeMarketStatus(status);
if (normalized === 'SUSPENDED') return t('bet.market_status_suspended');
if (normalized === 'CLOSED') return t('bet.market_status_closed');
return '';
}
function isMarketLocked(market: Market) {
if (!bettingOpen.value || !isMarketOpen(market)) return true;
return market.allowSingle !== true && market.allowParlay !== true;
}
function selectionLabel(sel: Selection, market: Market) {
const parsedScore = parseScoreCode(sel.selectionCode, t);
if (parsedScore) return parsedScore.display;
return resolveSelectionLabel(t, sel.selectionCode, sel.selectionName, {
lineValue: market.lineValue,
});
}
function isSelected(id: string) {
if (slip.isInSlip(id)) return true;
return popoverVisible.value && popoverItem.value?.selectionId === id;
}
function buildSlipItem(sel: Selection, market: Market) {
if (!match.value) return null;
return {
selectionId: sel.id,
oddsVersion: String(sel.oddsVersion),
matchId: match.value.id,
matchName: `${match.value.homeTeamName} vs ${match.value.awayTeamName}`,
marketId: market.id,
marketName: marketLabel(market),
selectionName: selectionLabel(sel, market),
odds: parseFloat(sel.odds),
marketType: market.marketType,
lineValue:
market.lineValue != null && market.lineValue !== ''
? parseFloat(String(market.lineValue))
: null,
allowSingle: market.allowSingle,
allowParlay: market.allowParlay,
};
}
function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
if (!match.value || isMarketLocked(market)) return;
if (!auth.token) {
auth.showLoginPrompt(route.fullPath);
return;
}
const item = buildSlipItem(sel, market);
if (!item || !event) return;
openAt(event, item);
}
</script>
<template>
<div class="desktop-match-detail">
<!-- Fixed Header Breadcrumb -->
<div class="detail-breadcrumbs">
<button type="button" class="back-btn" @click="goBack"> {{ t('bet.back') || '返回' }}</button>
<span class="sep">/</span>
<span class="curr">{{ match?.leagueName }}</span>
</div>
<div class="detail-main">
<!-- Loading -->
<div v-if="loading" class="loading-state">
<GoldSpinner :active="true" />
</div>
<template v-else-if="match">
<!-- High Density Score Band -->
<div class="match-score-band" :class="{ 'phase-settled': matchPhase === 'settled' }">
<div class="team-side home">
<TeamEmblem size="xl" :team-code="match.homeTeamCode" :team-name="match.homeTeamName" :logo-url="match.homeTeamLogoUrl" />
<span class="name">{{ match.homeTeamName }}</span>
</div>
<div class="score-arena">
<div class="versus-row" v-if="liveScoreText">
<span class="score">{{ match.score?.ftHome }}</span>
<span class="divider">-</span>
<span class="score">{{ match.score?.ftAway }}</span>
</div>
<div class="versus-row vs" v-else>VS</div>
<span class="kickoff-time">{{ kickoff }}</span>
</div>
<div class="team-side away">
<TeamEmblem size="xl" :team-code="match.awayTeamCode" :team-name="match.awayTeamName" :logo-url="match.awayTeamLogoUrl" />
<span class="name">{{ match.awayTeamName }}</span>
</div>
</div>
<!-- Result Stats -->
<div v-if="showResultStats" class="result-stats-card">
<h4>{{ t('bet.result_stats_title') || '赛果数据' }}</h4>
<div class="stats-row">
<div v-for="item in resultStats" :key="item.key" class="stat-pill">
<span class="lbl">{{ item.label }}</span>
<span class="val">{{ item.value }}</span>
</div>
</div>
</div>
<div v-if="marketFilterOptions.length" class="market-filter-bar">
<span class="market-filter-label">{{ t('bet.filter_market') }}</span>
<div class="market-filter-chips">
<button
type="button"
class="market-filter-chip"
:class="{ active: isAllMarketFilterActive() }"
@click="selectAllMarketFilters"
>
{{ t('bet.time_all') }}
</button>
<button
v-for="option in marketFilterOptions"
:key="option.type"
type="button"
class="market-filter-chip"
:class="{ active: isMarketFilterActive(option.type) }"
@click="toggleMarketFilter(option.type)"
>
{{ option.label }}
</button>
</div>
</div>
<!-- Standard markets: 2-col card grid -->
<div v-if="filteredCompactMarkets.length" class="markets-compact-grid">
<div
v-for="market in filteredCompactMarkets"
:key="market.id"
class="market-card"
:class="{ locked: isMarketLocked(market) }"
>
<div class="market-hdr">
<span class="market-title">{{ marketLabel(market) }}</span>
<span v-if="isMarketLocked(market)" class="locked-lbl">{{ t('bet.market_status_closed') }}</span>
</div>
<div class="market-body">
<MarketSelectionsPanel
desktop-card
compact
:locked="isMarketLocked(market)"
:line-value="market.lineValue"
:selections="market.selections"
:is-selected="isSelected"
@pick="(id, ev) => toggleSelection(market.selections.find(s => s.id === id)!, market, ev)"
/>
</div>
</div>
</div>
<!-- Correct score -->
<div v-if="filteredScoreMarkets.length" class="markets-score-section">
<h4 class="score-section-title">{{ t('bet.correct_score') }}</h4>
<div class="markets-score-grid">
<div
v-for="market in filteredScoreMarkets"
:key="market.id"
class="score-block"
:class="{ locked: isMarketLocked(market) }"
>
<div class="score-block-hdr">
<span>{{ marketLabel(market) }}</span>
<span v-if="isMarketLocked(market)" class="locked-lbl">{{ t('bet.market_status_closed') }}</span>
</div>
<CorrectScorePanel
dense
:market-type="market.marketType"
:selections="market.selections"
:locked="isMarketLocked(market)"
:is-selected="isSelected"
@pick="(id, ev) => toggleSelection(market.selections.find(s => s.id === id)!, market, ev)"
/>
</div>
</div>
</div>
<div
v-if="!filteredCompactMarkets.length && !filteredScoreMarkets.length"
class="markets-empty"
>
<p>{{ hasMarketFilter ? t('bet.no_markets_filtered') : (t('bet.no_markets') || '暂无可用盘口') }}</p>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.desktop-match-detail {
display: flex;
flex-direction: column;
max-width: 100%;
min-height: 100%;
}
.detail-breadcrumbs {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
font-size: 12px;
color: var(--text-muted);
background: #0c0c0c;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.detail-main {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 16px 14px;
}
.detail-breadcrumbs .sep {
flex-shrink: 0;
}
.detail-breadcrumbs .curr {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-weight: 700;
}
.back-btn {
flex-shrink: 0;
background: transparent;
color: var(--primary-light);
font-weight: 700;
border: none;
padding: 0;
cursor: pointer;
}
.back-btn:hover {
text-decoration: underline;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.match-score-band {
background: linear-gradient(180deg, #181818 0%, #111 100%);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.team-side {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
.team-side.away {
flex-direction: row-reverse;
}
.team-side .name {
font-size: 13px;
font-weight: 800;
color: #fff;
}
.score-arena {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 240px;
}
.versus-row {
display: flex;
align-items: center;
gap: 16px;
}
.versus-row .score {
font-size: 32px;
font-weight: 900;
color: var(--primary-light);
text-shadow: 0 0 10px rgba(212, 175, 55, 0.3);
}
.versus-row .divider {
font-size: 24px;
color: var(--text-muted);
}
.versus-row.vs {
font-size: 22px;
font-weight: 800;
color: var(--text-muted);
}
.kickoff-time {
font-size: 11px;
color: var(--text-muted);
}
.result-stats-card {
background: rgba(20, 20, 20, 0.8);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
}
.result-stats-card h4 {
margin: 0 0 12px;
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
text-transform: uppercase;
}
.stats-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.stat-pill {
background: #141414;
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 12px;
display: flex;
gap: 10px;
font-size: 11px;
}
.stat-pill .lbl { color: var(--text-muted); }
.stat-pill .val { color: #fff; font-weight: 700; }
.market-filter-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(18, 18, 18, 0.85);
min-width: 0;
}
.market-filter-label {
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
color: var(--primary-light);
white-space: nowrap;
}
.market-filter-chips {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
min-width: 0;
flex: 1;
overflow-x: auto;
scrollbar-width: none;
}
.market-filter-chips::-webkit-scrollbar {
display: none;
}
.market-filter-chip {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding: 3px 7px;
border-radius: 4px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
transition: background 0.18s, border-color 0.18s, color 0.18s;
}
.market-filter-chip:hover {
color: #fff;
background: rgba(255, 255, 255, 0.06);
}
.market-filter-chip.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
border-color: var(--border-gold-soft);
box-shadow: inset 0 0 0 1px rgba(212, 175, 55, 0.08);
}
.markets-score-section {
margin-top: 4px;
}
.score-section-title {
margin: 0 0 8px;
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
}
.markets-empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
color: var(--text-muted);
font-size: 13px;
}
.markets-compact-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
align-items: start;
}
@media (min-width: 1360px) {
.markets-compact-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.market-card {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: rgba(18, 18, 18, 0.98);
align-self: start;
}
.market-card.locked {
opacity: 0.55;
}
.market-hdr {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: #141414;
border-bottom: 1px solid var(--border);
}
.market-title {
font-size: 11px;
font-weight: 800;
color: #eee;
line-height: 1.25;
}
.market-body {
padding: 6px;
}
.markets-score-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 8px;
align-items: start;
margin-top: 4px;
}
.score-block {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: rgba(18, 18, 18, 0.98);
align-self: start;
}
.score-block.locked {
opacity: 0.55;
}
.score-block-hdr {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: #141414;
border-bottom: 1px solid var(--border);
font-size: 11px;
font-weight: 800;
color: #eee;
}
.locked-lbl {
font-size: 10px;
color: var(--danger);
font-weight: 700;
white-space: nowrap;
}
</style>