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

@@ -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)) {