feat(player/admin/api): 玩家端暗色极简主题重构与盘口单串关修复

## 管理端 / API(Bug 修复)
- matches.service:getAdminMatchDetail 返回 markets 时补充 allowSingle、allowParlay 字段
- MatchMarketsPanel:mapMarkets 从接口读取单关/串关开关,不再硬编码为 true
- match-form.ts:AdminMarket 类型补充 allowSingle、allowParlay

## 玩家端 — 全局主题(styles.css / index.html / site.webmanifest)
- 海军暗色 + 白色强调 token,卡片高光渐变(--gradient-card)
- 下拉/弹窗实底不透明(--dropdown-bg: #0A2540)
- 统一 sub-toolbar、status-ribbon、filter-tab 等全局样式
- theme-color 同步为 #001A33

## 玩家端 — 布局与壳层
- MainLayout:详情子页去顶栏/公告,sub-toolbar 全宽铺底
- WalletBalanceCard(新):个人页钱包卡片(反水 + 未结算)
- walletStats.ts(新):钱包统计逻辑抽取

## 玩家端 — 赛事 / 投注
- MatchDetailView:顶栏 8px 顶距、卡片全宽(--detail-gutter-x: 0)
- 盘口状态标签(暂停/已关闭)、去掉 MarketTypeTile 右侧箭头
- VsBadge(新):纯文字 VS,删除 vs.png
- isMarketLocked:仅关单关但允许串关时仍可点击,支持串关流程
- BetSlipDrawer:仅串关盘口禁用单关提交并提示 slip_parlay_only_hint
- betSlip:SlipItem 增加 allowSingle 字段
- MatchBetCard / LeagueAccordionItem:45° 待开赛角标,去掉展开左侧白边
- OutrightEventSection:去掉展开时左侧白色选中条
- FootballView:加大左右边距、筛选栏去 sticky、选中态增强
- BannerCarousel:恢复原始全宽轮播

## 玩家端 — 钱包 / 个人 / 认证 / 其他页面
- WalletView:移除顶部钱包卡片,保留账单列表
- ProfileView:保留 WalletBalanceCard
- 充值/账单/注单/登录注册等页面配色与间距统一
- 公告标签、余额面板、语言切换、区号选择等弹层改为实底

## 国际化
- zh-CN / en-US / ms-MY:新增 slip_parlay_only_hint(仅串关盘口提示)

## 资产
- 更新 banner.svg、empty-matches.svg 配色
- 删除 vs.png

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 11:00:25 +08:00
parent f34fe54489
commit d3ca8498fb
64 changed files with 2466 additions and 2084 deletions

View File

@@ -14,8 +14,8 @@ import CorrectScorePanel from '../components/match-detail/CorrectScorePanel.vue'
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import vsImg from '../assets/images/vs.png';
import GoldSpinner from '../components/GoldSpinner.vue';
import VsBadge from '../components/VsBadge.vue';
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
import { formatLocalMatchDateTime } from '@thebet365/shared';
@@ -36,6 +36,7 @@ interface Market {
lineKey?: string | null;
period: string;
lineValue?: string | number | null;
status?: string;
allowSingle?: boolean;
allowParlay?: boolean;
promoLabel?: string | null;
@@ -47,6 +48,7 @@ interface Selection {
selectionCode: string;
selectionName: string;
selectionDisplayName?: string;
status?: string;
odds: string;
oddsVersion: string;
}
@@ -191,6 +193,28 @@ function marketLabel(market: Market | null | undefined) {
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) {
const parsedScore = parseScoreCode(sel.selectionCode, t);
if (parsedScore) return parsedScore.display;
@@ -224,8 +248,7 @@ function isSelected(id: string) {
}
function toggleSelection(sel: Selection, market: Market) {
if (!match.value || !bettingOpen.value) return;
if (market.allowSingle === false) return;
if (!match.value || isMarketLocked(market)) return;
if (!auth.token) {
goLogin();
return;
@@ -244,6 +267,7 @@ function toggleSelection(sel: Selection, market: Market) {
market.lineValue != null && market.lineValue !== ''
? parseFloat(String(market.lineValue))
: null,
allowSingle: market.allowSingle,
allowParlay: market.allowParlay,
});
slip.openDrawer();
@@ -266,7 +290,7 @@ function onPickSelection(selId: string, marketId: string) {
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<header class="toolbar">
<header class="toolbar sub-toolbar sub-toolbar--sticky sub-toolbar--glass">
<button type="button" class="icon-btn" :aria-label="t('bet.back')" @click="router.back()"></button>
<span class="toolbar-title">{{ match?.leagueName ?? '' }}</span>
<div class="toolbar-actions">
@@ -288,7 +312,8 @@ function onPickSelection(selId: string, marketId: string) {
</div>
<template v-else-if="match">
<section class="match-hero" :class="{ 'match-hero--phase': matchPhase !== 'open' }">
<span v-if="matchPhase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
<span v-if="matchPhase === 'open'" class="status-ribbon">{{ t('bet.status_open') }}</span>
<span v-else-if="matchPhase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
<span v-else-if="matchPhase === 'closed_pending'" class="status-tag status-tag--pending">{{ phaseLabel }}</span>
<div class="hero-teams">
<!-- home -->
@@ -302,32 +327,7 @@ function onPickSelection(selId: string, marketId: string) {
<span class="hero-name">{{ match.homeTeamName }}</span>
</div>
<!-- VS arena with lightning -->
<div class="vs-arena">
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
<defs>
<linearGradient id="hzDetailGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#5eb8ff" stop-opacity="0.2" />
<stop offset="35%" stop-color="#b8ecff" stop-opacity="1" />
<stop offset="50%" stop-color="#ffffff" stop-opacity="1" />
<stop offset="65%" stop-color="#ffd080" stop-opacity="1" />
<stop offset="100%" stop-color="#ff9040" stop-opacity="0.2" />
</linearGradient>
</defs>
<path
class="hz-path hz-path-main"
stroke="url(#hzDetailGrad)"
d="M1 14 H16 L20 5 L24 23 L28 9 L32 14 H40 L44 6 L48 22 L52 12 L56 14 H71"
/>
<path
class="hz-path hz-path-sub"
stroke="url(#hzDetailGrad)"
d="M3 19 H14 L18 15 L22 19 H50 L54 16 L58 19 H69"
/>
</svg>
<span class="hz-beam" aria-hidden="true" />
<img :src="vsImg" alt="" class="vs-img" />
</div>
<VsBadge size="lg" />
<!-- away -->
<div class="hero-team">
@@ -388,24 +388,30 @@ function onPickSelection(selId: string, marketId: string) {
<MarketTypeTile
:label="marketLabel(market)"
:promo-label="market.promoLabel?.trim() || ''"
:has-market="true"
:expanded="true"
:status-label="bettingOpen && !isMarketOpen(market) ? marketStatusLabel(market.status) : ''"
/>
<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-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
v-if="isCorrectScoreMarket(market.marketType)"
:market-type="market.marketType"
:selections="market.selections"
:locked="!bettingOpen || market.allowSingle === false"
:locked="isMarketLocked(market)"
:is-selected="isSelected"
@pick="onPickSelection($event, market.id)"
/>
<MarketSelectionsPanel
v-else
compact
:locked="!bettingOpen || market.allowSingle === false"
:locked="isMarketLocked(market)"
:line-value="market.lineValue"
:selections="market.selections"
:is-selected="isSelected"
@@ -421,7 +427,8 @@ function onPickSelection(selId: string, marketId: string) {
<style scoped>
.detail-page {
margin: 0 -16px;
--detail-gutter-x: 0;
padding-top: var(--space-toolbar-top);
padding-bottom: 16px;
}
@@ -437,14 +444,12 @@ function onPickSelection(selId: string, marketId: string) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px 8px;
gap: 8px;
position: sticky;
top: -12px;
z-index: 50;
margin-top: -12px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
}
.detail-page > .sub-toolbar {
padding-top: 0;
margin-bottom: 8px;
}
.toolbar-title {
@@ -487,42 +492,59 @@ function onPickSelection(selId: string, marketId: string) {
.match-hero {
position: relative;
isolation: isolate;
margin: 0 0 10px;
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x))) 12px;
padding: 14px 12px 16px;
overflow: hidden;
border-radius: var(--radius);
background: var(--gradient-card);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: var(--shadow-card);
}
.match-hero::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: var(--gradient-card-shine);
pointer-events: none;
z-index: 2;
}
.match-hero--phase {
opacity: 0.98;
}
/* ── hero status tag (top-right corner) ── */
/* ── hero status tag (top-left ribbon) ── */
.status-tag {
position: absolute;
top: 0;
right: 0;
left: 0;
right: auto;
z-index: 5;
font-size: 11px;
font-weight: 800;
padding: 4px 14px;
border-radius: 0 6px 0 10px;
font-size: 10px;
font-weight: 600;
padding: 4px 12px 4px 10px;
border-radius: 0 0 10px 0;
line-height: 1.3;
white-space: nowrap;
letter-spacing: 0.02em;
letter-spacing: 0;
}
.status-tag--settled {
background: linear-gradient(180deg, #2a2a3a 0%, #1a1a28 100%);
color: #8a9ab8;
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
border-left: 1px solid rgba(120, 140, 180, 0.3);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 20, 40, 0.75) 100%);
color: var(--text-muted);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
.status-tag--pending {
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
color: #c8a84e;
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
border-left: 1px solid rgba(200, 168, 78, 0.3);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(10, 53, 88, 0.6) 100%);
color: var(--text-muted);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
/* ── market panel smaller status tag ── */
@@ -548,10 +570,24 @@ function onPickSelection(selId: string, marketId: string) {
}
.market-status-tag--pending {
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
color: #c8a84e;
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
border-left: 1px solid rgba(200, 168, 78, 0.3);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(0, 51, 102, 0.95) 100%);
color: var(--primary-light);
border-bottom: 1px solid var(--border-gold-soft);
border-left: 1px solid var(--border-gold-soft);
}
.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: var(--text-muted);
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
border-left: 1px solid rgba(120, 140, 180, 0.3);
}
.market-panel-wrap {
@@ -577,20 +613,20 @@ function onPickSelection(selId: string, marketId: string) {
position: relative;
z-index: 2;
margin: 8px 0 0;
font-size: 28px;
font-weight: 900;
color: #fff;
font-size: 24px;
font-weight: 700;
color: var(--text);
text-align: center;
letter-spacing: 0.06em;
letter-spacing: 0.02em;
}
.result-stats-section {
margin: 0 10px 12px;
padding: 12px;
border: 1px solid rgba(212, 175, 55, 0.22);
border-radius: 8px;
background: linear-gradient(180deg, #1b1b1b 0%, #121212 100%);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x))) 12px;
padding: var(--space-card);
border: 1px solid var(--border-gold-soft);
border-radius: var(--radius-sm);
background: var(--bg-card);
box-shadow: var(--shadow);
}
.result-stats-head {
@@ -605,8 +641,8 @@ function onPickSelection(selId: string, marketId: string) {
margin: 0;
font-size: 14px;
line-height: 1.2;
font-weight: 900;
color: var(--primary-light);
font-weight: 600;
color: var(--text);
}
.result-stats-head span {
@@ -633,8 +669,8 @@ function onPickSelection(selId: string, marketId: string) {
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.06);
background: var(--bg-body);
border: 1px solid var(--border);
}
.result-stat-label {
@@ -684,138 +720,30 @@ function onPickSelection(selId: string, marketId: string) {
max-width: 100%;
}
/* VS arena — same as HomeView */
.vs-arena {
position: relative;
flex-shrink: 0;
width: 140px;
height: 126px;
display: flex;
align-items: center;
justify-content: center;
}
.hz-lightning {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
overflow: visible;
}
.hz-path {
fill: none;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 4px rgba(120, 210, 255, 0.95)) drop-shadow(0 0 8px rgba(255, 180, 80, 0.55));
opacity: 0;
}
.hz-path-main {
animation: hz-strike-main 2.6s ease-in-out infinite;
}
.hz-path-sub {
stroke-width: 1.6;
animation: hz-strike-sub 2.6s ease-in-out infinite;
animation-delay: 0.12s;
}
.hz-beam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
transform: translateY(-50%);
pointer-events: none;
z-index: 1;
background: linear-gradient(
90deg,
rgba(94, 184, 255, 0) 0%,
rgba(184, 236, 255, 0.95) 28%,
#fff 50%,
rgba(255, 208, 128, 0.95) 72%,
rgba(255, 144, 64, 0) 100%
);
opacity: 0;
filter: blur(0.4px);
animation: hz-beam-flash 2.6s ease-in-out infinite;
}
.vs-img {
position: relative;
z-index: 0;
width: 126px;
height: auto;
object-fit: contain;
}
@keyframes hz-strike-main {
0%, 72%, 100% { opacity: 0; }
74% { opacity: 1; }
75% { opacity: 0.25; }
76% { opacity: 0.95; }
78% { opacity: 0; }
}
@keyframes hz-strike-sub {
0%, 74%, 100% { opacity: 0; }
76% { opacity: 0.85; }
77% { opacity: 0.2; }
78% { opacity: 0.7; }
80% { opacity: 0; }
}
@keyframes hz-beam-flash {
0%, 71%, 100% { opacity: 0; transform: translateY(-50%) scaleX(0.6); }
73% { opacity: 0.85; transform: translateY(-50%) scaleX(1); }
75% { opacity: 0.15; transform: translateY(-50%) scaleX(0.95); }
76% { opacity: 0.75; transform: translateY(-50%) scaleX(1); }
78% { opacity: 0; transform: translateY(-50%) scaleX(1.05); }
}
@keyframes vs-glow {
0%, 100% {
opacity: 0.82;
filter: drop-shadow(0 0 2px rgba(212, 175, 55, 0.3));
}
50% {
opacity: 1;
filter: drop-shadow(0 0 3px rgba(255, 230, 140, 0.7)) drop-shadow(0 0 6px rgba(212, 175, 55, 0.35));
}
}
@media (prefers-reduced-motion: reduce) {
.hz-path, .hz-beam { animation: none; opacity: 0; }
}
.markets-section {
padding: 0 10px 8px;
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x)));
padding: 0 0 12px;
}
.market-list {
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 0;
gap: 10px;
padding: 6px 0;
}
.market-group {
border-radius: 8px;
border: 1px solid #252525;
background: linear-gradient(180deg, #1a1a1a 0%, #151515 100%);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.35);
box-shadow: var(--shadow);
transition: border-color 0.2s, box-shadow 0.2s;
}
.market-group.open {
border-color: rgba(212, 175, 55, 0.25);
box-shadow: 0 4px 16px rgba(212, 175, 55, 0.08);
border-color: var(--border-gold-soft);
box-shadow: none;
}
.market-group :deep(.row) {
@@ -833,7 +761,7 @@ function onPickSelection(selId: string, marketId: string) {
padding: 9px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: rgba(212, 175, 55, 0.1);
background: var(--surface-accent);
color: var(--primary-light);
font-size: 13px;
font-weight: 800;
@@ -854,14 +782,14 @@ function onPickSelection(selId: string, marketId: string) {
/* ── 我的投注 ── */
.my-bets-section {
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x))) 12px;
padding: 0 12px;
margin-bottom: 12px;
}
.my-bets-title {
font-size: 14px;
font-weight: 700;
color: #ccc;
color: var(--text-muted);
margin: 0 0 8px;
}
@@ -872,16 +800,16 @@ function onPickSelection(selId: string, marketId: string) {
}
.my-bet-card {
background: #1c1c1c;
border: 1px solid #2e2e2e;
border-radius: 8px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
cursor: pointer;
transition: background 0.15s;
}
.my-bet-card:active {
background: #252525;
background: var(--bg-hover);
}
.bet-header {
@@ -912,7 +840,7 @@ function onPickSelection(selId: string, marketId: string) {
.bet-status-won {
background: rgba(61, 184, 101, 0.15);
color: #3db865;
color: #2ECC71;
}
.bet-status-lost {
@@ -933,7 +861,7 @@ function onPickSelection(selId: string, marketId: string) {
.bet-stake {
font-size: 11px;
color: #888;
color: var(--text-muted);
}
.bet-return {