feat(admin,api,player): 赛事分组管理、盘口独立页与多语言展示优化
- 管理端按联赛展示单场,新增赛事/单场流程与列表展开状态保持 - 盘口赔率迁至独立页面,保存按钮仅在有修改时高亮 - API 新增联赛列表与子场查询,按 locale 返回队名并修复编译 - 波胆其它选项与促销标签等 i18n 补齐,文案更易懂
This commit is contained in:
@@ -20,13 +20,19 @@ interface Match {
|
||||
awayTeamName: string;
|
||||
homeTeamCode?: string;
|
||||
awayTeamCode?: string;
|
||||
homeTeamLogoUrl?: string | null;
|
||||
awayTeamLogoUrl?: string | null;
|
||||
startTime: string;
|
||||
leagueName: string;
|
||||
leagueLogoUrl?: string | null;
|
||||
displayOrder?: number;
|
||||
isHot?: boolean;
|
||||
}
|
||||
|
||||
interface LeagueGroup {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
leagueLogoUrl?: string | null;
|
||||
matches: Match[];
|
||||
}
|
||||
|
||||
@@ -80,11 +86,28 @@ const leagueGroups = computed<LeagueGroup[]>(() => {
|
||||
for (const m of filteredMatches.value) {
|
||||
const id = m.leagueId ?? m.leagueName;
|
||||
if (!map.has(id)) {
|
||||
map.set(id, { leagueId: id, leagueName: m.leagueName, matches: [] });
|
||||
map.set(id, {
|
||||
leagueId: id,
|
||||
leagueName: m.leagueName,
|
||||
leagueLogoUrl: m.leagueLogoUrl ?? null,
|
||||
matches: [],
|
||||
});
|
||||
}
|
||||
map.get(id)!.matches.push(m);
|
||||
}
|
||||
return [...map.values()];
|
||||
const groups = [...map.values()];
|
||||
for (const g of groups) {
|
||||
g.matches.sort(
|
||||
(a, b) =>
|
||||
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
}
|
||||
return groups.sort(
|
||||
(a, b) =>
|
||||
(a.matches[0]?.displayOrder ?? 0) - (b.matches[0]?.displayOrder ?? 0) ||
|
||||
a.leagueName.localeCompare(b.leagueName),
|
||||
);
|
||||
});
|
||||
|
||||
watch(leagueGroups, (groups) => {
|
||||
@@ -176,6 +199,7 @@ function goMatch(id: string) {
|
||||
:key="group.leagueId"
|
||||
:league-id="group.leagueId"
|
||||
:league-name="group.leagueName"
|
||||
:league-logo-url="group.leagueLogoUrl"
|
||||
:matches="group.matches"
|
||||
:expanded="isLeagueExpanded(group.leagueId)"
|
||||
@toggle="toggleLeague(group.leagueId)"
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||
import vsImg from '../assets/images/vs.png';
|
||||
import cardBg from '../assets/images/卡片.png';
|
||||
import BannerCarousel from '../components/BannerCarousel.vue';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import { teamFlagUrl } from '../utils/teamFlag';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const { banners, hotMatches, loading } = usePlayerHome();
|
||||
@@ -42,7 +45,7 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
||||
<div
|
||||
v-for="match in hotMatches"
|
||||
:key="match.id"
|
||||
class="card match-card"
|
||||
class="match-card"
|
||||
@click="goMatch(match.id)"
|
||||
>
|
||||
<div class="match-info">
|
||||
@@ -52,7 +55,31 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
||||
<div class="match-flags" aria-hidden="true">
|
||||
<img v-if="homeFlag(match)" :src="homeFlag(match)" alt="" class="flag" />
|
||||
<span v-else class="flag-ph">⚽</span>
|
||||
<span class="vs">VS</span>
|
||||
<div class="vs-arena">
|
||||
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient :id="`hzBoltGrad-${match.id}`" 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(#hzBoltGrad-${match.id})`"
|
||||
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(#hzBoltGrad-${match.id})`"
|
||||
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>
|
||||
<img v-if="awayFlag(match)" :src="awayFlag(match)" alt="" class="flag" />
|
||||
<span v-else class="flag-ph">⚽</span>
|
||||
</div>
|
||||
@@ -67,16 +94,43 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
||||
|
||||
<style scoped>
|
||||
.match-card {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 14px 16px;
|
||||
min-height: 72px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: none;
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.match-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
|
||||
opacity: 0.25;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.match-card:active {
|
||||
border-color: var(--border-gold-soft);
|
||||
opacity: 0.92;
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.match-info,
|
||||
.match-flags {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.match-info {
|
||||
@@ -102,35 +156,199 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.flag {
|
||||
width: 32px;
|
||||
height: 22px;
|
||||
width: 40px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.flag-ph {
|
||||
width: 32px;
|
||||
height: 22px;
|
||||
width: 40px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.vs {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
color: var(--primary-light);
|
||||
letter-spacing: 0.04em;
|
||||
.vs-arena {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hz-lightning {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
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: 0;
|
||||
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: 1;
|
||||
width: 26px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
animation: vs-glow 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@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) {
|
||||
.vs-img {
|
||||
animation: none;
|
||||
filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
|
||||
.hz-path,
|
||||
.hz-beam {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -27,6 +27,7 @@ interface Market {
|
||||
period: string;
|
||||
lineValue?: string | number | null;
|
||||
allowParlay?: boolean;
|
||||
promoLabel?: string | null;
|
||||
selections: Selection[];
|
||||
}
|
||||
|
||||
@@ -40,11 +41,17 @@ interface 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;
|
||||
stage?: string | null;
|
||||
groupName?: string | null;
|
||||
markets: Market[];
|
||||
}
|
||||
|
||||
@@ -65,12 +72,17 @@ const marketsByType = computed(() => {
|
||||
});
|
||||
|
||||
const homeFlag = computed(() =>
|
||||
teamFlagUrl(match.value?.homeTeamCode, match.value?.homeTeamName),
|
||||
teamFlagUrl(match.value?.homeTeamCode, match.value?.homeTeamName, match.value?.homeTeamLogoUrl),
|
||||
);
|
||||
const awayFlag = computed(() =>
|
||||
teamFlagUrl(match.value?.awayTeamCode, match.value?.awayTeamName),
|
||||
teamFlagUrl(match.value?.awayTeamCode, match.value?.awayTeamName, match.value?.awayTeamLogoUrl),
|
||||
);
|
||||
|
||||
function marketPromoLabel(marketType: string) {
|
||||
const m = marketsByType.value.get(marketType);
|
||||
return m?.promoLabel?.trim() || '';
|
||||
}
|
||||
|
||||
const kickoff = computed(() => {
|
||||
if (!match.value) return '';
|
||||
return new Date(match.value.startTime).toLocaleString(locale.value, {
|
||||
@@ -123,7 +135,7 @@ const csConfirmLines = computed((): CsConfirmLine[] => {
|
||||
return market.selections
|
||||
.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0)
|
||||
.map((s) => {
|
||||
const parsed = parseScoreCode(s.selectionCode);
|
||||
const parsed = parseScoreCode(s.selectionCode, t);
|
||||
return {
|
||||
scoreDisplay: parsed?.display ?? s.selectionName,
|
||||
odds: s.odds,
|
||||
@@ -262,6 +274,16 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
|
||||
|
||||
<template v-else-if="match">
|
||||
<section v-if="match.leagueName" class="league-banner">
|
||||
<span class="league-title">*{{ match.leagueName }}</span>
|
||||
<img
|
||||
v-if="match.leagueLogoUrl"
|
||||
:src="match.leagueLogoUrl"
|
||||
alt=""
|
||||
class="league-logo"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="match-hero">
|
||||
<p class="kickoff">{{ kickoff }}</p>
|
||||
<div class="match-line">
|
||||
@@ -298,6 +320,7 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
>
|
||||
<MarketTypeTile
|
||||
:label="marketLabel(marketType)"
|
||||
:promo-label="marketPromoLabel(marketType)"
|
||||
:has-market="marketsByType.has(marketType)"
|
||||
:expanded="isExpanded(marketType)"
|
||||
@toggle="toggleMarket(marketType)"
|
||||
@@ -382,6 +405,30 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
padding: 2px 12px 10px;
|
||||
}
|
||||
|
||||
.league-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 12px 2px;
|
||||
}
|
||||
|
||||
.league-banner .league-title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.league-logo {
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
width: auto;
|
||||
max-width: 44px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.kickoff {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
|
||||
Reference in New Issue
Block a user