diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue index 49701da..c24a9b5 100644 --- a/apps/admin/src/App.vue +++ b/apps/admin/src/App.vue @@ -111,20 +111,24 @@ html, body, #app { min-height: 0; overflow: hidden; } -.admin-list-page > .page-header, +.admin-list-page > .page-toolbar, .admin-list-page > .filter-card, .admin-list-page > .tool-card { flex-shrink: 0; } +.admin-list-page > .page-toolbar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} .admin-list-page > .tool-card { margin-bottom: 16px; } .admin-list-page > .filter-card { margin-bottom: 16px; } -.admin-list-page > .page-header { - margin-bottom: 20px; -} .admin-list-page > .data-card { flex: 1; min-height: 0; diff --git a/apps/admin/src/components/dashboard/EChartPanel.vue b/apps/admin/src/components/dashboard/EChartPanel.vue index e441a47..15282d5 100644 --- a/apps/admin/src/components/dashboard/EChartPanel.vue +++ b/apps/admin/src/components/dashboard/EChartPanel.vue @@ -1,8 +1,6 @@ - - - - diff --git a/apps/admin/src/views/outrights/OutrightEventEditor.vue b/apps/admin/src/views/outrights/OutrightEventEditor.vue new file mode 100644 index 0000000..0686413 --- /dev/null +++ b/apps/admin/src/views/outrights/OutrightEventEditor.vue @@ -0,0 +1,433 @@ + + + + + diff --git a/apps/admin/src/views/outrights/OutrightList.vue b/apps/admin/src/views/outrights/OutrightList.vue new file mode 100644 index 0000000..e3ffddf --- /dev/null +++ b/apps/admin/src/views/outrights/OutrightList.vue @@ -0,0 +1,384 @@ + + + + + diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index b5f0e4a..395196d 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ resolve: { // 避免 src 内遗留的 .js 抢先于 .ts/.vue 被解析(曾导致 i18n 文案缺失) extensions: ['.mjs', '.ts', '.mts', '.tsx', '.vue', '.js', '.jsx', '.json'], + dedupe: ['echarts', 'vue-echarts', 'vue'], + }, + optimizeDeps: { + include: ['echarts', 'vue-echarts'], }, publicDir: resolve(__dirname, '../../packages/shared/public'), server: { diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index 27ba58c..8be4c3c 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -260,6 +260,52 @@ class BatchOutrightOddsDto { updates!: OutrightOddsUpdateItemDto[]; } +class CreateOutrightDto { + @IsString() + leagueId!: string; + + @IsString() + titleZh!: string; + + @IsString() + titleEn!: string; + + @IsOptional() + @IsString() + status?: string; +} + +class UpdateOutrightDto { + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + matchName?: string; + + @IsOptional() + isHot?: boolean; + + @IsOptional() + displayOrder?: number; +} + +class AddOutrightSelectionDto { + @IsString() + teamCode!: string; + + @IsString() + teamZh!: string; + + @IsString() + teamEn!: string; + + @IsNumber() + @Min(1.01) + odds!: number; +} + class CashbackPreviewDto { @IsString() periodStart!: string; @@ -648,24 +694,111 @@ export class AdminController { return jsonResponse(selection); } - @Get('outrights/wc2026') - async getWc2026Outright() { - const data = await this.outright.getWc2026ForAdmin(); + @Get('outrights') + async listOutrights() { + const data = await this.outright.listForAdmin(); return jsonResponse(data); } + @Get('outrights/leagues') + async listOutrightLeagues() { + const data = await this.outright.listLeagueOptions(); + return jsonResponse(data); + } + + @Post('outrights') + async createOutright(@Body() dto: CreateOutrightDto) { + const data = await this.outright.createForAdmin({ + leagueId: BigInt(dto.leagueId), + titleZh: dto.titleZh, + titleEn: dto.titleEn, + status: dto.status, + }); + return jsonResponse(data); + } + + @Post('outrights/import/wc2026') + async importWc2026Outright() { + const data = await this.outright.importWc2026Canonical(); + return jsonResponse(data); + } + + /** @deprecated */ + @Get('outrights/wc2026') + async getWc2026OutrightLegacy() { + const list = await this.outright.listForAdmin(); + const wc = list.find((e) => e.leagueCode === 'WC2026'); + if (!wc) throw new BadRequestException('WC2026 outright not found — run import'); + return jsonResponse(await this.outright.getForAdmin(BigInt(wc.id))); + } + + /** @deprecated */ @Put('outrights/wc2026/odds') - async updateWc2026OutrightOdds( + async updateWc2026OutrightOddsLegacy( @CurrentUser('id') operatorId: bigint, @Body() dto: BatchOutrightOddsDto, ) { - const data = await this.outright.updateWc2026Odds(dto.updates, operatorId); + const list = await this.outright.listForAdmin(); + const wc = list.find((e) => e.leagueCode === 'WC2026'); + if (!wc) throw new BadRequestException('WC2026 outright not found'); + return jsonResponse( + await this.outright.batchUpdateOdds(BigInt(wc.id), dto.updates, operatorId), + ); + } + + /** @deprecated */ + @Post('outrights/wc2026/apply-canonical') + async applyWc2026CanonicalLegacy() { + return jsonResponse(await this.outright.importWc2026Canonical()); + } + + @Get('outrights/:matchId') + async getOutright(@Param('matchId') matchId: string) { + const data = await this.outright.getForAdmin(BigInt(matchId)); return jsonResponse(data); } - @Post('outrights/wc2026/apply-canonical') - async applyWc2026Canonical() { - const data = await this.outright.applyWc2026Canonical(); + @Put('outrights/:matchId') + async updateOutright( + @Param('matchId') matchId: string, + @Body() dto: UpdateOutrightDto, + ) { + const data = await this.outright.updateForAdmin(BigInt(matchId), dto); + return jsonResponse(data); + } + + @Put('outrights/:matchId/odds') + async updateOutrightOdds( + @CurrentUser('id') operatorId: bigint, + @Param('matchId') matchId: string, + @Body() dto: BatchOutrightOddsDto, + ) { + const data = await this.outright.batchUpdateOdds( + BigInt(matchId), + dto.updates, + operatorId, + ); + return jsonResponse(data); + } + + @Post('outrights/:matchId/selections') + async addOutrightSelection( + @Param('matchId') matchId: string, + @Body() dto: AddOutrightSelectionDto, + ) { + const data = await this.outright.addSelection(BigInt(matchId), dto); + return jsonResponse(data); + } + + @Delete('outrights/:matchId/selections/:selectionId') + async removeOutrightSelection( + @Param('matchId') matchId: string, + @Param('selectionId') selectionId: string, + ) { + const data = await this.outright.closeSelection( + BigInt(matchId), + BigInt(selectionId), + ); return jsonResponse(data); } diff --git a/apps/api/src/applications/player/player.controller.ts b/apps/api/src/applications/player/player.controller.ts index b9dae16..de84789 100644 --- a/apps/api/src/applications/player/player.controller.ts +++ b/apps/api/src/applications/player/player.controller.ts @@ -15,6 +15,7 @@ import { jsonResponse } from '../../shared/common/filters'; import { UsersService } from '../../domains/identity/users.service'; import { WalletService } from '../../domains/ledger/wallet.service'; import { MatchesService } from '../../domains/catalog/matches.service'; +import { OutrightService } from '../../domains/catalog/outright.service'; import { BetsService } from '../../domains/betting/bets.service'; import { ContentService } from '../../domains/operations/content/content.service'; import { CashbackService } from '../../domains/operations/cashback/cashback.service'; @@ -82,6 +83,7 @@ export class PlayerController { private users: UsersService, private wallet: WalletService, private matches: MatchesService, + private outright: OutrightService, private bets: BetsService, private content: ContentService, private cashback: CashbackService, @@ -134,7 +136,7 @@ export class PlayerController { @Get('outrights') async listOutrights(@CurrentUser('locale') locale: string) { - const items = await this.matches.listOutrights(locale); + const items = await this.outright.listForPlayer(locale); return jsonResponse(items); } diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index c8e2a6c..6d0b334 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -14,12 +14,9 @@ import { toVenueJson, translationsFromZhiboNames, } from './zhibo-match.mapper'; -import { WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams'; +import { syncWc2026OutrightMarket } from './wc2026-outright.sync'; -const WC2026_OUTRIGHT_RANK = new Map( - WC2026_OUTRIGHT_TEAMS.map((t) => [t.code, t.rank]), -); -const WC2026_OUTRIGHT_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code)); +const OUTRIGHT_PLACEHOLDER_CODE = 'OUT'; @Injectable() export class MatchesService { @@ -570,8 +567,19 @@ export class MatchesService { } async listOutrights(locale = 'en-US') { + try { + await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false }); + } catch { + /* 联赛未 seed 时忽略,仍返回已有数据 */ + } + const matches = await this.prisma.match.findMany({ - where: { status: 'PUBLISHED', isOutright: true, sportType: 'FOOTBALL' }, + where: { + status: 'PUBLISHED', + isOutright: true, + sportType: 'FOOTBALL', + deletedAt: null, + }, include: { markets: { where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' }, @@ -592,27 +600,28 @@ export class MatchesService { const market = match.markets[0]; if (!market) continue; - const selections = ( - await Promise.all( - market.selections - .filter((sel) => WC2026_OUTRIGHT_CODES.has(sel.selectionCode)) - .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, - rank: WC2026_OUTRIGHT_RANK.get(teamCode) ?? 999, - odds: sel.odds.toString(), - oddsVersion: sel.oddsVersion.toString(), - }; - }), - ) - ).sort((a, b) => a.rank - b.rank); + const selections = await Promise.all( + market.selections + .filter((sel) => sel.selectionCode !== OUTRIGHT_PLACEHOLDER_CODE) + .map(async (sel) => { + const team = await this.prisma.team.findUnique({ + where: { code: sel.selectionCode }, + }); + const teamName = team + ? await this.getTranslation('TEAM', team.id, locale) + : sel.selectionName; + return { + id: sel.id.toString(), + teamCode: sel.selectionCode, + teamName, + rank: sel.sortOrder + 1, + odds: sel.odds.toString(), + oddsVersion: sel.oddsVersion.toString(), + }; + }), + ); + + if (selections.length === 0) continue; results.push({ id: match.id.toString(), diff --git a/apps/api/src/domains/catalog/outright.service.ts b/apps/api/src/domains/catalog/outright.service.ts index 36f8c4e..f6000ed 100644 --- a/apps/api/src/domains/catalog/outright.service.ts +++ b/apps/api/src/domains/catalog/outright.service.ts @@ -1,10 +1,15 @@ -import { Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { MarketsService } from '../odds/markets.service'; -import { WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams'; +import { WC2026_LEAGUE_CODE, WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams'; import { syncWc2026OutrightMarket } from './wc2026-outright.sync'; -const CANONICAL_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code)); +const PLACEHOLDER_TEAM_CODE = 'OUT'; +const OUTRIGHT_MARKET_TYPE = 'OUTRIGHT_WINNER'; @Injectable() export class OutrightService { @@ -13,86 +18,518 @@ export class OutrightService { private markets: MarketsService, ) {} - async getWc2026ForAdmin() { - const { matchId, marketId } = await syncWc2026OutrightMarket(this.prisma, { - forceCanonical: false, + async listForAdmin() { + const matches = await this.prisma.match.findMany({ + where: { isOutright: true, sportType: 'FOOTBALL', deletedAt: null }, + include: { + league: true, + markets: { + where: { marketType: OUTRIGHT_MARKET_TYPE }, + select: { + id: true, + status: true, + _count: { + select: { + selections: { + where: { + status: { not: 'CLOSED' }, + selectionCode: { not: PLACEHOLDER_TEAM_CODE }, + }, + }, + }, + }, + selections: { + where: { + status: 'OPEN', + selectionCode: { not: PLACEHOLDER_TEAM_CODE }, + }, + select: { id: true }, + take: 1, + }, + }, + }, + }, + orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }], }); - const match = await this.prisma.match.findUniqueOrThrow({ where: { id: matchId } }); - const [leagueZh, leagueEn, market] = await Promise.all([ - this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'), - this.getTranslation('LEAGUE', match.leagueId, 'en-US'), - this.prisma.market.findUniqueOrThrow({ - where: { id: marketId }, - include: { - selections: { orderBy: { sortOrder: 'asc' } }, - }, - }), - ]); - - const teamByCode = new Map( - WC2026_OUTRIGHT_TEAMS.map((t) => [t.code, t]), - ); - - const canonicalSelections = market.selections.filter((sel) => - CANONICAL_CODES.has(sel.selectionCode), - ); - - const selections = await Promise.all( - canonicalSelections.map(async (sel) => { - const meta = teamByCode.get(sel.selectionCode); - const team = await this.prisma.team.findUnique({ where: { code: sel.selectionCode } }); - const teamZh = team - ? await this.getTranslation('TEAM', team.id, 'zh-CN') - : sel.selectionName; - const teamEn = team - ? await this.getTranslation('TEAM', team.id, 'en-US') - : sel.selectionName; + return Promise.all( + matches.map(async (m) => { + const league = m.league; + const leagueCode = league.code; + const [leagueZh, leagueEn] = await Promise.all([ + this.getTranslation('LEAGUE', league.id, 'zh-CN'), + this.getTranslation('LEAGUE', league.id, 'en-US'), + ]); + const market = m.markets[0]; + const openCount = market?.selections.length ?? 0; + const selectionCount = market?._count.selections ?? 0; + const visibility = this.playerVisibilityByCounts( + m.status, + market, + openCount, + ); return { - id: sel.id.toString(), - teamCode: sel.selectionCode, - rank: meta?.rank ?? sel.sortOrder + 1, - teamZh, - teamEn, - odds: sel.odds.toString(), - oddsVersion: sel.oddsVersion.toString(), - status: sel.status, + id: m.id.toString(), + leagueId: league.id.toString(), + leagueCode, + leagueZh, + leagueEn, + matchName: m.matchName ?? '', + status: m.status, + selectionCount, + canImportCanonical: leagueCode === WC2026_LEAGUE_CODE, + playerVisible: visibility.playerVisible, + playerHiddenReason: visibility.playerHiddenReason, }; }), ); + } - selections.sort((a, b) => a.rank - b.rank); + async listLeagueOptions() { + const leagues = await this.prisma.league.findMany({ + where: { isActive: true, deletedAt: null, sportType: 'FOOTBALL' }, + orderBy: { displayOrder: 'asc' }, + }); + return Promise.all( + leagues.map(async (l) => ({ + id: l.id.toString(), + code: l.code, + nameZh: await this.getTranslation('LEAGUE', l.id, 'zh-CN'), + nameEn: await this.getTranslation('LEAGUE', l.id, 'en-US'), + })), + ); + } + + async getForAdmin(matchId: bigint) { + const match = await this.getOutrightMatchOrThrow(matchId); + const market = await this.ensureOutrightMarket(match.id); + const league = await this.prisma.league.findUniqueOrThrow({ + where: { id: match.leagueId }, + }); + + const [leagueZh, leagueEn] = await Promise.all([ + this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'), + this.getTranslation('LEAGUE', match.leagueId, 'en-US'), + ]); + + const fullMarket = await this.prisma.market.findUniqueOrThrow({ + where: { id: market.id }, + include: { + selections: { orderBy: { sortOrder: 'asc' } }, + }, + }); + + const selections = await Promise.all( + fullMarket.selections + .filter((s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE) + .map(async (sel, index) => { + const team = await this.prisma.team.findUnique({ + where: { code: sel.selectionCode }, + }); + const teamZh = team + ? await this.getTranslation('TEAM', team.id, 'zh-CN') + : sel.selectionName; + const teamEn = team + ? await this.getTranslation('TEAM', team.id, 'en-US') + : sel.selectionName; + return { + id: sel.id.toString(), + teamCode: sel.selectionCode, + rank: sel.sortOrder + 1 || index + 1, + teamZh: teamZh || sel.selectionName, + teamEn: teamEn || sel.selectionName, + odds: sel.odds.toString(), + oddsVersion: sel.oddsVersion.toString(), + status: sel.status, + }; + }), + ); + + const visibility = this.playerVisibility( + match.status, + fullMarket, + fullMarket.selections.filter( + (s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE, + ), + ); return { - matchId: matchId.toString(), - marketId: marketId.toString(), - matchStatus: match.status, - marketStatus: market.status, + id: match.id.toString(), + leagueId: match.leagueId.toString(), + leagueCode: league.code, leagueZh, leagueEn, + matchName: match.matchName ?? '', + status: match.status, + marketId: fullMarket.id.toString(), + marketStatus: fullMarket.status, + canImportCanonical: league.code === WC2026_LEAGUE_CODE, + expectedCanonicalCount: + league.code === WC2026_LEAGUE_CODE ? WC2026_OUTRIGHT_TEAMS.length : null, + playerVisible: visibility.playerVisible, + playerHiddenReason: visibility.playerHiddenReason, selections, - expectedCount: WC2026_OUTRIGHT_TEAMS.length, }; } - async applyWc2026Canonical() { - await syncWc2026OutrightMarket(this.prisma, { forceCanonical: true }); - return this.getWc2026ForAdmin(); + async createForAdmin(data: { + leagueId: bigint; + titleZh: string; + titleEn: string; + status?: string; + isHot?: boolean; + displayOrder?: number; + startTime?: Date; + }) { + const league = await this.prisma.league.findUnique({ + where: { id: data.leagueId }, + }); + if (!league) throw new NotFoundException('League not found'); + + const placeholder = await this.ensurePlaceholderTeam(); + const status = data.status ?? 'PUBLISHED'; + const match = await this.prisma.match.create({ + data: { + leagueId: data.leagueId, + homeTeamId: placeholder.id, + awayTeamId: placeholder.id, + isOutright: true, + matchName: data.titleEn || data.titleZh, + startTime: data.startTime ?? new Date('2030-01-01T00:00:00Z'), + status, + publishTime: status === 'PUBLISHED' ? new Date() : undefined, + isHot: data.isHot ?? false, + displayOrder: data.displayOrder ?? 0, + }, + }); + + await this.ensureOutrightMarket(match.id); + return this.getForAdmin(match.id); } - async updateWc2026Odds( + async updateForAdmin( + matchId: bigint, + data: { + status?: string; + matchName?: string; + isHot?: boolean; + displayOrder?: number; + titleZh?: string; + titleEn?: string; + }, + ) { + const match = await this.getOutrightMatchOrThrow(matchId); + const status = data.status ?? match.status; + await this.prisma.match.update({ + where: { id: matchId }, + data: { + status, + matchName: data.matchName, + isHot: data.isHot, + displayOrder: data.displayOrder, + publishTime: + status === 'PUBLISHED' && !match.publishTime + ? new Date() + : match.publishTime, + }, + }); + return this.getForAdmin(matchId); + } + + async addSelection( + matchId: bigint, + data: { + teamCode: string; + teamZh: string; + teamEn: string; + odds: number; + }, + ) { + if (!data.teamCode?.trim()) { + throw new BadRequestException('Team code required'); + } + if (data.odds <= 1) { + throw new BadRequestException('Odds must be greater than 1'); + } + + const match = await this.getOutrightMatchOrThrow(matchId); + const market = await this.ensureOutrightMarket(match.id); + const code = data.teamCode.trim().toUpperCase(); + + const team = await this.prisma.team.upsert({ + where: { code }, + create: { code }, + update: {}, + }); + await this.upsertTeamTranslations(team.id, { + 'zh-CN': data.teamZh.trim() || data.teamEn, + 'en-US': data.teamEn.trim() || data.teamZh, + }); + + const existing = await this.prisma.marketSelection.findFirst({ + where: { marketId: market.id, selectionCode: code }, + }); + if (existing) { + throw new BadRequestException('Selection already exists for this team code'); + } + + const maxSort = await this.prisma.marketSelection.aggregate({ + where: { marketId: market.id }, + _max: { sortOrder: true }, + }); + + await this.prisma.marketSelection.create({ + data: { + marketId: market.id, + selectionCode: code, + selectionName: data.teamZh.trim() || data.teamEn, + odds: data.odds, + sortOrder: (maxSort._max.sortOrder ?? -1) + 1, + status: 'OPEN', + }, + }); + + return this.getForAdmin(matchId); + } + + async closeSelection(matchId: bigint, selectionId: bigint) { + const match = await this.getOutrightMatchOrThrow(matchId); + const market = await this.ensureOutrightMarket(match.id); + const sel = await this.prisma.marketSelection.findFirst({ + where: { id: selectionId, marketId: market.id }, + }); + if (!sel) throw new NotFoundException('Selection not found'); + await this.prisma.marketSelection.update({ + where: { id: selectionId }, + data: { status: 'CLOSED' }, + }); + return this.getForAdmin(matchId); + } + + async batchUpdateOdds( + matchId: bigint, updates: Array<{ selectionId: string; odds: number }>, operatorId: bigint, ) { - const results = await this.markets.batchUpdateOdds( + await this.getOutrightMatchOrThrow(matchId); + const market = await this.ensureOutrightMarket(matchId); + const allowed = new Set( + ( + await this.prisma.marketSelection.findMany({ + where: { marketId: market.id }, + select: { id: true }, + }) + ).map((s) => s.id.toString()), + ); + + for (const u of updates) { + if (!allowed.has(u.selectionId)) { + throw new BadRequestException('Invalid selection for this outright event'); + } + } + + await this.markets.batchUpdateOdds( updates.map((u) => ({ selectionId: BigInt(u.selectionId), odds: u.odds })), operatorId, ); - return results.map((r) => ({ - id: r.id.toString(), - odds: r.odds.toString(), - oddsVersion: r.oddsVersion.toString(), - })); + return this.getForAdmin(matchId); + } + + async importWc2026Canonical() { + const { matchId } = await syncWc2026OutrightMarket(this.prisma, { + forceCanonical: true, + }); + return this.getForAdmin(matchId); + } + + async listForPlayer(locale: string) { + await this.trySyncWc2026(); + + const matches = await this.prisma.match.findMany({ + where: { + status: 'PUBLISHED', + isOutright: true, + sportType: 'FOOTBALL', + deletedAt: null, + }, + include: { + markets: { + where: { marketType: OUTRIGHT_MARKET_TYPE, status: 'OPEN' }, + include: { + selections: { + where: { status: 'OPEN' }, + orderBy: { sortOrder: 'asc' }, + }, + }, + }, + }, + orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }], + }); + + const results = []; + for (const match of matches) { + const league = await this.prisma.league.findUniqueOrThrow({ + where: { id: match.leagueId }, + }); + const leagueName = await this.getTranslation('LEAGUE', match.leagueId, locale); + const market = match.markets[0]; + if (!market) continue; + + const selections = await Promise.all( + market.selections + .filter((sel) => sel.selectionCode !== PLACEHOLDER_TEAM_CODE) + .map(async (sel) => { + const team = await this.prisma.team.findUnique({ + where: { code: sel.selectionCode }, + }); + const translated = team + ? await this.getTranslation('TEAM', team.id, locale) + : ''; + const teamZh = team + ? await this.getTranslation('TEAM', team.id, 'zh-CN') + : sel.selectionName; + const teamEn = team + ? await this.getTranslation('TEAM', team.id, 'en-US') + : sel.selectionName; + const teamName = + translated || + (locale.startsWith('zh') ? teamZh : teamEn) || + sel.selectionName; + return { + id: sel.id.toString(), + teamCode: sel.selectionCode, + teamName, + odds: sel.odds.toString(), + oddsVersion: sel.oddsVersion.toString(), + }; + }), + ); + + if (!selections.length) continue; + + const title = + match.matchName?.trim() || + `*${leagueName || 'Outright'} ${locale.startsWith('zh') ? '冠军' : 'Winner'}`; + + results.push({ + id: match.id.toString(), + leagueId: match.leagueId.toString(), + leagueCode: league.code, + leagueName: leagueName || '', + title: title.startsWith('*') ? title : `*${title}`, + marketId: market.id.toString(), + selectionCount: selections.length, + selections, + }); + } + + return results; + } + + /** @deprecated 使用 listForPlayer */ + async getWc2026ForPlayer(locale: string) { + return this.listForPlayer(locale); + } + + private async trySyncWc2026() { + try { + await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false }); + } catch { + /* 联赛未 seed 时忽略 */ + } + } + + private async getOutrightMatchOrThrow(matchId: bigint) { + const match = await this.prisma.match.findFirst({ + where: { id: matchId, isOutright: true, deletedAt: null }, + }); + if (!match) throw new NotFoundException('Outright event not found'); + return match; + } + + private async ensureOutrightMarket(matchId: bigint) { + let market = await this.prisma.market.findFirst({ + where: { matchId, marketType: OUTRIGHT_MARKET_TYPE }, + }); + if (!market) { + market = await this.prisma.market.create({ + data: { + matchId, + marketType: OUTRIGHT_MARKET_TYPE, + period: 'OUTRIGHT', + allowSingle: true, + allowParlay: false, + sortOrder: 1, + status: 'OPEN', + }, + }); + } + return market; + } + + private async ensurePlaceholderTeam() { + const existing = await this.prisma.team.findUnique({ + where: { code: PLACEHOLDER_TEAM_CODE }, + }); + if (existing) return existing; + return this.prisma.team.create({ + data: { code: PLACEHOLDER_TEAM_CODE }, + }); + } + + private async upsertTeamTranslations( + teamId: bigint, + names: Record, + ) { + for (const [locale, value] of Object.entries(names)) { + if (!value) continue; + await this.prisma.entityTranslation.upsert({ + where: { + entityType_entityId_locale_fieldName: { + entityType: 'TEAM', + entityId: teamId, + locale, + fieldName: 'name', + }, + }, + create: { + entityType: 'TEAM', + entityId: teamId, + locale, + fieldName: 'name', + value, + }, + update: { value }, + }); + } + } + + private playerVisibility( + matchStatus: string, + market: { status: string } | null | undefined, + selections: Array<{ selectionCode: string; status: string }>, + ): { playerVisible: boolean; playerHiddenReason: string | null } { + const openCount = selections.filter( + (s) => s.status === 'OPEN' && s.selectionCode !== PLACEHOLDER_TEAM_CODE, + ).length; + return this.playerVisibilityByCounts(matchStatus, market, openCount); + } + + private playerVisibilityByCounts( + matchStatus: string, + market: { status: string } | null | undefined, + openSelectionCount: number, + ): { playerVisible: boolean; playerHiddenReason: string | null } { + if (matchStatus !== 'PUBLISHED') { + return { playerVisible: false, playerHiddenReason: 'NOT_PUBLISHED' }; + } + if (!market || market.status !== 'OPEN') { + return { playerVisible: false, playerHiddenReason: 'MARKET_CLOSED' }; + } + if (openSelectionCount === 0) { + return { playerVisible: false, playerHiddenReason: 'NO_SELECTIONS' }; + } + return { playerVisible: true, playerHiddenReason: null }; } private async getTranslation( diff --git a/apps/player/src/components/outright/OutrightEventSection.vue b/apps/player/src/components/outright/OutrightEventSection.vue new file mode 100644 index 0000000..d952e41 --- /dev/null +++ b/apps/player/src/components/outright/OutrightEventSection.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/apps/player/src/components/outright/OutrightOptionCard.vue b/apps/player/src/components/outright/OutrightOptionCard.vue index 4e21a20..667c2d8 100644 --- a/apps/player/src/components/outright/OutrightOptionCard.vue +++ b/apps/player/src/components/outright/OutrightOptionCard.vue @@ -1,5 +1,5 @@