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

@@ -373,6 +373,10 @@ class CreatePlatformLeagueDto {
@IsOptional()
@IsNumber()
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
class CreatePlatformMatchDto {
@@ -456,6 +460,59 @@ class CreatePlatformMatchDto {
awayTeamLogoUrl?: string;
}
class UpdatePlatformMatchDto {
@IsString()
homeTeamEn!: string;
@IsString()
homeTeamZh!: string;
@IsOptional()
@IsString()
homeTeamMs?: string;
@IsString()
awayTeamEn!: string;
@IsString()
awayTeamZh!: string;
@IsOptional()
@IsString()
awayTeamMs?: string;
@IsString()
startTime!: string;
@IsOptional()
@IsBoolean()
isHot?: boolean;
@IsOptional()
@IsNumber()
displayOrder?: number;
@IsOptional()
@IsString()
matchName?: string;
@IsOptional()
@IsString()
stage?: string;
@IsOptional()
@IsString()
groupName?: string;
@IsOptional()
@IsString()
homeTeamLogoUrl?: string;
@IsOptional()
@IsString()
awayTeamLogoUrl?: string;
}
class BatchMatchOddsDto {
@IsArray()
updates!: OutrightOddsUpdateItemDto[];
@@ -518,6 +575,16 @@ class ScoreDto {
winnerTeamId?: number;
}
class SettlementPreviewDto extends ScoreDto {
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
@IsNumber()
pageSize?: number;
}
/* 智能比分推荐已关闭
class SmartScoreSuggestDto {
@IsOptional()
@@ -1252,6 +1319,7 @@ export class AdminController {
leagueMs: body.leagueMs,
logoUrl: body.logoUrl,
displayOrder: body.displayOrder,
isActive: body.isActive,
});
return jsonResponse(league);
}
@@ -1260,6 +1328,23 @@ export class AdminController {
return jsonResponse(league);
}
@Put('leagues/:leagueId')
@RequirePermissions(P.matches)
async updateLeague(
@Param('leagueId') leagueId: string,
@Body() dto: CreatePlatformLeagueDto,
) {
const league = await this.matches.updatePlatformLeague(BigInt(leagueId), {
leagueEn: dto.leagueEn,
leagueZh: dto.leagueZh,
leagueMs: dto.leagueMs,
logoUrl: dto.logoUrl,
displayOrder: dto.displayOrder,
isActive: dto.isActive,
});
return jsonResponse(league);
}
@Get('leagues')
@RequirePermissions(P.matches, P.reports)
async listLeagues(
@@ -1360,12 +1445,9 @@ export class AdminController {
async updateMatch(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: CreatePlatformMatchDto,
@Body() dto: UpdatePlatformMatchDto,
) {
const match = await this.matches.updatePlatformMatch(BigInt(id), {
leagueEn: dto.leagueEn ?? '',
leagueZh: dto.leagueZh ?? '',
leagueMs: dto.leagueMs,
homeTeamEn: dto.homeTeamEn,
homeTeamZh: dto.homeTeamZh,
homeTeamMs: dto.homeTeamMs,
@@ -1378,11 +1460,11 @@ export class AdminController {
matchName: dto.matchName,
stage: dto.stage,
groupName: dto.groupName,
leagueLogoUrl: dto.leagueLogoUrl,
homeTeamLogoUrl: dto.homeTeamLogoUrl,
awayTeamLogoUrl: dto.awayTeamLogoUrl,
updatedBy: operatorId,
});
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
return jsonResponse(match);
}
@@ -1420,6 +1502,7 @@ export class AdminController {
awayTeamLogoUrl: dto.awayTeamLogoUrl,
createdBy: operatorId,
});
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
return jsonResponse(match);
}
@@ -1712,9 +1795,27 @@ export class AdminController {
async settlementPreview(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto?: { page?: number; pageSize?: number },
@Body() dto?: SettlementPreviewDto,
) {
const preview = await this.settlement.previewSettlement(BigInt(id), operatorId, {
const matchId = BigInt(id);
const hasScore =
dto?.htHome !== undefined ||
dto?.htAway !== undefined ||
dto?.ftHome !== undefined ||
dto?.ftAway !== undefined ||
dto?.winnerTeamId !== undefined;
if (hasScore) {
await this.settlement.recordScore(
matchId,
dto!.htHome ?? 0,
dto!.htAway ?? 0,
dto!.ftHome ?? 0,
dto!.ftAway ?? 0,
operatorId,
dto!.winnerTeamId != null ? BigInt(dto!.winnerTeamId) : undefined,
);
}
const preview = await this.settlement.previewSettlement(matchId, operatorId, {
page: dto?.page ? Math.max(1, dto.page) : 1,
pageSize: dto?.pageSize ? Math.min(100, Math.max(1, dto.pageSize)) : 10,
});
@@ -1980,6 +2081,24 @@ export class AdminController {
return jsonResponse({ ok: true });
}
@Delete('uploads/by-url')
@RequirePermissions(P.content, P.matches)
async deleteFileByUrl(@Body() body: { url: string }) {
const { url } = body;
if (!url || typeof url !== 'string') throw new BadRequestException('url is required');
const record = await this.prisma.uploadedFile.findFirst({ where: { url } });
if (!record) return jsonResponse({ ok: true, note: 'not_found' });
const root = getUploadRoot();
try {
await unlink(join(root, record.category, record.filename));
} catch { /* already gone */ }
await this.prisma.uploadedFile.delete({ where: { id: record.id } });
return jsonResponse({ ok: true });
}
private async getUsedFileUrls(): Promise<Set<string>> {
const [ctRows, leagueRows, teamRows, prefRows] = await Promise.all([
this.prisma.contentTranslation.findMany({ select: { imageUrl: true } }),