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

@@ -262,31 +262,28 @@ export class MatchesService {
const kw = opts.keyword?.trim();
let idFilter: bigint[] | undefined;
if (kw || opts.status) {
// 状态仅用于单场计数/展开列表筛选,不隐藏无该状态单场的联赛(含新建空联赛)
if (kw) {
const ids = new Set<bigint>();
if (kw) {
const trRows = await this.prisma.entityTranslation.findMany({
where: {
entityType: 'LEAGUE',
fieldName: 'name',
value: { contains: kw, mode: 'insensitive' },
},
select: { entityId: true },
});
for (const r of trRows) ids.add(r.entityId);
}
const trRows = await this.prisma.entityTranslation.findMany({
where: {
entityType: 'LEAGUE',
fieldName: 'name',
value: { contains: kw, mode: 'insensitive' },
},
select: { entityId: true },
});
for (const r of trRows) ids.add(r.entityId);
const matchWhere: Prisma.MatchWhereInput = {
deletedAt: null,
isOutright: false,
};
if (opts.status) matchWhere.status = opts.status;
if (kw) {
matchWhere.OR = [
OR: [
{ matchName: { contains: kw, mode: 'insensitive' } },
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
];
}
],
};
if (opts.status) matchWhere.status = opts.status;
const matchLeagues = await this.prisma.match.findMany({
where: matchWhere,
select: { leagueId: true },
@@ -351,6 +348,27 @@ export class MatchesService {
},
select: { id: true, leagueId: true },
});
const fixtureTeamRows = await this.prisma.match.findMany({
where: {
leagueId: { in: leagueIds },
deletedAt: null,
isOutright: false,
},
select: {
leagueId: true,
homeTeam: { select: { id: true, code: true } },
awayTeam: { select: { id: true, code: true } },
},
});
const fixtureTeamSets = new Map<string, Set<string>>();
for (const row of fixtureTeamRows) {
const lid = row.leagueId.toString();
if (!fixtureTeamSets.has(lid)) fixtureTeamSets.set(lid, new Set());
const set = fixtureTeamSets.get(lid)!;
for (const team of [row.homeTeam, row.awayTeam]) {
if (team.code !== 'OUT') set.add(team.id.toString());
}
}
const matchStats = await this.betStatsForMatches(
leagueMatches.map((m) => m.id),
);
@@ -373,6 +391,50 @@ export class MatchesService {
}
leagueBetRollup.set(lid, cur);
}
const outrightMatches = await this.prisma.match.findMany({
where: {
leagueId: { in: leagueIds },
isOutright: true,
deletedAt: null,
},
select: { id: true, leagueId: true },
});
const outrightTeamCounts = new Map<string, number>();
if (outrightMatches.length > 0) {
const matchIdToLeagueId = new Map(
outrightMatches.map((m) => [m.id.toString(), m.leagueId.toString()]),
);
const outrightMatchIds = outrightMatches.map((m) => m.id);
const markets = await this.prisma.market.findMany({
where: {
matchId: { in: outrightMatchIds },
marketType: 'OUTRIGHT_WINNER',
},
select: { id: true, matchId: true },
});
if (markets.length > 0) {
const marketIdToMatchId = new Map(
markets.map((m) => [m.id.toString(), m.matchId.toString()]),
);
const selectionCounts = await this.prisma.marketSelection.groupBy({
by: ['marketId'],
where: {
marketId: { in: markets.map((m) => m.id) },
status: 'OPEN',
selectionCode: { not: 'OUT' },
},
_count: { id: true },
});
for (const row of selectionCounts) {
const matchId = marketIdToMatchId.get(row.marketId.toString());
const leagueId = matchId ? matchIdToLeagueId.get(matchId) : undefined;
if (leagueId) {
outrightTeamCounts.set(leagueId, row._count.id);
}
}
}
}
for (const item of items) {
const roll = leagueBetRollup.get(item.id);
if (roll) {
@@ -382,6 +444,10 @@ export class MatchesService {
pendingCount: roll.pendingCount,
};
}
(item as { fixtureTeamCount?: number }).fixtureTeamCount =
fixtureTeamSets.get(item.id)?.size ?? 0;
(item as { outrightTeamCount?: number }).outrightTeamCount =
outrightTeamCounts.get(item.id) ?? 0;
}
return { items, total, page: opts.page, pageSize: opts.pageSize };
@@ -501,11 +567,37 @@ export class MatchesService {
return result;
}
private async upsertTeamByCode(data: {
code: string;
teamZh: string;
teamEn: string;
teamMs?: string;
logoUrl?: string;
}) {
const code = data.code.trim().toUpperCase();
if (!code) throw new BadRequestException('请填写球队代码');
const logoUrl = data.logoUrl?.trim() || undefined;
const team = await this.prisma.team.upsert({
where: { code },
create: { code, logoUrl },
update: logoUrl ? { logoUrl } : {},
});
const translations: Record<string, string> = {
'zh-CN': data.teamZh.trim() || data.teamEn.trim(),
'en-US': data.teamEn.trim() || data.teamZh.trim(),
};
if (data.teamMs?.trim()) translations['ms-MY'] = data.teamMs.trim();
await this.upsertEntityTranslations('TEAM', team.id, translations);
return team;
}
async createPlatformMatch(data: {
leagueId?: bigint;
leagueEn?: string;
leagueZh?: string;
leagueMs?: string;
homeTeamCode?: string;
awayTeamCode?: string;
homeTeamZh: string;
homeTeamEn: string;
homeTeamMs?: string;
@@ -563,32 +655,56 @@ export class MatchesService {
});
}
}
const homeTeam = await this.upsertTeamFromZhiboExport({
id: null,
name: homeEn || homeZh || homeMs,
names: {
zh: homeZh || null,
en: homeEn || null,
zhTw: '',
vi: null,
km: null,
ms: homeMs || null,
},
image: data.homeTeamLogoUrl?.trim() || '',
});
const awayTeam = await this.upsertTeamFromZhiboExport({
id: null,
name: awayEn || awayZh || awayMs,
names: {
zh: awayZh || null,
en: awayEn || null,
zhTw: '',
vi: null,
km: null,
ms: awayMs || null,
},
image: data.awayTeamLogoUrl?.trim() || '',
});
const homeCode = data.homeTeamCode?.trim().toUpperCase();
const awayCode = data.awayTeamCode?.trim().toUpperCase();
let homeTeam;
let awayTeam;
if (homeCode && awayCode) {
if (homeCode === awayCode) {
throw new BadRequestException('主客队不能为同一支球队,请选择不同的队伍');
}
homeTeam = await this.upsertTeamByCode({
code: homeCode,
teamZh: homeZh,
teamEn: homeEn,
teamMs: homeMs,
logoUrl: data.homeTeamLogoUrl,
});
awayTeam = await this.upsertTeamByCode({
code: awayCode,
teamZh: awayZh,
teamEn: awayEn,
teamMs: awayMs,
logoUrl: data.awayTeamLogoUrl,
});
} else {
homeTeam = await this.upsertTeamFromZhiboExport({
id: null,
name: homeEn || homeZh || homeMs,
names: {
zh: homeZh || null,
en: homeEn || null,
zhTw: '',
vi: null,
km: null,
ms: homeMs || null,
},
image: data.homeTeamLogoUrl?.trim() || '',
});
awayTeam = await this.upsertTeamFromZhiboExport({
id: null,
name: awayEn || awayZh || awayMs,
names: {
zh: awayZh || null,
en: awayEn || null,
zhTw: '',
vi: null,
km: null,
ms: awayMs || null,
},
image: data.awayTeamLogoUrl?.trim() || '',
});
}
if (homeTeam.id === awayTeam.id) {
throw new BadRequestException('主客队不能为同一支球队,请填写不同的队名');
@@ -1199,13 +1315,18 @@ export class MatchesService {
matchIds.length > 0
? await this.prisma.match.findMany({
where: { id: { in: matchIds } },
include: { homeTeam: true, awayTeam: true },
include: { homeTeam: true, awayTeam: true, score: true },
})
: [];
const matchMeta = new Map<
string,
{ leagueName: string; matchTitle: string; isOutright: boolean }
{
leagueName: string;
matchTitle: string;
isOutright: boolean;
score: { ht: string | null; ft: string | null } | null;
}
>();
for (const m of matches) {
@@ -1214,10 +1335,20 @@ export class MatchesService {
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
]);
const s = m.score;
const ftScore =
s?.ftHomeScore != null && s?.ftAwayScore != null
? `${s.ftHomeScore}-${s.ftAwayScore}`
: null;
const htScore =
s?.htHomeScore != null && s?.htAwayScore != null
? `${s.htHomeScore}-${s.htAwayScore}`
: null;
matchMeta.set(m.id.toString(), {
leagueName,
matchTitle: m.isOutright ? leagueName : `${homeName} vs ${awayName}`,
isOutright: m.isOutright,
score: ftScore || htScore ? { ht: htScore, ft: ftScore } : null,
});
}
@@ -1237,9 +1368,12 @@ export class MatchesService {
resultStatus: sel.resultStatus,
matchTitle: m?.matchTitle ?? sel.selectionNameSnapshot,
leagueName: m?.leagueName ?? '',
score: m?.score ?? null,
};
});
const firstScore = meta?.score ?? legs[0]?.score ?? null;
return {
betNo: bet.betNo,
betType: bet.betType,
@@ -1261,6 +1395,7 @@ export class MatchesService {
: `${legs[0]?.marketLabel ?? ''}: ${legs[0]?.selectionName ?? ''}`,
legs,
isParlay,
matchScore: isParlay ? null : firstScore,
};
});
}