feat(admin,api,player): 优胜赛配置、赛事管理重构与玩家端投注体验优化
管理端拆分赛事/优胜赛 Tab,新增联赛优胜赔率面板(批量、排序、外侧删除);统一 list-chrome 工具栏对齐与列表页布局;Dashboard 失败重试、Users 操作下拉、小屏侧栏等体验修复。 API 扩展优胜赛与赛事目录接口,完善投注与钱包查询;玩家端重构赛事卡片、串关面板、注单/钱包页,新增注单详情、下注成功动画与下拉刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user