diff --git a/apps/admin/package.json b/apps/admin/package.json index cce5a98..951ebbd 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -5,7 +5,7 @@ "description": "统一管理后台(平台管理员 + 代理,单入口登录)", "type": "module", "scripts": { - "dev": "vite --port 5174", + "dev": "vite --port 5174 --host", "build": "vue-tsc -b && vite build", "preview": "vite preview" }, diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue index c24a9b5..8cbaf7c 100644 --- a/apps/admin/src/App.vue +++ b/apps/admin/src/App.vue @@ -299,4 +299,17 @@ body { .el-input-number .el-input__wrapper { background: #0d0d0d !important; } .el-date-editor .el-input__wrapper { background: #0d0d0d !important; } +.el-date-editor .el-input__inner { color: #fff !important; } +.el-picker-panel { + background: #1c1c1c !important; + border-color: #333 !important; + color: #ddd !important; +} +.el-picker-panel__footer { background: #1c1c1c !important; border-top-color: #333 !important; } +.el-date-picker__header-label, +.el-date-table th, +.el-date-table td .el-date-table-cell__text { color: #ccc !important; } +.el-time-panel { background: #1c1c1c !important; border-color: #333 !important; } +.el-time-spinner__item { color: #aaa !important; } +.el-time-spinner__item.is-active:not(.is-disabled) { color: #fff !important; } diff --git a/apps/admin/src/components/LogoUrlField.vue b/apps/admin/src/components/LogoUrlField.vue new file mode 100644 index 0000000..63d705c --- /dev/null +++ b/apps/admin/src/components/LogoUrlField.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/apps/admin/src/components/outright/CountryFlagSelect.vue b/apps/admin/src/components/outright/CountryFlagSelect.vue index 0f66a7d..5683736 100644 --- a/apps/admin/src/components/outright/CountryFlagSelect.vue +++ b/apps/admin/src/components/outright/CountryFlagSelect.vue @@ -1,8 +1,9 @@ - - -

- {{ t('match.hint.edit_published') }} -

- - - - - - - - - - - - - - - - - - - - - - - - -
- -
-

{{ t('match.import_hint') }}

diff --git a/apps/admin/src/views/match-form.ts b/apps/admin/src/views/match-form.ts index 8cbdf72..a7f4802 100644 --- a/apps/admin/src/views/match-form.ts +++ b/apps/admin/src/views/match-form.ts @@ -3,54 +3,133 @@ import { FormValidationError } from '../i18n/form-validation'; export interface MatchCreateForm { + leagueId: string; leagueEn: string; leagueZh: string; + leagueMs: string; startTime: string; homeTeamZh: string; homeTeamEn: string; + homeTeamMs: string; awayTeamZh: string; awayTeamEn: string; + awayTeamMs: string; isHot: boolean; + displayOrder: number; + matchName: string; + stage: string; + groupName: string; + leagueLogoUrl: string; + homeTeamLogoUrl: string; + awayTeamLogoUrl: string; } export function emptyMatchForm(): MatchCreateForm { return { + leagueId: '', leagueEn: 'FIFA World Cup 2026', leagueZh: '2026 世界杯', + leagueMs: 'Piala Dunia 2026', startTime: '', homeTeamZh: '', homeTeamEn: '', + homeTeamMs: '', awayTeamZh: '', awayTeamEn: '', + awayTeamMs: '', isHot: false, + displayOrder: 0, + matchName: '', + stage: '', + groupName: '', + leagueLogoUrl: '', + homeTeamLogoUrl: '', + awayTeamLogoUrl: '', }; } +export interface AdminMarketSelection { + id: string; + selectionCode: string; + selectionName: string; + odds: number; + status: string; +} + +export interface AdminMarket { + id: string; + marketType: string; + period: string; + lineValue: number | null; + status: string; + promoLabel: string; + selections: AdminMarketSelection[]; +} + export type AdminMatchDetail = { id: string; status: string; isOutright: boolean; isHot: boolean; + displayOrder: number; startTime: string; leagueEn: string; leagueZh: string; + leagueMs: string; + leagueLogoUrl?: string; homeTeamEn: string; homeTeamZh: string; + homeTeamMs: string; + homeTeamCode?: string; + homeTeamLogoUrl?: string; awayTeamEn: string; awayTeamZh: string; + awayTeamMs: string; + awayTeamCode?: string; + awayTeamLogoUrl?: string; matchName: string; + stage?: string; + groupName?: string; + markets?: AdminMarket[]; }; +export function normalizeStartTimeForPicker(iso?: string): string { + if (!iso?.trim()) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso.slice(0, 19); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +export function normalizeStartTimeForApi(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ''; + const d = new Date(trimmed); + if (Number.isNaN(d.getTime())) return trimmed; + return d.toISOString(); +} + export function formFromDetail(d: AdminMatchDetail): MatchCreateForm { return { + leagueId: '', leagueEn: d.leagueEn, leagueZh: d.leagueZh, - startTime: d.startTime, + leagueMs: d.leagueMs ?? '', + startTime: normalizeStartTimeForPicker(d.startTime), homeTeamZh: d.homeTeamZh, homeTeamEn: d.homeTeamEn, + homeTeamMs: d.homeTeamMs ?? '', awayTeamZh: d.awayTeamZh, awayTeamEn: d.awayTeamEn, + awayTeamMs: d.awayTeamMs ?? '', isHot: d.isHot, + displayOrder: d.displayOrder ?? 0, + matchName: d.matchName ?? '', + stage: d.stage ?? '', + groupName: d.groupName ?? '', + leagueLogoUrl: d.leagueLogoUrl ?? '', + homeTeamLogoUrl: d.homeTeamLogoUrl ?? '', + awayTeamLogoUrl: d.awayTeamLogoUrl ?? '', }; } @@ -58,23 +137,39 @@ export function buildPlatformPayload(form: MatchCreateForm) { if (!form.startTime.trim()) { throw new FormValidationError('err.kickoff_required'); } - const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim(); - const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim(); + const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim() || form.homeTeamMs.trim(); + const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim() || form.awayTeamMs.trim(); if (!homeOk || !awayOk) { throw new FormValidationError('err.teams_required'); } - if (!form.leagueZh.trim() && !form.leagueEn.trim()) { + if ( + !form.leagueId.trim() && + !form.leagueZh.trim() && + !form.leagueEn.trim() && + !form.leagueMs.trim() + ) { throw new FormValidationError('err.league_required'); } return { + leagueId: form.leagueId.trim() || undefined, leagueEn: form.leagueEn.trim(), leagueZh: form.leagueZh.trim(), + leagueMs: form.leagueMs.trim() || undefined, homeTeamEn: form.homeTeamEn.trim(), homeTeamZh: form.homeTeamZh.trim(), + homeTeamMs: form.homeTeamMs.trim() || undefined, awayTeamEn: form.awayTeamEn.trim(), awayTeamZh: form.awayTeamZh.trim(), - startTime: form.startTime.trim(), + awayTeamMs: form.awayTeamMs.trim() || undefined, + startTime: normalizeStartTimeForApi(form.startTime), isHot: form.isHot, + displayOrder: form.displayOrder, + matchName: form.matchName.trim() || undefined, + stage: form.stage.trim() || undefined, + groupName: form.groupName.trim() || undefined, + leagueLogoUrl: form.leagueLogoUrl.trim() || undefined, + homeTeamLogoUrl: form.homeTeamLogoUrl.trim() || undefined, + awayTeamLogoUrl: form.awayTeamLogoUrl.trim() || undefined, }; } diff --git a/apps/admin/src/views/matches/LeagueMatchesPanel.vue b/apps/admin/src/views/matches/LeagueMatchesPanel.vue new file mode 100644 index 0000000..6986a52 --- /dev/null +++ b/apps/admin/src/views/matches/LeagueMatchesPanel.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/apps/admin/src/views/matches/MatchEventEditor.vue b/apps/admin/src/views/matches/MatchEventEditor.vue new file mode 100644 index 0000000..cee44da --- /dev/null +++ b/apps/admin/src/views/matches/MatchEventEditor.vue @@ -0,0 +1,367 @@ + + + + + diff --git a/apps/admin/src/views/matches/MatchMarketsPage.vue b/apps/admin/src/views/matches/MatchMarketsPage.vue new file mode 100644 index 0000000..5a8e62f --- /dev/null +++ b/apps/admin/src/views/matches/MatchMarketsPage.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/apps/admin/src/views/matches/MatchMarketsPanel.vue b/apps/admin/src/views/matches/MatchMarketsPanel.vue new file mode 100644 index 0000000..d3f0a81 --- /dev/null +++ b/apps/admin/src/views/matches/MatchMarketsPanel.vue @@ -0,0 +1,480 @@ + + + + + diff --git a/apps/api/prisma/migrations/20260604140000_match_customization/migration.sql b/apps/api/prisma/migrations/20260604140000_match_customization/migration.sql new file mode 100644 index 0000000..0c8f354 --- /dev/null +++ b/apps/api/prisma/migrations/20260604140000_match_customization/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "leagues" ADD COLUMN "logo_url" VARCHAR(500); + +-- AlterTable +ALTER TABLE "markets" ADD COLUMN "promo_label" VARCHAR(100); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a9f31da..be52d70 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -219,6 +219,7 @@ model League { id BigInt @id @default(autoincrement()) sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20) code String @unique @db.VarChar(64) + logoUrl String? @map("logo_url") @db.VarChar(500) displayOrder Int @default(0) @map("display_order") isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") @@ -330,6 +331,7 @@ model Market { allowSingle Boolean @default(true) @map("allow_single") allowParlay Boolean @default(true) @map("allow_parlay") sortOrder Int @default(0) @map("sort_order") + promoLabel String? @map("promo_label") @db.VarChar(100) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index 6057d85..a0f37e8 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -39,6 +39,7 @@ import { MinLength, IsIn, Min, + ValidateIf, } from 'class-validator'; import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types'; @@ -207,25 +208,63 @@ class DepositDto { remark?: string; } -class CreatePlatformMatchDto { +class CreatePlatformLeagueDto { @IsString() leagueEn!: string; @IsString() leagueZh!: string; + @IsOptional() + @IsString() + leagueMs?: string; + + @IsOptional() + @IsString() + logoUrl?: string; + + @IsOptional() + @IsNumber() + displayOrder?: number; +} + +class CreatePlatformMatchDto { + @IsOptional() + @IsString() + leagueId?: string; + + @ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId) + @IsString() + leagueEn?: string; + + @ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId) + @IsString() + leagueZh?: string; + + @IsOptional() + @IsString() + leagueMs?: string; + @IsString() homeTeamEn!: string; @IsString() homeTeamZh!: string; + @IsOptional() + @IsString() + homeTeamMs?: string; + @IsString() awayTeamEn!: string; @IsString() awayTeamZh!: string; + @IsOptional() + @IsString() + awayTeamMs?: string; + @IsString() startTime!: string; @@ -236,6 +275,64 @@ class CreatePlatformMatchDto { @IsOptional() @IsNumber() displayOrder?: number; + + @IsOptional() + @IsString() + matchName?: string; + + @IsOptional() + @IsString() + stage?: string; + + @IsOptional() + @IsString() + groupName?: string; + + @IsOptional() + @IsString() + leagueLogoUrl?: string; + + @IsOptional() + @IsString() + homeTeamLogoUrl?: string; + + @IsOptional() + @IsString() + awayTeamLogoUrl?: string; +} + +class BatchMatchOddsDto { + @IsArray() + updates!: OutrightOddsUpdateItemDto[]; +} + +class UpdateMarketDto { + @IsOptional() + @IsString() + promoLabel?: string | null; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsNumber() + lineValue?: number | null; +} + +class UpdateSelectionDto { + @IsOptional() + @IsString() + selectionName?: string; + + @IsOptional() + @IsNumber() + @Min(1.01) + odds?: number; + + @IsOptional() + @IsString() + status?: string; } function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport { @@ -704,11 +801,58 @@ export class AdminController { } @Post('leagues') - async createLeague(@Body() dto: { code: string; translations: Record }) { - const league = await this.matches.createLeague(dto.code, dto.translations); + async createLeague( + @Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record }, + ) { + if ('leagueZh' in dto || 'leagueEn' in dto) { + const body = dto as CreatePlatformLeagueDto; + const league = await this.matches.createPlatformLeague({ + leagueEn: body.leagueEn, + leagueZh: body.leagueZh, + leagueMs: body.leagueMs, + logoUrl: body.logoUrl, + displayOrder: body.displayOrder, + }); + return jsonResponse(league); + } + const legacy = dto as { code: string; translations: Record }; + const league = await this.matches.createLeague(legacy.code, legacy.translations); return jsonResponse(league); } + @Get('leagues') + async listLeagues( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('status') status?: string, + @Query('keyword') keyword?: string, + ) { + const p = Math.max(1, page ? parseInt(page, 10) : 1); + const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100); + const result = await this.matches.listAdminLeagues({ + page: p, + pageSize: size, + status: status || undefined, + keyword: keyword || undefined, + }); + return jsonResponse(result); + } + + @Get('leagues/:leagueId/matches') + async listLeagueMatches( + @Param('leagueId') leagueId: string, + @Query('status') status?: string, + @Query('keyword') keyword?: string, + @Query('locale') locale?: string, + ) { + const items = await this.matches.listAdminLeagueMatches(BigInt(leagueId), { + status: status || undefined, + keyword: keyword || undefined, + locale: locale || undefined, + }); + return jsonResponse({ items }); + } + @Post('teams') async createTeam(@Body() dto: { code: string; translations: Record }) { const team = await this.matches.createTeam(dto.code, dto.translations); @@ -764,15 +908,24 @@ export class AdminController { @Body() dto: CreatePlatformMatchDto, ) { const match = await this.matches.updatePlatformMatch(BigInt(id), { - leagueEn: dto.leagueEn, - leagueZh: dto.leagueZh, + leagueEn: dto.leagueEn ?? '', + leagueZh: dto.leagueZh ?? '', + leagueMs: dto.leagueMs, homeTeamEn: dto.homeTeamEn, homeTeamZh: dto.homeTeamZh, + homeTeamMs: dto.homeTeamMs, awayTeamEn: dto.awayTeamEn, awayTeamZh: dto.awayTeamZh, + awayTeamMs: dto.awayTeamMs, startTime: new Date(dto.startTime), isHot: dto.isHot, displayOrder: dto.displayOrder, + matchName: dto.matchName, + stage: dto.stage, + groupName: dto.groupName, + leagueLogoUrl: dto.leagueLogoUrl, + homeTeamLogoUrl: dto.homeTeamLogoUrl, + awayTeamLogoUrl: dto.awayTeamLogoUrl, updatedBy: operatorId, }); return jsonResponse(match); @@ -787,15 +940,25 @@ export class AdminController { @Post('matches') async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) { const match = await this.matches.createPlatformMatch({ - leagueEn: dto.leagueEn, - leagueZh: dto.leagueZh, + leagueId: dto.leagueId ? BigInt(dto.leagueId) : undefined, + leagueEn: dto.leagueEn ?? '', + leagueZh: dto.leagueZh ?? '', + leagueMs: dto.leagueMs, homeTeamEn: dto.homeTeamEn, homeTeamZh: dto.homeTeamZh, + homeTeamMs: dto.homeTeamMs, awayTeamEn: dto.awayTeamEn, awayTeamZh: dto.awayTeamZh, + awayTeamMs: dto.awayTeamMs, startTime: new Date(dto.startTime), isHot: dto.isHot, displayOrder: dto.displayOrder, + matchName: dto.matchName, + stage: dto.stage, + groupName: dto.groupName, + leagueLogoUrl: dto.leagueLogoUrl, + homeTeamLogoUrl: dto.homeTeamLogoUrl, + awayTeamLogoUrl: dto.awayTeamLogoUrl, createdBy: operatorId, }); return jsonResponse(match); @@ -835,6 +998,48 @@ export class AdminController { return jsonResponse(markets); } + @Put('matches/:id/odds') + async batchUpdateMatchOdds( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: BatchMatchOddsDto, + ) { + const updates = dto.updates.map((u) => ({ + selectionId: BigInt(u.selectionId), + odds: u.odds, + })); + const results = await this.markets.batchUpdateOdds(updates, operatorId); + return jsonResponse({ matchId: id, updated: results.length }); + } + + @Patch('markets/:id') + async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) { + const market = await this.markets.updateMarket(BigInt(id), { + promoLabel: dto.promoLabel, + status: dto.status, + lineValue: dto.lineValue, + }); + return jsonResponse(market); + } + + @Patch('selections/:id') + async updateSelection( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: UpdateSelectionDto, + ) { + const selection = await this.markets.updateSelection( + BigInt(id), + { + selectionName: dto.selectionName, + odds: dto.odds, + status: dto.status, + }, + operatorId, + ); + return jsonResponse(selection); + } + @Put('selections/:id/odds') async updateOdds( @CurrentUser('id') operatorId: bigint, diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index 6d0b334..2b5fde9 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -193,46 +193,284 @@ export class MatchesService { return null; } - async createPlatformMatch(data: { + async createPlatformLeague(data: { leagueEn: string; leagueZh: string; + leagueMs?: string; + logoUrl?: string; + displayOrder?: number; + }) { + const leagueEn = data.leagueEn.trim(); + const leagueZh = data.leagueZh.trim(); + if (!leagueEn && !leagueZh) { + throw new BadRequestException('请填写赛事名称(中文或英文至少一项)'); + } + const league = await this.upsertLeagueFromZhiboExport({ + type: 'FOOTBALL', + en: leagueEn || leagueZh, + zh: leagueZh || leagueEn, + }); + if (data.leagueMs?.trim()) { + await this.upsertEntityTranslations('LEAGUE', league.id, { + 'ms-MY': data.leagueMs.trim(), + }); + } + const updates: { logoUrl?: string; displayOrder?: number } = {}; + if (data.logoUrl?.trim()) updates.logoUrl = data.logoUrl.trim(); + if (data.displayOrder != null) updates.displayOrder = data.displayOrder; + if (Object.keys(updates).length) { + await this.prisma.league.update({ where: { id: league.id }, data: updates }); + } + const [en, zh, ms] = await Promise.all([ + this.getTranslationExact('LEAGUE', league.id, 'en-US'), + this.getTranslationExact('LEAGUE', league.id, 'zh-CN'), + this.getTranslationExact('LEAGUE', league.id, 'ms-MY'), + ]); + const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: league.id } }); + return { + id: fresh.id.toString(), + code: fresh.code, + logoUrl: fresh.logoUrl, + displayOrder: fresh.displayOrder, + leagueEn: en, + leagueZh: zh, + leagueMs: ms, + }; + } + + async listAdminLeagues(opts: { + page: number; + pageSize: number; + keyword?: string; + status?: string; + }) { + const skip = (opts.page - 1) * opts.pageSize; + const kw = opts.keyword?.trim(); + let idFilter: bigint[] | undefined; + + if (kw || opts.status) { + const ids = new Set(); + 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 matchWhere: Prisma.MatchWhereInput = { + deletedAt: null, + isOutright: false, + }; + if (opts.status) matchWhere.status = opts.status; + if (kw) { + matchWhere.OR = [ + { matchName: { contains: kw, mode: 'insensitive' } }, + { homeTeam: { code: { contains: kw, mode: 'insensitive' } } }, + { awayTeam: { code: { contains: kw, mode: 'insensitive' } } }, + ]; + } + const matchLeagues = await this.prisma.match.findMany({ + where: matchWhere, + select: { leagueId: true }, + distinct: ['leagueId'], + }); + for (const m of matchLeagues) ids.add(m.leagueId); + idFilter = [...ids]; + if (!idFilter.length) { + return { items: [], total: 0, page: opts.page, pageSize: opts.pageSize }; + } + } + + const where: Prisma.LeagueWhereInput = { deletedAt: null }; + if (idFilter) where.id = { in: idFilter }; + + const [leagues, total] = await Promise.all([ + this.prisma.league.findMany({ + where, + orderBy: [{ displayOrder: 'asc' }, { id: 'desc' }], + skip, + take: opts.pageSize, + }), + this.prisma.league.count({ where }), + ]); + + const items = await Promise.all( + leagues.map(async (league) => { + const [leagueEn, leagueZh, leagueMs, matchCount] = await Promise.all([ + this.getTranslationExact('LEAGUE', league.id, 'en-US'), + this.getTranslationExact('LEAGUE', league.id, 'zh-CN'), + this.getTranslationExact('LEAGUE', league.id, 'ms-MY'), + this.prisma.match.count({ + where: { + leagueId: league.id, + deletedAt: null, + isOutright: false, + ...(opts.status ? { status: opts.status } : {}), + }, + }), + ]); + return { + id: league.id.toString(), + code: league.code, + logoUrl: league.logoUrl, + displayOrder: league.displayOrder, + leagueEn, + leagueZh, + leagueMs, + matchCount, + }; + }), + ); + + return { items, total, page: opts.page, pageSize: opts.pageSize }; + } + + async listAdminLeagueMatches( + leagueId: bigint, + opts: { status?: string; keyword?: string; locale?: string }, + ) { + const where: Prisma.MatchWhereInput = { + leagueId, + deletedAt: null, + isOutright: false, + }; + if (opts.status) where.status = opts.status; + const kw = opts.keyword?.trim(); + if (kw) { + where.OR = [ + { matchName: { contains: kw, mode: 'insensitive' } }, + { homeTeam: { code: { contains: kw, mode: 'insensitive' } } }, + { awayTeam: { code: { contains: kw, mode: 'insensitive' } } }, + ]; + } + const items = await this.prisma.match.findMany({ + where, + include: { homeTeam: true, awayTeam: true }, + orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }], + }); + const locale = opts.locale ?? 'zh-CN'; + return Promise.all( + items.map(async (m) => { + const [homeTeamName, awayTeamName] = await Promise.all([ + this.getTranslation('TEAM', m.homeTeamId, locale), + this.getTranslation('TEAM', m.awayTeamId, locale), + ]); + return { + id: m.id.toString(), + status: m.status, + isOutright: m.isOutright, + isHot: m.isHot, + displayOrder: m.displayOrder, + startTime: m.startTime, + matchName: m.matchName, + homeTeamName, + awayTeamName, + homeTeam: { code: m.homeTeam.code }, + awayTeam: { code: m.awayTeam.code }, + }; + }), + ); + } + + async createPlatformMatch(data: { + leagueId?: bigint; + leagueEn?: string; + leagueZh?: string; + leagueMs?: string; homeTeamZh: string; homeTeamEn: string; + homeTeamMs?: string; awayTeamZh: string; awayTeamEn: string; + awayTeamMs?: string; startTime: Date; isHot?: boolean; displayOrder?: number; + matchName?: string; + stage?: string; + groupName?: string; + leagueLogoUrl?: string; + homeTeamLogoUrl?: string; + awayTeamLogoUrl?: string; createdBy?: bigint; }) { const homeEn = data.homeTeamEn.trim(); const homeZh = data.homeTeamZh.trim(); + const homeMs = data.homeTeamMs?.trim() ?? ''; const awayEn = data.awayTeamEn.trim(); const awayZh = data.awayTeamZh.trim(); - if ((!homeEn && !homeZh) || (!awayEn && !awayZh)) { - throw new BadRequestException('请填写主客队中英文名至少各一项'); + const awayMs = data.awayTeamMs?.trim() ?? ''; + if ((!homeEn && !homeZh && !homeMs) || (!awayEn && !awayZh && !awayMs)) { + throw new BadRequestException('请填写主客队名称(中文、英文或马来文至少一项)'); } - const league = await this.upsertLeagueFromZhiboExport({ - type: 'FOOTBALL', - en: data.leagueEn.trim(), - zh: data.leagueZh.trim(), - }); + let league; + if (data.leagueId) { + league = await this.prisma.league.findFirst({ + where: { id: data.leagueId, deletedAt: null }, + }); + if (!league) throw new NotFoundException('赛事不存在'); + } else { + const leagueEn = data.leagueEn?.trim() ?? ''; + const leagueZh = data.leagueZh?.trim() ?? ''; + const leagueMs = data.leagueMs?.trim() ?? ''; + if (!leagueEn && !leagueZh && !leagueMs) { + throw new BadRequestException('请填写赛事名称(中文、英文或马来文至少一项)'); + } + league = await this.upsertLeagueFromZhiboExport({ + type: 'FOOTBALL', + en: leagueEn || leagueZh || leagueMs, + zh: leagueZh || leagueEn || leagueMs, + }); + if (leagueMs) { + await this.upsertEntityTranslations('LEAGUE', league.id, { + 'ms-MY': leagueMs, + }); + } + if (data.leagueLogoUrl?.trim()) { + await this.prisma.league.update({ + where: { id: league.id }, + data: { logoUrl: data.leagueLogoUrl.trim() }, + }); + } + } const [homeTeam, awayTeam] = await Promise.all([ this.upsertTeamFromZhiboExport({ id: null, - name: homeEn || homeZh, - names: { zh: homeZh || null, en: homeEn || null, zhTw: '', vi: null, km: null, ms: null }, - image: '', + name: homeEn || homeZh || homeMs, + names: { + zh: homeZh || null, + en: homeEn || null, + zhTw: '', + vi: null, + km: null, + ms: homeMs || null, + }, + image: data.homeTeamLogoUrl?.trim() || '', }), this.upsertTeamFromZhiboExport({ id: null, - name: awayEn || awayZh, - names: { zh: awayZh || null, en: awayEn || null, zhTw: '', vi: null, km: null, ms: null }, - image: '', + name: awayEn || awayZh || awayMs, + names: { + zh: awayZh || null, + en: awayEn || null, + zhTw: '', + vi: null, + km: null, + ms: awayMs || null, + }, + image: data.awayTeamLogoUrl?.trim() || '', }), ]); + const matchName = + data.matchName?.trim() || + `${homeEn || homeZh || homeMs} - ${awayEn || awayZh || awayMs}`; + return this.createMatch({ leagueId: league.id, homeTeamId: homeTeam.id, @@ -243,7 +481,9 @@ export class MatchesService { createdBy: data.createdBy, status: 'DRAFT', zhibo: { - matchName: `${homeEn || homeZh} - ${awayEn || awayZh}`, + matchName, + stage: data.stage?.trim() || undefined, + groupName: data.groupName?.trim() || undefined, }, }); } @@ -251,7 +491,7 @@ export class MatchesService { private async requireAdminMatch(matchId: bigint) { const match = await this.prisma.match.findFirst({ where: { id: matchId, deletedAt: null }, - include: { homeTeam: true, awayTeam: true }, + include: { homeTeam: true, awayTeam: true, league: true }, }); if (!match) throw new NotFoundException('赛事不存在'); return match; @@ -259,27 +499,66 @@ export class MatchesService { async getAdminMatchDetail(matchId: bigint) { const match = await this.requireAdminMatch(matchId); - const [leagueEn, leagueZh, homeEn, homeZh, awayEn, awayZh] = await Promise.all([ - this.getTranslation('LEAGUE', match.leagueId, 'en-US'), - this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'), - this.getTranslation('TEAM', match.homeTeamId, 'en-US'), - this.getTranslation('TEAM', match.homeTeamId, 'zh-CN'), - this.getTranslation('TEAM', match.awayTeamId, 'en-US'), - this.getTranslation('TEAM', match.awayTeamId, 'zh-CN'), + const markets = await this.prisma.market.findMany({ + where: { matchId }, + include: { selections: { orderBy: { sortOrder: 'asc' } } }, + orderBy: { sortOrder: 'asc' }, + }); + const [leagueEn, leagueZh, leagueMs, homeEn, homeZh, homeMs, awayEn, awayZh, awayMs] = + await Promise.all([ + this.getTranslationExact('LEAGUE', match.leagueId, 'en-US'), + this.getTranslationExact('LEAGUE', match.leagueId, 'zh-CN'), + this.getTranslationExact('LEAGUE', match.leagueId, 'ms-MY'), + this.getTranslationExact('TEAM', match.homeTeamId, 'en-US'), + this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'), + this.getTranslationExact('TEAM', match.homeTeamId, 'ms-MY'), + this.getTranslationExact('TEAM', match.awayTeamId, 'en-US'), + this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'), + this.getTranslationExact('TEAM', match.awayTeamId, 'ms-MY'), ]); return { id: match.id.toString(), status: match.status, isOutright: match.isOutright, isHot: match.isHot, + displayOrder: match.displayOrder, startTime: match.startTime.toISOString(), + leagueId: match.leagueId.toString(), + leagueCode: match.league.code, leagueEn, leagueZh, + leagueMs, + leagueLogoUrl: match.league.logoUrl ?? '', homeTeamEn: homeEn, homeTeamZh: homeZh, + homeTeamMs: homeMs, + homeTeamCode: match.homeTeam.code, + homeTeamLogoUrl: match.homeTeam.logoUrl ?? '', awayTeamEn: awayEn, awayTeamZh: awayZh, + awayTeamMs: awayMs, + awayTeamCode: match.awayTeam.code, + awayTeamLogoUrl: match.awayTeam.logoUrl ?? '', matchName: match.matchName ?? '', + stage: match.stage ?? '', + groupName: match.groupName ?? '', + markets: markets.map((m) => ({ + id: m.id.toString(), + marketType: m.marketType, + period: m.period, + lineValue: m.lineValue != null ? Number(m.lineValue) : null, + status: m.status, + promoLabel: m.promoLabel ?? '', + sortOrder: m.sortOrder, + selections: m.selections.map((s) => ({ + id: s.id.toString(), + selectionCode: s.selectionCode, + selectionName: s.selectionName, + odds: Number(s.odds), + status: s.status, + sortOrder: s.sortOrder, + })), + })), }; } @@ -288,13 +567,22 @@ export class MatchesService { data: { leagueEn: string; leagueZh: string; + leagueMs?: string; homeTeamZh: string; homeTeamEn: string; + homeTeamMs?: string; awayTeamZh: string; awayTeamEn: string; + awayTeamMs?: string; startTime: Date; isHot?: boolean; displayOrder?: number; + matchName?: string; + stage?: string; + groupName?: string; + leagueLogoUrl?: string; + homeTeamLogoUrl?: string; + awayTeamLogoUrl?: string; updatedBy?: bigint; }, ) { @@ -306,23 +594,55 @@ export class MatchesService { throw new BadRequestException('当前状态不可编辑'); } - const matchName = `${data.homeTeamEn.trim() || data.homeTeamZh.trim()} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim()}`; + const matchName = + data.matchName?.trim() || + `${data.homeTeamEn.trim() || data.homeTeamZh.trim() || data.homeTeamMs?.trim() || ''} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim() || data.awayTeamMs?.trim() || ''}`; await Promise.all([ this.upsertEntityTranslations('LEAGUE', match.leagueId, { 'zh-CN': data.leagueZh.trim(), 'en-US': data.leagueEn.trim(), + 'ms-MY': (data.leagueMs ?? '').trim(), }), this.upsertEntityTranslations('TEAM', match.homeTeamId, { 'zh-CN': data.homeTeamZh.trim(), 'en-US': data.homeTeamEn.trim(), + 'ms-MY': (data.homeTeamMs ?? '').trim(), }), this.upsertEntityTranslations('TEAM', match.awayTeamId, { 'zh-CN': data.awayTeamZh.trim(), 'en-US': data.awayTeamEn.trim(), + 'ms-MY': (data.awayTeamMs ?? '').trim(), }), ]); + const logoUpdates: Promise[] = []; + if (data.leagueLogoUrl !== undefined) { + logoUpdates.push( + this.prisma.league.update({ + where: { id: match.leagueId }, + data: { logoUrl: data.leagueLogoUrl.trim() || null }, + }), + ); + } + if (data.homeTeamLogoUrl !== undefined) { + logoUpdates.push( + this.prisma.team.update({ + where: { id: match.homeTeamId }, + data: { logoUrl: data.homeTeamLogoUrl.trim() || null }, + }), + ); + } + if (data.awayTeamLogoUrl !== undefined) { + logoUpdates.push( + this.prisma.team.update({ + where: { id: match.awayTeamId }, + data: { logoUrl: data.awayTeamLogoUrl.trim() || null }, + }), + ); + } + if (logoUpdates.length) await Promise.all(logoUpdates); + return this.prisma.match.update({ where: { id: matchId }, data: { @@ -330,6 +650,8 @@ export class MatchesService { isHot: data.isHot ?? match.isHot, displayOrder: data.displayOrder ?? match.displayOrder, matchName, + stage: data.stage !== undefined ? data.stage.trim() || null : match.stage, + groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName, updatedBy: data.updatedBy, }, }); @@ -484,6 +806,13 @@ export class MatchesService { }); } + private async getTranslationExact(entityType: string, entityId: bigint, locale: string) { + const row = await this.prisma.entityTranslation.findFirst({ + where: { entityType, entityId, locale, fieldName: 'name' }, + }); + return row?.value ?? ''; + } + async getTranslation(entityType: string, entityId: bigint, locale: string) { const translations = await this.prisma.entityTranslation.findMany({ where: { entityType, entityId }, @@ -500,25 +829,63 @@ export class MatchesService { leagueId: bigint; homeTeamId: bigint; awayTeamId: bigint; - homeTeam?: { code: string }; - awayTeam?: { code: string }; - markets?: unknown[]; + startTime: Date; + status?: string; + isHot?: boolean; + displayOrder?: number; + matchName?: string | null; + stage?: string | null; + groupName?: string | null; + homeTeam?: { code: string; logoUrl?: string | null }; + awayTeam?: { code: string; logoUrl?: string | null }; + league?: { logoUrl?: string | null }; + markets?: Array>; }; 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), ]); - return { - ...match, + const base = { id: m.id.toString(), leagueId: m.leagueId.toString(), leagueName, + leagueLogoUrl: m.league?.logoUrl ?? null, homeTeamName: homeName, awayTeamName: awayName, homeTeamCode: m.homeTeam?.code ?? '', awayTeamCode: m.awayTeam?.code ?? '', + homeTeamLogoUrl: m.homeTeam?.logoUrl ?? null, + awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null, + startTime: m.startTime.toISOString(), + isHot: m.isHot ?? false, + displayOrder: m.displayOrder ?? 0, + matchName: m.matchName ?? null, + stage: m.stage ?? null, + groupName: m.groupName ?? null, + status: m.status ?? 'PUBLISHED', }; + if (m.markets) { + return { + ...base, + markets: m.markets.map((market) => ({ + id: (market.id as bigint).toString(), + marketType: market.marketType as string, + period: market.period as string, + lineValue: market.lineValue != null ? Number(market.lineValue) : null, + allowParlay: (market.allowParlay as boolean | undefined) ?? true, + promoLabel: (market.promoLabel as string | null | undefined) ?? null, + selections: ((market.selections as Array>) ?? []).map((s) => ({ + id: (s.id as bigint).toString(), + selectionCode: s.selectionCode as string, + selectionName: s.selectionName as string, + odds: Number(s.odds), + oddsVersion: (s.oddsVersion as bigint).toString(), + })), + })), + }; + } + return base; } async listPublished(locale = 'en-US', leagueId?: bigint) { @@ -528,27 +895,37 @@ export class MatchesService { status: 'PUBLISHED', isOutright: false, sportType: 'FOOTBALL', + deletedAt: null, startTime: { gt: now }, ...(leagueId ? { leagueId } : {}), }, include: { + league: true, homeTeam: true, awayTeam: true, markets: { where: { status: 'OPEN' }, - include: { selections: { where: { status: 'OPEN' } } }, + include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } }, + orderBy: { sortOrder: 'asc' }, }, }, - orderBy: [{ isHot: 'desc' }, { startTime: 'asc' }], + orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }], }); return Promise.all(matches.map((m) => this.enrichMatch(m, locale))); } async getMatchDetail(matchId: bigint, locale = 'en-US') { - const match = await this.prisma.match.findUnique({ - where: { id: matchId }, + const match = await this.prisma.match.findFirst({ + where: { + id: matchId, + deletedAt: null, + sportType: 'FOOTBALL', + isOutright: false, + status: { in: ['PUBLISHED', 'CLOSED'] }, + }, include: { + league: true, homeTeam: true, awayTeam: true, markets: { @@ -560,9 +937,6 @@ export class MatchesService { }, }); if (!match) throw new NotFoundException('Match not found'); - if (match.sportType !== 'FOOTBALL') { - throw new NotFoundException('Match not found'); - } return this.enrichMatch(match, locale); } diff --git a/apps/api/src/domains/odds/markets.service.ts b/apps/api/src/domains/odds/markets.service.ts index e9b738a..32d1436 100644 --- a/apps/api/src/domains/odds/markets.service.ts +++ b/apps/api/src/domains/odds/markets.service.ts @@ -49,6 +49,22 @@ export class MarketsService { return created; } + private formatHandicapName(side: 'home' | 'away', line: number, half = false) { + const sideLabel = side === 'home' ? '主队' : '客队'; + const value = side === 'home' ? line : -line; + const lineText = value > 0 ? `+${value}` : `${value}`; + return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`; + } + + private formatOuName(side: 'over' | 'under', line: number, half = false) { + const sideLabel = side === 'over' ? '大' : '小'; + return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`; + } + + private formatScoreName(code: string) { + return code.replace('SCORE_', '').replace('_', '-'); + } + private getMarketConfig(marketType: string) { const configs: Record ({ code, - name: code.replace('SCORE_', '').replace('_', '-') || code, + name: this.formatScoreName(code), odds: 8.0, })), }, @@ -142,7 +158,7 @@ export class MarketsService { sortOrder: 9, selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({ code, - name: code.replace('SCORE_', '').replace('_', '-') || code, + name: this.formatScoreName(code), odds: 6.0, })), }, @@ -152,7 +168,7 @@ export class MarketsService { sortOrder: 10, selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({ code, - name: code.replace('SCORE_', '').replace('_', '-') || code, + name: this.formatScoreName(code), odds: 6.0, })), }, @@ -206,4 +222,45 @@ export class MarketsService { } return results; } + + async updateMarket( + marketId: bigint, + data: { promoLabel?: string | null; status?: string; lineValue?: number | null }, + ) { + const market = await this.prisma.market.findUnique({ where: { id: marketId } }); + if (!market) throw new NotFoundException('Market not found'); + + return this.prisma.market.update({ + where: { id: marketId }, + data: { + ...(data.promoLabel !== undefined ? { promoLabel: data.promoLabel?.trim() || null } : {}), + ...(data.status !== undefined ? { status: data.status } : {}), + ...(data.lineValue !== undefined ? { lineValue: data.lineValue } : {}), + }, + }); + } + + async updateSelection( + selectionId: bigint, + data: { selectionName?: string; odds?: number; status?: string }, + operatorId?: bigint, + ) { + const selection = await this.prisma.marketSelection.findUnique({ + where: { id: selectionId }, + }); + if (!selection) throw new NotFoundException('Selection not found'); + + if (data.odds != null) { + if (!operatorId) throw new BadRequestException('Operator required for odds update'); + return this.updateOdds(selectionId, data.odds, operatorId); + } + + return this.prisma.marketSelection.update({ + where: { id: selectionId }, + data: { + ...(data.selectionName !== undefined ? { selectionName: data.selectionName.trim() } : {}), + ...(data.status !== undefined ? { status: data.status } : {}), + }, + }); + } } diff --git a/apps/player/package.json b/apps/player/package.json index bd37dfc..a32faf3 100644 --- a/apps/player/package.json +++ b/apps/player/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5173", + "dev": "vite --port 5173 --host", "build": "vue-tsc -b && vite build", "preview": "vite preview" }, diff --git a/apps/player/src/assets/images/卡片.png b/apps/player/src/assets/images/卡片.png new file mode 100644 index 0000000..5ef494e Binary files /dev/null and b/apps/player/src/assets/images/卡片.png differ diff --git a/apps/player/src/components/LeagueAccordionItem.vue b/apps/player/src/components/LeagueAccordionItem.vue index 25b8c0e..71dd2f7 100644 --- a/apps/player/src/components/LeagueAccordionItem.vue +++ b/apps/player/src/components/LeagueAccordionItem.vue @@ -5,6 +5,7 @@ import saishiImg from '../assets/images/saishi.png'; defineProps<{ leagueId: string; leagueName: string; + leagueLogoUrl?: string | null; expanded: boolean; matches: { id: string; @@ -12,6 +13,8 @@ defineProps<{ awayTeamName: string; homeTeamCode?: string; awayTeamCode?: string; + homeTeamLogoUrl?: string | null; + awayTeamLogoUrl?: string | null; startTime: string; }[]; }>(); @@ -26,7 +29,11 @@ const emit = defineEmits<{ toggle: []; bet: [id: string] }>(); {{ expanded ? '−' : '+' }} *{{ leagueName }} - +
diff --git a/apps/player/src/components/MatchBetCard.vue b/apps/player/src/components/MatchBetCard.vue index 743a05b..693f9c0 100644 --- a/apps/player/src/components/MatchBetCard.vue +++ b/apps/player/src/components/MatchBetCard.vue @@ -10,6 +10,8 @@ const props = defineProps<{ awayTeamName: string; homeTeamCode?: string; awayTeamCode?: string; + homeTeamLogoUrl?: string | null; + awayTeamLogoUrl?: string | null; startTime: string; }; }>(); @@ -29,8 +31,12 @@ const kickoff = computed(() => { }); }); -const homeFlag = computed(() => teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName)); -const awayFlag = computed(() => teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName)); +const homeFlag = computed(() => + teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName, props.match.homeTeamLogoUrl), +); +const awayFlag = computed(() => + teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName, props.match.awayTeamLogoUrl), +);