feat(admin,api,player): 赛事分组管理、盘口独立页与多语言展示优化

- 管理端按联赛展示单场,新增赛事/单场流程与列表展开状态保持

- 盘口赔率迁至独立页面,保存按钮仅在有修改时高亮

- API 新增联赛列表与子场查询,按 locale 返回队名并修复编译

- 波胆其它选项与促销标签等 i18n 补齐,文案更易懂
This commit is contained in:
2026-06-04 16:25:03 +08:00
parent c68abadceb
commit cc737e2924
39 changed files with 3330 additions and 378 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -5,6 +5,7 @@ import saishiImg from '../assets/images/saishi.png';
defineProps<{
leagueId: string;
leagueName: string;
leagueLogoUrl?: string | null;
expanded: boolean;
matches: {
id: string;
@@ -12,6 +13,8 @@ defineProps<{
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
homeTeamLogoUrl?: string | null;
awayTeamLogoUrl?: string | null;
startTime: string;
}[];
}>();
@@ -26,7 +29,11 @@ const emit = defineEmits<{ toggle: []; bet: [id: string] }>();
<span class="toggle-mark">{{ expanded ? '' : '+' }}</span>
</span>
<span class="league-title">*{{ leagueName }}</span>
<img :src="saishiImg" alt="" class="league-saishi" />
<img
:src="leagueLogoUrl || saishiImg"
alt=""
class="league-saishi"
/>
</button>
<div v-show="expanded" class="match-panel">

View File

@@ -10,6 +10,8 @@ const props = defineProps<{
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
homeTeamLogoUrl?: string | null;
awayTeamLogoUrl?: string | null;
startTime: string;
};
}>();
@@ -29,8 +31,12 @@ const kickoff = computed(() => {
});
});
const homeFlag = computed(() => teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName));
const awayFlag = computed(() => teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName));
const homeFlag = computed(() =>
teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName, props.match.homeTeamLogoUrl),
);
const awayFlag = computed(() =>
teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName, props.match.awayTeamLogoUrl),
);
</script>
<template>

View File

