feat(player): 完善 H5 投注端与 API 演示数据

- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑
- 固定顶栏、公告与底栏,仅内容区滚动
- 底部导航与站点 favicon 使用 logo,登录页精简
- API 种子、冠军盘与历史注单增强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 17:18:11 +08:00
parent 7af2e418c3
commit b5dca1bfb1
75 changed files with 7077 additions and 384 deletions

View File

@@ -96,9 +96,8 @@ export class MatchesService {
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
league?: unknown;
homeTeam?: unknown;
awayTeam?: unknown;
homeTeam?: { code: string };
awayTeam?: { code: string };
markets?: unknown[];
};
const [leagueName, homeName, awayName] = await Promise.all([
@@ -108,9 +107,13 @@ export class MatchesService {
]);
return {
...match,
id: m.id.toString(),
leagueId: m.leagueId.toString(),
leagueName,
homeTeamName: homeName,
awayTeamName: awayName,
homeTeamCode: m.homeTeam?.code ?? '',
awayTeamCode: m.awayTeam?.code ?? '',
};
}
@@ -118,9 +121,12 @@ export class MatchesService {
const matches = await this.prisma.match.findMany({
where: {
status: 'PUBLISHED',
isOutright: false,
...(leagueId ? { leagueId } : {}),
},
include: {
homeTeam: true,
awayTeam: true,
markets: {
where: { status: 'OPEN' },
include: { selections: { where: { status: 'OPEN' } } },
@@ -136,8 +142,11 @@ export class MatchesService {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
homeTeam: true,
awayTeam: true,
markets: {
include: { selections: true },
where: { status: 'OPEN' },
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
orderBy: { sortOrder: 'asc' },
},
score: true,
@@ -147,12 +156,180 @@ export class MatchesService {
return this.enrichMatch(match, locale);
}
async listOutrights(locale = 'en-US') {
const matches = await this.prisma.match.findMany({
where: { status: 'PUBLISHED', isOutright: true },
include: {
markets: {
where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' },
include: {
selections: {
where: { status: 'OPEN' },
orderBy: { sortOrder: 'asc' },
},
},
},
},
orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }],
});
const results = [];
for (const match of matches) {
const leagueName = await this.getTranslation('LEAGUE', match.leagueId, locale);
const market = match.markets[0];
if (!market) continue;
const selections = await Promise.all(
market.selections.map(async (sel) => {
const teamCode = sel.selectionCode.replace(/^TEAM_/, '');
const team = await this.prisma.team.findUnique({ where: { code: teamCode } });
const teamName = team
? await this.getTranslation('TEAM', team.id, locale)
: sel.selectionName;
return {
id: sel.id.toString(),
teamCode,
teamName,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
};
}),
);
results.push({
id: match.id.toString(),
leagueId: match.leagueId.toString(),
leagueName,
title: `*${leagueName} 冠军`,
marketId: market.id.toString(),
selections,
});
}
return results;
}
private marketLabelKey(marketType: string): string {
const keys: Record<string, string> = {
FT_1X2: '全场独赢',
FT_HANDICAP: '全场让球',
FT_OVER_UNDER: '全场大小',
FT_ODD_EVEN: '全场单双',
HT_1X2: '半场独赢',
HT_HANDICAP: '半场让球',
HT_OVER_UNDER: '半场大小',
OUTRIGHT_WINNER: '冠军',
FT_CORRECT_SCORE: '波胆',
HT_CORRECT_SCORE: '上半场波胆',
SH_CORRECT_SCORE: '下半场波胆',
};
return keys[marketType] ?? marketType;
}
async enrichBetsForHistory(
bets: Array<{
betNo: string;
betType: string;
stake: unknown;
totalOdds: unknown;
potentialReturn: unknown;
actualReturn: unknown;
status: string;
placedAt: Date;
selections: Array<{
matchId: bigint | null;
marketType: string;
selectionNameSnapshot: string;
odds: unknown;
resultStatus?: string | null;
}>;
}>,
locale: string,
) {
const matchIds = [
...new Set(
bets.flatMap((b) =>
b.selections.map((s) => s.matchId).filter((id): id is bigint => id != null),
),
),
];
const matches =
matchIds.length > 0
? await this.prisma.match.findMany({
where: { id: { in: matchIds } },
include: { homeTeam: true, awayTeam: true },
})
: [];
const matchMeta = new Map<
string,
{ leagueName: string; matchTitle: string; isOutright: boolean }
>();
for (const m of matches) {
const [leagueName, homeName, awayName] = await Promise.all([
this.getTranslation('LEAGUE', m.leagueId, locale),
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
]);
matchMeta.set(m.id.toString(), {
leagueName,
matchTitle: m.isOutright ? leagueName : `${homeName} vs ${awayName}`,
isOutright: m.isOutright,
});
}
return bets.map((bet) => {
const firstMatchId = bet.selections.find((s) => s.matchId)?.matchId?.toString();
const meta = firstMatchId ? matchMeta.get(firstMatchId) : undefined;
const isParlay = bet.betType === 'PARLAY' || bet.selections.length > 1;
const legs = bet.selections.map((sel) => {
const mid = sel.matchId?.toString();
const m = mid ? matchMeta.get(mid) : undefined;
return {
marketType: sel.marketType,
marketLabel: this.marketLabelKey(sel.marketType),
selectionName: sel.selectionNameSnapshot,
odds: sel.odds,
resultStatus: sel.resultStatus,
matchTitle: m?.matchTitle ?? sel.selectionNameSnapshot,
leagueName: m?.leagueName ?? '',
};
});
return {
betNo: bet.betNo,
betType: bet.betType,
stake: bet.stake,
totalOdds: bet.totalOdds,
potentialReturn: bet.potentialReturn,
actualReturn: bet.actualReturn,
status: bet.status,
placedAt: bet.placedAt,
leagueName: isParlay
? 'Parlay'
: meta?.leagueName ?? legs[0]?.leagueName ?? '',
legCount: bet.selections.length,
matchTitle: isParlay
? ''
: meta?.matchTitle ?? legs[0]?.matchTitle ?? bet.betNo,
pickLabel: isParlay
? ''
: `${legs[0]?.marketLabel ?? ''}: ${legs[0]?.selectionName ?? ''}`,
legs,
isParlay,
};
});
}
@Cron(CronExpression.EVERY_MINUTE)
async autoCloseMatches() {
const now = new Date();
await this.prisma.match.updateMany({
where: {
status: 'PUBLISHED',
isOutright: false,
startTime: { lte: now },
},
data: { status: 'CLOSED', closeTime: now },