feat(admin,api,player): 优胜赛配置、赛事管理重构与玩家端投注体验优化

管理端拆分赛事/优胜赛 Tab,新增联赛优胜赔率面板(批量、排序、外侧删除);统一 list-chrome 工具栏对齐与列表页布局;Dashboard 失败重试、Users 操作下拉、小屏侧栏等体验修复。

API 扩展优胜赛与赛事目录接口,完善投注与钱包查询;玩家端重构赛事卡片、串关面板、注单/钱包页,新增注单详情、下注成功动画与下拉刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 09:55:56 +08:00
parent efff7c27e6
commit 24fa1b275c
66 changed files with 6289 additions and 1426 deletions

View File

@@ -168,6 +168,11 @@ 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(),
@@ -187,9 +192,146 @@ 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({
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
if (!match) {
const league = await this.prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) throw new NotFoundException('League not found');
const [leagueZh, leagueEn, leagueMs] = await Promise.all([
this.getTranslation('LEAGUE', leagueId, 'zh-CN'),
this.getTranslation('LEAGUE', leagueId, 'en-US'),
this.getTranslation('LEAGUE', leagueId, 'ms-MY'),
]);
await this.createForAdmin({
leagueId,
titleZh: leagueZh || league.code,
titleEn: leagueEn || league.code,
titleMs: leagueMs || undefined,
status: 'DRAFT',
});
match = await this.prisma.match.findFirstOrThrow({
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
}
return this.syncSelectionsFromLeagueFixtures(match.id);
}
async syncSelectionsFromLeagueFixtures(matchId: bigint) {
const match = await this.getOutrightMatchOrThrow(matchId);
const market = await this.ensureOutrightMarket(match.id);
const teams = await this.collectFixtureTeamsForLeague(match.leagueId);
const fixtureCodes = new Set(teams.map((t) => t.code));
const existing = await this.prisma.marketSelection.findMany({
where: {
marketId: market.id,
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
},
});
// 仅当球队重新出现在单场赛程时,恢复曾被关闭的选项;不因「暂无单场」而自动关闭
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' },
});
}
}
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([
this.getTranslation('TEAM', team.id, 'zh-CN'),
this.getTranslation('TEAM', team.id, 'en-US'),
]);
sortOrder += 1;
await this.prisma.marketSelection.create({
data: {
marketId: market.id,
selectionCode: team.code,
selectionName: teamZh || teamEn || team.code,
odds: 10,
sortOrder,
status: 'OPEN',
},
});
}
return this.getForAdmin(matchId);
}
private async collectFixtureTeamsForLeague(leagueId: bigint) {
const matches = await this.prisma.match.findMany({
where: { leagueId, isOutright: false, deletedAt: null },
select: { homeTeamId: true, awayTeamId: true },
});
const teamIds = [
...new Set(matches.flatMap((m) => [m.homeTeamId, m.awayTeamId])),
];
if (teamIds.length === 0) return [];
return this.prisma.team.findMany({
where: {
id: { in: teamIds },
code: { not: PLACEHOLDER_TEAM_CODE },
},
orderBy: { code: 'asc' },
});
}
async createForAdmin(data: {
leagueId: bigint;
titleZh: string;
@@ -325,6 +467,17 @@ export class OutrightService {
where: { marketId: market.id, selectionCode: code },
});
if (existing) {
if (existing.status === 'CLOSED') {
await this.prisma.marketSelection.update({
where: { id: existing.id },
data: {
status: 'OPEN',
odds: data.odds,
selectionName: data.teamZh.trim() || data.teamEn,
},
});
return this.getForAdmin(matchId);
}
throw new BadRequestException('Selection already exists for this team code');
}
@@ -347,6 +500,38 @@ export class OutrightService {
return this.getForAdmin(matchId);
}
async addSelectionsBatch(
matchId: bigint,
items: Array<{
teamCode: string;
teamZh: string;
teamEn: string;
odds: number;
logoUrl?: string;
}>,
) {
if (!items.length) {
throw new BadRequestException('At least one team required');
}
let added = 0;
let skipped = 0;
for (const item of items) {
try {
await this.addSelection(matchId, item);
added += 1;
} catch (e) {
const msg = e instanceof Error ? e.message : '';
if (msg.includes('already exists')) {
skipped += 1;
continue;
}
throw e;
}
}
const data = await this.getForAdmin(matchId);
return { ...data, batchResult: { added, skipped } };
}
async updateSelectionTeam(
matchId: bigint,
selectionId: bigint,