@@ -21,7 +21,9 @@ const emit = defineEmits<{
const { t } = useI18n();
const columns = computed(() => groupCorrectScoreSelections(props.selections, props.marketType));
const columns = computed(() =>
groupCorrectScoreSelections(props.selections, props.marketType, t),
);
function setStake(sel: CsSelection, raw: string) {
const n = Math.max(0, Number(raw) || 0);

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
defineProps<{
import { useI18n } from 'vue-i18n';
import { resolveSelectionLabel } from '../../utils/selectionLabel';
const props = defineProps<{
selections: {
id: string;
selectionCode?: string;
selectionName: string;
odds: string;
}[];
@@ -10,6 +14,14 @@ defineProps<{
}>();
const emit = defineEmits<{ pick: [id: string] }>();
const { t } = useI18n();
function label(sel: (typeof props.selections)[number]) {
if (sel.selectionCode) {
return resolveSelectionLabel(t, sel.selectionCode, sel.selectionName);
}
return sel.selectionName;
}
</script>
<template>
@@ -23,7 +35,7 @@ const emit = defineEmits<{ pick: [id: string] }>();
:class="{ selected: isSelected(sel.id) }"
@click="emit('pick', sel.id)"
>
<span class="label">{{ sel.selectionName }}</span>
<span class="label">{{ label(sel) }}</span>
<span class="odds">{{ sel.odds }}</span>
</button>
</div>

View File

@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
defineProps<{
label: string;
promoLabel?: string;
expanded: boolean;
hasMarket: boolean;
}>();
@@ -20,6 +21,7 @@ const { t } = useI18n();
@click="emit('toggle')"
>
<span class="row-label">{{ label }}</span>
<span v-if="promoLabel" class="row-promo">{{ promoLabel }}</span>
<span v-if="!hasMarket" class="row-muted">{{ t('bet.market_closed') }}</span>
<span v-else class="row-chevron" aria-hidden="true">{{ expanded ? '▾' : '▸' }}</span>
</button>
@@ -53,6 +55,17 @@ const { t } = useI18n();
color: var(--text);
}
.row-promo {
flex-shrink: 0;
font-size: 9px;
font-weight: 700;
color: #ffb800;
padding: 2px 6px;
border-radius: 3px;
background: rgba(255, 184, 0, 0.12);
border: 1px solid rgba(255, 184, 0, 0.35);
}
.row.expanded .row-label {
color: var(--primary-light);
}

View File

@@ -7,12 +7,17 @@ import { resolveAnnouncements } from '../constants/defaultAnnouncement';
export interface PlayerHomeMatch {
id: string;
leagueName?: string;
leagueLogoUrl?: string | null;
homeTeamName: string;
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
homeTeamLogoUrl?: string | null;
awayTeamLogoUrl?: string | null;
startTime: string;
isHot?: boolean;
displayOrder?: number;
}
interface HomePayload {

View File

@@ -126,6 +126,9 @@ const i18n = createI18n({
parlay_sel_under: '小',
parlay_sel_odd: '单',
parlay_sel_even: '双',
cs_other_home: '主胜其它比分',
cs_other_draw: '和局其它比分',
cs_other_away: '客胜其它比分',
col_home: '主场',
col_draw: '平',
col_away: '客场',
@@ -335,6 +338,9 @@ const i18n = createI18n({
parlay_sel_under: 'U',
parlay_sel_odd: 'Odd',
parlay_sel_even: 'Even',
cs_other_home: 'Home win (other score)',
cs_other_draw: 'Draw (other score)',
cs_other_away: 'Away win (other score)',
col_home: 'Home',
col_draw: 'Draw',
col_away: 'Away',
@@ -550,6 +556,9 @@ const i18n = createI18n({
parlay_sel_under: 'Bwh',
parlay_sel_odd: 'G',
parlay_sel_even: 'Gn',
cs_other_home: 'Menang rumah (skor lain)',
cs_other_draw: 'Seri (skor lain)',
cs_other_away: 'Menang pelawat (skor lain)',
col_home: 'Home',
col_draw: 'Seri',
col_away: 'Away',

View File

@@ -33,10 +33,39 @@ function orderForMarket(marketType: string) {
return HT_CORRECT_SCORE_ORDER;
}
export function parseScoreCode(code: string): { display: string; column: CsColumn } | null {
if (code === 'OTHER_HOME') return { display: '其它', column: 'home' };
if (code === 'OTHER_DRAW') return { display: '其它', column: 'draw' };
if (code === 'OTHER_AWAY') return { display: '其它', column: 'away' };
const OTHER_SCORE_FALLBACK: Record<string, string> = {
OTHER_HOME: '主胜其它比分',
OTHER_DRAW: '和局其它比分',
OTHER_AWAY: '客胜其它比分',
};
export type OtherScoreCode = 'OTHER_HOME' | 'OTHER_DRAW' | 'OTHER_AWAY';
export function otherScoreDisplay(
code: OtherScoreCode,
t?: (key: string) => string,
): string {
if (t) {
const key = `bet.cs_${code.toLowerCase()}`;
const v = t(key);
if (v !== key) return v;
}
return OTHER_SCORE_FALLBACK[code];
}
export function parseScoreCode(
code: string,
t?: (key: string) => string,
): { display: string; column: CsColumn } | null {
if (code === 'OTHER_HOME') {
return { display: otherScoreDisplay('OTHER_HOME', t), column: 'home' };
}
if (code === 'OTHER_DRAW') {
return { display: otherScoreDisplay('OTHER_DRAW', t), column: 'draw' };
}
if (code === 'OTHER_AWAY') {
return { display: otherScoreDisplay('OTHER_AWAY', t), column: 'away' };
}
const m = code.match(/^SCORE_(\d+)_(\d+)$/);
if (!m) return null;
const h = Number(m[1]);
@@ -63,6 +92,7 @@ export function groupCorrectScoreSelections(
oddsVersion: string;
}>,
marketType: string,
t?: (key: string) => string,
) {
const template = orderForMarket(marketType);
const home: CsSelection[] = [];
@@ -70,7 +100,7 @@ export function groupCorrectScoreSelections(
const away: CsSelection[] = [];
for (const sel of selections) {
const parsed = parseScoreCode(sel.selectionCode);
const parsed = parseScoreCode(sel.selectionCode, t);
if (!parsed) continue;
const row: CsSelection = { ...sel, scoreDisplay: parsed.display };
if (parsed.column === 'home') home.push(row);

View File

@@ -0,0 +1,28 @@
import { parseScoreCode } from './correctScoreLayout';
const CODE_I18N: Record<string, string> = {
HOME: 'parlay_sel_home',
AWAY: 'parlay_sel_away',
DRAW: 'parlay_sel_draw',
OVER: 'parlay_sel_over',
UNDER: 'parlay_sel_under',
ODD: 'parlay_sel_odd',
EVEN: 'parlay_sel_even',
};
/** 标准选项按 code 显示固定文案,不依赖后台手填的 selectionName */
export function resolveSelectionLabel(
t: (key: string) => string,
code: string,
fallback: string,
): string {
const i18nKey = CODE_I18N[code];
if (i18nKey) {
const fullKey = `bet.${i18nKey}`;
const v = t(fullKey);
if (v !== fullKey) return v;
}
const parsed = parseScoreCode(code, t);
if (parsed) return parsed.display;
return fallback;
}

View File

@@ -159,7 +159,12 @@ const NAME_TO_ISO: Record<string, string> = {
西: 'gb',
};
export function teamFlagUrl(code?: string, name?: string): string {
export function teamFlagUrl(
code?: string,
name?: string,
logoUrl?: string | null,
): string {
if (logoUrl?.trim()) return logoUrl.trim();
const key = (code ?? '').toUpperCase();
if (key && CODE_TO_ISO[key]) {
return `https://flagcdn.com/w40/${CODE_TO_ISO[key]}.png`;

View File

@@ -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)"

View File

@@ -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 {

View File

@@ -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);