feat(i18n): 管理端与玩家端三语支持(中/英/马来语)

- 管理后台 adminT 文案库、结算与代理端页面、表单校验
- 玩家端 vue-i18n 补全首页/公告/串关与 ms 文案
- Element Plus ms 语言包与共享 locale 工具
This commit is contained in:
2026-06-03 15:05:36 +08:00
parent 80adc0e928
commit cbfa18d1d3
63 changed files with 3081 additions and 1038 deletions

View File

@@ -0,0 +1,7 @@
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
p.match
.findMany({ take: 1, include: { homeTeam: true, awayTeam: true } })
.then((r) => console.log('OK', r.length))
.catch((e) => console.error('ERR', e.message))
.finally(() => p.$disconnect());

View File

@@ -372,6 +372,9 @@ export class AgentsService {
cashbackRate?: number;
},
) {
if (data.level !== 1 && data.level !== 2) {
throw new BadRequestException('Agent level must be 1 or 2');
}
if (data.level === 2 && !data.parentAgentId) {
throw new BadRequestException('Level 2 agent requires parent');
}

View File

@@ -4,8 +4,13 @@ import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBetNo } from '../../shared/common/decorators';
import { isQuarterHandicapOrTotal } from '../settlement/domain/settlement-calculator';
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
import {
PARLAY_MIN_LEGS,
PARLAY_MAX_LEGS,
canSelectForParlay,
isPreMatchKickoff,
isSupportedSport,
} from '@thebet365/shared';
interface BetSelectionInput {
selectionId: bigint;
@@ -20,7 +25,11 @@ export class BetsService {
private wallet: WalletService,
) {}
private async validateSelection(selectionId: bigint, oddsVersion: bigint) {
private async validateSelection(
selectionId: bigint,
oddsVersion: bigint,
options?: { forParlay?: boolean },
) {
const selection = await this.prisma.marketSelection.findUnique({
where: { id: selectionId },
include: { market: { include: { match: true } } },
@@ -32,10 +41,35 @@ export class BetsService {
if (selection.market.match.status !== 'PUBLISHED') {
throw new BadRequestException('Match not available for betting');
}
if (!isSupportedSport(selection.market.match.sportType)) {
throw new BadRequestException('Only football betting is supported');
}
if (!selection.market.match.isOutright && !isPreMatchKickoff(selection.market.match.startTime)) {
throw new BadRequestException('Pre-match betting only; match has started');
}
if (selection.oddsVersion !== oddsVersion) {
throw new BadRequestException('Odds changed, please confirm again');
}
if (options?.forParlay) {
const line = selection.market.lineValue ? Number(selection.market.lineValue) : null;
const check = canSelectForParlay({
marketType: selection.market.marketType,
lineValue: line,
allowParlay: selection.market.allowParlay,
isOutright: selection.market.match.isOutright,
});
if (!check.ok) {
const msg =
check.reason === 'OUTRIGHT'
? 'Outright cannot be in parlay'
: check.reason === 'QUARTER_LINE'
? 'Quarter line markets cannot be in parlay'
: 'Market not allowed in parlay';
throw new BadRequestException(msg);
}
}
return selection;
}
@@ -54,7 +88,7 @@ export class BetsService {
});
if (existing) return existing;
const selection = await this.validateSelection(selectionId, oddsVersion);
const selection = await this.validateSelection(selectionId, oddsVersion, { forParlay: false });
const odds = new Decimal(selection.odds.toString());
const stakeDec = new Decimal(stake);
const potentialReturn = stakeDec.mul(odds);
@@ -117,21 +151,7 @@ export class BetsService {
const matchIds = new Set<string>();
for (const leg of legs) {
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion);
if (sel.market.marketType === 'OUTRIGHT_WINNER') {
throw new BadRequestException('Outright cannot be in parlay');
}
const line = sel.market.lineValue ? Number(sel.market.lineValue) : null;
if (
['FT_HANDICAP', 'HT_HANDICAP', 'FT_OVER_UNDER', 'HT_OVER_UNDER'].includes(
sel.market.marketType,
) &&
isQuarterHandicapOrTotal(line)
) {
throw new BadRequestException('Quarter line markets cannot be in parlay');
}
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, { forParlay: true });
const matchKey = sel.market.matchId.toString();
if (matchIds.has(matchKey)) {

View File

@@ -1,4 +1,5 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { resolveTranslationFallback } from '@thebet365/shared';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
@@ -487,7 +488,7 @@ export class MatchesService {
const map = Object.fromEntries(
translations.filter((t) => t.fieldName === 'name').map((t) => [t.locale, t.value]),
);
return map[locale] || map['en-US'] || map['zh-CN'] || Object.values(map)[0] || '';
return resolveTranslationFallback(map, locale);
}
async enrichMatch(match: Record<string, unknown>, locale: string) {
@@ -518,10 +519,13 @@ export class MatchesService {
}
async listPublished(locale = 'en-US', leagueId?: bigint) {
const now = new Date();
const matches = await this.prisma.match.findMany({
where: {
status: 'PUBLISHED',
isOutright: false,
sportType: 'FOOTBALL',
startTime: { gt: now },
...(leagueId ? { leagueId } : {}),
},
include: {
@@ -553,12 +557,15 @@ 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);
}
async listOutrights(locale = 'en-US') {
const matches = await this.prisma.match.findMany({
where: { status: 'PUBLISHED', isOutright: true },
where: { status: 'PUBLISHED', isOutright: true, sportType: 'FOOTBALL' },
include: {
markets: {
where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' },

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { SUPPORTED_LOCALES } from '@thebet365/shared';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AgentsService } from '../agent/agents.service';
@@ -92,6 +93,9 @@ export class UsersService {
}
async updateLocale(userId: bigint, locale: string) {
if (!(SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
throw new BadRequestException('Unsupported locale');
}
await this.prisma.user.update({
where: { id: userId },
data: { locale },

View File

@@ -156,6 +156,12 @@ export class MarketsService {
odds: 6.0,
})),
},
OUTRIGHT_WINNER: {
period: 'OUTRIGHT',
allowParlay: false,
sortOrder: 1,
selections: [],
},
};
const config = configs[marketType];

View File

@@ -1,6 +1,18 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma/prisma.service';
function pickContentTranslation<T extends { locale: string }>(
translations: T[],
locale: string,
): T | undefined {
const chain = [locale, 'en-US', 'zh-CN'];
for (const loc of chain) {
const hit = translations.find((tr) => tr.locale === loc);
if (hit) return hit;
}
return translations[0];
}
@Injectable()
export class ContentService {
constructor(private prisma: PrismaService) {}
@@ -19,10 +31,7 @@ export class ContentService {
});
return items.map((item) => {
const t =
item.translations.find((tr) => tr.locale === locale) ||
item.translations.find((tr) => tr.locale === 'en-US') ||
item.translations[0];
const t = pickContentTranslation(item.translations, locale);
return { ...item, translation: t };
});
}

View File

@@ -1,8 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { DEFAULT_LOCALE } from '@thebet365/shared';
const FALLBACK_ORDER = ['en-US', 'zh-CN', 'ms-MY'];
import { resolveTranslationFallback } from '@thebet365/shared';
@Injectable()
export class I18nService {
@@ -10,7 +8,7 @@ export class I18nService {
async getMessages(locale: string) {
const messages = await this.prisma.i18nMessage.findMany({
where: { locale: { in: [locale, ...FALLBACK_ORDER] } },
where: { locale: { in: [locale, 'en-US', 'zh-CN', 'ms-MY'] } },
});
const byKey: Record<string, Record<string, string>> = {};
@@ -21,10 +19,8 @@ export class I18nService {
const result: Record<string, string> = {};
for (const [key, locales] of Object.entries(byKey)) {
result[key] =
locales[locale] ||
FALLBACK_ORDER.map((l) => locales[l]).find(Boolean) ||
key;
const resolved = resolveTranslationFallback(locales, locale);
result[key] = resolved || key;
}
return result;
}

View File

@@ -259,10 +259,7 @@ export function calculateParlayPayout(
return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds };
}
export function isQuarterHandicapOrTotal(line: number | null | undefined): boolean {
if (line == null) return false;
return isQuarterLine(line);
}
export { isQuarterHandicapOrTotal } from '@thebet365/shared';
export const FT_CORRECT_SCORE_TEMPLATE = [
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',