feat: split admin dashboard, improve match ops, and player closed-match UX

Admin: add match/player overview sub-nav; refine settlement flow and league
match management UI; improve action button enabled/disabled styles; enhance
logo upload and outright odds sync.

API: expose matchPhase/bettingOpen for closed matches; league publish guards;
settlement preview with auto score save; outright team auto-sync.

Player: watermark for closed/settled states; keep match and bet details visible;
remove default login credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-10 13:00:14 +08:00
parent 6124313369
commit 03f54ca689
43 changed files with 2787 additions and 519 deletions

View File

@@ -1,5 +1,5 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { resolveTranslationFallback } from '@thebet365/shared';
import { isPreMatchKickoff, resolveTranslationFallback } from '@thebet365/shared';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
@@ -213,6 +213,7 @@ export class MatchesService {
leagueMs?: string;
logoUrl?: string;
displayOrder?: number;
isActive?: boolean;
}) {
const leagueEn = data.leagueEn.trim();
const leagueZh = data.leagueZh.trim();
@@ -229,12 +230,12 @@ export class MatchesService {
'ms-MY': data.leagueMs.trim(),
});
}
const updates: { logoUrl?: string; displayOrder?: number } = {};
const updates: { logoUrl?: string; displayOrder?: number; isActive: boolean } = {
isActive: data.isActive ?? false,
};
if (data.logoUrl?.trim()) updates.logoUrl = data.logoUrl.trim();
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
if (Object.keys(updates).length) {
await this.prisma.league.update({ where: { id: league.id }, data: updates });
}
await this.prisma.league.update({ where: { id: league.id }, data: updates });
const [en, zh, ms] = await Promise.all([
this.getTranslationExact('LEAGUE', league.id, 'en-US'),
this.getTranslationExact('LEAGUE', league.id, 'zh-CN'),
@@ -246,6 +247,68 @@ export class MatchesService {
code: fresh.code,
logoUrl: fresh.logoUrl,
displayOrder: fresh.displayOrder,
isPublished: fresh.isActive,
leagueEn: en,
leagueZh: zh,
leagueMs: ms,
};
}
async updatePlatformLeague(
leagueId: bigint,
data: {
leagueEn: string;
leagueZh: string;
leagueMs?: string;
logoUrl?: string;
displayOrder?: number;
isActive?: boolean;
},
) {
const league = await this.prisma.league.findFirst({
where: { id: leagueId, deletedAt: null },
});
if (!league) throw new NotFoundException('赛事不存在');
const leagueEn = data.leagueEn.trim();
const leagueZh = data.leagueZh.trim();
if (!leagueEn && !leagueZh) {
throw new BadRequestException('请填写赛事名称(中文或英文至少一项)');
}
await this.upsertEntityTranslations('LEAGUE', leagueId, {
'zh-CN': leagueZh,
'en-US': leagueEn,
'ms-MY': (data.leagueMs ?? '').trim(),
});
const updates: { logoUrl?: string | null; displayOrder?: number; isActive?: boolean } = {};
if (data.logoUrl !== undefined) {
updates.logoUrl = data.logoUrl.trim() || null;
}
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
if (data.isActive !== undefined) {
if (league.isActive && data.isActive === false) {
throw new BadRequestException('已发布的联赛不可下架');
}
updates.isActive = data.isActive;
}
if (Object.keys(updates).length) {
await this.prisma.league.update({ where: { id: leagueId }, data: updates });
}
const [en, zh, ms] = await Promise.all([
this.getTranslationExact('LEAGUE', leagueId, 'en-US'),
this.getTranslationExact('LEAGUE', leagueId, 'zh-CN'),
this.getTranslationExact('LEAGUE', leagueId, 'ms-MY'),
]);
const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: leagueId } });
return {
id: fresh.id.toString(),
code: fresh.code,
logoUrl: fresh.logoUrl,
displayOrder: fresh.displayOrder,
isPublished: fresh.isActive,
leagueEn: en,
leagueZh: zh,
leagueMs: ms,
@@ -329,6 +392,7 @@ export class MatchesService {
code: league.code,
logoUrl: league.logoUrl,
displayOrder: league.displayOrder,
isPublished: league.isActive,
leagueEn,
leagueZh,
leagueMs,
@@ -819,9 +883,6 @@ export class MatchesService {
async updatePlatformMatch(
matchId: bigint,
data: {
leagueEn: string;
leagueZh: string;
leagueMs?: string;
homeTeamZh: string;
homeTeamEn: string;
homeTeamMs?: string;
@@ -834,7 +895,6 @@ export class MatchesService {
matchName?: string;
stage?: string;
groupName?: string;
leagueLogoUrl?: string;
homeTeamLogoUrl?: string;
awayTeamLogoUrl?: string;
updatedBy?: bigint;
@@ -853,11 +913,6 @@ export class MatchesService {
`${data.homeTeamEn.trim() || data.homeTeamZh.trim() || data.homeTeamMs?.trim() || ''} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim() || data.awayTeamMs?.trim() || ''}`;
await Promise.all([
this.upsertEntityTranslations('LEAGUE', match.leagueId, {
'zh-CN': data.leagueZh.trim(),
'en-US': data.leagueEn.trim(),
'ms-MY': (data.leagueMs ?? '').trim(),
}),
this.upsertEntityTranslations('TEAM', match.homeTeamId, {
'zh-CN': data.homeTeamZh.trim(),
'en-US': data.homeTeamEn.trim(),
@@ -871,14 +926,6 @@ export class MatchesService {
]);
const logoUpdates: Promise<unknown>[] = [];
if (data.leagueLogoUrl !== undefined) {
logoUpdates.push(
this.prisma.league.update({
where: { id: match.leagueId },
data: { logoUrl: data.leagueLogoUrl.trim() || null },
}),
);
}
if (data.homeTeamLogoUrl !== undefined) {
logoUpdates.push(
this.prisma.team.update({
@@ -1093,6 +1140,12 @@ export class MatchesService {
homeTeam?: { code: string; logoUrl?: string | null };
awayTeam?: { code: string; logoUrl?: string | null };
league?: { logoUrl?: string | null };
score?: {
htHomeScore: number;
htAwayScore: number;
ftHomeScore: number;
ftAwayScore: number;
} | null;
markets?: Array<Record<string, unknown>>;
};
const [leagueName, homeName, awayName] = await Promise.all([
@@ -1118,6 +1171,30 @@ export class MatchesService {
stage: m.stage ?? null,
groupName: m.groupName ?? null,
status: m.status ?? 'PUBLISHED',
score: m.score
? {
htHome: m.score.htHomeScore,
htAway: m.score.htAwayScore,
ftHome: m.score.ftHomeScore,
ftAway: m.score.ftAwayScore,
}
: null,
bettingOpen: this.isMatchBettingOpen({
status: m.status,
startTime: m.startTime,
markets: (m.markets ?? []) as Array<{
status: string;
selections: Array<{ status: string }>;
}>,
}),
matchPhase: this.resolvePlayerMatchPhase(m.status ?? 'PUBLISHED', {
status: m.status,
startTime: m.startTime,
markets: (m.markets ?? []) as Array<{
status: string;
selections: Array<{ status: string }>;
}>,
}),
};
if (m.markets) {
return {
@@ -1126,6 +1203,7 @@ export class MatchesService {
id: (market.id as bigint).toString(),
marketType: market.marketType as string,
period: market.period as string,
status: (market.status as string) ?? 'OPEN',
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
promoLabel: (market.promoLabel as string | null | undefined) ?? null,
@@ -1133,6 +1211,7 @@ export class MatchesService {
id: (s.id as bigint).toString(),
selectionCode: s.selectionCode as string,
selectionName: s.selectionName as string,
status: (s.status as string) ?? 'OPEN',
odds: Number(s.odds),
oddsVersion: (s.oddsVersion as bigint).toString(),
})),
@@ -1142,26 +1221,69 @@ export class MatchesService {
return base;
}
private resolvePlayerMatchPhaseFromStatus(
status: string,
startTime: Date,
): 'open' | 'closed_pending' | 'settled' {
if (status === 'SETTLED') return 'settled';
if (status === 'CLOSED' || status === 'PENDING_SETTLEMENT') return 'closed_pending';
if (status === 'PUBLISHED' && !isPreMatchKickoff(startTime)) return 'closed_pending';
return 'open';
}
private resolvePlayerMatchPhase(
status: string,
m: {
status?: string;
startTime: Date;
markets?: Array<{ status: string; selections: Array<{ status: string }> }>;
},
): 'open' | 'closed_pending' | 'settled' {
if (status === 'SETTLED') return 'settled';
if (status === 'CLOSED' || status === 'PENDING_SETTLEMENT') return 'closed_pending';
if (status === 'PUBLISHED' && !this.isMatchBettingOpen(m)) return 'closed_pending';
return 'open';
}
private isMatchBettingOpen(m: {
status?: string;
startTime: Date;
markets?: Array<{ status: string; selections: Array<{ status: string }> }>;
}): boolean {
if (m.status !== 'PUBLISHED') return false;
if (!isPreMatchKickoff(m.startTime)) return false;
return (m.markets ?? []).some(
(mk) => mk.status === 'OPEN' && mk.selections.some((s) => s.status === 'OPEN'),
);
}
private playerMarketInclude = {
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
include: {
selections: {
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
orderBy: { sortOrder: 'asc' as const },
},
},
orderBy: { sortOrder: 'asc' as const },
};
async listPublished(locale = 'en-US', leagueId?: bigint) {
const now = new Date();
const matches = await this.prisma.match.findMany({
where: {
status: 'PUBLISHED',
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'] },
isOutright: false,
sportType: 'FOOTBALL',
deletedAt: null,
startTime: { gt: now },
league: { isActive: true, deletedAt: null },
...(leagueId ? { leagueId } : {}),
},
include: {
league: true,
homeTeam: true,
awayTeam: true,
markets: {
where: { status: 'OPEN' },
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
orderBy: { sortOrder: 'asc' },
},
score: true,
markets: this.playerMarketInclude,
},
orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }],
});
@@ -1176,17 +1298,14 @@ export class MatchesService {
deletedAt: null,
sportType: 'FOOTBALL',
isOutright: false,
status: { in: ['PUBLISHED', 'CLOSED'] },
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'] },
league: { isActive: true, deletedAt: null },
},
include: {
league: true,
homeTeam: true,
awayTeam: true,
markets: {
where: { status: 'OPEN' },
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
orderBy: { sortOrder: 'asc' },
},
markets: this.playerMarketInclude,
score: true,
},
});
@@ -1325,6 +1444,7 @@ export class MatchesService {
leagueName: string;
matchTitle: string;
isOutright: boolean;
matchPhase: 'open' | 'closed_pending' | 'settled';
score: { ht: string | null; ft: string | null } | null;
}
>();
@@ -1348,6 +1468,7 @@ export class MatchesService {
leagueName,
matchTitle: m.isOutright ? leagueName : `${homeName} vs ${awayName}`,
isOutright: m.isOutright,
matchPhase: this.resolvePlayerMatchPhaseFromStatus(m.status, m.startTime),
score: ftScore || htScore ? { ht: htScore, ft: ftScore } : null,
});
}
@@ -1396,6 +1517,7 @@ export class MatchesService {
legs,
isParlay,
matchScore: isParlay ? null : firstScore,
matchPhase: isParlay ? null : meta?.matchPhase ?? null,
};
});
}

View File

@@ -168,11 +168,6 @@ export class OutrightService {
this.getOutrightTitle(match.id, 'ms-MY'),
]);
const addableFixtureTeams = await this.listAddableFixtureTeams(
match.leagueId,
fullMarket.id,
);
return {
id: match.id.toString(),
leagueId: match.leagueId.toString(),
@@ -192,49 +187,9 @@ export class OutrightService {
playerVisible: visibility.playerVisible,
playerHiddenReason: visibility.playerHiddenReason,
selections,
addableFixtureTeams,
};
}
private async listAddableFixtureTeams(leagueId: bigint, marketId: bigint) {
const teams = await this.collectFixtureTeamsForLeague(leagueId);
const openCodes = new Set(
(
await this.prisma.marketSelection.findMany({
where: {
marketId,
status: 'OPEN',
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
},
select: { selectionCode: true },
})
).map((s) => s.selectionCode),
);
const result: Array<{
teamCode: string;
teamZh: string;
teamEn: string;
logoUrl: string | null;
}> = [];
for (const team of teams) {
if (openCodes.has(team.code)) continue;
const [teamZh, teamEn] = await Promise.all([
this.getTranslation('TEAM', team.id, 'zh-CN'),
this.getTranslation('TEAM', team.id, 'en-US'),
]);
result.push({
teamCode: team.code,
teamZh: teamZh || team.code,
teamEn: teamEn || team.code,
logoUrl: team.logoUrl ?? null,
});
}
return result;
}
/** 按联赛获取或创建冠军盘,并从单场赛程同步参赛队伍 */
async getOrCreateAndSyncForLeague(leagueId: bigint) {
let match = await this.prisma.match.findFirst({
@@ -263,6 +218,22 @@ export class OutrightService {
orderBy: { id: 'asc' },
});
}
const sync = await this.syncSelectionsFromLeagueFixtures(match.id);
const data = await this.getForAdmin(match.id);
return {
...data,
fixtureSyncAdded: sync.addedCount,
fixtureSyncReopened: sync.reopenedCount,
};
}
/** 若联赛已有冠军盘,则从单场同步球队(不自动创建冠军盘) */
async syncOutrightTeamsForLeagueIfExists(leagueId: bigint) {
const match = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
if (!match) return { addedCount: 0, reopenedCount: 0 };
return this.syncSelectionsFromLeagueFixtures(match.id);
}
@@ -279,19 +250,21 @@ export class OutrightService {
},
});
// 仅当球队重新出现在单场赛程时,恢复曾被关闭的选项;不因「暂无单场」而自动关闭
const existingCodes = new Set(existing.map((s) => s.selectionCode));
let sortOrder = existing.reduce((max, s) => Math.max(max, s.sortOrder), -1);
let addedCount = 0;
let reopenedCount = 0;
for (const sel of existing) {
if (fixtureCodes.has(sel.selectionCode) && sel.status === 'CLOSED') {
await this.prisma.marketSelection.update({
where: { id: sel.id },
data: { status: 'OPEN' },
});
reopenedCount += 1;
}
}
const existingCodes = new Set(existing.map((s) => s.selectionCode));
let sortOrder = existing.reduce((max, s) => Math.max(max, s.sortOrder), -1);
for (const team of teams) {
if (existingCodes.has(team.code)) continue;
const [teamZh, teamEn] = await Promise.all([
@@ -309,9 +282,10 @@ export class OutrightService {
status: 'OPEN',
},
});
addedCount += 1;
}
return this.getForAdmin(matchId);
return { addedCount, reopenedCount };
}
private async collectFixtureTeamsForLeague(leagueId: bigint) {