fix(admin/player/api): 同步单关串关持久化、仅串关可选与盘口状态展示

1. 后台/API:赛事详情返回 allowSingle/allowParlay,管理端 mapMarkets 正确回读
2. 玩家端:仅关单关时仍可点选加入串关;投注抽屉禁用单关提交并提示
3. 玩家端:盘口暂停/已关闭状态标签;MarketTypeTile 展示 statusLabel

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 11:08:43 +08:00
parent cb4e48b9f4
commit 9f1d263741
10 changed files with 91 additions and 23 deletions

View File

@@ -81,6 +81,8 @@ export interface AdminMarket {
lineValue: number | null; lineValue: number | null;
paramsJson?: Record<string, unknown> | null; paramsJson?: Record<string, unknown> | null;
status: string; status: string;
allowSingle?: boolean;
allowParlay?: boolean;
showOnPlayer: boolean; showOnPlayer: boolean;
promoLabel: string; promoLabel: string;
promoLabelI18n?: Record<string, string>; promoLabelI18n?: Record<string, string>;

View File

@@ -198,8 +198,8 @@ function mapMarkets(detail: AdminMatchDetail) {
lineValue: m.lineValue, lineValue: m.lineValue,
paramsJson: m.paramsJson ?? null, paramsJson: m.paramsJson ?? null,
status: m.status, status: m.status,
allowSingle: true, allowSingle: m.allowSingle ?? true,
allowParlay: true, allowParlay: m.allowParlay ?? true,
showOnPlayer: m.showOnPlayer ?? true, showOnPlayer: m.showOnPlayer ?? true,
sortOrder: m.sortOrder ?? index, sortOrder: m.sortOrder ?? index,
promoLabel: m.promoLabel ?? '', promoLabel: m.promoLabel ?? '',

View File

@@ -871,6 +871,8 @@ export class MatchesService {
lineValue: m.lineValue != null ? Number(m.lineValue) : null, lineValue: m.lineValue != null ? Number(m.lineValue) : null,
paramsJson: m.paramsJson ?? null, paramsJson: m.paramsJson ?? null,
status: m.status, status: m.status,
allowSingle: m.allowSingle,
allowParlay: m.allowParlay,
showOnPlayer: m.showOnPlayer, showOnPlayer: m.showOnPlayer,
promoLabel: m.promoLabel ?? '', promoLabel: m.promoLabel ?? '',
promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n), promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n),

View File

@@ -57,9 +57,17 @@ const canSubmitActive = computed(() => {
if (activeTab.value === 'parlay') { if (activeTab.value === 'parlay') {
return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS; return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS;
} }
return Boolean(slip.singleItem); return Boolean(slip.singleItem) && slip.singleItem.allowSingle !== false;
}); });
const singleParlayOnlyHint = computed(
() =>
activeTab.value === 'single' &&
Boolean(slip.singleItem) &&
slip.singleItem!.allowSingle === false &&
slip.singleItem!.allowParlay !== false,
);
const balanceText = computed(() => { const balanceText = computed(() => {
if (balanceLoading.value) return t('bet.loading'); if (balanceLoading.value) return t('bet.loading');
if (balance.value == null) return '--'; if (balance.value == null) return '--';
@@ -356,6 +364,7 @@ watch(
</div> </div>
<div class="item-odds">{{ slip.singleItem.odds.toFixed(2) }}</div> <div class="item-odds">{{ slip.singleItem.odds.toFixed(2) }}</div>
</div> </div>
<p v-if="singleParlayOnlyHint" class="warning">{{ t('bet.slip_parlay_only_hint') }}</p>
<template v-if="activeTab === 'parlay'"> <template v-if="activeTab === 'parlay'">
<p v-if="parlayWarning" class="warning">{{ parlayWarning }}</p> <p v-if="parlayWarning" class="warning">{{ parlayWarning }}</p>

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
defineProps<{ defineProps<{
label: string; label: string;
promoLabel?: string; promoLabel?: string;
statusLabel?: string;
expanded: boolean; expanded: boolean;
hasMarket: boolean; hasMarket: boolean;
}>(); }>();
@@ -21,9 +22,9 @@ const { t } = useI18n();
@click="emit('toggle')" @click="emit('toggle')"
> >
<span class="row-label">{{ label }}</span> <span class="row-label">{{ label }}</span>
<span v-if="promoLabel" class="row-promo">{{ promoLabel }}</span> <span v-if="promoLabel" class="row-promo">{{ promoLabel }}</span>
<span v-if="!hasMarket" class="row-muted">{{ t('bet.market_closed') }}</span> <span v-if="statusLabel" class="row-status">{{ statusLabel }}</span>
<span v-else class="row-chevron" :class="{ open: expanded }" aria-hidden="true"></span> <span v-else-if="!hasMarket" class="row-muted">{{ t('bet.market_closed') }}</span>
</button> </button>
</template> </template>
@@ -86,6 +87,17 @@ const { t } = useI18n();
border: 1px solid rgba(248, 151, 31, 0.3); border: 1px solid rgba(248, 151, 31, 0.3);
} }
.row-status {
flex-shrink: 0;
font-size: 9px;
font-weight: 700;
color: #f0b429;
padding: 2px 6px;
border-radius: 3px;
background: rgba(240, 180, 41, 0.12);
border: 1px solid rgba(240, 180, 41, 0.35);
}
.row.expanded .row-label { .row.expanded .row-label {
color: #003D6B; color: #003D6B;
} }
@@ -95,16 +107,4 @@ const { t } = useI18n();
color: #6B7280; color: #6B7280;
font-weight: 600; font-weight: 600;
} }
.row-chevron {
font-size: 11px;
color: #6B7280;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s;
flex-shrink: 0;
}
.row-chevron.open {
transform: rotate(90deg);
color: #003D6B;
}
</style> </style>

View File

@@ -304,6 +304,8 @@ export default {
parlay_block_quarter: 'Quarter-ball HDP/O-U cannot be parlayed', parlay_block_quarter: 'Quarter-ball HDP/O-U cannot be parlayed',
parlay_block_not_allowed: 'This market cannot be parlayed', parlay_block_not_allowed: 'This market cannot be parlayed',
parlay_need_more: 'Select at least 2 legs for parlay', parlay_need_more: 'Select at least 2 legs for parlay',
market_status_suspended: 'Suspended',
market_status_closed: 'Closed',
back: 'Back', back: 'Back',
refresh: 'Refresh', refresh: 'Refresh',
download: 'Download', download: 'Download',
@@ -387,6 +389,7 @@ export default {
slip_tab_parlay: 'Parlay', slip_tab_parlay: 'Parlay',
slip_parlay_empty_hint: 'Pick a selection, then tap Add to parlay', slip_parlay_empty_hint: 'Pick a selection, then tap Add to parlay',
slip_add_parlay: 'Add to parlay', slip_add_parlay: 'Add to parlay',
slip_parlay_only_hint: 'This market is parlay-only. Tap Add to parlay to bet.',
slip_parlay_same_match: 'Parlay selections cannot be from the same match. Remove the same-match leg first.', slip_parlay_same_match: 'Parlay selections cannot be from the same match. Remove the same-match leg first.',
slip_parlay_count: '{n} parlay leg(s)', slip_parlay_count: '{n} parlay leg(s)',
slip_total_stake: 'Total stake', slip_total_stake: 'Total stake',

View File

@@ -310,6 +310,8 @@ export default {
parlay_block_quarter: 'HDP/O-U suku bola tidak boleh parlay', parlay_block_quarter: 'HDP/O-U suku bola tidak boleh parlay',
parlay_block_not_allowed: 'Pasaran ini tidak boleh parlay', parlay_block_not_allowed: 'Pasaran ini tidak boleh parlay',
parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan', parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
market_status_suspended: 'Digantung',
market_status_closed: 'Ditutup',
back: 'Kembali', back: 'Kembali',
refresh: 'Muat semula', refresh: 'Muat semula',
download: 'Muat turun', download: 'Muat turun',
@@ -393,6 +395,7 @@ export default {
slip_tab_parlay: 'Parlay', slip_tab_parlay: 'Parlay',
slip_parlay_empty_hint: 'Pilih satu odds, kemudian ketik Tambah ke parlay', slip_parlay_empty_hint: 'Pilih satu odds, kemudian ketik Tambah ke parlay',
slip_add_parlay: 'Tambah ke parlay', slip_add_parlay: 'Tambah ke parlay',
slip_parlay_only_hint: 'Pasaran ini hanya parlay. Ketik Tambah ke parlay untuk bertaruh.',
slip_parlay_same_match: 'Pilihan parlay tidak boleh daripada perlawanan sama. Buang pilihan perlawanan sama dahulu.', slip_parlay_same_match: 'Pilihan parlay tidak boleh daripada perlawanan sama. Buang pilihan perlawanan sama dahulu.',
slip_parlay_count: '{n} pilihan parlay', slip_parlay_count: '{n} pilihan parlay',
slip_total_stake: 'Jumlah pertaruhan', slip_total_stake: 'Jumlah pertaruhan',

View File

@@ -304,6 +304,8 @@ export default {
parlay_block_quarter: '四分盘让球/大小不可串关', parlay_block_quarter: '四分盘让球/大小不可串关',
parlay_block_not_allowed: '该玩法不可串关', parlay_block_not_allowed: '该玩法不可串关',
parlay_need_more: '请至少选择 2 项进行串关', parlay_need_more: '请至少选择 2 项进行串关',
market_status_suspended: '暂停',
market_status_closed: '已关闭',
back: '返回', back: '返回',
refresh: '刷新', refresh: '刷新',
download: '下载', download: '下载',
@@ -387,6 +389,7 @@ export default {
slip_tab_parlay: '串关', slip_tab_parlay: '串关',
slip_parlay_empty_hint: '先选择一个下注项,再点「加入串关」', slip_parlay_empty_hint: '先选择一个下注项,再点「加入串关」',
slip_add_parlay: '加入串关', slip_add_parlay: '加入串关',
slip_parlay_only_hint: '此盘口不支持单关,请点「加入串关」后再投注',
slip_parlay_same_match: '串关选项不得为同一场比赛,请先移除同场选项后再加入。', slip_parlay_same_match: '串关选项不得为同一场比赛,请先移除同场选项后再加入。',
slip_parlay_count: '{n} 项串关', slip_parlay_count: '{n} 项串关',
slip_total_stake: '总投注金额', slip_total_stake: '总投注金额',

View File

@@ -18,6 +18,7 @@ export interface SlipItem {
odds: number; odds: number;
marketType: string; marketType: string;
lineValue?: number | null; lineValue?: number | null;
allowSingle?: boolean;
allowParlay?: boolean; allowParlay?: boolean;
} }

View File

@@ -36,6 +36,7 @@ interface Market {
lineKey?: string | null; lineKey?: string | null;
period: string; period: string;
lineValue?: string | number | null; lineValue?: string | number | null;
status?: string;
allowSingle?: boolean; allowSingle?: boolean;
allowParlay?: boolean; allowParlay?: boolean;
promoLabel?: string | null; promoLabel?: string | null;
@@ -191,6 +192,28 @@ function marketLabel(market: Market | null | undefined) {
return market?.marketDisplayName?.trim() || market?.marketType || ''; 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;
const singleOk = market.allowSingle !== false;
const parlayOk = market.allowParlay !== false;
return !singleOk && !parlayOk;
}
function selectionLabel(sel: Selection) { function selectionLabel(sel: Selection) {
const parsedScore = parseScoreCode(sel.selectionCode, t); const parsedScore = parseScoreCode(sel.selectionCode, t);
if (parsedScore) return parsedScore.display; if (parsedScore) return parsedScore.display;
@@ -224,8 +247,7 @@ function isSelected(id: string) {
} }
function toggleSelection(sel: Selection, market: Market) { function toggleSelection(sel: Selection, market: Market) {
if (!match.value || !bettingOpen.value) return; if (!match.value || isMarketLocked(market)) return;
if (market.allowSingle === false) return;
if (!auth.token) { if (!auth.token) {
goLogin(); goLogin();
return; return;
@@ -244,6 +266,7 @@ function toggleSelection(sel: Selection, market: Market) {
market.lineValue != null && market.lineValue !== '' market.lineValue != null && market.lineValue !== ''
? parseFloat(String(market.lineValue)) ? parseFloat(String(market.lineValue))
: null, : null,
allowSingle: market.allowSingle,
allowParlay: market.allowParlay, allowParlay: market.allowParlay,
}); });
slip.openDrawer(); slip.openDrawer();
@@ -388,24 +411,32 @@ function onPickSelection(selId: string, marketId: string) {
<MarketTypeTile <MarketTypeTile
:label="marketLabel(market)" :label="marketLabel(market)"
:promo-label="market.promoLabel?.trim() || ''" :promo-label="market.promoLabel?.trim() || ''"
:status-label="bettingOpen && !isMarketOpen(market) ? marketStatusLabel(market.status) : ''"
:has-market="true" :has-market="true"
:expanded="true" :expanded="true"
/> />
<div class="market-panel-wrap" :class="{ locked: !bettingOpen || market.allowSingle === false }"> <div class="market-panel-wrap" :class="{ locked: isMarketLocked(market) }">
<span v-if="!bettingOpen && matchPhase === 'settled'" class="market-status-tag market-status-tag--settled">{{ phaseLabel }}</span> <span v-if="!bettingOpen && matchPhase === 'settled'" class="market-status-tag market-status-tag--settled">{{ phaseLabel }}</span>
<span v-else-if="!bettingOpen" class="market-status-tag market-status-tag--pending">{{ phaseLabel }}</span> <span v-else-if="!bettingOpen" class="market-status-tag market-status-tag--pending">{{ phaseLabel }}</span>
<span
v-else-if="!isMarketOpen(market)"
class="market-status-tag"
:class="normalizeMarketStatus(market.status) === 'CLOSED' ? 'market-status-tag--closed' : 'market-status-tag--suspended'"
>
{{ marketStatusLabel(market.status) }}
</span>
<CorrectScorePanel <CorrectScorePanel
v-if="isCorrectScoreMarket(market.marketType)" v-if="isCorrectScoreMarket(market.marketType)"
:market-type="market.marketType" :market-type="market.marketType"
:selections="market.selections" :selections="market.selections"
:locked="!bettingOpen || market.allowSingle === false" :locked="isMarketLocked(market)"
:is-selected="isSelected" :is-selected="isSelected"
@pick="onPickSelection($event, market.id)" @pick="onPickSelection($event, market.id)"
/> />
<MarketSelectionsPanel <MarketSelectionsPanel
v-else v-else
compact compact
:locked="!bettingOpen || market.allowSingle === false" :locked="isMarketLocked(market)"
:line-value="market.lineValue" :line-value="market.lineValue"
:selections="market.selections" :selections="market.selections"
:is-selected="isSelected" :is-selected="isSelected"
@@ -557,6 +588,20 @@ function onPickSelection(selId: string, marketId: string) {
border-left: 1px solid #FDE68A; border-left: 1px solid #FDE68A;
} }
.market-status-tag--suspended {
background: linear-gradient(180deg, rgba(240, 180, 41, 0.28) 0%, rgba(120, 80, 10, 0.92) 100%);
color: #ffe08a;
border-bottom: 1px solid rgba(240, 180, 41, 0.45);
border-left: 1px solid rgba(240, 180, 41, 0.45);
}
.market-status-tag--closed {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(40, 40, 55, 0.95) 100%);
color: #8a9ab8;
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
border-left: 1px solid rgba(120, 140, 180, 0.3);
}
.market-panel-wrap { .market-panel-wrap {
position: relative; position: relative;
overflow: hidden; overflow: hidden;