feat(player): 注册账号、登录双模式与移动端性能优化
注册必填 7-32 位账号,手机号区号/本地号分存;登录默认账号模式并支持切换手机号登录;Player i18n 拆包与赛事接口优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
ALTER TABLE "user_preferences"
|
||||
ADD COLUMN IF NOT EXISTS "phone_country_dial" VARCHAR(8),
|
||||
ADD COLUMN IF NOT EXISTS "phone_local" VARCHAR(24);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "user_preferences_phone_country_dial_phone_local_idx"
|
||||
ON "user_preferences" ("phone_country_dial", "phone_local");
|
||||
|
||||
-- 回填已有手机号的区号/本地号(username 为纯数字国际号码时)
|
||||
UPDATE "user_preferences" up
|
||||
SET
|
||||
"phone_country_dial" = matched.dial,
|
||||
"phone_local" = SUBSTRING(u.username FROM (LENGTH(matched.dial) + 1))
|
||||
FROM "users" u
|
||||
JOIN LATERAL (
|
||||
SELECT dial FROM (
|
||||
VALUES ('880'), ('886'), ('60'), ('65'), ('66'), ('84'), ('91'), ('61')
|
||||
) AS d(dial)
|
||||
WHERE u.username LIKE d.dial || '%'
|
||||
ORDER BY LENGTH(d.dial) DESC
|
||||
LIMIT 1
|
||||
) matched ON TRUE
|
||||
WHERE up.user_id = u.id
|
||||
AND up.phone IS NOT NULL
|
||||
AND up.phone_country_dial IS NULL
|
||||
AND u.username ~ '^[0-9]+$'
|
||||
AND LENGTH(u.username) >= 10;
|
||||
@@ -85,6 +85,8 @@ model UserPreference {
|
||||
userId BigInt @unique @map("user_id")
|
||||
locale String @default("en-US") @db.VarChar(10)
|
||||
phone String? @db.VarChar(32)
|
||||
phoneCountryDial String? @map("phone_country_dial") @db.VarChar(8)
|
||||
phoneLocal String? @map("phone_local") @db.VarChar(24)
|
||||
email String? @db.VarChar(128)
|
||||
avatarKey String? @map("avatar_key") @db.VarChar(128)
|
||||
allowPasswordChange Boolean @default(true) @map("allow_password_change")
|
||||
@@ -95,6 +97,7 @@ model UserPreference {
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([phoneCountryDial, phoneLocal])
|
||||
@@map("user_preferences")
|
||||
}
|
||||
|
||||
|
||||
@@ -162,12 +162,24 @@ export class PlayerController {
|
||||
@Headers('x-locale') headerLocale?: string,
|
||||
) {
|
||||
const locale = userLocale || headerLocale || 'zh-CN';
|
||||
const [banners, announcements, hotMatches, todayMatches] = await Promise.all([
|
||||
const [banners, announcements, allMatches] = await Promise.all([
|
||||
this.content.listActive('BANNER', locale),
|
||||
this.content.listActiveAnnouncements(locale),
|
||||
this.matches.listPublished(locale),
|
||||
this.matches.listPublished(locale),
|
||||
this.matches.listPublished(locale, undefined, { includeMarkets: false }),
|
||||
]);
|
||||
const now = new Date();
|
||||
const isKickoffToday = (startTime: string) => {
|
||||
const d = new Date(startTime);
|
||||
return (
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate()
|
||||
);
|
||||
};
|
||||
const hotMatches = (allMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot);
|
||||
const todayMatches = (allMatches as Array<{ startTime: string }>).filter((m) =>
|
||||
isKickoffToday(m.startTime),
|
||||
);
|
||||
return jsonResponse({
|
||||
banners,
|
||||
announcements,
|
||||
@@ -175,7 +187,7 @@ export class PlayerController {
|
||||
ticker: announcements,
|
||||
/** @deprecated 使用 announcements */
|
||||
notices: announcements,
|
||||
hotMatches: (hotMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot),
|
||||
hotMatches,
|
||||
todayMatches,
|
||||
});
|
||||
}
|
||||
@@ -186,9 +198,18 @@ export class PlayerController {
|
||||
@CurrentUser('locale') userLocale: string | undefined,
|
||||
@Headers('x-locale') headerLocale: string | undefined,
|
||||
@Query('leagueId') leagueId?: string,
|
||||
@Query('scope') scope?: string,
|
||||
) {
|
||||
const locale = userLocale || headerLocale || 'zh-CN';
|
||||
const items = await this.matches.listPublished(locale, leagueId ? BigInt(leagueId) : undefined);
|
||||
const lid = leagueId ? BigInt(leagueId) : undefined;
|
||||
if (scope === 'parlay') {
|
||||
const items = await this.matches.listPublished(locale, lid, {
|
||||
includeMarkets: true,
|
||||
parlayMarketsOnly: true,
|
||||
});
|
||||
return jsonResponse(items);
|
||||
}
|
||||
const items = await this.matches.listPublished(locale, lid, { includeMarkets: false });
|
||||
return jsonResponse(items);
|
||||
}
|
||||
|
||||
@@ -246,8 +267,15 @@ export class PlayerController {
|
||||
@CurrentUser('locale') locale: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('matchId') matchId?: string,
|
||||
) {
|
||||
const result = await this.bets.getUserBets(userId, status, page ? parseInt(page) : 1);
|
||||
const result = await this.bets.getUserBets(
|
||||
userId,
|
||||
status,
|
||||
page ? parseInt(page, 10) : 1,
|
||||
20,
|
||||
matchId ? BigInt(matchId) : undefined,
|
||||
);
|
||||
const items = await this.matches.enrichBetsForHistory(result.items, locale);
|
||||
return jsonResponse({ ...result, items });
|
||||
}
|
||||
|
||||
@@ -238,8 +238,18 @@ export class BetsService {
|
||||
return bet;
|
||||
}
|
||||
|
||||
async getUserBets(userId: bigint, status?: string, page = 1, pageSize = 20) {
|
||||
const where = { userId, ...(status ? { status } : {}) };
|
||||
async getUserBets(
|
||||
userId: bigint,
|
||||
status?: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
matchId?: bigint,
|
||||
) {
|
||||
const where = {
|
||||
userId,
|
||||
...(status ? { status } : {}),
|
||||
...(matchId ? { selections: { some: { matchId } } } : {}),
|
||||
};
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isPreMatchKickoff, resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { isPreMatchKickoff, MarketType, resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
@@ -26,6 +26,23 @@ import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
|
||||
|
||||
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
|
||||
|
||||
const PLAYER_PARLAY_MARKET_TYPES = [
|
||||
MarketType.FT_HANDICAP,
|
||||
MarketType.FT_OVER_UNDER,
|
||||
MarketType.FT_1X2,
|
||||
MarketType.FT_ODD_EVEN,
|
||||
MarketType.HT_HANDICAP,
|
||||
MarketType.HT_OVER_UNDER,
|
||||
MarketType.HT_1X2,
|
||||
] as const;
|
||||
|
||||
export type ListPublishedOptions = {
|
||||
/** 响应中是否包含 markets(列表页默认 false,仅摘要) */
|
||||
includeMarkets?: boolean;
|
||||
/** 仅返回串关玩法 markets(需 includeMarkets=true) */
|
||||
parlayMarketsOnly?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MatchesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -1158,7 +1175,11 @@ export class MatchesService {
|
||||
return resolveTranslationFallback(map, locale);
|
||||
}
|
||||
|
||||
async enrichMatch(match: Record<string, unknown>, locale: string) {
|
||||
async enrichMatch(
|
||||
match: Record<string, unknown>,
|
||||
locale: string,
|
||||
options?: { omitMarkets?: boolean },
|
||||
) {
|
||||
const m = match as {
|
||||
id: bigint;
|
||||
leagueId: bigint;
|
||||
@@ -1230,7 +1251,7 @@ export class MatchesService {
|
||||
}>,
|
||||
}),
|
||||
};
|
||||
if (m.markets) {
|
||||
if (m.markets && !options?.omitMarkets) {
|
||||
return {
|
||||
...base,
|
||||
markets: m.markets.map((market) => ({
|
||||
@@ -1302,7 +1323,41 @@ export class MatchesService {
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
};
|
||||
|
||||
async listPublished(locale = 'en-US', leagueId?: bigint) {
|
||||
/** 仅 status 字段,用于列表页计算 bettingOpen,不返回给客户端 */
|
||||
private playerMarketStatusInclude = {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
select: {
|
||||
status: true,
|
||||
selections: { select: { status: true } },
|
||||
},
|
||||
};
|
||||
|
||||
async listPublished(
|
||||
locale = 'en-US',
|
||||
leagueId?: bigint,
|
||||
options?: ListPublishedOptions,
|
||||
) {
|
||||
const includeMarkets = options?.includeMarkets ?? true;
|
||||
const parlayMarketsOnly = options?.parlayMarketsOnly ?? false;
|
||||
|
||||
const marketWhere = {
|
||||
status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] },
|
||||
...(parlayMarketsOnly ? { marketType: { in: [...PLAYER_PARLAY_MARKET_TYPES] } } : {}),
|
||||
};
|
||||
|
||||
const marketsRelation = includeMarkets
|
||||
? {
|
||||
where: marketWhere,
|
||||
include: {
|
||||
selections: {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
}
|
||||
: this.playerMarketStatusInclude;
|
||||
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'] },
|
||||
@@ -1317,12 +1372,22 @@ export class MatchesService {
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
score: true,
|
||||
markets: this.playerMarketInclude,
|
||||
markets: marketsRelation,
|
||||
},
|
||||
orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }],
|
||||
});
|
||||
|
||||
return Promise.all(matches.map((m) => this.enrichMatch(m, locale)));
|
||||
const enriched = await Promise.all(
|
||||
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: !includeMarkets })),
|
||||
);
|
||||
|
||||
if (!includeMarkets || !parlayMarketsOnly) return enriched;
|
||||
|
||||
return enriched.filter(
|
||||
(m) =>
|
||||
Array.isArray((m as { markets?: unknown[] }).markets) &&
|
||||
(m as { markets: unknown[] }).markets.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
async getMatchDetail(matchId: bigint, locale = 'en-US') {
|
||||
|
||||
@@ -33,6 +33,7 @@ export class AuthController {
|
||||
@Post('player/auth/register')
|
||||
async playerRegister(@Body() dto: RegisterDto) {
|
||||
const result = await this.auth.registerPlayer({
|
||||
username: dto.username,
|
||||
phone: dto.phone,
|
||||
countryCode: dto.countryCode,
|
||||
password: dto.password,
|
||||
|
||||
@@ -18,6 +18,11 @@ export class LoginDto {
|
||||
}
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ description: '登录账号,7-32 位字母数字' })
|
||||
@IsString()
|
||||
@MinLength(7)
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ description: '本地手机号,不含国家区号' })
|
||||
@IsString()
|
||||
phone!: string;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { InvitesService } from './invites.service';
|
||||
import { SmsService } from './sms/sms.service';
|
||||
import { normalizePhone, resolvePlayerLoginUsername } from './sms/phone.util';
|
||||
import { normalizePhone, resolvePlayerLoginCandidates, stripLocalPhoneDigits } from './sms/phone.util';
|
||||
|
||||
const MAX_LOGIN_FAILS = 5;
|
||||
const LOCK_DURATION_MS = 15 * 60 * 1000;
|
||||
@@ -51,15 +51,51 @@ export class AuthService {
|
||||
portal: 'player' | 'admin' | 'agent',
|
||||
countryCode?: string,
|
||||
) {
|
||||
const lookupUsername =
|
||||
portal === 'player'
|
||||
? resolvePlayerLoginUsername(username, countryCode)
|
||||
: username.trim();
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { username: lookupUsername },
|
||||
let user;
|
||||
if (portal === 'player') {
|
||||
const trimmed = username.trim();
|
||||
if (/[a-zA-Z]/.test(trimmed)) {
|
||||
user = await this.prisma.user.findUnique({
|
||||
where: { username: trimmed },
|
||||
include: { auth: true, adminRole: { include: { role: true } } },
|
||||
});
|
||||
} else {
|
||||
const candidates = resolvePlayerLoginCandidates(username, countryCode);
|
||||
let matches = await this.prisma.user.findMany({
|
||||
where: {
|
||||
username: { in: candidates },
|
||||
userType: 'PLAYER',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { auth: true, adminRole: { include: { role: true } } },
|
||||
});
|
||||
|
||||
if (matches.length === 0 && countryCode?.trim()) {
|
||||
const dial = countryCode.replace(/\D/g, '');
|
||||
const local = stripLocalPhoneDigits(username);
|
||||
matches = await this.prisma.user.findMany({
|
||||
where: {
|
||||
userType: 'PLAYER',
|
||||
deletedAt: null,
|
||||
preferences: {
|
||||
phoneCountryDial: dial,
|
||||
phoneLocal: local,
|
||||
},
|
||||
},
|
||||
include: { auth: true, adminRole: { include: { role: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.length === 1) {
|
||||
user = matches[0];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
user = await this.prisma.user.findUnique({
|
||||
where: { username: username.trim() },
|
||||
include: { auth: true, adminRole: { include: { role: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
if (!user || !user.auth) {
|
||||
throw appUnauthorized('INVALID_CREDENTIALS');
|
||||
@@ -156,6 +192,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async registerPlayer(data: {
|
||||
username: string;
|
||||
phone: string;
|
||||
countryCode: string;
|
||||
password: string;
|
||||
@@ -164,14 +201,20 @@ export class AuthService {
|
||||
inviteCode?: string;
|
||||
locale?: string;
|
||||
}) {
|
||||
const phone = normalizePhone(data.countryCode, data.phone);
|
||||
const username = phone;
|
||||
|
||||
const username = data.username.trim();
|
||||
if (!username) {
|
||||
throw appBadRequest('USERNAME_REQUIRED');
|
||||
}
|
||||
try {
|
||||
assertPlayerUsername(username);
|
||||
} catch {
|
||||
throw appBadRequest('PHONE_INVALID');
|
||||
throw appBadRequest('USERNAME_FORMAT_INVALID');
|
||||
}
|
||||
|
||||
const dial = data.countryCode.replace(/\D/g, '');
|
||||
const phoneLocal = stripLocalPhoneDigits(data.phone);
|
||||
const phone = normalizePhone(dial, data.phone);
|
||||
|
||||
if (!data.password || data.password.length < 8) {
|
||||
throw appBadRequest('PASSWORD_MIN_LENGTH');
|
||||
}
|
||||
@@ -189,11 +232,23 @@ export class AuthService {
|
||||
const { parentId, sponsorId, inviteId } = await this.resolveInviteSponsor(data.inviteCode);
|
||||
const inviteSponsorId = parentId == null && sponsorId != null ? sponsorId : null;
|
||||
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
if (existingUser) {
|
||||
throw appBadRequest('USERNAME_TAKEN');
|
||||
}
|
||||
|
||||
const existingPhone = await this.prisma.userPreference.findFirst({
|
||||
where: {
|
||||
phoneCountryDial: dial,
|
||||
phoneLocal,
|
||||
user: { deletedAt: null, userType: 'PLAYER' },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
if (existingPhone) {
|
||||
throw appBadRequest('PHONE_TAKEN');
|
||||
}
|
||||
|
||||
@@ -220,7 +275,13 @@ export class AuthService {
|
||||
});
|
||||
|
||||
await tx.userPreference.create({
|
||||
data: { userId: created.id, locale, phone },
|
||||
data: {
|
||||
userId: created.id,
|
||||
locale,
|
||||
phone,
|
||||
phoneCountryDial: dial,
|
||||
phoneLocal,
|
||||
},
|
||||
});
|
||||
|
||||
if (inviteId) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isSupportedPhoneDial } from '@thebet365/shared';
|
||||
/** 归一化为创蓝格式:国家区号 + 本地号码(纯数字,无 +) */
|
||||
export function normalizePhone(countryDial: string, localInput: string): string {
|
||||
const dial = countryDial.replace(/\D/g, '');
|
||||
let local = localInput.replace(/\D/g, '');
|
||||
let local = stripLocalPhoneDigits(localInput);
|
||||
|
||||
if (!dial || !local) {
|
||||
throw appBadRequest('PHONE_REQUIRED');
|
||||
@@ -13,11 +13,6 @@ export function normalizePhone(countryDial: string, localInput: string): string
|
||||
throw appBadRequest('PHONE_COUNTRY_UNSUPPORTED');
|
||||
}
|
||||
|
||||
// 马来西亚等本地号常以 0 开头
|
||||
if (local.startsWith('0') && local.length > 1) {
|
||||
local = local.replace(/^0+/, '');
|
||||
}
|
||||
|
||||
const combined = `${dial}${local}`;
|
||||
if (combined.length < 10 || combined.length > 15) {
|
||||
throw appBadRequest('PHONE_INVALID');
|
||||
@@ -25,6 +20,15 @@ export function normalizePhone(countryDial: string, localInput: string): string
|
||||
return combined;
|
||||
}
|
||||
|
||||
/** 提取本地号码(纯数字,去掉前导 0) */
|
||||
export function stripLocalPhoneDigits(localInput: string): string {
|
||||
let local = localInput.replace(/\D/g, '');
|
||||
if (local.startsWith('0') && local.length > 1) {
|
||||
local = local.replace(/^0+/, '');
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
/** 玩家登录:选国家 + 本地号;字母账号(如 player1)原样返回 */
|
||||
export function resolvePlayerLoginUsername(
|
||||
input: string,
|
||||
@@ -38,6 +42,15 @@ export function resolvePlayerLoginUsername(
|
||||
}
|
||||
|
||||
if (countryDial?.trim()) {
|
||||
const dial = countryDial.replace(/\D/g, '');
|
||||
let digits = trimmed.replace(/\D/g, '');
|
||||
if (digits.startsWith('0') && digits.length > 1) {
|
||||
digits = digits.replace(/^0+/, '');
|
||||
}
|
||||
// 已输入含区号完整号码时,不再重复拼接
|
||||
if (dial && digits.startsWith(dial) && digits.length >= 10) {
|
||||
return digits;
|
||||
}
|
||||
try {
|
||||
return normalizePhone(countryDial, trimmed);
|
||||
} catch {
|
||||
@@ -50,3 +63,27 @@ export function resolvePlayerLoginUsername(
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** 玩家登录:本地号时在开放国家中生成候选完整号码;字母账号原样返回 */
|
||||
export function resolvePlayerLoginCandidates(
|
||||
input: string,
|
||||
countryDial?: string,
|
||||
): string[] {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
if (!/^[\d+\s\-()]+$/.test(trimmed)) {
|
||||
return [trimmed];
|
||||
}
|
||||
|
||||
if (countryDial?.trim()) {
|
||||
return [resolvePlayerLoginUsername(input, countryDial)];
|
||||
}
|
||||
|
||||
const digits = trimmed.replace(/\D/g, '');
|
||||
if (digits.length >= 10) {
|
||||
return [digits];
|
||||
}
|
||||
|
||||
return [digits];
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.1",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue3-slide-verify": "^1.1.8"
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
|
||||
@@ -128,6 +128,7 @@ onUnmounted(stopAutoPlay);
|
||||
:src="imageUrl(banner)"
|
||||
:alt="title(banner)"
|
||||
class="slide-img"
|
||||
:loading="i === 0 ? 'eager' : 'lazy'"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<div v-else class="slide-fallback">{{ title(banner) }}</div>
|
||||
|
||||
@@ -102,7 +102,7 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: v-bind(matchCardBg) center top / 100% auto no-repeat;
|
||||
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
|
||||
opacity: 0.25;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -111,7 +111,7 @@ const headMeta = computed(() => {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: v-bind(matchCardBg) center top / 100% auto no-repeat;
|
||||
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
|
||||
opacity: 0.25;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../../api';
|
||||
import { useBetSlipStore } from '../../stores/betSlip';
|
||||
import { usePlayerMatches, type ParlayMatch } from '../../composables/usePlayerMatches';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
|
||||
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS, PARLAY_MARKET_GROUPS } from '../../utils/parlayColumns';
|
||||
@@ -33,28 +33,6 @@ interface Market {
|
||||
selections: Selection[];
|
||||
}
|
||||
|
||||
interface ParlayMatch {
|
||||
id: string;
|
||||
leagueName: string;
|
||||
leagueId?: string;
|
||||
homeTeamName: string;
|
||||
awayTeamName: string;
|
||||
homeTeamCode?: string;
|
||||
awayTeamCode?: string;
|
||||
homeTeamLogoUrl?: string | null;
|
||||
awayTeamLogoUrl?: string | null;
|
||||
startTime: string;
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: MatchPhase;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
} | null;
|
||||
markets: Market[];
|
||||
}
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
@@ -63,8 +41,9 @@ function goLogin() {
|
||||
auth.showLoginPrompt('/bet');
|
||||
}
|
||||
|
||||
const loading = ref(true);
|
||||
const matches = ref<ParlayMatch[]>([]);
|
||||
const { parlayMatches, parlayLoading, loadParlay } = usePlayerMatches();
|
||||
const matches = parlayMatches;
|
||||
const loading = parlayLoading;
|
||||
const timeFilter = ref<TimeFilter>('all');
|
||||
const leagueFilter = ref('');
|
||||
const showClosed = ref(false);
|
||||
@@ -72,25 +51,6 @@ const collapsed = ref<Set<string>>(new Set());
|
||||
|
||||
const parlayMarketKeys = PARLAY_MARKET_TYPES.map((c) => c.key);
|
||||
|
||||
async function loadParlayMatches() {
|
||||
const hadData = matches.value.length > 0;
|
||||
if (!hadData) loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/matches');
|
||||
const fresh = (data.data ?? []).filter(
|
||||
(m: ParlayMatch) => m.markets?.length && hasParlayMarkets(m),
|
||||
);
|
||||
if (!hadData) {
|
||||
matches.value = fresh;
|
||||
syncCollapsedAfterLoad();
|
||||
} else {
|
||||
mergeOddsOnly(fresh);
|
||||
}
|
||||
} finally {
|
||||
if (!hadData) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncCollapsedAfterLoad() {
|
||||
const ids = matches.value.map((m) => m.id);
|
||||
// 只保留仍然存在的 id
|
||||
@@ -107,32 +67,10 @@ function syncCollapsedAfterLoad() {
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOddsOnly(fresh: ParlayMatch[]) {
|
||||
const matchMap = new Map<string, ParlayMatch>();
|
||||
for (const m of fresh) matchMap.set(m.id, m);
|
||||
|
||||
for (const match of matches.value) {
|
||||
const freshMatch = matchMap.get(match.id);
|
||||
if (!freshMatch) continue;
|
||||
const marketMap = new Map<string, Market>();
|
||||
for (const mk of freshMatch.markets) marketMap.set(mk.id, mk);
|
||||
for (const market of match.markets) {
|
||||
const freshMarket = marketMap.get(market.id);
|
||||
if (!freshMarket) continue;
|
||||
const selMap = new Map<string, Selection>();
|
||||
for (const s of freshMarket.selections) selMap.set(s.id, s);
|
||||
for (const sel of market.selections) {
|
||||
const fs = selMap.get(sel.id);
|
||||
if (fs) {
|
||||
sel.odds = fs.odds;
|
||||
sel.oddsVersion = fs.oddsVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useOnLocaleChange(loadParlayMatches);
|
||||
useOnLocaleChange(async () => {
|
||||
await loadParlay(true);
|
||||
syncCollapsedAfterLoad();
|
||||
});
|
||||
|
||||
const leagues = computed(() => {
|
||||
const seen = new Set<string>();
|
||||
@@ -583,7 +521,7 @@ function toggleCollapse(id: string) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: v-bind(matchCardBg) center top / 100% auto no-repeat;
|
||||
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
|
||||
opacity: 0.25;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
@@ -600,6 +538,13 @@ function toggleCollapse(id: string) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.match-head-teams {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SUPPORTED_LOCALES, LOCALE_UI_LABELS } from '@thebet365/shared';
|
||||
import { SUPPORTED_LOCALES, LOCALE_UI_LABELS, type Locale } from '@thebet365/shared';
|
||||
import api from '../api';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { ensurePlayerLocale } from '../i18n';
|
||||
|
||||
const STORAGE_KEY = 'locale';
|
||||
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
|
||||
@@ -17,7 +18,8 @@ function persistLocale(code: string) {
|
||||
}
|
||||
|
||||
export function useAppLocale() {
|
||||
const { locale } = useI18n();
|
||||
const i18n = useI18n({ useScope: 'global' });
|
||||
const { locale } = i18n;
|
||||
const auth = useAuthStore();
|
||||
|
||||
function applyLocale(code: string) {
|
||||
@@ -41,6 +43,7 @@ export function useAppLocale() {
|
||||
/* 离线或 token 过期时仍保留本地语言 */
|
||||
}
|
||||
}
|
||||
await ensurePlayerLocale(i18n, code as Locale);
|
||||
applyLocale(code);
|
||||
}
|
||||
|
||||
@@ -58,8 +61,9 @@ export function useAppLocale() {
|
||||
}
|
||||
}
|
||||
|
||||
function initFromUser(userLocale?: string | null) {
|
||||
async function initFromUser(userLocale?: string | null) {
|
||||
if (userLocale && (SUPPORTED_LOCALES as readonly string[]).includes(userLocale)) {
|
||||
await ensurePlayerLocale(i18n, userLocale as Locale);
|
||||
applyLocale(userLocale);
|
||||
}
|
||||
}
|
||||
|
||||
149
apps/player/src/composables/usePlayerMatches.ts
Normal file
149
apps/player/src/composables/usePlayerMatches.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { ref, shallowRef } from 'vue';
|
||||
import api from '../api';
|
||||
import type { MatchPhase } from '../utils/matchPhase';
|
||||
|
||||
export interface PlayerMatchSummary {
|
||||
id: string;
|
||||
leagueId?: string;
|
||||
homeTeamName: string;
|
||||
awayTeamName: string;
|
||||
homeTeamCode?: string;
|
||||
awayTeamCode?: string;
|
||||
homeTeamLogoUrl?: string | null;
|
||||
awayTeamLogoUrl?: string | null;
|
||||
startTime: string;
|
||||
leagueName: string;
|
||||
leagueLogoUrl?: string | null;
|
||||
displayOrder?: number;
|
||||
isHot?: boolean;
|
||||
status?: string;
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: MatchPhase;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ParlayMarketSelection {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}
|
||||
|
||||
export interface ParlayMarket {
|
||||
id: string;
|
||||
marketType: string;
|
||||
lineValue?: string | number | null;
|
||||
allowParlay?: boolean;
|
||||
selections: ParlayMarketSelection[];
|
||||
}
|
||||
|
||||
export interface ParlayMatch extends PlayerMatchSummary {
|
||||
markets: ParlayMarket[];
|
||||
}
|
||||
|
||||
const summaryMatches = shallowRef<PlayerMatchSummary[]>([]);
|
||||
const parlayMatches = shallowRef<ParlayMatch[]>([]);
|
||||
const summaryLoading = ref(false);
|
||||
const parlayLoading = ref(false);
|
||||
|
||||
let summaryInflight: Promise<void> | null = null;
|
||||
let parlayInflight: Promise<void> | null = null;
|
||||
|
||||
async function loadSummary(force = false): Promise<void> {
|
||||
if (force) summaryMatches.value = [];
|
||||
if (!force && summaryMatches.value.length > 0) return;
|
||||
if (summaryInflight) return summaryInflight;
|
||||
|
||||
summaryLoading.value = true;
|
||||
summaryInflight = (async () => {
|
||||
try {
|
||||
const { data } = await api.get('/player/matches');
|
||||
summaryMatches.value = (data.data ?? []) as PlayerMatchSummary[];
|
||||
} catch {
|
||||
if (!summaryMatches.value.length) summaryMatches.value = [];
|
||||
} finally {
|
||||
summaryLoading.value = false;
|
||||
summaryInflight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return summaryInflight;
|
||||
}
|
||||
|
||||
async function loadParlay(force = false): Promise<void> {
|
||||
if (force) parlayMatches.value = [];
|
||||
|
||||
const hadData = parlayMatches.value.length > 0;
|
||||
if (!force && hadData) {
|
||||
if (parlayInflight) return parlayInflight;
|
||||
parlayInflight = (async () => {
|
||||
try {
|
||||
const { data } = await api.get('/player/matches', { params: { scope: 'parlay' } });
|
||||
mergeParlayOdds((data.data ?? []) as ParlayMatch[]);
|
||||
} finally {
|
||||
parlayInflight = null;
|
||||
}
|
||||
})();
|
||||
return parlayInflight;
|
||||
}
|
||||
|
||||
if (parlayInflight) return parlayInflight;
|
||||
parlayLoading.value = true;
|
||||
|
||||
parlayInflight = (async () => {
|
||||
try {
|
||||
const { data } = await api.get('/player/matches', { params: { scope: 'parlay' } });
|
||||
parlayMatches.value = (data.data ?? []) as ParlayMatch[];
|
||||
} catch {
|
||||
parlayMatches.value = [];
|
||||
} finally {
|
||||
parlayLoading.value = false;
|
||||
parlayInflight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return parlayInflight;
|
||||
}
|
||||
|
||||
function mergeParlayOdds(fresh: ParlayMatch[]) {
|
||||
const matchMap = new Map<string, ParlayMatch>();
|
||||
for (const m of fresh) matchMap.set(m.id, m);
|
||||
|
||||
for (const match of parlayMatches.value) {
|
||||
const freshMatch = matchMap.get(match.id);
|
||||
if (!freshMatch) continue;
|
||||
const marketMap = new Map<string, ParlayMarket>();
|
||||
for (const mk of freshMatch.markets) marketMap.set(mk.id, mk);
|
||||
for (const market of match.markets) {
|
||||
const freshMarket = marketMap.get(market.id);
|
||||
if (!freshMarket) continue;
|
||||
const selMap = new Map<string, ParlayMarketSelection>();
|
||||
for (const s of freshMarket.selections) selMap.set(s.id, s);
|
||||
for (const sel of market.selections) {
|
||||
const fs = selMap.get(sel.id);
|
||||
if (fs) {
|
||||
sel.odds = fs.odds;
|
||||
sel.oddsVersion = fs.oddsVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 赛事列表与串关共享缓存,避免重复拉取大 JSON */
|
||||
export function usePlayerMatches() {
|
||||
return {
|
||||
summaryMatches,
|
||||
parlayMatches,
|
||||
summaryLoading,
|
||||
parlayLoading,
|
||||
loadSummary,
|
||||
loadParlay,
|
||||
};
|
||||
}
|
||||
407
apps/player/src/i18n/en-US.ts
Normal file
407
apps/player/src/i18n/en-US.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
export default {
|
||||
common: {
|
||||
pull_refresh: 'Pull to refresh',
|
||||
release_refresh: 'Release to refresh',
|
||||
refreshing: 'Refreshing…',
|
||||
loading_more: 'Loading more…',
|
||||
no_more: 'No more',
|
||||
load_failed: 'Failed to load',
|
||||
retry: 'Retry',
|
||||
},
|
||||
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
|
||||
home: {
|
||||
hot_matches: 'Hot matches',
|
||||
no_matches: 'No matches',
|
||||
announcement_badge: 'Notice',
|
||||
announcement_default:
|
||||
'Welcome to TheBet365 · Football events are live · Bet responsibly',
|
||||
banner_prev: 'Previous slide',
|
||||
banner_next: 'Next slide',
|
||||
banner_slide: 'Slide {n}',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
history: {
|
||||
league_default: 'Football',
|
||||
stake: 'Stake',
|
||||
return: 'Return',
|
||||
est_return: 'Est. Return',
|
||||
odds: 'Odds',
|
||||
ft: 'FT',
|
||||
ht: 'HT',
|
||||
parlay_title: 'Parlay · {n} legs',
|
||||
parlay_league: 'Parlay',
|
||||
empty: 'No bets yet',
|
||||
no_more: 'No more bets',
|
||||
status_won: 'WON',
|
||||
status_pending: 'PENDING',
|
||||
status_lost: 'LOST',
|
||||
status_push: 'PUSH',
|
||||
back: 'Back',
|
||||
not_found: 'Bet not found',
|
||||
my_pick: 'My pick',
|
||||
my_bets: 'My bets',
|
||||
legs: 'Parlay legs',
|
||||
summary: 'Summary',
|
||||
bet_no: 'Bet ID',
|
||||
awaiting_result: 'Awaiting result…',
|
||||
filter_all: 'All',
|
||||
filter_won: 'Won',
|
||||
filter_lost: 'Lost',
|
||||
filter_pending: 'Pending',
|
||||
filter_push: 'Push',
|
||||
stats_total: 'Total',
|
||||
stats_won: 'Won',
|
||||
stats_lost: 'Lost',
|
||||
stats_pending: 'Pending',
|
||||
stats_push: 'Push',
|
||||
stats_stake: 'Total Stake',
|
||||
stats_return: 'Total Return',
|
||||
cashbacked: 'Cashbacked',
|
||||
},
|
||||
auth:
|
||||
{ login: 'Login',
|
||||
register: 'Create Account',
|
||||
logout: 'Log out',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
invite_code: 'Invitation Code',
|
||||
optional: 'Optional',
|
||||
captcha_placeholder: 'Code',
|
||||
captcha_refresh: 'Click to refresh',
|
||||
captcha_wrong: 'Incorrect captcha code',
|
||||
slide_to_verify: 'Slide to verify',
|
||||
click_to_verify: 'Click to verify',
|
||||
verified: 'Verified',
|
||||
login_required: 'Login Required',
|
||||
login_hint: 'Log in to place bets and access more features',
|
||||
go_login: 'Go to login',
|
||||
go_register: 'No account? Register now',
|
||||
have_account: 'Already have an account? Log in',
|
||||
register_btn: 'Register',
|
||||
register_failed: 'Registration failed, please try again',
|
||||
continue_browsing: 'Skip login',
|
||||
username_placeholder: 'Enter username',
|
||||
username_register_placeholder: '7-32 letters or digits',
|
||||
username_required: 'Username is required',
|
||||
username_format_invalid: 'Username must be 7-32 letters or digits',
|
||||
login_account: 'Phone / Username',
|
||||
login_by_phone: 'Log in with phone',
|
||||
login_by_account: 'Log in with username',
|
||||
login_account_placeholder: 'Phone number or username',
|
||||
login_username_placeholder: 'Phone number or username',
|
||||
confirm_password: 'Confirm password',
|
||||
password_mismatch: 'Passwords do not match',
|
||||
password_placeholder: 'Enter password',
|
||||
login_btn: 'Log In',
|
||||
login_failed: 'Login failed, please try again',
|
||||
phone: 'Phone',
|
||||
phone_placeholder: 'Enter phone number',
|
||||
phone_local_placeholder: 'Enter phone number',
|
||||
phone_required: 'Phone number is required',
|
||||
phone_invalid: 'Invalid phone number format',
|
||||
phone_country_unsupported: 'This country or region is not supported',
|
||||
sms_code: 'SMS Code',
|
||||
sms_code_placeholder: '6-digit code',
|
||||
sms_code_required: 'Please enter the SMS code',
|
||||
sms_required: 'Please request an SMS code first',
|
||||
send_sms: 'Get Code',
|
||||
resend_sms: 'Retry in {sec}s',
|
||||
country_search: 'Search country or code',
|
||||
country_not_found: 'No matching country',
|
||||
},
|
||||
support: {
|
||||
short: 'Support',
|
||||
title: 'Customer Support',
|
||||
open: 'Open customer support',
|
||||
close: 'Close',
|
||||
url_pending: 'Support URL is not configured yet.',
|
||||
},
|
||||
wallet: {
|
||||
balance: 'Balance',
|
||||
cash_balance: 'Cash Balance',
|
||||
card_holder: 'Cardholder',
|
||||
unsettled: 'Unsettled',
|
||||
available: 'Available',
|
||||
no_records: 'No records',
|
||||
tx_deposit: 'Deposit',
|
||||
tx_admin_deposit: 'Admin top-up',
|
||||
tx_agent_deposit: 'Agent top-up',
|
||||
tx_player_deposit: 'Self deposit',
|
||||
tx_withdraw: 'Withdrawal',
|
||||
tx_admin_withdraw: 'Admin withdraw',
|
||||
tx_agent_withdraw: 'Agent withdraw',
|
||||
tx_adjust: 'Manual Adjust',
|
||||
tx_bet_freeze: 'Bet Frozen',
|
||||
tx_bet_deduct: 'Bet Deducted',
|
||||
tx_bet_win: 'Bet Payout',
|
||||
tx_bet_lose: 'Bet Settled',
|
||||
tx_bet_push: 'Bet Push',
|
||||
tx_bet_refund: 'Bet Refund',
|
||||
tx_bet_void: 'Bet Voided',
|
||||
tx_cashback: 'Cashback credit',
|
||||
tx_resettle: 'Resettlement',
|
||||
summary_bet: 'Bet {betNo}',
|
||||
summary_opening_bonus: 'Opening bonus',
|
||||
stats_income: 'Income',
|
||||
stats_expense: 'Expense',
|
||||
stats_net: 'Net',
|
||||
stats_cashback: 'Cashback',
|
||||
filter_all: 'All',
|
||||
filter_deposit: 'Deposit',
|
||||
filter_withdraw: 'Withdraw',
|
||||
filter_bet: 'Bet',
|
||||
filter_cashback: 'Cashback',
|
||||
view_all: 'View all transactions',
|
||||
detail_summary: 'Details',
|
||||
detail_amount: 'Amount',
|
||||
detail_balance_before: 'Balance Before',
|
||||
detail_balance_after: 'Balance After',
|
||||
detail_frozen_before: 'Frozen Before',
|
||||
detail_frozen_after: 'Frozen After',
|
||||
detail_reference: 'Reference',
|
||||
detail_reference_type: 'Type',
|
||||
detail_reference_id: 'Reference ID',
|
||||
detail_remark: 'Remark',
|
||||
detail_bet_link: 'View Bet',
|
||||
detail_tx_id: 'Transaction ID',
|
||||
detail_not_found: 'Transaction not found',
|
||||
ref_bet: 'Bet',
|
||||
ref_deposit: 'Deposit',
|
||||
ref_withdraw: 'Withdraw',
|
||||
view_cashbacks: 'Cashback details',
|
||||
view_cashbacks_detail: 'View cashback details (period/rate)',
|
||||
cashback_filter_hint: 'This list shows wallet credits; see cashback details for period and rate.',
|
||||
ref_cashback: 'Cashback batch',
|
||||
detail_cashback_link: 'View cashback details',
|
||||
},
|
||||
recharge: {
|
||||
title: 'Recharge',
|
||||
history: 'History',
|
||||
history_title: 'Recharge History',
|
||||
bank_transfer: 'Bank Transfer',
|
||||
bank_name: 'Bank Name',
|
||||
account_holder: 'Account Holder',
|
||||
account_number: 'Account Number',
|
||||
usdt_address: 'USDT Address',
|
||||
amount_label: 'Amount',
|
||||
amount_placeholder: 'Enter recharge amount',
|
||||
screenshot_label: 'Upload Screenshot',
|
||||
upload_hint: 'Click to upload screenshot (max 5MB)',
|
||||
compressing: 'Compressing',
|
||||
submit: 'Submit',
|
||||
submitting: 'Submitting',
|
||||
submitted: 'Recharge Submitted',
|
||||
pending_review: 'Admin is reviewing, please wait',
|
||||
new_recharge: 'New Recharge',
|
||||
no_methods: 'No payment methods available',
|
||||
select_method: 'Please select a payment method',
|
||||
enter_amount: 'Please enter the amount',
|
||||
upload_screenshot: 'Please upload a screenshot',
|
||||
submit_failed: 'Submit failed, please retry',
|
||||
file_must_be_image: 'Please upload an image file',
|
||||
file_too_large: 'File exceeds 10MB',
|
||||
status_pending: 'Processing',
|
||||
status_approved: 'Approved',
|
||||
status_rejected: 'Rejected',
|
||||
no_orders: 'No recharge records',
|
||||
credited: 'Credited',
|
||||
reject_reason: 'Rejection reason',
|
||||
apply_time: 'Apply time',
|
||||
review_time: 'Review time',
|
||||
remark: 'Remark',
|
||||
},
|
||||
cashback: {
|
||||
title: 'Cashback Details',
|
||||
list_title: 'Payout details',
|
||||
total_received: 'Total cashback',
|
||||
record_count: '{n} record(s)',
|
||||
period: 'Period',
|
||||
effective_stake: 'Effective stake',
|
||||
bet_count: '{n} bet(s)',
|
||||
empty: 'No cashback records yet',
|
||||
empty_hint: 'Cashback is issued by the platform after each settlement period.',
|
||||
ledger_hint: 'Matches wallet entries under the Cashback filter; amounts are the same.',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: 'Bet Slip',
|
||||
stake: 'Stake',
|
||||
place_bet: 'Place Bet',
|
||||
place_bet_short: 'Bet',
|
||||
parlay: 'Parlay',
|
||||
tab_matches: 'Matches',
|
||||
tab_outright: 'Outright',
|
||||
tab_parlay: 'Parlay',
|
||||
tab_today: 'Today',
|
||||
tab_early: 'Early',
|
||||
show_open_only: 'Open only',
|
||||
show_all_matches: 'Show all',
|
||||
today: 'Today',
|
||||
loading: 'Loading…',
|
||||
no_matches: 'No matches',
|
||||
outright_coming: 'Outright markets coming soon',
|
||||
outright_enter_stake: 'Enter stake',
|
||||
outright_balance: 'Balance',
|
||||
outright_stake_amount: 'Stake',
|
||||
outright_success: 'Bet placed',
|
||||
outright_done: 'Done',
|
||||
outright_bet_failed: 'Bet failed',
|
||||
outright_insufficient: 'Insufficient balance',
|
||||
stake_label: 'Stake',
|
||||
stake_placeholder: 'Enter amount',
|
||||
stake_max: 'Max',
|
||||
placing: 'Placing…',
|
||||
no_outright: 'No outright markets',
|
||||
no_outright_hint: 'Sign in as a player. If empty, ask admin to publish outright events.',
|
||||
outright_events_summary: '{events} outright events · {teams} teams',
|
||||
outright_teams_count: '{n} teams',
|
||||
outright_load_failed: 'Failed to load outright markets',
|
||||
outright_player_only: 'Player login required',
|
||||
outright_shown_count: '{shown} / {total} teams shown',
|
||||
outright_load_more: 'Load more',
|
||||
cancel: 'Cancel',
|
||||
parlay_title: 'Parlay',
|
||||
parlay_guide_title: 'How to parlay',
|
||||
parlay_guide_help: 'Parlay help',
|
||||
parlay_desc: 'Combine 2–5 pre-match legs (2-fold to 5-fold). No live, outright, or quarter-ball HDP/O-U in parlay.',
|
||||
parlay_guide_1: 'Tap odds in the list; selected cells show a gold border. Tap again to remove',
|
||||
parlay_guide_2: 'Pick 2–5 legs from different matches. No outright or quarter-ball HDP/O-U',
|
||||
parlay_guide_3: 'Tap Confirm order at the bottom, enter stake in the bet slip, and submit',
|
||||
parlay_max_legs: 'Parlay allows up to 5 legs',
|
||||
parlay_block_outright: 'Outright cannot be parlayed',
|
||||
parlay_block_quarter: 'Quarter-ball HDP/O-U cannot be parlayed',
|
||||
parlay_block_not_allowed: 'This market cannot be parlayed',
|
||||
parlay_filter_all: 'All',
|
||||
parlay_empty: 'No matches available for parlay betting',
|
||||
parlay_same_match: 'Cannot parlay selections from the same match',
|
||||
parlay_same_match_singles: '{n} selection(s) → {n} separate single bet(s)',
|
||||
parlay_confirm_singles: 'Place {n} single bet(s)',
|
||||
parlay_confirm_parlay: 'Place parlay',
|
||||
parlay_need_more: 'Select at least 2 legs for parlay',
|
||||
back: 'Back',
|
||||
refresh: 'Refresh',
|
||||
download: 'Download',
|
||||
reward_active: 'Reward active!',
|
||||
market_closed: 'Not open',
|
||||
match_phase_closed_pending: 'Closed pending',
|
||||
match_phase_settled: 'Settled',
|
||||
view_match: 'View match',
|
||||
expand_market: 'Expand',
|
||||
collapse_market: 'Collapse',
|
||||
market_cs: 'Correct Score',
|
||||
market_ht_cs: '1H Correct Score',
|
||||
market_sh_cs: '2H Correct Score',
|
||||
market_ft_handicap: 'FT Handicap',
|
||||
market_ft_ou: 'FT O/U',
|
||||
market_ft_1x2: 'FT 1X2',
|
||||
market_ft_oe: 'FT Odd/Even',
|
||||
market_ht_handicap: 'HT Handicap',
|
||||
market_ht_ou: 'HT O/U',
|
||||
market_ht_1x2: 'HT 1X2',
|
||||
parlay_lbl_handicap: 'Handicap',
|
||||
parlay_lbl_ou: 'O/U',
|
||||
parlay_lbl_1x2: '1X2',
|
||||
parlay_lbl_oe: 'Odd/Even',
|
||||
parlay_sel_home: 'H',
|
||||
parlay_sel_away: 'A',
|
||||
parlay_sel_draw: 'D',
|
||||
parlay_sel_over: 'O',
|
||||
parlay_sel_under: 'U',
|
||||
parlay_sel_odd: 'Odd',
|
||||
parlay_sel_even: 'Even',
|
||||
cs_other_home: 'Home win (other score)',
|
||||
cs_other_draw: 'Draw (other score)',
|
||||
cs_other_away: 'Away win (other score)',
|
||||
col_home: 'Home',
|
||||
col_draw: 'Draw',
|
||||
col_away: 'Away',
|
||||
cs_stake_required: 'Enter stake on at least one score',
|
||||
cs_confirm_title: 'Confirm correct score bets',
|
||||
cs_confirm_count: '{n} bet(s)',
|
||||
cs_confirm_total_stake: 'Total stake',
|
||||
cs_place_success: 'Bet placed',
|
||||
cs_place_failed: 'Bet failed',
|
||||
kickoff_time: 'Kickoff: ',
|
||||
guide_title: 'How to bet',
|
||||
guide_help_aria: 'Betting help',
|
||||
guide_got_it: 'Got it',
|
||||
guide_flow_normal: 'Handicap / O-U / 1X2 etc.',
|
||||
guide_normal_1: 'Tap Expand to show odds',
|
||||
guide_normal_2: 'Tap one odds to select (gold border); tap again to cancel',
|
||||
guide_normal_3: 'Tap Place order under that market, enter stake and confirm',
|
||||
guide_flow_cs: 'Correct score',
|
||||
guide_cs_1: 'Expand and enter stake on each score',
|
||||
guide_cs_2: 'Enter stakes, then tap Place order at the bottom of that market',
|
||||
guide_cs_3: 'Multiple scores = multiple bets',
|
||||
guide_flow_parlay: 'Parlay (2–5 legs)',
|
||||
guide_parlay_1: 'This page is for singles and correct score. Parlay: tap Bet in the bottom nav, then the Parlay tab at the top of that page, pick 2–5 different matches, and submit from the bet slip.',
|
||||
guide_rules_link: 'Full rules: Profile → Betting Rules.',
|
||||
mode_cs_tag: 'Bet here',
|
||||
mode_slip_tag: 'Add to slip',
|
||||
cs_confirm_btn: 'Confirm bet',
|
||||
cs_confirm_cell: 'Place order',
|
||||
cs_panel_hint: 'Enter stakes below, then tap Confirm bet above',
|
||||
slip_panel_hint: 'Tap odds to add; use the bottom bar when done',
|
||||
slip_pick_hint: 'Tap to add/remove from slip; gold border = selected',
|
||||
picked_tag: 'Selected',
|
||||
pick_added: 'Added to bet slip',
|
||||
pick_removed: 'Removed from bet slip',
|
||||
slip_bar_ready: '1 selection',
|
||||
slip_bar_go: 'Bet slip',
|
||||
cs_top_hint: '① Enter stake ② Tap Confirm bet above',
|
||||
slip_empty_hint: 'Tap odds to add to bet slip',
|
||||
slip_remove: 'Remove',
|
||||
slip_singles_hint: '{n} single bet(s). Parlay: Bet page → top Parlay tab.',
|
||||
slip_stake_per_bet: 'Stake per bet',
|
||||
slip_est_return: 'Est. total return',
|
||||
slip_parlay_odds: 'Combined odds {odds}',
|
||||
place_success: 'Bet placed',
|
||||
place_failed: 'Bet failed',
|
||||
},
|
||||
profile: {
|
||||
edit: 'Edit Profile',
|
||||
language: 'Language',
|
||||
avatar: 'Avatar',
|
||||
avatar_change: 'Change avatar',
|
||||
avatar_confirm: 'Confirm',
|
||||
section_contact: 'Contact',
|
||||
section_account: 'Account',
|
||||
change_password: 'Change password',
|
||||
show_password: 'Show',
|
||||
hide_password: 'Hide',
|
||||
password_unavailable: '••••••••',
|
||||
password_unavailable_hint: 'Password not available; contact support to reset',
|
||||
section_password: 'Change password (optional)',
|
||||
avatar_hint: 'Choose from built-in player portraits',
|
||||
avatar_search: 'Search player, position or country',
|
||||
avatar_empty: 'No players found',
|
||||
phone: 'Phone',
|
||||
email: 'Email',
|
||||
phone_placeholder: 'Phone number',
|
||||
email_placeholder: 'Email address',
|
||||
save: 'Save',
|
||||
password_optional_hint: 'Leave password fields blank to keep current password',
|
||||
old_password_placeholder: 'Leave blank to skip',
|
||||
new_password_placeholder: 'Leave blank to skip',
|
||||
confirm_password_placeholder: 'Leave blank to skip',
|
||||
old_password: 'Current password',
|
||||
new_password: 'New password',
|
||||
confirm_password: 'Confirm password',
|
||||
back: 'Back',
|
||||
saved: 'Contact saved',
|
||||
save_failed: 'Save failed',
|
||||
password_changed: 'Password updated',
|
||||
password_failed: 'Password change failed',
|
||||
password_mismatch: 'Passwords do not match',
|
||||
password_incomplete: 'Fill current, new and confirm password to change password',
|
||||
username_placeholder: 'Login username',
|
||||
username_readonly_hint: 'Username is managed by admin; contact support to change',
|
||||
username_updated: 'Username updated',
|
||||
password_disabled: 'Password change is disabled for this account; contact support',
|
||||
rules_title: 'Betting Rules',
|
||||
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
|
||||
rules_p2: 'Parlays: 2–5 legs from different matches (one per match). Outright and quarter-ball HDP/O-U are excluded.',
|
||||
rules_p3: 'Results use admin-entered half-time and full-time scores; payouts apply after settlement preview is confirmed.',
|
||||
rules_p4: 'If this text conflicts with site notices, the latest notice and market rules prevail.',
|
||||
rules_p5: 'How to bet: open any match, tap the ? icon on the top right.',
|
||||
},
|
||||
} as const;
|
||||
43
apps/player/src/i18n/index.ts
Normal file
43
apps/player/src/i18n/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Locale } from '@thebet365/shared';
|
||||
|
||||
export const PLAYER_LOCALE_STORAGE_KEY = 'locale';
|
||||
|
||||
export type PlayerLocale = Locale;
|
||||
|
||||
const localeLoaders: Record<PlayerLocale, () => Promise<{ default: Record<string, unknown> }>> = {
|
||||
'zh-CN': () => import('./zh-CN'),
|
||||
'en-US': () => import('./en-US'),
|
||||
'ms-MY': () => import('./ms-MY'),
|
||||
};
|
||||
|
||||
export async function loadLocaleMessages(locale: PlayerLocale) {
|
||||
const loader = localeLoaders[locale] ?? localeLoaders['zh-CN'];
|
||||
const mod = await loader();
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
const loadedLocales = new Set<PlayerLocale>();
|
||||
|
||||
export function markLocaleLoaded(locale: PlayerLocale) {
|
||||
loadedLocales.add(locale);
|
||||
}
|
||||
|
||||
/** 切换语言时按需加载文案包(与首屏单语言拆包配合) */
|
||||
export async function ensurePlayerLocale(
|
||||
composer: {
|
||||
mergeLocaleMessage: (locale: string, message: Record<string, unknown>) => void;
|
||||
availableLocales: string[];
|
||||
},
|
||||
locale: PlayerLocale,
|
||||
) {
|
||||
if (loadedLocales.has(locale) || composer.availableLocales.includes(locale)) return;
|
||||
const messages = await loadLocaleMessages(locale);
|
||||
composer.mergeLocaleMessage(locale, messages);
|
||||
loadedLocales.add(locale);
|
||||
}
|
||||
|
||||
export function readStoredLocale(): PlayerLocale {
|
||||
const raw = localStorage.getItem(PLAYER_LOCALE_STORAGE_KEY) || 'zh-CN';
|
||||
if (raw in localeLoaders) return raw as PlayerLocale;
|
||||
return 'zh-CN';
|
||||
}
|
||||
413
apps/player/src/i18n/ms-MY.ts
Normal file
413
apps/player/src/i18n/ms-MY.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
export default {
|
||||
common: {
|
||||
pull_refresh: 'Tarik untuk segar',
|
||||
release_refresh: 'Lepas untuk segar',
|
||||
refreshing: 'Menyegarkan…',
|
||||
loading_more: 'Memuat lagi…',
|
||||
no_more: 'Tiada lagi',
|
||||
load_failed: 'Gagal dimuat',
|
||||
retry: 'Cuba lagi',
|
||||
},
|
||||
nav: {
|
||||
home: 'Laman Utama',
|
||||
bet: 'Pertaruhan',
|
||||
bet_history: 'Sejarah',
|
||||
wallet: 'Bil',
|
||||
profile: 'Profil',
|
||||
},
|
||||
home: {
|
||||
hot_matches: 'Perlawanan popular',
|
||||
no_matches: 'Tiada perlawanan',
|
||||
announcement_badge: 'Notis',
|
||||
announcement_default:
|
||||
'Selamat datang ke TheBet365 · Perlawanan bola sepak sedang berlangsung · Bertaruh secara bertanggungjawab',
|
||||
banner_prev: 'Slaid sebelumnya',
|
||||
banner_next: 'Slaid seterusnya',
|
||||
banner_slide: 'Slaid {n}',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
history: {
|
||||
league_default: 'Bola Sepak',
|
||||
stake: 'Jumlah',
|
||||
return: 'Pulangan',
|
||||
est_return: 'Anggaran pulangan',
|
||||
odds: 'Odds',
|
||||
ft: 'PT',
|
||||
ht: 'SP',
|
||||
parlay_title: 'Berganda · {n} perlawanan',
|
||||
parlay_league: 'Berganda',
|
||||
empty: 'Tiada rekod pertaruhan',
|
||||
no_more: 'Tiada lagi rekod',
|
||||
status_won: 'MENANG',
|
||||
status_pending: 'MENUNGGU',
|
||||
status_lost: 'KALAH',
|
||||
status_push: 'SERI',
|
||||
back: 'Kembali',
|
||||
not_found: 'Pertaruhan tidak dijumpai',
|
||||
my_pick: 'Pilihan saya',
|
||||
my_bets: 'Pertaruhan saya',
|
||||
legs: 'Butiran berganda',
|
||||
summary: 'Ringkasan',
|
||||
bet_no: 'ID Pertaruhan',
|
||||
awaiting_result: 'Menunggu keputusan…',
|
||||
filter_all: 'Semua',
|
||||
filter_won: 'Menang',
|
||||
filter_lost: 'Kalah',
|
||||
filter_pending: 'Menunggu',
|
||||
filter_push: 'Seri',
|
||||
stats_total: 'Jumlah',
|
||||
stats_won: 'Menang',
|
||||
stats_lost: 'Kalah',
|
||||
stats_pending: 'Menunggu',
|
||||
stats_push: 'Seri',
|
||||
stats_stake: 'Jumlah Taruhan',
|
||||
stats_return: 'Jumlah Pulangan',
|
||||
cashbacked: 'Rebat dibayar',
|
||||
},
|
||||
auth: {
|
||||
login: 'Log Masuk',
|
||||
register: 'Daftar Akaun',
|
||||
logout: 'Log Keluar',
|
||||
username: 'Nama Pengguna',
|
||||
password: 'Kata Laluan',
|
||||
invite_code: 'Kod Jemputan',
|
||||
optional: 'Pilihan',
|
||||
captcha_placeholder: 'Kod',
|
||||
captcha_refresh: 'Klik untuk muat semula',
|
||||
captcha_wrong: 'Kod captcha salah',
|
||||
slide_to_verify: 'Gelongsor untuk mengesahkan',
|
||||
click_to_verify: 'Klik untuk mengesahkan',
|
||||
verified: 'Disahkan',
|
||||
login_required: 'Sila Log Masuk',
|
||||
login_hint: 'Log masuk untuk bertaruh dan akses lebih banyak ciri',
|
||||
go_login: 'Pergi log masuk',
|
||||
go_register: 'Tiada akaun? Daftar sekarang',
|
||||
have_account: 'Sudah ada akaun? Log masuk',
|
||||
register_btn: 'Daftar',
|
||||
register_failed: 'Pendaftaran gagal, sila cuba lagi',
|
||||
continue_browsing: 'Langkau log masuk',
|
||||
username_placeholder: 'Masukkan nama pengguna',
|
||||
username_register_placeholder: '7-32 huruf atau digit',
|
||||
username_required: 'Nama pengguna diperlukan',
|
||||
username_format_invalid: 'Nama pengguna mesti 7-32 huruf atau digit',
|
||||
login_account: 'Telefon / Akaun',
|
||||
login_by_phone: 'Log masuk dengan telefon',
|
||||
login_by_account: 'Log masuk dengan akaun',
|
||||
login_account_placeholder: 'Nombor telefon atau akaun',
|
||||
login_username_placeholder: 'Nombor telefon atau akaun',
|
||||
confirm_password: 'Sahkan kata laluan',
|
||||
password_mismatch: 'Kata laluan tidak sepadan',
|
||||
password_placeholder: 'Masukkan kata laluan',
|
||||
login_btn: 'Log Masuk',
|
||||
login_failed: 'Log masuk gagal, sila cuba lagi',
|
||||
phone: 'Telefon',
|
||||
phone_placeholder: 'Masukkan nombor telefon',
|
||||
phone_local_placeholder: 'Masukkan nombor telefon',
|
||||
phone_required: 'Nombor telefon diperlukan',
|
||||
phone_invalid: 'Format nombor telefon tidak sah',
|
||||
phone_country_unsupported: 'Negara atau wilayah ini tidak disokong',
|
||||
sms_code: 'Kod SMS',
|
||||
sms_code_placeholder: 'Kod 6 digit',
|
||||
sms_code_required: 'Sila masukkan kod SMS',
|
||||
sms_required: 'Sila minta kod SMS dahulu',
|
||||
send_sms: 'Dapatkan Kod',
|
||||
resend_sms: 'Cuba lagi dalam {sec}s',
|
||||
country_search: 'Cari negara atau kod',
|
||||
country_not_found: 'Tiada negara sepadan',
|
||||
},
|
||||
support: {
|
||||
short: 'Sokongan',
|
||||
title: 'Khidmat Pelanggan',
|
||||
open: 'Buka khidmat pelanggan',
|
||||
close: 'Tutup',
|
||||
url_pending: 'Pautan khidmat pelanggan belum dikonfigurasi.',
|
||||
},
|
||||
wallet: {
|
||||
balance: 'Baki',
|
||||
cash_balance: 'Baki Tunai',
|
||||
card_holder: 'Pemegang',
|
||||
unsettled: 'Belum Selesai',
|
||||
available: 'Tersedia',
|
||||
no_records: 'Tiada rekod',
|
||||
tx_deposit: 'Deposit',
|
||||
tx_admin_deposit: 'Tambah baki admin',
|
||||
tx_agent_deposit: 'Tambah baki ejen',
|
||||
tx_player_deposit: 'Deposit sendiri',
|
||||
tx_withdraw: 'Pengeluaran',
|
||||
tx_admin_withdraw: 'Pengeluaran admin',
|
||||
tx_agent_withdraw: 'Pengeluaran ejen',
|
||||
tx_adjust: 'Pelarasan Manual',
|
||||
tx_bet_freeze: 'Pertaruhan Ditahan',
|
||||
tx_bet_deduct: 'Pertaruhan Ditolak',
|
||||
tx_bet_win: 'Bayaran Pertaruhan',
|
||||
tx_bet_lose: 'Pertaruhan Selesai',
|
||||
tx_bet_push: 'Pertaruhan Seri',
|
||||
tx_bet_refund: 'Bayaran Balik',
|
||||
tx_bet_void: 'Pertaruhan Dibatalkan',
|
||||
tx_cashback: 'Kredit rebat',
|
||||
tx_resettle: 'Penyelesaian Semula',
|
||||
summary_bet: 'Pertaruhan {betNo}',
|
||||
summary_opening_bonus: 'Bonus pembukaan',
|
||||
stats_income: 'Pendapatan',
|
||||
stats_expense: 'Perbelanjaan',
|
||||
stats_net: 'Bersih',
|
||||
stats_cashback: 'Rebat',
|
||||
filter_all: 'Semua',
|
||||
filter_deposit: 'Deposit',
|
||||
filter_withdraw: 'Pengeluaran',
|
||||
filter_bet: 'Pertaruhan',
|
||||
filter_cashback: 'Rebat',
|
||||
view_all: 'Lihat semua transaksi',
|
||||
detail_summary: 'Butiran',
|
||||
detail_amount: 'Jumlah',
|
||||
detail_balance_before: 'Baki Sebelum',
|
||||
detail_balance_after: 'Baki Selepas',
|
||||
detail_frozen_before: 'Beku Sebelum',
|
||||
detail_frozen_after: 'Beku Selepas',
|
||||
detail_reference: 'Rujukan',
|
||||
detail_reference_type: 'Jenis',
|
||||
detail_reference_id: 'ID Rujukan',
|
||||
detail_remark: 'Catatan',
|
||||
detail_bet_link: 'Lihat Pertaruhan',
|
||||
detail_tx_id: 'ID Transaksi',
|
||||
detail_not_found: 'Rekod tidak dijumpai',
|
||||
ref_bet: 'Pertaruhan',
|
||||
ref_deposit: 'Deposit',
|
||||
ref_withdraw: 'Pengeluaran',
|
||||
view_cashbacks: 'Butiran rebat',
|
||||
view_cashbacks_detail: 'Lihat butiran rebat (tempoh/kadar)',
|
||||
cashback_filter_hint: 'Senarai ini ialah kredit dompet; tempoh dan kadar ada di butiran rebat.',
|
||||
ref_cashback: 'Batch rebat',
|
||||
detail_cashback_link: 'Lihat butiran rebat',
|
||||
},
|
||||
recharge: {
|
||||
title: 'Topup',
|
||||
history: 'Sejarah',
|
||||
history_title: 'Sejarah Topup',
|
||||
bank_transfer: 'Pindahan Bank',
|
||||
bank_name: 'Nama Bank',
|
||||
account_holder: 'Pemegang Akaun',
|
||||
account_number: 'Nombor Akaun',
|
||||
usdt_address: 'Alamat USDT',
|
||||
amount_label: 'Jumlah',
|
||||
amount_placeholder: 'Masukkan jumlah topup',
|
||||
screenshot_label: 'Muat Naik Screenshot',
|
||||
upload_hint: 'Klik untuk muat naik (maks 5MB)',
|
||||
compressing: 'Memampat',
|
||||
submit: 'Hantar',
|
||||
submitting: 'Menghantar',
|
||||
submitted: 'Topup Dihantar',
|
||||
pending_review: 'Admin sedang menyemak, sila tunggu',
|
||||
new_recharge: 'Topup Baru',
|
||||
no_methods: 'Tiada kaedah pembayaran tersedia',
|
||||
select_method: 'Sila pilih kaedah pembayaran',
|
||||
enter_amount: 'Sila masukkan jumlah',
|
||||
upload_screenshot: 'Sila muat naik screenshot',
|
||||
submit_failed: 'Gagal, sila cuba lagi',
|
||||
file_must_be_image: 'Sila muat naik fail imej',
|
||||
file_too_large: 'Fail melebihi 10MB',
|
||||
status_pending: 'Memproses',
|
||||
status_approved: 'Diluluskan',
|
||||
status_rejected: 'Ditolak',
|
||||
no_orders: 'Tiada rekod topup',
|
||||
credited: 'Dikreditkan',
|
||||
reject_reason: 'Sebab penolakan',
|
||||
apply_time: 'Masa permohonan',
|
||||
review_time: 'Masa semakan',
|
||||
remark: 'Catatan',
|
||||
},
|
||||
cashback: {
|
||||
title: 'Butiran Rebat',
|
||||
list_title: 'Butiran pembayaran',
|
||||
total_received: 'Jumlah rebat',
|
||||
record_count: '{n} rekod',
|
||||
period: 'Tempoh',
|
||||
effective_stake: 'Pertaruhan sah',
|
||||
bet_count: '{n} pertaruhan',
|
||||
empty: 'Tiada rekod rebat',
|
||||
empty_hint: 'Rebat dikeluarkan oleh platform mengikut kitaran penyelesaian.',
|
||||
ledger_hint: 'Sepadan dengan entri dompet di penapis Rebat; jumlah adalah sama.',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: 'Slip Pertaruhan',
|
||||
stake: 'Jumlah',
|
||||
place_bet: 'Letak Pertaruhan',
|
||||
place_bet_short: 'Pertaruhan',
|
||||
parlay: 'Berganda',
|
||||
tab_matches: 'Perlawanan',
|
||||
tab_outright: 'Juara',
|
||||
tab_parlay: 'Berganda',
|
||||
tab_today: 'Hari Ini',
|
||||
tab_early: 'Awal',
|
||||
show_open_only: 'Buka sahaja',
|
||||
show_all_matches: 'Tunjuk semua',
|
||||
today: 'Hari Ini',
|
||||
loading: 'Memuatkan…',
|
||||
no_matches: 'Tiada perlawanan',
|
||||
outright_coming: 'Pasaran juara akan datang',
|
||||
outright_enter_stake: 'Masukkan jumlah',
|
||||
outright_balance: 'Baki',
|
||||
outright_stake_amount: 'Jumlah pertaruhan',
|
||||
outright_success: 'Pertaruhan berjaya',
|
||||
outright_done: 'Selesai',
|
||||
outright_bet_failed: 'Pertaruhan gagal',
|
||||
outright_insufficient: 'Baki tidak mencukupi',
|
||||
stake_label: 'Jumlah',
|
||||
stake_placeholder: 'Masukkan jumlah',
|
||||
stake_max: 'Maks',
|
||||
placing: 'Memproses…',
|
||||
no_outright: 'Tiada pasaran juara',
|
||||
no_outright_hint: 'Log masuk sebagai pemain. Jika kosong, minta admin terbitkan acara juara.',
|
||||
outright_events_summary: '{events} acara juara · {teams} pasukan',
|
||||
outright_teams_count: '{n} pasukan',
|
||||
outright_load_failed: 'Gagal memuatkan pasaran juara',
|
||||
outright_player_only: 'Log masuk pemain diperlukan',
|
||||
outright_shown_count: '{shown} / {total} pasukan dipaparkan',
|
||||
outright_load_more: 'Muat lagi',
|
||||
cancel: 'Batal',
|
||||
parlay_title: 'Pertaruhan Berganda',
|
||||
parlay_guide_title: 'Cara parlay',
|
||||
parlay_guide_help: 'Bantuan parlay',
|
||||
parlay_desc: 'Gabung 2–5 perlawanan pra-perlawanan (2 hingga 5 liputan). Tiada live, outright atau suku bola HDP/O-U.',
|
||||
parlay_guide_1: 'Ketik odds dalam senarai; pilihan dipilih ada sempadan emas. Ketik lagi untuk batal',
|
||||
parlay_guide_2: 'Pilih 2–5 pilihan dari perlawanan berbeza. Tiada outright atau suku bola HDP/O-U',
|
||||
parlay_guide_3: 'Ketik Sahkan pesanan di bawah, isi pegangan dalam slip, dan hantar',
|
||||
parlay_max_legs: 'Maksimum 5 pilihan parlay',
|
||||
parlay_block_outright: 'Outright tidak boleh parlay',
|
||||
parlay_block_quarter: 'HDP/O-U suku bola tidak boleh parlay',
|
||||
parlay_block_not_allowed: 'Pasaran ini tidak boleh parlay',
|
||||
parlay_filter_all: 'Semua',
|
||||
parlay_empty: 'Tiada perlawanan untuk pertaruhan berganda',
|
||||
parlay_same_match: 'Perlawanan sama tidak boleh berganda',
|
||||
parlay_same_match_singles: '{n} pilihan → {n} pertaruhan tunggal berasingan',
|
||||
parlay_confirm_singles: 'Sahkan {n} pertaruhan tunggal',
|
||||
parlay_confirm_parlay: 'Sahkan parlay',
|
||||
parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
|
||||
back: 'Kembali',
|
||||
refresh: 'Muat semula',
|
||||
download: 'Muat turun',
|
||||
reward_active: 'Ganjaran aktif!',
|
||||
market_closed: 'Belum dibuka',
|
||||
match_phase_closed_pending: 'Ditutup menunggu',
|
||||
match_phase_settled: 'Selesai',
|
||||
view_match: 'Lihat perlawanan',
|
||||
expand_market: 'Kembang',
|
||||
collapse_market: 'Tutup',
|
||||
market_cs: 'Skor Tepat',
|
||||
market_ht_cs: 'Skor Tepat PB1',
|
||||
market_sh_cs: 'Skor Tepat PB2',
|
||||
market_ft_handicap: 'Handicap Penuh',
|
||||
market_ft_ou: 'Atas/Bawah Penuh',
|
||||
market_ft_1x2: '1X2 Penuh',
|
||||
market_ft_oe: 'Ganjil/Genap Penuh',
|
||||
market_ht_handicap: 'Handicap Separuh',
|
||||
market_ht_ou: 'Atas/Bawah Separuh',
|
||||
market_ht_1x2: '1X2 Separuh',
|
||||
parlay_lbl_handicap: 'Handicap',
|
||||
parlay_lbl_ou: 'Atas/Bawah',
|
||||
parlay_lbl_1x2: '1X2',
|
||||
parlay_lbl_oe: 'Ganjil/Genap',
|
||||
parlay_sel_home: 'R',
|
||||
parlay_sel_away: 'P',
|
||||
parlay_sel_draw: 'S',
|
||||
parlay_sel_over: 'Atas',
|
||||
parlay_sel_under: 'Bwh',
|
||||
parlay_sel_odd: 'G',
|
||||
parlay_sel_even: 'Gn',
|
||||
cs_other_home: 'Menang rumah (skor lain)',
|
||||
cs_other_draw: 'Seri (skor lain)',
|
||||
cs_other_away: 'Menang pelawat (skor lain)',
|
||||
col_home: 'Home',
|
||||
col_draw: 'Seri',
|
||||
col_away: 'Away',
|
||||
cs_stake_required: 'Masukkan jumlah pada sekurang-kurangnya satu skor',
|
||||
cs_confirm_title: 'Sahkan pertaruhan skor tepat',
|
||||
cs_confirm_count: '{n} pertaruhan',
|
||||
cs_confirm_total_stake: 'Jumlah pertaruhan',
|
||||
cs_place_success: 'Pertaruhan berjaya',
|
||||
cs_place_failed: 'Pertaruhan gagal',
|
||||
kickoff_time: 'Masa mula: ',
|
||||
guide_title: 'Cara pertaruhan',
|
||||
guide_help_aria: 'Bantuan pertaruhan',
|
||||
guide_got_it: 'Faham',
|
||||
guide_flow_normal: 'Handicap / O-U / 1X2',
|
||||
guide_normal_1: 'Ketik Kembang untuk lihat odds',
|
||||
guide_normal_2: 'Pilih satu odds (sisi emas); ketik lagi untuk batal',
|
||||
guide_normal_3: 'Ketik Sahkan pesanan di bawah pasaran, isi jumlah dan sahkan',
|
||||
guide_flow_cs: 'Skor tepat',
|
||||
guide_cs_1: 'Kembang dan isi jumlah setiap skor',
|
||||
guide_cs_2: 'Isi jumlah, kemudian Sahkan pesanan di bawah pasaran itu',
|
||||
guide_cs_3: 'Beberapa skor = beberapa pertaruhan',
|
||||
guide_flow_parlay: 'Parlay (2–5 perlawanan)',
|
||||
guide_parlay_1: 'Halaman ini untuk tunggal dan skor tepat. Parlay: ketik Pertaruhan di nav bawah, kemudian tab Parlay di bahagian atas halaman, pilih 2–5 perlawanan berbeza, hantar dari slip.',
|
||||
guide_rules_link: 'Peraturan penuh: Profil → Peraturan Pertaruhan.',
|
||||
mode_cs_tag: 'Pertaruhan di sini',
|
||||
mode_slip_tag: 'Tambah ke slip',
|
||||
cs_confirm_btn: 'Sahkan pertaruhan',
|
||||
cs_confirm_cell: 'Sahkan pesanan',
|
||||
cs_panel_hint: 'Isi jumlah di bawah, kemudian Sahkan di atas',
|
||||
slip_panel_hint: 'Ketik odds; guna bar bawah apabila siap',
|
||||
slip_pick_hint: 'Ketik untuk tambah/buang; sisi emas = dipilih',
|
||||
picked_tag: 'Dipilih',
|
||||
pick_added: 'Ditambah ke slip',
|
||||
pick_removed: 'Dikeluarkan dari slip',
|
||||
slip_bar_ready: '1 pilihan',
|
||||
slip_bar_go: 'Buka slip',
|
||||
cs_top_hint: '① Isi jumlah ② Ketik Sahkan di atas',
|
||||
slip_empty_hint: 'Ketik odds untuk tambah ke slip',
|
||||
slip_remove: 'Buang',
|
||||
slip_singles_hint: '{n} pertaruhan tunggal. Parlay: halaman Pertaruhan → tab Berganda di atas.',
|
||||
slip_stake_per_bet: 'Jumlah setiap pertaruhan',
|
||||
slip_est_return: 'Anggaran pulangan',
|
||||
slip_parlay_odds: 'Odds gabungan {odds}',
|
||||
place_success: 'Pertaruhan berjaya',
|
||||
place_failed: 'Pertaruhan gagal',
|
||||
},
|
||||
profile: {
|
||||
edit: 'Edit Profil',
|
||||
language: 'Bahasa',
|
||||
avatar: 'Avatar',
|
||||
avatar_change: 'Tukar avatar',
|
||||
avatar_confirm: 'Sahkan',
|
||||
section_contact: 'Maklumat hubungan',
|
||||
section_account: 'Akaun',
|
||||
change_password: 'Tukar kata laluan',
|
||||
show_password: 'Lihat',
|
||||
hide_password: 'Sembunyi',
|
||||
password_unavailable: '••••••••',
|
||||
password_unavailable_hint: 'Kata laluan tidak tersedia; hubungi sokongan',
|
||||
section_password: 'Tukar kata laluan (pilihan)',
|
||||
avatar_hint: 'Pilih dari potret pemain terbina',
|
||||
avatar_search: 'Cari pemain, posisi atau negara',
|
||||
avatar_empty: 'Tiada pemain dijumpai',
|
||||
phone: 'Telefon',
|
||||
email: 'E-mel',
|
||||
phone_placeholder: 'Nombor telefon',
|
||||
email_placeholder: 'Alamat e-mel',
|
||||
save: 'Simpan',
|
||||
password_optional_hint: 'Biarkan kosong jika tidak mahu tukar kata laluan',
|
||||
old_password_placeholder: 'Biarkan kosong untuk langkau',
|
||||
new_password_placeholder: 'Biarkan kosong untuk langkau',
|
||||
confirm_password_placeholder: 'Biarkan kosong untuk langkau',
|
||||
old_password: 'Kata laluan semasa',
|
||||
new_password: 'Kata laluan baharu',
|
||||
confirm_password: 'Sahkan kata laluan',
|
||||
back: 'Kembali',
|
||||
saved: 'Hubungan disimpan',
|
||||
save_failed: 'Gagal simpan',
|
||||
password_changed: 'Kata laluan dikemas kini',
|
||||
password_failed: 'Gagal tukar kata laluan',
|
||||
password_mismatch: 'Kata laluan tidak sepadan',
|
||||
password_incomplete: 'Isi kata laluan semasa, baharu dan pengesahan untuk menukar',
|
||||
username_placeholder: 'Nama log masuk',
|
||||
username_readonly_hint: 'Nama akaun diurus admin; hubungi sokongan untuk ubah',
|
||||
username_updated: 'Nama akaun dikemas kini',
|
||||
password_disabled: 'Akaun ini tidak dibenarkan tukar kata laluan; hubungi sokongan',
|
||||
rules_title: 'Peraturan Pertaruhan',
|
||||
rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.',
|
||||
rules_p2: 'Parlay 2–5 pilihan, satu pilihan setiap perlawanan. Outright dan suku bola HDP/O-U tidak boleh parlay.',
|
||||
rules_p3: 'Keputusan berdasarkan skor separuh masa/penuh yang dimasukkan admin; bayaran selepas pratonton disahkan.',
|
||||
rules_p4: 'Jika bercanggah dengan notis laman, ikut notis terkini dan peraturan pasaran.',
|
||||
rules_p5: 'Langkah operasi: buka butiran perlawanan, ketik ikon ? di atas kanan.',
|
||||
},
|
||||
} as const;
|
||||
407
apps/player/src/i18n/zh-CN.ts
Normal file
407
apps/player/src/i18n/zh-CN.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
export default {
|
||||
common: {
|
||||
pull_refresh: '下拉刷新',
|
||||
release_refresh: '释放刷新',
|
||||
refreshing: '刷新中…',
|
||||
loading_more: '加载更多…',
|
||||
no_more: '没有更多了',
|
||||
load_failed: '加载失败',
|
||||
retry: '重试',
|
||||
},
|
||||
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
|
||||
home: {
|
||||
hot_matches: '热门赛事',
|
||||
no_matches: '暂无赛事',
|
||||
announcement_badge: '公告',
|
||||
announcement_default:
|
||||
'欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
|
||||
banner_prev: '上一张',
|
||||
banner_next: '下一张',
|
||||
banner_slide: '第 {n} 张',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
history: {
|
||||
league_default: '足球',
|
||||
stake: '投注',
|
||||
return: '回报',
|
||||
est_return: '预计回报',
|
||||
odds: '赔率',
|
||||
ft: '全场',
|
||||
ht: '半场',
|
||||
parlay_title: '串关 · {n} 场',
|
||||
parlay_league: '串关 Parlay',
|
||||
empty: '暂无投注记录',
|
||||
no_more: '没有更多记录了',
|
||||
status_won: '赢',
|
||||
status_pending: '待定',
|
||||
status_lost: '输',
|
||||
status_push: '走盘',
|
||||
back: '返回',
|
||||
not_found: '注单不存在',
|
||||
my_pick: '我的选择',
|
||||
my_bets: '我的投注',
|
||||
legs: '串关明细',
|
||||
summary: '投注摘要',
|
||||
bet_no: '注单号',
|
||||
awaiting_result: '等待比赛结果…',
|
||||
filter_all: '全部',
|
||||
filter_won: '已赢',
|
||||
filter_lost: '已输',
|
||||
filter_pending: '待定',
|
||||
filter_push: '走盘',
|
||||
stats_total: '总投注',
|
||||
stats_won: '赢',
|
||||
stats_lost: '输',
|
||||
stats_pending: '待定',
|
||||
stats_push: '走盘',
|
||||
stats_stake: '总投注额',
|
||||
stats_return: '总回报',
|
||||
cashbacked: '已回水',
|
||||
},
|
||||
auth: {
|
||||
login: '登录',
|
||||
register: '注册账号',
|
||||
logout: '退出登录',
|
||||
username: '账号',
|
||||
password: '密码',
|
||||
invite_code: '邀请码',
|
||||
optional: '选填',
|
||||
captcha_placeholder: '验证码',
|
||||
captcha_refresh: '点击换一张',
|
||||
captcha_wrong: '验证码错误',
|
||||
slide_to_verify: '向右滑动完成验证',
|
||||
click_to_verify: '点击验证',
|
||||
verified: '验证成功',
|
||||
login_required: '请先登录',
|
||||
login_hint: '登录后可下注及访问更多功能',
|
||||
go_login: '去登录',
|
||||
go_register: '没有账号?立即注册',
|
||||
have_account: '已有账号?去登录',
|
||||
register_btn: '注册',
|
||||
register_failed: '注册失败,请重试',
|
||||
continue_browsing: '暂不登录',
|
||||
username_placeholder: '请输入账号',
|
||||
username_register_placeholder: '7-32 位字母或数字',
|
||||
username_required: '请填写账号',
|
||||
username_format_invalid: '账号须为 7-32 位字母或数字',
|
||||
login_account: '手机号 / 账号',
|
||||
login_by_phone: '手机号登录',
|
||||
login_by_account: '账号登录',
|
||||
login_account_placeholder: '手机号或账号',
|
||||
login_username_placeholder: '手机号或账号',
|
||||
confirm_password: '确认密码',
|
||||
password_mismatch: '两次密码不一致',
|
||||
password_placeholder: '请输入密码',
|
||||
login_btn: '登录',
|
||||
login_failed: '登录失败,请重试',
|
||||
phone: '手机号',
|
||||
phone_placeholder: '请输入手机号',
|
||||
phone_local_placeholder: '请输入手机号',
|
||||
phone_required: '请填写手机号',
|
||||
phone_invalid: '手机号格式无效,请检查位数与号码',
|
||||
phone_country_unsupported: '暂不支持该国家/地区',
|
||||
sms_code: '短信验证码',
|
||||
sms_code_placeholder: '6 位验证码',
|
||||
sms_code_required: '请填写短信验证码',
|
||||
sms_required: '请先获取短信验证码',
|
||||
send_sms: '获取验证码',
|
||||
resend_sms: '{sec}s 后重试',
|
||||
country_search: '搜索国家或区号',
|
||||
country_not_found: '未找到匹配国家',
|
||||
},
|
||||
support: {
|
||||
short: '客服',
|
||||
title: '在线客服',
|
||||
open: '打开在线客服',
|
||||
close: '关闭',
|
||||
url_pending: '客服链接暂未配置,请联系管理员。',
|
||||
},
|
||||
wallet: {
|
||||
balance: '余额',
|
||||
cash_balance: '现金余额',
|
||||
card_holder: '持卡人',
|
||||
unsettled: '未结算',
|
||||
available: '可用',
|
||||
no_records: '暂无账单记录',
|
||||
tx_deposit: '充值',
|
||||
tx_admin_deposit: '管理员上分',
|
||||
tx_agent_deposit: '代理上分',
|
||||
tx_player_deposit: '自助充值',
|
||||
tx_withdraw: '人工提款',
|
||||
tx_admin_withdraw: '管理员下分',
|
||||
tx_agent_withdraw: '代理下分',
|
||||
tx_adjust: '人工调整',
|
||||
tx_bet_freeze: '投注冻结',
|
||||
tx_bet_deduct: '投注扣款',
|
||||
tx_bet_win: '投注派彩',
|
||||
tx_bet_lose: '投注结算',
|
||||
tx_bet_push: '投注退水',
|
||||
tx_bet_refund: '投注退款',
|
||||
tx_bet_void: '投注撤销',
|
||||
tx_cashback: '返水入账',
|
||||
tx_resettle: '重新结算',
|
||||
summary_bet: '注单 {betNo}',
|
||||
summary_opening_bonus: '开户赠金',
|
||||
stats_income: '收入',
|
||||
stats_expense: '支出',
|
||||
stats_net: '净额',
|
||||
stats_cashback: '反水',
|
||||
filter_all: '全部',
|
||||
filter_deposit: '充值',
|
||||
filter_withdraw: '提款',
|
||||
filter_bet: '投注',
|
||||
filter_cashback: '反水',
|
||||
view_all: '查看全部账单',
|
||||
detail_summary: '账务明细',
|
||||
detail_amount: '变动金额',
|
||||
detail_balance_before: '变动前余额',
|
||||
detail_balance_after: '变动后余额',
|
||||
detail_frozen_before: '变动前冻结',
|
||||
detail_frozen_after: '变动后冻结',
|
||||
detail_reference: '关联信息',
|
||||
detail_reference_type: '业务类型',
|
||||
detail_reference_id: '关联编号',
|
||||
detail_remark: '备注',
|
||||
detail_bet_link: '查看注单',
|
||||
detail_tx_id: '流水号',
|
||||
detail_not_found: '账单不存在',
|
||||
ref_bet: '投注',
|
||||
ref_deposit: '充值',
|
||||
ref_withdraw: '提款',
|
||||
view_cashbacks: '返水明细',
|
||||
view_cashbacks_detail: '查看返水周期明细',
|
||||
cashback_filter_hint: '此处为入账流水;周期、比例等详见返水明细。',
|
||||
detail_cashback_link: '查看返水明细',
|
||||
ref_cashback: '返水批次',
|
||||
},
|
||||
recharge: {
|
||||
title: '充值',
|
||||
history: '记录',
|
||||
history_title: '充值记录',
|
||||
bank_transfer: '银行转账',
|
||||
bank_name: '银行名称',
|
||||
account_holder: '账户名',
|
||||
account_number: '账号',
|
||||
usdt_address: 'USDT 地址',
|
||||
amount_label: '充值金额',
|
||||
amount_placeholder: '请输入充值金额',
|
||||
screenshot_label: '上传转账截图',
|
||||
upload_hint: '点击上传截图(最大 5MB)',
|
||||
compressing: '压缩中',
|
||||
submit: '提交充值',
|
||||
submitting: '提交中',
|
||||
submitted: '充值已提交',
|
||||
pending_review: '管理员正在审核,请耐心等待',
|
||||
new_recharge: '继续充值',
|
||||
no_methods: '暂无可用充值方式',
|
||||
select_method: '请选择充值方式',
|
||||
enter_amount: '请输入充值金额',
|
||||
upload_screenshot: '请上传转账截图',
|
||||
submit_failed: '提交失败,请重试',
|
||||
file_must_be_image: '请上传图片文件',
|
||||
file_too_large: '文件不能超过 10MB',
|
||||
status_pending: '充值中',
|
||||
status_approved: '已通过',
|
||||
status_rejected: '已拒绝',
|
||||
no_orders: '暂无充值记录',
|
||||
credited: '实际到账',
|
||||
reject_reason: '拒绝原因',
|
||||
apply_time: '申请时间',
|
||||
review_time: '审核时间',
|
||||
remark: '审核备注',
|
||||
},
|
||||
cashback: {
|
||||
title: '返水明细',
|
||||
list_title: '发放明细',
|
||||
total_received: '累计返水',
|
||||
record_count: '共 {n} 笔',
|
||||
period: '统计周期',
|
||||
effective_stake: '有效投注',
|
||||
bet_count: '{n} 笔注单',
|
||||
empty: '暂无返水记录',
|
||||
empty_hint: '返水由后台按周期统计并发放,到账后可在此查看。',
|
||||
ledger_hint: '每笔返水确认后,账单中会有对应的「返水入账」流水,金额一致。',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: '投注单',
|
||||
stake: '投注金额',
|
||||
place_bet: '确认下注',
|
||||
place_bet_short: '下注',
|
||||
parlay: '串关',
|
||||
tab_matches: '球赛',
|
||||
tab_outright: '优胜冠军',
|
||||
tab_parlay: '串关投注',
|
||||
tab_today: '今日',
|
||||
tab_early: '早盘',
|
||||
show_open_only: '仅显示待开赛',
|
||||
show_all_matches: '显示全部',
|
||||
today: '今日',
|
||||
loading: '加载中…',
|
||||
no_matches: '暂无赛事',
|
||||
outright_coming: '优胜冠军玩法即将上线',
|
||||
outright_enter_stake: '请输入投注金额',
|
||||
outright_balance: '结余',
|
||||
outright_stake_amount: '投注额度',
|
||||
outright_success: '下注成功',
|
||||
outright_done: '完毕',
|
||||
outright_bet_failed: '下注失败',
|
||||
outright_insufficient: '余额不足',
|
||||
stake_label: '投注金额',
|
||||
stake_placeholder: '输入金额',
|
||||
stake_max: '全部',
|
||||
placing: '提交中…',
|
||||
no_outright: '暂无冠军盘口',
|
||||
no_outright_hint: '请使用玩家账号登录;若仍无数据,请联系管理员在后台发布优胜冠军赛事',
|
||||
outright_events_summary: '共 {events} 个冠军赛事 · {teams} 支队伍',
|
||||
outright_teams_count: '{n} 支队伍',
|
||||
outright_load_failed: '冠军盘加载失败,请检查网络或稍后重试',
|
||||
outright_player_only: '请使用玩家账号登录后查看',
|
||||
outright_shown_count: '已显示 {shown} / {total} 队',
|
||||
outright_load_more: '加载更多',
|
||||
cancel: '取消',
|
||||
parlay_title: '串关投注',
|
||||
parlay_guide_title: '串关怎么投?',
|
||||
parlay_guide_help: '查看串关说明',
|
||||
parlay_desc: '选择 2–5 场赛前赛事组合串关(2 串 1 至 5 串 1)。赔率相乘,不含滚球、冠军盘与四分盘让球/大小。',
|
||||
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
|
||||
parlay_guide_2: '须选 2–5 项,且须为不同赛事;冠军盘与四分盘让球/大小不可选',
|
||||
parlay_guide_3: '选好后点底部「确认下单」打开投注单,填写金额并提交',
|
||||
parlay_max_legs: '串关最多 5 项',
|
||||
parlay_block_outright: '冠军盘不可串关',
|
||||
parlay_block_quarter: '四分盘让球/大小不可串关',
|
||||
parlay_block_not_allowed: '该玩法不可串关',
|
||||
parlay_filter_all: '全部',
|
||||
parlay_empty: '暂无可用串关赛事',
|
||||
parlay_same_match: '同一场比赛不能串关',
|
||||
parlay_same_match_singles: '已选 {n} 项,将分 {n} 笔单关下单',
|
||||
parlay_confirm_singles: '确认下单({n}笔单关)',
|
||||
parlay_confirm_parlay: '确认串关下单',
|
||||
parlay_need_more: '请至少选择 2 项进行串关',
|
||||
back: '返回',
|
||||
refresh: '刷新',
|
||||
download: '下载',
|
||||
reward_active: '奖励生效中!',
|
||||
market_closed: '暂未开盘',
|
||||
match_phase_closed_pending: '封盘待结算',
|
||||
match_phase_settled: '已结算',
|
||||
view_match: '查看赛况',
|
||||
expand_market: '展开玩法',
|
||||
collapse_market: '收起玩法',
|
||||
market_cs: '波胆',
|
||||
market_ht_cs: '上半场波胆',
|
||||
market_sh_cs: '下半场波胆',
|
||||
market_ft_handicap: '全场 让球',
|
||||
market_ft_ou: '全场 大小',
|
||||
market_ft_1x2: '全场 独赢盘',
|
||||
market_ft_oe: '全场 单/双',
|
||||
market_ht_handicap: '半场 让球',
|
||||
market_ht_ou: '半场 大小',
|
||||
market_ht_1x2: '半场 独赢盘',
|
||||
parlay_lbl_handicap: '让球',
|
||||
parlay_lbl_ou: '大小',
|
||||
parlay_lbl_1x2: '独赢盘',
|
||||
parlay_lbl_oe: '单/双',
|
||||
parlay_sel_home: '主',
|
||||
parlay_sel_away: '客',
|
||||
parlay_sel_draw: '和',
|
||||
parlay_sel_over: '大',
|
||||
parlay_sel_under: '小',
|
||||
parlay_sel_odd: '单',
|
||||
parlay_sel_even: '双',
|
||||
cs_other_home: '主胜其它比分',
|
||||
cs_other_draw: '和局其它比分',
|
||||
cs_other_away: '客胜其它比分',
|
||||
col_home: '主场',
|
||||
col_draw: '平',
|
||||
col_away: '客场',
|
||||
cs_stake_required: '请至少在一个比分输入投注金额',
|
||||
cs_confirm_title: '确认波胆下注',
|
||||
cs_confirm_count: '共 {n} 注',
|
||||
cs_confirm_total_stake: '总投注额',
|
||||
cs_place_success: '下注成功',
|
||||
cs_place_failed: '下注失败',
|
||||
kickoff_time: '开赛时间:',
|
||||
guide_title: '怎么下注?',
|
||||
guide_help_aria: '查看下注说明',
|
||||
guide_got_it: '知道了',
|
||||
guide_flow_normal: '让球 / 大小 / 独赢等',
|
||||
guide_normal_1: '点「展开玩法」打开赔率',
|
||||
guide_normal_2: '点一项赔率选中(金边),再点同一项可取消',
|
||||
guide_normal_3: '选中后在当前玩法底部点「确认下单」填金额并提交',
|
||||
guide_flow_cs: '波胆(猜比分)',
|
||||
guide_cs_1: '点「展开玩法」在表格里填各比分金额',
|
||||
guide_cs_2: '填好金额后点该玩法底部「确认下单」,核对后提交',
|
||||
guide_cs_3: '可一次填多个比分,会拆成多笔注单',
|
||||
guide_flow_parlay: '串关(2–5 场)',
|
||||
guide_parlay_1: '本页为单关/波胆。串关:底部导航点「投注」,在页面顶部切换到「串关投注」,选 2–5 场不同赛事后在投注单提交。',
|
||||
guide_rules_link: '完整规则见「我的」→ 投注规则。',
|
||||
mode_cs_tag: '本页直接下注',
|
||||
mode_slip_tag: '加入投注单',
|
||||
cs_confirm_btn: '确认下注',
|
||||
cs_confirm_cell: '确认下单',
|
||||
cs_panel_hint: '在下方表格填写金额,填好后点上方「确认下注」',
|
||||
slip_panel_hint: '点赔率加入投注单,选好后用页面底部入口打开投注单',
|
||||
slip_pick_hint: '点选项加入投注单;金边表示已选,再点一次可取消',
|
||||
picked_tag: '已选',
|
||||
pick_added: '已加入投注单',
|
||||
pick_removed: '已从投注单移除',
|
||||
slip_bar_ready: '已选一项',
|
||||
slip_bar_go: '投注单',
|
||||
cs_top_hint: '① 在比分格填金额 ② 点上方「确认下注」',
|
||||
slip_empty_hint: '点击赔率加入投注单',
|
||||
slip_remove: '移除',
|
||||
slip_singles_hint: '共 {n} 笔单关(串关请到「投注」页顶部「串关投注」)',
|
||||
slip_stake_per_bet: '每笔投注金额',
|
||||
slip_est_return: '预计总返还',
|
||||
slip_parlay_odds: '组合赔率 {odds}',
|
||||
place_success: '下注成功',
|
||||
place_failed: '下注失败',
|
||||
},
|
||||
profile: {
|
||||
edit: '修改资料',
|
||||
language: '语言',
|
||||
avatar: '选择头像',
|
||||
avatar_change: '修改头像',
|
||||
avatar_confirm: '确定',
|
||||
section_contact: '联系方式',
|
||||
section_account: '账号信息',
|
||||
change_password: '修改密码',
|
||||
show_password: '查看',
|
||||
hide_password: '隐藏',
|
||||
password_unavailable: '••••••••',
|
||||
password_unavailable_hint: '密码不可查看,如需重置请联系客服',
|
||||
section_password: '修改密码(可选)',
|
||||
avatar_hint: '从内置球员中选择头像',
|
||||
avatar_search: '搜索球员、位置或国家',
|
||||
avatar_empty: '未找到匹配球员',
|
||||
phone: '手机号',
|
||||
email: '邮箱',
|
||||
phone_placeholder: '请输入手机号',
|
||||
email_placeholder: '请输入邮箱',
|
||||
save: '保存',
|
||||
password_optional_hint: '不修改密码可留空',
|
||||
old_password_placeholder: '留空则不修改',
|
||||
new_password_placeholder: '留空则不修改',
|
||||
confirm_password_placeholder: '留空则不修改',
|
||||
old_password: '当前密码',
|
||||
new_password: '新密码',
|
||||
confirm_password: '确认新密码',
|
||||
back: '返回',
|
||||
saved: '联系方式已保存',
|
||||
save_failed: '保存失败',
|
||||
password_changed: '密码已更新',
|
||||
password_failed: '密码修改失败',
|
||||
password_mismatch: '两次新密码不一致',
|
||||
password_incomplete: '修改密码需填写当前密码、新密码及确认密码',
|
||||
username_placeholder: '登录账号名',
|
||||
username_readonly_hint: '账号名称由后台管理,如需修改请联系客服',
|
||||
username_updated: '账号名称已更新',
|
||||
password_disabled: '当前账号不允许自行修改密码,请联系客服',
|
||||
rules_title: '投注规则',
|
||||
rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。',
|
||||
rules_p2: '串关为 2 串 1 至 5 串 1,每场最多选 1 项;冠军盘、四分盘让球/大小不可进入串关。',
|
||||
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
|
||||
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
|
||||
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
|
||||
},
|
||||
} as const;
|
||||
@@ -3,15 +3,18 @@ import { RouterView, RouterLink, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import BetSlipDrawer from '../components/BetSlipDrawer.vue';
|
||||
import CashBalanceChip from '../components/CashBalanceChip.vue';
|
||||
import UserAvatarMenu from '../components/UserAvatarMenu.vue';
|
||||
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
|
||||
import BottomNavIcon from '../components/BottomNavIcon.vue';
|
||||
import CustomerServiceModal from '../components/CustomerServiceModal.vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
|
||||
const CustomerServiceModal = defineAsyncComponent(
|
||||
() => import('../components/CustomerServiceModal.vue'),
|
||||
);
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
|
||||
@@ -62,7 +65,7 @@ watch(
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.user?.locale) initFromUser(auth.user.locale);
|
||||
if (auth.user?.locale) void initFromUser(auth.user.locale);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -121,7 +124,7 @@ watch(
|
||||
|
||||
<main ref="mainRef" :class="['main', { 'has-nav': showBottomNav }]">
|
||||
<RouterView v-slot="{ Component, route: viewRoute }">
|
||||
<KeepAlive v-if="viewRoute.meta.keepAlive">
|
||||
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="3">
|
||||
<component :is="Component" :key="viewRoute.path" />
|
||||
</KeepAlive>
|
||||
<component v-else :is="Component" :key="viewRoute.fullPath" />
|
||||
@@ -173,9 +176,7 @@ watch(
|
||||
flex-shrink: 0;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: rgba(26, 26, 26, 0.65);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
background: rgba(17, 17, 17, 0.94);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 110;
|
||||
}
|
||||
@@ -270,9 +271,7 @@ watch(
|
||||
}
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
background: rgba(17, 17, 17, 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: rgba(17, 17, 17, 0.96);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.35);
|
||||
z-index: 100;
|
||||
|
||||
@@ -4,1227 +4,28 @@ import { createI18n } from 'vue-i18n';
|
||||
import App from './App.vue';
|
||||
import router from './router/index.ts';
|
||||
import './styles.css';
|
||||
import {
|
||||
loadLocaleMessages,
|
||||
markLocaleLoaded,
|
||||
readStoredLocale,
|
||||
type PlayerLocale,
|
||||
} from './i18n/index.ts';
|
||||
|
||||
async function bootstrap() {
|
||||
const initialLocale = readStoredLocale();
|
||||
const initialMessages = await loadLocaleMessages(initialLocale);
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: localStorage.getItem('locale') || 'zh-CN',
|
||||
locale: initialLocale,
|
||||
fallbackLocale: ['en-US', 'zh-CN'],
|
||||
messages: {
|
||||
'zh-CN': {
|
||||
common: {
|
||||
pull_refresh: '下拉刷新',
|
||||
release_refresh: '释放刷新',
|
||||
refreshing: '刷新中…',
|
||||
loading_more: '加载更多…',
|
||||
no_more: '没有更多了',
|
||||
load_failed: '加载失败',
|
||||
retry: '重试',
|
||||
},
|
||||
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
|
||||
home: {
|
||||
hot_matches: '热门赛事',
|
||||
no_matches: '暂无赛事',
|
||||
announcement_badge: '公告',
|
||||
announcement_default:
|
||||
'欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
|
||||
banner_prev: '上一张',
|
||||
banner_next: '下一张',
|
||||
banner_slide: '第 {n} 张',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
history: {
|
||||
league_default: '足球',
|
||||
stake: '投注',
|
||||
return: '回报',
|
||||
est_return: '预计回报',
|
||||
odds: '赔率',
|
||||
ft: '全场',
|
||||
ht: '半场',
|
||||
parlay_title: '串关 · {n} 场',
|
||||
parlay_league: '串关 Parlay',
|
||||
empty: '暂无投注记录',
|
||||
no_more: '没有更多记录了',
|
||||
status_won: '赢',
|
||||
status_pending: '待定',
|
||||
status_lost: '输',
|
||||
status_push: '走盘',
|
||||
back: '返回',
|
||||
not_found: '注单不存在',
|
||||
my_pick: '我的选择',
|
||||
my_bets: '我的投注',
|
||||
legs: '串关明细',
|
||||
summary: '投注摘要',
|
||||
bet_no: '注单号',
|
||||
awaiting_result: '等待比赛结果…',
|
||||
filter_all: '全部',
|
||||
filter_won: '已赢',
|
||||
filter_lost: '已输',
|
||||
filter_pending: '待定',
|
||||
filter_push: '走盘',
|
||||
stats_total: '总投注',
|
||||
stats_won: '赢',
|
||||
stats_lost: '输',
|
||||
stats_pending: '待定',
|
||||
stats_push: '走盘',
|
||||
stats_stake: '总投注额',
|
||||
stats_return: '总回报',
|
||||
cashbacked: '已回水',
|
||||
},
|
||||
auth: {
|
||||
login: '登录',
|
||||
register: '注册账号',
|
||||
logout: '退出登录',
|
||||
username: '账号',
|
||||
password: '密码',
|
||||
invite_code: '邀请码',
|
||||
optional: '选填',
|
||||
captcha_placeholder: '验证码',
|
||||
captcha_refresh: '点击换一张',
|
||||
captcha_wrong: '验证码错误',
|
||||
slide_to_verify: '向右滑动完成验证',
|
||||
click_to_verify: '点击验证',
|
||||
verified: '验证成功',
|
||||
login_required: '请先登录',
|
||||
login_hint: '登录后可下注及访问更多功能',
|
||||
go_login: '去登录',
|
||||
go_register: '没有账号?立即注册',
|
||||
have_account: '已有账号?去登录',
|
||||
register_btn: '注册',
|
||||
register_failed: '注册失败,请重试',
|
||||
continue_browsing: '暂不登录',
|
||||
username_placeholder: '请输入账号',
|
||||
login_account: '手机号 / 账号',
|
||||
login_account_placeholder: '本地号码或账号',
|
||||
login_username_placeholder: '手机号(含区号)或账号',
|
||||
confirm_password: '确认密码',
|
||||
password_mismatch: '两次密码不一致',
|
||||
password_placeholder: '请输入密码',
|
||||
login_btn: '登录',
|
||||
login_failed: '登录失败,请重试',
|
||||
phone: '手机号',
|
||||
phone_placeholder: '请输入手机号',
|
||||
phone_local_placeholder: '请输入手机号',
|
||||
phone_required: '请填写手机号',
|
||||
phone_invalid: '手机号格式无效,请检查位数与号码',
|
||||
phone_country_unsupported: '暂不支持该国家/地区',
|
||||
sms_code: '短信验证码',
|
||||
sms_code_placeholder: '6 位验证码',
|
||||
sms_code_required: '请填写短信验证码',
|
||||
sms_required: '请先获取短信验证码',
|
||||
send_sms: '获取验证码',
|
||||
resend_sms: '{sec}s 后重试',
|
||||
country_search: '搜索国家或区号',
|
||||
country_not_found: '未找到匹配国家',
|
||||
},
|
||||
support: {
|
||||
short: '客服',
|
||||
title: '在线客服',
|
||||
open: '打开在线客服',
|
||||
close: '关闭',
|
||||
url_pending: '客服链接暂未配置,请联系管理员。',
|
||||
},
|
||||
wallet: {
|
||||
balance: '余额',
|
||||
cash_balance: '现金余额',
|
||||
card_holder: '持卡人',
|
||||
unsettled: '未结算',
|
||||
available: '可用',
|
||||
no_records: '暂无账单记录',
|
||||
tx_deposit: '充值',
|
||||
tx_admin_deposit: '管理员上分',
|
||||
tx_agent_deposit: '代理上分',
|
||||
tx_player_deposit: '自助充值',
|
||||
tx_withdraw: '人工提款',
|
||||
tx_admin_withdraw: '管理员下分',
|
||||
tx_agent_withdraw: '代理下分',
|
||||
tx_adjust: '人工调整',
|
||||
tx_bet_freeze: '投注冻结',
|
||||
tx_bet_deduct: '投注扣款',
|
||||
tx_bet_win: '投注派彩',
|
||||
tx_bet_lose: '投注结算',
|
||||
tx_bet_push: '投注退水',
|
||||
tx_bet_refund: '投注退款',
|
||||
tx_bet_void: '投注撤销',
|
||||
tx_cashback: '返水入账',
|
||||
tx_resettle: '重新结算',
|
||||
summary_bet: '注单 {betNo}',
|
||||
summary_opening_bonus: '开户赠金',
|
||||
stats_income: '收入',
|
||||
stats_expense: '支出',
|
||||
stats_net: '净额',
|
||||
stats_cashback: '反水',
|
||||
filter_all: '全部',
|
||||
filter_deposit: '充值',
|
||||
filter_withdraw: '提款',
|
||||
filter_bet: '投注',
|
||||
filter_cashback: '反水',
|
||||
view_all: '查看全部账单',
|
||||
detail_summary: '账务明细',
|
||||
detail_amount: '变动金额',
|
||||
detail_balance_before: '变动前余额',
|
||||
detail_balance_after: '变动后余额',
|
||||
detail_frozen_before: '变动前冻结',
|
||||
detail_frozen_after: '变动后冻结',
|
||||
detail_reference: '关联信息',
|
||||
detail_reference_type: '业务类型',
|
||||
detail_reference_id: '关联编号',
|
||||
detail_remark: '备注',
|
||||
detail_bet_link: '查看注单',
|
||||
detail_tx_id: '流水号',
|
||||
detail_not_found: '账单不存在',
|
||||
ref_bet: '投注',
|
||||
ref_deposit: '充值',
|
||||
ref_withdraw: '提款',
|
||||
view_cashbacks: '返水明细',
|
||||
view_cashbacks_detail: '查看返水周期明细',
|
||||
cashback_filter_hint: '此处为入账流水;周期、比例等详见返水明细。',
|
||||
detail_cashback_link: '查看返水明细',
|
||||
ref_cashback: '返水批次',
|
||||
},
|
||||
recharge: {
|
||||
title: '充值',
|
||||
history: '记录',
|
||||
history_title: '充值记录',
|
||||
bank_transfer: '银行转账',
|
||||
bank_name: '银行名称',
|
||||
account_holder: '账户名',
|
||||
account_number: '账号',
|
||||
usdt_address: 'USDT 地址',
|
||||
amount_label: '充值金额',
|
||||
amount_placeholder: '请输入充值金额',
|
||||
screenshot_label: '上传转账截图',
|
||||
upload_hint: '点击上传截图(最大 5MB)',
|
||||
compressing: '压缩中',
|
||||
submit: '提交充值',
|
||||
submitting: '提交中',
|
||||
submitted: '充值已提交',
|
||||
pending_review: '管理员正在审核,请耐心等待',
|
||||
new_recharge: '继续充值',
|
||||
no_methods: '暂无可用充值方式',
|
||||
select_method: '请选择充值方式',
|
||||
enter_amount: '请输入充值金额',
|
||||
upload_screenshot: '请上传转账截图',
|
||||
submit_failed: '提交失败,请重试',
|
||||
file_must_be_image: '请上传图片文件',
|
||||
file_too_large: '文件不能超过 10MB',
|
||||
status_pending: '充值中',
|
||||
status_approved: '已通过',
|
||||
status_rejected: '已拒绝',
|
||||
no_orders: '暂无充值记录',
|
||||
credited: '实际到账',
|
||||
reject_reason: '拒绝原因',
|
||||
apply_time: '申请时间',
|
||||
review_time: '审核时间',
|
||||
remark: '审核备注',
|
||||
},
|
||||
cashback: {
|
||||
title: '返水明细',
|
||||
list_title: '发放明细',
|
||||
total_received: '累计返水',
|
||||
record_count: '共 {n} 笔',
|
||||
period: '统计周期',
|
||||
effective_stake: '有效投注',
|
||||
bet_count: '{n} 笔注单',
|
||||
empty: '暂无返水记录',
|
||||
empty_hint: '返水由后台按周期统计并发放,到账后可在此查看。',
|
||||
ledger_hint: '每笔返水确认后,账单中会有对应的「返水入账」流水,金额一致。',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: '投注单',
|
||||
stake: '投注金额',
|
||||
place_bet: '确认下注',
|
||||
place_bet_short: '下注',
|
||||
parlay: '串关',
|
||||
tab_matches: '球赛',
|
||||
tab_outright: '优胜冠军',
|
||||
tab_parlay: '串关投注',
|
||||
tab_today: '今日',
|
||||
tab_early: '早盘',
|
||||
show_open_only: '仅显示待开赛',
|
||||
show_all_matches: '显示全部',
|
||||
today: '今日',
|
||||
loading: '加载中…',
|
||||
no_matches: '暂无赛事',
|
||||
outright_coming: '优胜冠军玩法即将上线',
|
||||
outright_enter_stake: '请输入投注金额',
|
||||
outright_balance: '结余',
|
||||
outright_stake_amount: '投注额度',
|
||||
outright_success: '下注成功',
|
||||
outright_done: '完毕',
|
||||
outright_bet_failed: '下注失败',
|
||||
outright_insufficient: '余额不足',
|
||||
stake_label: '投注金额',
|
||||
stake_placeholder: '输入金额',
|
||||
stake_max: '全部',
|
||||
placing: '提交中…',
|
||||
no_outright: '暂无冠军盘口',
|
||||
no_outright_hint: '请使用玩家账号登录;若仍无数据,请联系管理员在后台发布优胜冠军赛事',
|
||||
outright_events_summary: '共 {events} 个冠军赛事 · {teams} 支队伍',
|
||||
outright_teams_count: '{n} 支队伍',
|
||||
outright_load_failed: '冠军盘加载失败,请检查网络或稍后重试',
|
||||
outright_player_only: '请使用玩家账号登录后查看',
|
||||
outright_shown_count: '已显示 {shown} / {total} 队',
|
||||
outright_load_more: '加载更多',
|
||||
cancel: '取消',
|
||||
parlay_title: '串关投注',
|
||||
parlay_guide_title: '串关怎么投?',
|
||||
parlay_guide_help: '查看串关说明',
|
||||
parlay_desc: '选择 2–5 场赛前赛事组合串关(2 串 1 至 5 串 1)。赔率相乘,不含滚球、冠军盘与四分盘让球/大小。',
|
||||
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
|
||||
parlay_guide_2: '须选 2–5 项,且须为不同赛事;冠军盘与四分盘让球/大小不可选',
|
||||
parlay_guide_3: '选好后点底部「确认下单」打开投注单,填写金额并提交',
|
||||
parlay_max_legs: '串关最多 5 项',
|
||||
parlay_block_outright: '冠军盘不可串关',
|
||||
parlay_block_quarter: '四分盘让球/大小不可串关',
|
||||
parlay_block_not_allowed: '该玩法不可串关',
|
||||
parlay_filter_all: '全部',
|
||||
parlay_empty: '暂无可用串关赛事',
|
||||
parlay_same_match: '同一场比赛不能串关',
|
||||
parlay_same_match_singles: '已选 {n} 项,将分 {n} 笔单关下单',
|
||||
parlay_confirm_singles: '确认下单({n}笔单关)',
|
||||
parlay_confirm_parlay: '确认串关下单',
|
||||
parlay_need_more: '请至少选择 2 项进行串关',
|
||||
back: '返回',
|
||||
refresh: '刷新',
|
||||
download: '下载',
|
||||
reward_active: '奖励生效中!',
|
||||
market_closed: '暂未开盘',
|
||||
match_phase_closed_pending: '封盘待结算',
|
||||
match_phase_settled: '已结算',
|
||||
view_match: '查看赛况',
|
||||
expand_market: '展开玩法',
|
||||
collapse_market: '收起玩法',
|
||||
market_cs: '波胆',
|
||||
market_ht_cs: '上半场波胆',
|
||||
market_sh_cs: '下半场波胆',
|
||||
market_ft_handicap: '全场 让球',
|
||||
market_ft_ou: '全场 大小',
|
||||
market_ft_1x2: '全场 独赢盘',
|
||||
market_ft_oe: '全场 单/双',
|
||||
market_ht_handicap: '半场 让球',
|
||||
market_ht_ou: '半场 大小',
|
||||
market_ht_1x2: '半场 独赢盘',
|
||||
parlay_lbl_handicap: '让球',
|
||||
parlay_lbl_ou: '大小',
|
||||
parlay_lbl_1x2: '独赢盘',
|
||||
parlay_lbl_oe: '单/双',
|
||||
parlay_sel_home: '主',
|
||||
parlay_sel_away: '客',
|
||||
parlay_sel_draw: '和',
|
||||
parlay_sel_over: '大',
|
||||
parlay_sel_under: '小',
|
||||
parlay_sel_odd: '单',
|
||||
parlay_sel_even: '双',
|
||||
cs_other_home: '主胜其它比分',
|
||||
cs_other_draw: '和局其它比分',
|
||||
cs_other_away: '客胜其它比分',
|
||||
col_home: '主场',
|
||||
col_draw: '平',
|
||||
col_away: '客场',
|
||||
cs_stake_required: '请至少在一个比分输入投注金额',
|
||||
cs_confirm_title: '确认波胆下注',
|
||||
cs_confirm_count: '共 {n} 注',
|
||||
cs_confirm_total_stake: '总投注额',
|
||||
cs_place_success: '下注成功',
|
||||
cs_place_failed: '下注失败',
|
||||
kickoff_time: '开赛时间:',
|
||||
guide_title: '怎么下注?',
|
||||
guide_help_aria: '查看下注说明',
|
||||
guide_got_it: '知道了',
|
||||
guide_flow_normal: '让球 / 大小 / 独赢等',
|
||||
guide_normal_1: '点「展开玩法」打开赔率',
|
||||
guide_normal_2: '点一项赔率选中(金边),再点同一项可取消',
|
||||
guide_normal_3: '选中后在当前玩法底部点「确认下单」填金额并提交',
|
||||
guide_flow_cs: '波胆(猜比分)',
|
||||
guide_cs_1: '点「展开玩法」在表格里填各比分金额',
|
||||
guide_cs_2: '填好金额后点该玩法底部「确认下单」,核对后提交',
|
||||
guide_cs_3: '可一次填多个比分,会拆成多笔注单',
|
||||
guide_flow_parlay: '串关(2–5 场)',
|
||||
guide_parlay_1: '本页为单关/波胆。串关:底部导航点「投注」,在页面顶部切换到「串关投注」,选 2–5 场不同赛事后在投注单提交。',
|
||||
guide_rules_link: '完整规则见「我的」→ 投注规则。',
|
||||
mode_cs_tag: '本页直接下注',
|
||||
mode_slip_tag: '加入投注单',
|
||||
cs_confirm_btn: '确认下注',
|
||||
cs_confirm_cell: '确认下单',
|
||||
cs_panel_hint: '在下方表格填写金额,填好后点上方「确认下注」',
|
||||
slip_panel_hint: '点赔率加入投注单,选好后用页面底部入口打开投注单',
|
||||
slip_pick_hint: '点选项加入投注单;金边表示已选,再点一次可取消',
|
||||
picked_tag: '已选',
|
||||
pick_added: '已加入投注单',
|
||||
pick_removed: '已从投注单移除',
|
||||
slip_bar_ready: '已选一项',
|
||||
slip_bar_go: '投注单',
|
||||
cs_top_hint: '① 在比分格填金额 ② 点上方「确认下注」',
|
||||
slip_empty_hint: '点击赔率加入投注单',
|
||||
slip_remove: '移除',
|
||||
slip_singles_hint: '共 {n} 笔单关(串关请到「投注」页顶部「串关投注」)',
|
||||
slip_stake_per_bet: '每笔投注金额',
|
||||
slip_est_return: '预计总返还',
|
||||
slip_parlay_odds: '组合赔率 {odds}',
|
||||
place_success: '下注成功',
|
||||
place_failed: '下注失败',
|
||||
},
|
||||
profile: {
|
||||
edit: '修改资料',
|
||||
language: '语言',
|
||||
avatar: '选择头像',
|
||||
avatar_change: '修改头像',
|
||||
avatar_confirm: '确定',
|
||||
section_contact: '联系方式',
|
||||
section_account: '账号信息',
|
||||
change_password: '修改密码',
|
||||
show_password: '查看',
|
||||
hide_password: '隐藏',
|
||||
password_unavailable: '••••••••',
|
||||
password_unavailable_hint: '密码不可查看,如需重置请联系客服',
|
||||
section_password: '修改密码(可选)',
|
||||
avatar_hint: '从内置球员中选择头像',
|
||||
avatar_search: '搜索球员、位置或国家',
|
||||
avatar_empty: '未找到匹配球员',
|
||||
phone: '手机号',
|
||||
email: '邮箱',
|
||||
phone_placeholder: '请输入手机号',
|
||||
email_placeholder: '请输入邮箱',
|
||||
save: '保存',
|
||||
password_optional_hint: '不修改密码可留空',
|
||||
old_password_placeholder: '留空则不修改',
|
||||
new_password_placeholder: '留空则不修改',
|
||||
confirm_password_placeholder: '留空则不修改',
|
||||
old_password: '当前密码',
|
||||
new_password: '新密码',
|
||||
confirm_password: '确认新密码',
|
||||
back: '返回',
|
||||
saved: '联系方式已保存',
|
||||
save_failed: '保存失败',
|
||||
password_changed: '密码已更新',
|
||||
password_failed: '密码修改失败',
|
||||
password_mismatch: '两次新密码不一致',
|
||||
password_incomplete: '修改密码需填写当前密码、新密码及确认密码',
|
||||
username_placeholder: '登录账号名',
|
||||
username_readonly_hint: '账号名称由后台管理,如需修改请联系客服',
|
||||
username_updated: '账号名称已更新',
|
||||
password_disabled: '当前账号不允许自行修改密码,请联系客服',
|
||||
rules_title: '投注规则',
|
||||
rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。',
|
||||
rules_p2: '串关为 2 串 1 至 5 串 1,每场最多选 1 项;冠军盘、四分盘让球/大小不可进入串关。',
|
||||
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
|
||||
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
|
||||
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
|
||||
},
|
||||
},
|
||||
'en-US': {
|
||||
common: {
|
||||
pull_refresh: 'Pull to refresh',
|
||||
release_refresh: 'Release to refresh',
|
||||
refreshing: 'Refreshing…',
|
||||
loading_more: 'Loading more…',
|
||||
no_more: 'No more',
|
||||
load_failed: 'Failed to load',
|
||||
retry: 'Retry',
|
||||
},
|
||||
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
|
||||
home: {
|
||||
hot_matches: 'Hot matches',
|
||||
no_matches: 'No matches',
|
||||
announcement_badge: 'Notice',
|
||||
announcement_default:
|
||||
'Welcome to TheBet365 · Football events are live · Bet responsibly',
|
||||
banner_prev: 'Previous slide',
|
||||
banner_next: 'Next slide',
|
||||
banner_slide: 'Slide {n}',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
history: {
|
||||
league_default: 'Football',
|
||||
stake: 'Stake',
|
||||
return: 'Return',
|
||||
est_return: 'Est. Return',
|
||||
odds: 'Odds',
|
||||
ft: 'FT',
|
||||
ht: 'HT',
|
||||
parlay_title: 'Parlay · {n} legs',
|
||||
parlay_league: 'Parlay',
|
||||
empty: 'No bets yet',
|
||||
no_more: 'No more bets',
|
||||
status_won: 'WON',
|
||||
status_pending: 'PENDING',
|
||||
status_lost: 'LOST',
|
||||
status_push: 'PUSH',
|
||||
back: 'Back',
|
||||
not_found: 'Bet not found',
|
||||
my_pick: 'My pick',
|
||||
my_bets: 'My bets',
|
||||
legs: 'Parlay legs',
|
||||
summary: 'Summary',
|
||||
bet_no: 'Bet ID',
|
||||
awaiting_result: 'Awaiting result…',
|
||||
filter_all: 'All',
|
||||
filter_won: 'Won',
|
||||
filter_lost: 'Lost',
|
||||
filter_pending: 'Pending',
|
||||
filter_push: 'Push',
|
||||
stats_total: 'Total',
|
||||
stats_won: 'Won',
|
||||
stats_lost: 'Lost',
|
||||
stats_pending: 'Pending',
|
||||
stats_push: 'Push',
|
||||
stats_stake: 'Total Stake',
|
||||
stats_return: 'Total Return',
|
||||
cashbacked: 'Cashbacked',
|
||||
},
|
||||
auth:
|
||||
{ login: 'Login',
|
||||
register: 'Create Account',
|
||||
logout: 'Log out',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
invite_code: 'Invitation Code',
|
||||
optional: 'Optional',
|
||||
captcha_placeholder: 'Code',
|
||||
captcha_refresh: 'Click to refresh',
|
||||
captcha_wrong: 'Incorrect captcha code',
|
||||
slide_to_verify: 'Slide to verify',
|
||||
click_to_verify: 'Click to verify',
|
||||
verified: 'Verified',
|
||||
login_required: 'Login Required',
|
||||
login_hint: 'Log in to place bets and access more features',
|
||||
go_login: 'Go to login',
|
||||
go_register: 'No account? Register now',
|
||||
have_account: 'Already have an account? Log in',
|
||||
register_btn: 'Register',
|
||||
register_failed: 'Registration failed, please try again',
|
||||
continue_browsing: 'Skip login',
|
||||
username_placeholder: 'Enter username',
|
||||
login_account: 'Phone / Username',
|
||||
login_account_placeholder: 'Local number or username',
|
||||
login_username_placeholder: 'Registered phone (with country code) or username',
|
||||
confirm_password: 'Confirm password',
|
||||
password_mismatch: 'Passwords do not match',
|
||||
password_placeholder: 'Enter password',
|
||||
login_btn: 'Log In',
|
||||
login_failed: 'Login failed, please try again',
|
||||
phone: 'Phone',
|
||||
phone_placeholder: 'Enter phone number',
|
||||
phone_local_placeholder: 'Enter phone number',
|
||||
phone_required: 'Phone number is required',
|
||||
phone_invalid: 'Invalid phone number format',
|
||||
phone_country_unsupported: 'This country or region is not supported',
|
||||
sms_code: 'SMS Code',
|
||||
sms_code_placeholder: '6-digit code',
|
||||
sms_code_required: 'Please enter the SMS code',
|
||||
sms_required: 'Please request an SMS code first',
|
||||
send_sms: 'Get Code',
|
||||
resend_sms: 'Retry in {sec}s',
|
||||
country_search: 'Search country or code',
|
||||
country_not_found: 'No matching country',
|
||||
},
|
||||
support: {
|
||||
short: 'Support',
|
||||
title: 'Customer Support',
|
||||
open: 'Open customer support',
|
||||
close: 'Close',
|
||||
url_pending: 'Support URL is not configured yet.',
|
||||
},
|
||||
wallet: {
|
||||
balance: 'Balance',
|
||||
cash_balance: 'Cash Balance',
|
||||
card_holder: 'Cardholder',
|
||||
unsettled: 'Unsettled',
|
||||
available: 'Available',
|
||||
no_records: 'No records',
|
||||
tx_deposit: 'Deposit',
|
||||
tx_admin_deposit: 'Admin top-up',
|
||||
tx_agent_deposit: 'Agent top-up',
|
||||
tx_player_deposit: 'Self deposit',
|
||||
tx_withdraw: 'Withdrawal',
|
||||
tx_admin_withdraw: 'Admin withdraw',
|
||||
tx_agent_withdraw: 'Agent withdraw',
|
||||
tx_adjust: 'Manual Adjust',
|
||||
tx_bet_freeze: 'Bet Frozen',
|
||||
tx_bet_deduct: 'Bet Deducted',
|
||||
tx_bet_win: 'Bet Payout',
|
||||
tx_bet_lose: 'Bet Settled',
|
||||
tx_bet_push: 'Bet Push',
|
||||
tx_bet_refund: 'Bet Refund',
|
||||
tx_bet_void: 'Bet Voided',
|
||||
tx_cashback: 'Cashback credit',
|
||||
tx_resettle: 'Resettlement',
|
||||
summary_bet: 'Bet {betNo}',
|
||||
summary_opening_bonus: 'Opening bonus',
|
||||
stats_income: 'Income',
|
||||
stats_expense: 'Expense',
|
||||
stats_net: 'Net',
|
||||
stats_cashback: 'Cashback',
|
||||
filter_all: 'All',
|
||||
filter_deposit: 'Deposit',
|
||||
filter_withdraw: 'Withdraw',
|
||||
filter_bet: 'Bet',
|
||||
filter_cashback: 'Cashback',
|
||||
view_all: 'View all transactions',
|
||||
detail_summary: 'Details',
|
||||
detail_amount: 'Amount',
|
||||
detail_balance_before: 'Balance Before',
|
||||
detail_balance_after: 'Balance After',
|
||||
detail_frozen_before: 'Frozen Before',
|
||||
detail_frozen_after: 'Frozen After',
|
||||
detail_reference: 'Reference',
|
||||
detail_reference_type: 'Type',
|
||||
detail_reference_id: 'Reference ID',
|
||||
detail_remark: 'Remark',
|
||||
detail_bet_link: 'View Bet',
|
||||
detail_tx_id: 'Transaction ID',
|
||||
detail_not_found: 'Transaction not found',
|
||||
ref_bet: 'Bet',
|
||||
ref_deposit: 'Deposit',
|
||||
ref_withdraw: 'Withdraw',
|
||||
view_cashbacks: 'Cashback details',
|
||||
view_cashbacks_detail: 'View cashback details (period/rate)',
|
||||
cashback_filter_hint: 'This list shows wallet credits; see cashback details for period and rate.',
|
||||
ref_cashback: 'Cashback batch',
|
||||
detail_cashback_link: 'View cashback details',
|
||||
},
|
||||
recharge: {
|
||||
title: 'Recharge',
|
||||
history: 'History',
|
||||
history_title: 'Recharge History',
|
||||
bank_transfer: 'Bank Transfer',
|
||||
bank_name: 'Bank Name',
|
||||
account_holder: 'Account Holder',
|
||||
account_number: 'Account Number',
|
||||
usdt_address: 'USDT Address',
|
||||
amount_label: 'Amount',
|
||||
amount_placeholder: 'Enter recharge amount',
|
||||
screenshot_label: 'Upload Screenshot',
|
||||
upload_hint: 'Click to upload screenshot (max 5MB)',
|
||||
compressing: 'Compressing',
|
||||
submit: 'Submit',
|
||||
submitting: 'Submitting',
|
||||
submitted: 'Recharge Submitted',
|
||||
pending_review: 'Admin is reviewing, please wait',
|
||||
new_recharge: 'New Recharge',
|
||||
no_methods: 'No payment methods available',
|
||||
select_method: 'Please select a payment method',
|
||||
enter_amount: 'Please enter the amount',
|
||||
upload_screenshot: 'Please upload a screenshot',
|
||||
submit_failed: 'Submit failed, please retry',
|
||||
file_must_be_image: 'Please upload an image file',
|
||||
file_too_large: 'File exceeds 10MB',
|
||||
status_pending: 'Processing',
|
||||
status_approved: 'Approved',
|
||||
status_rejected: 'Rejected',
|
||||
no_orders: 'No recharge records',
|
||||
credited: 'Credited',
|
||||
reject_reason: 'Rejection reason',
|
||||
apply_time: 'Apply time',
|
||||
review_time: 'Review time',
|
||||
remark: 'Remark',
|
||||
},
|
||||
cashback: {
|
||||
title: 'Cashback Details',
|
||||
list_title: 'Payout details',
|
||||
total_received: 'Total cashback',
|
||||
record_count: '{n} record(s)',
|
||||
period: 'Period',
|
||||
effective_stake: 'Effective stake',
|
||||
bet_count: '{n} bet(s)',
|
||||
empty: 'No cashback records yet',
|
||||
empty_hint: 'Cashback is issued by the platform after each settlement period.',
|
||||
ledger_hint: 'Matches wallet entries under the Cashback filter; amounts are the same.',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: 'Bet Slip',
|
||||
stake: 'Stake',
|
||||
place_bet: 'Place Bet',
|
||||
place_bet_short: 'Bet',
|
||||
parlay: 'Parlay',
|
||||
tab_matches: 'Matches',
|
||||
tab_outright: 'Outright',
|
||||
tab_parlay: 'Parlay',
|
||||
tab_today: 'Today',
|
||||
tab_early: 'Early',
|
||||
show_open_only: 'Open only',
|
||||
show_all_matches: 'Show all',
|
||||
today: 'Today',
|
||||
loading: 'Loading…',
|
||||
no_matches: 'No matches',
|
||||
outright_coming: 'Outright markets coming soon',
|
||||
outright_enter_stake: 'Enter stake',
|
||||
outright_balance: 'Balance',
|
||||
outright_stake_amount: 'Stake',
|
||||
outright_success: 'Bet placed',
|
||||
outright_done: 'Done',
|
||||
outright_bet_failed: 'Bet failed',
|
||||
outright_insufficient: 'Insufficient balance',
|
||||
stake_label: 'Stake',
|
||||
stake_placeholder: 'Enter amount',
|
||||
stake_max: 'Max',
|
||||
placing: 'Placing…',
|
||||
no_outright: 'No outright markets',
|
||||
no_outright_hint: 'Sign in as a player. If empty, ask admin to publish outright events.',
|
||||
outright_events_summary: '{events} outright events · {teams} teams',
|
||||
outright_teams_count: '{n} teams',
|
||||
outright_load_failed: 'Failed to load outright markets',
|
||||
outright_player_only: 'Player login required',
|
||||
outright_shown_count: '{shown} / {total} teams shown',
|
||||
outright_load_more: 'Load more',
|
||||
cancel: 'Cancel',
|
||||
parlay_title: 'Parlay',
|
||||
parlay_guide_title: 'How to parlay',
|
||||
parlay_guide_help: 'Parlay help',
|
||||
parlay_desc: 'Combine 2–5 pre-match legs (2-fold to 5-fold). No live, outright, or quarter-ball HDP/O-U in parlay.',
|
||||
parlay_guide_1: 'Tap odds in the list; selected cells show a gold border. Tap again to remove',
|
||||
parlay_guide_2: 'Pick 2–5 legs from different matches. No outright or quarter-ball HDP/O-U',
|
||||
parlay_guide_3: 'Tap Confirm order at the bottom, enter stake in the bet slip, and submit',
|
||||
parlay_max_legs: 'Parlay allows up to 5 legs',
|
||||
parlay_block_outright: 'Outright cannot be parlayed',
|
||||
parlay_block_quarter: 'Quarter-ball HDP/O-U cannot be parlayed',
|
||||
parlay_block_not_allowed: 'This market cannot be parlayed',
|
||||
parlay_filter_all: 'All',
|
||||
parlay_empty: 'No matches available for parlay betting',
|
||||
parlay_same_match: 'Cannot parlay selections from the same match',
|
||||
parlay_same_match_singles: '{n} selection(s) → {n} separate single bet(s)',
|
||||
parlay_confirm_singles: 'Place {n} single bet(s)',
|
||||
parlay_confirm_parlay: 'Place parlay',
|
||||
parlay_need_more: 'Select at least 2 legs for parlay',
|
||||
back: 'Back',
|
||||
refresh: 'Refresh',
|
||||
download: 'Download',
|
||||
reward_active: 'Reward active!',
|
||||
market_closed: 'Not open',
|
||||
match_phase_closed_pending: 'Closed pending',
|
||||
match_phase_settled: 'Settled',
|
||||
view_match: 'View match',
|
||||
expand_market: 'Expand',
|
||||
collapse_market: 'Collapse',
|
||||
market_cs: 'Correct Score',
|
||||
market_ht_cs: '1H Correct Score',
|
||||
market_sh_cs: '2H Correct Score',
|
||||
market_ft_handicap: 'FT Handicap',
|
||||
market_ft_ou: 'FT O/U',
|
||||
market_ft_1x2: 'FT 1X2',
|
||||
market_ft_oe: 'FT Odd/Even',
|
||||
market_ht_handicap: 'HT Handicap',
|
||||
market_ht_ou: 'HT O/U',
|
||||
market_ht_1x2: 'HT 1X2',
|
||||
parlay_lbl_handicap: 'Handicap',
|
||||
parlay_lbl_ou: 'O/U',
|
||||
parlay_lbl_1x2: '1X2',
|
||||
parlay_lbl_oe: 'Odd/Even',
|
||||
parlay_sel_home: 'H',
|
||||
parlay_sel_away: 'A',
|
||||
parlay_sel_draw: 'D',
|
||||
parlay_sel_over: 'O',
|
||||
parlay_sel_under: 'U',
|
||||
parlay_sel_odd: 'Odd',
|
||||
parlay_sel_even: 'Even',
|
||||
cs_other_home: 'Home win (other score)',
|
||||
cs_other_draw: 'Draw (other score)',
|
||||
cs_other_away: 'Away win (other score)',
|
||||
col_home: 'Home',
|
||||
col_draw: 'Draw',
|
||||
col_away: 'Away',
|
||||
cs_stake_required: 'Enter stake on at least one score',
|
||||
cs_confirm_title: 'Confirm correct score bets',
|
||||
cs_confirm_count: '{n} bet(s)',
|
||||
cs_confirm_total_stake: 'Total stake',
|
||||
cs_place_success: 'Bet placed',
|
||||
cs_place_failed: 'Bet failed',
|
||||
kickoff_time: 'Kickoff: ',
|
||||
guide_title: 'How to bet',
|
||||
guide_help_aria: 'Betting help',
|
||||
guide_got_it: 'Got it',
|
||||
guide_flow_normal: 'Handicap / O-U / 1X2 etc.',
|
||||
guide_normal_1: 'Tap Expand to show odds',
|
||||
guide_normal_2: 'Tap one odds to select (gold border); tap again to cancel',
|
||||
guide_normal_3: 'Tap Place order under that market, enter stake and confirm',
|
||||
guide_flow_cs: 'Correct score',
|
||||
guide_cs_1: 'Expand and enter stake on each score',
|
||||
guide_cs_2: 'Enter stakes, then tap Place order at the bottom of that market',
|
||||
guide_cs_3: 'Multiple scores = multiple bets',
|
||||
guide_flow_parlay: 'Parlay (2–5 legs)',
|
||||
guide_parlay_1: 'This page is for singles and correct score. Parlay: tap Bet in the bottom nav, then the Parlay tab at the top of that page, pick 2–5 different matches, and submit from the bet slip.',
|
||||
guide_rules_link: 'Full rules: Profile → Betting Rules.',
|
||||
mode_cs_tag: 'Bet here',
|
||||
mode_slip_tag: 'Add to slip',
|
||||
cs_confirm_btn: 'Confirm bet',
|
||||
cs_confirm_cell: 'Place order',
|
||||
cs_panel_hint: 'Enter stakes below, then tap Confirm bet above',
|
||||
slip_panel_hint: 'Tap odds to add; use the bottom bar when done',
|
||||
slip_pick_hint: 'Tap to add/remove from slip; gold border = selected',
|
||||
picked_tag: 'Selected',
|
||||
pick_added: 'Added to bet slip',
|
||||
pick_removed: 'Removed from bet slip',
|
||||
slip_bar_ready: '1 selection',
|
||||
slip_bar_go: 'Bet slip',
|
||||
cs_top_hint: '① Enter stake ② Tap Confirm bet above',
|
||||
slip_empty_hint: 'Tap odds to add to bet slip',
|
||||
slip_remove: 'Remove',
|
||||
slip_singles_hint: '{n} single bet(s). Parlay: Bet page → top Parlay tab.',
|
||||
slip_stake_per_bet: 'Stake per bet',
|
||||
slip_est_return: 'Est. total return',
|
||||
slip_parlay_odds: 'Combined odds {odds}',
|
||||
place_success: 'Bet placed',
|
||||
place_failed: 'Bet failed',
|
||||
},
|
||||
profile: {
|
||||
edit: 'Edit Profile',
|
||||
language: 'Language',
|
||||
avatar: 'Avatar',
|
||||
avatar_change: 'Change avatar',
|
||||
avatar_confirm: 'Confirm',
|
||||
section_contact: 'Contact',
|
||||
section_account: 'Account',
|
||||
change_password: 'Change password',
|
||||
show_password: 'Show',
|
||||
hide_password: 'Hide',
|
||||
password_unavailable: '••••••••',
|
||||
password_unavailable_hint: 'Password not available; contact support to reset',
|
||||
section_password: 'Change password (optional)',
|
||||
avatar_hint: 'Choose from built-in player portraits',
|
||||
avatar_search: 'Search player, position or country',
|
||||
avatar_empty: 'No players found',
|
||||
phone: 'Phone',
|
||||
email: 'Email',
|
||||
phone_placeholder: 'Phone number',
|
||||
email_placeholder: 'Email address',
|
||||
save: 'Save',
|
||||
password_optional_hint: 'Leave password fields blank to keep current password',
|
||||
old_password_placeholder: 'Leave blank to skip',
|
||||
new_password_placeholder: 'Leave blank to skip',
|
||||
confirm_password_placeholder: 'Leave blank to skip',
|
||||
old_password: 'Current password',
|
||||
new_password: 'New password',
|
||||
confirm_password: 'Confirm password',
|
||||
back: 'Back',
|
||||
saved: 'Contact saved',
|
||||
save_failed: 'Save failed',
|
||||
password_changed: 'Password updated',
|
||||
password_failed: 'Password change failed',
|
||||
password_mismatch: 'Passwords do not match',
|
||||
password_incomplete: 'Fill current, new and confirm password to change password',
|
||||
username_placeholder: 'Login username',
|
||||
username_readonly_hint: 'Username is managed by admin; contact support to change',
|
||||
username_updated: 'Username updated',
|
||||
password_disabled: 'Password change is disabled for this account; contact support',
|
||||
rules_title: 'Betting Rules',
|
||||
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
|
||||
rules_p2: 'Parlays: 2–5 legs from different matches (one per match). Outright and quarter-ball HDP/O-U are excluded.',
|
||||
rules_p3: 'Results use admin-entered half-time and full-time scores; payouts apply after settlement preview is confirmed.',
|
||||
rules_p4: 'If this text conflicts with site notices, the latest notice and market rules prevail.',
|
||||
rules_p5: 'How to bet: open any match, tap the ? icon on the top right.',
|
||||
},
|
||||
},
|
||||
'ms-MY': {
|
||||
common: {
|
||||
pull_refresh: 'Tarik untuk segar',
|
||||
release_refresh: 'Lepas untuk segar',
|
||||
refreshing: 'Menyegarkan…',
|
||||
loading_more: 'Memuat lagi…',
|
||||
no_more: 'Tiada lagi',
|
||||
load_failed: 'Gagal dimuat',
|
||||
retry: 'Cuba lagi',
|
||||
},
|
||||
nav: {
|
||||
home: 'Laman Utama',
|
||||
bet: 'Pertaruhan',
|
||||
bet_history: 'Sejarah',
|
||||
wallet: 'Bil',
|
||||
profile: 'Profil',
|
||||
},
|
||||
home: {
|
||||
hot_matches: 'Perlawanan popular',
|
||||
no_matches: 'Tiada perlawanan',
|
||||
announcement_badge: 'Notis',
|
||||
announcement_default:
|
||||
'Selamat datang ke TheBet365 · Perlawanan bola sepak sedang berlangsung · Bertaruh secara bertanggungjawab',
|
||||
banner_prev: 'Slaid sebelumnya',
|
||||
banner_next: 'Slaid seterusnya',
|
||||
banner_slide: 'Slaid {n}',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
history: {
|
||||
league_default: 'Bola Sepak',
|
||||
stake: 'Jumlah',
|
||||
return: 'Pulangan',
|
||||
est_return: 'Anggaran pulangan',
|
||||
odds: 'Odds',
|
||||
ft: 'PT',
|
||||
ht: 'SP',
|
||||
parlay_title: 'Berganda · {n} perlawanan',
|
||||
parlay_league: 'Berganda',
|
||||
empty: 'Tiada rekod pertaruhan',
|
||||
no_more: 'Tiada lagi rekod',
|
||||
status_won: 'MENANG',
|
||||
status_pending: 'MENUNGGU',
|
||||
status_lost: 'KALAH',
|
||||
status_push: 'SERI',
|
||||
back: 'Kembali',
|
||||
not_found: 'Pertaruhan tidak dijumpai',
|
||||
my_pick: 'Pilihan saya',
|
||||
my_bets: 'Pertaruhan saya',
|
||||
legs: 'Butiran berganda',
|
||||
summary: 'Ringkasan',
|
||||
bet_no: 'ID Pertaruhan',
|
||||
awaiting_result: 'Menunggu keputusan…',
|
||||
filter_all: 'Semua',
|
||||
filter_won: 'Menang',
|
||||
filter_lost: 'Kalah',
|
||||
filter_pending: 'Menunggu',
|
||||
filter_push: 'Seri',
|
||||
stats_total: 'Jumlah',
|
||||
stats_won: 'Menang',
|
||||
stats_lost: 'Kalah',
|
||||
stats_pending: 'Menunggu',
|
||||
stats_push: 'Seri',
|
||||
stats_stake: 'Jumlah Taruhan',
|
||||
stats_return: 'Jumlah Pulangan',
|
||||
cashbacked: 'Rebat dibayar',
|
||||
},
|
||||
auth: {
|
||||
login: 'Log Masuk',
|
||||
register: 'Daftar Akaun',
|
||||
logout: 'Log Keluar',
|
||||
username: 'Nama Pengguna',
|
||||
password: 'Kata Laluan',
|
||||
invite_code: 'Kod Jemputan',
|
||||
optional: 'Pilihan',
|
||||
captcha_placeholder: 'Kod',
|
||||
captcha_refresh: 'Klik untuk muat semula',
|
||||
captcha_wrong: 'Kod captcha salah',
|
||||
slide_to_verify: 'Gelongsor untuk mengesahkan',
|
||||
click_to_verify: 'Klik untuk mengesahkan',
|
||||
verified: 'Disahkan',
|
||||
login_required: 'Sila Log Masuk',
|
||||
login_hint: 'Log masuk untuk bertaruh dan akses lebih banyak ciri',
|
||||
go_login: 'Pergi log masuk',
|
||||
go_register: 'Tiada akaun? Daftar sekarang',
|
||||
have_account: 'Sudah ada akaun? Log masuk',
|
||||
register_btn: 'Daftar',
|
||||
register_failed: 'Pendaftaran gagal, sila cuba lagi',
|
||||
continue_browsing: 'Langkau log masuk',
|
||||
username_placeholder: 'Masukkan nama pengguna',
|
||||
login_account: 'Telefon / Akaun',
|
||||
login_account_placeholder: 'Nombor tempatan atau akaun',
|
||||
login_username_placeholder: 'Telefon berdaftar (dengan kod negara) atau akaun',
|
||||
confirm_password: 'Sahkan kata laluan',
|
||||
password_mismatch: 'Kata laluan tidak sepadan',
|
||||
password_placeholder: 'Masukkan kata laluan',
|
||||
login_btn: 'Log Masuk',
|
||||
login_failed: 'Log masuk gagal, sila cuba lagi',
|
||||
phone: 'Telefon',
|
||||
phone_placeholder: 'Masukkan nombor telefon',
|
||||
phone_local_placeholder: 'Masukkan nombor telefon',
|
||||
phone_required: 'Nombor telefon diperlukan',
|
||||
phone_invalid: 'Format nombor telefon tidak sah',
|
||||
phone_country_unsupported: 'Negara atau wilayah ini tidak disokong',
|
||||
sms_code: 'Kod SMS',
|
||||
sms_code_placeholder: 'Kod 6 digit',
|
||||
sms_code_required: 'Sila masukkan kod SMS',
|
||||
sms_required: 'Sila minta kod SMS dahulu',
|
||||
send_sms: 'Dapatkan Kod',
|
||||
resend_sms: 'Cuba lagi dalam {sec}s',
|
||||
country_search: 'Cari negara atau kod',
|
||||
country_not_found: 'Tiada negara sepadan',
|
||||
},
|
||||
support: {
|
||||
short: 'Sokongan',
|
||||
title: 'Khidmat Pelanggan',
|
||||
open: 'Buka khidmat pelanggan',
|
||||
close: 'Tutup',
|
||||
url_pending: 'Pautan khidmat pelanggan belum dikonfigurasi.',
|
||||
},
|
||||
wallet: {
|
||||
balance: 'Baki',
|
||||
cash_balance: 'Baki Tunai',
|
||||
card_holder: 'Pemegang',
|
||||
unsettled: 'Belum Selesai',
|
||||
available: 'Tersedia',
|
||||
no_records: 'Tiada rekod',
|
||||
tx_deposit: 'Deposit',
|
||||
tx_admin_deposit: 'Tambah baki admin',
|
||||
tx_agent_deposit: 'Tambah baki ejen',
|
||||
tx_player_deposit: 'Deposit sendiri',
|
||||
tx_withdraw: 'Pengeluaran',
|
||||
tx_admin_withdraw: 'Pengeluaran admin',
|
||||
tx_agent_withdraw: 'Pengeluaran ejen',
|
||||
tx_adjust: 'Pelarasan Manual',
|
||||
tx_bet_freeze: 'Pertaruhan Ditahan',
|
||||
tx_bet_deduct: 'Pertaruhan Ditolak',
|
||||
tx_bet_win: 'Bayaran Pertaruhan',
|
||||
tx_bet_lose: 'Pertaruhan Selesai',
|
||||
tx_bet_push: 'Pertaruhan Seri',
|
||||
tx_bet_refund: 'Bayaran Balik',
|
||||
tx_bet_void: 'Pertaruhan Dibatalkan',
|
||||
tx_cashback: 'Kredit rebat',
|
||||
tx_resettle: 'Penyelesaian Semula',
|
||||
summary_bet: 'Pertaruhan {betNo}',
|
||||
summary_opening_bonus: 'Bonus pembukaan',
|
||||
stats_income: 'Pendapatan',
|
||||
stats_expense: 'Perbelanjaan',
|
||||
stats_net: 'Bersih',
|
||||
stats_cashback: 'Rebat',
|
||||
filter_all: 'Semua',
|
||||
filter_deposit: 'Deposit',
|
||||
filter_withdraw: 'Pengeluaran',
|
||||
filter_bet: 'Pertaruhan',
|
||||
filter_cashback: 'Rebat',
|
||||
view_all: 'Lihat semua transaksi',
|
||||
detail_summary: 'Butiran',
|
||||
detail_amount: 'Jumlah',
|
||||
detail_balance_before: 'Baki Sebelum',
|
||||
detail_balance_after: 'Baki Selepas',
|
||||
detail_frozen_before: 'Beku Sebelum',
|
||||
detail_frozen_after: 'Beku Selepas',
|
||||
detail_reference: 'Rujukan',
|
||||
detail_reference_type: 'Jenis',
|
||||
detail_reference_id: 'ID Rujukan',
|
||||
detail_remark: 'Catatan',
|
||||
detail_bet_link: 'Lihat Pertaruhan',
|
||||
detail_tx_id: 'ID Transaksi',
|
||||
detail_not_found: 'Rekod tidak dijumpai',
|
||||
ref_bet: 'Pertaruhan',
|
||||
ref_deposit: 'Deposit',
|
||||
ref_withdraw: 'Pengeluaran',
|
||||
view_cashbacks: 'Butiran rebat',
|
||||
view_cashbacks_detail: 'Lihat butiran rebat (tempoh/kadar)',
|
||||
cashback_filter_hint: 'Senarai ini ialah kredit dompet; tempoh dan kadar ada di butiran rebat.',
|
||||
ref_cashback: 'Batch rebat',
|
||||
detail_cashback_link: 'Lihat butiran rebat',
|
||||
},
|
||||
recharge: {
|
||||
title: 'Topup',
|
||||
history: 'Sejarah',
|
||||
history_title: 'Sejarah Topup',
|
||||
bank_transfer: 'Pindahan Bank',
|
||||
bank_name: 'Nama Bank',
|
||||
account_holder: 'Pemegang Akaun',
|
||||
account_number: 'Nombor Akaun',
|
||||
usdt_address: 'Alamat USDT',
|
||||
amount_label: 'Jumlah',
|
||||
amount_placeholder: 'Masukkan jumlah topup',
|
||||
screenshot_label: 'Muat Naik Screenshot',
|
||||
upload_hint: 'Klik untuk muat naik (maks 5MB)',
|
||||
compressing: 'Memampat',
|
||||
submit: 'Hantar',
|
||||
submitting: 'Menghantar',
|
||||
submitted: 'Topup Dihantar',
|
||||
pending_review: 'Admin sedang menyemak, sila tunggu',
|
||||
new_recharge: 'Topup Baru',
|
||||
no_methods: 'Tiada kaedah pembayaran tersedia',
|
||||
select_method: 'Sila pilih kaedah pembayaran',
|
||||
enter_amount: 'Sila masukkan jumlah',
|
||||
upload_screenshot: 'Sila muat naik screenshot',
|
||||
submit_failed: 'Gagal, sila cuba lagi',
|
||||
file_must_be_image: 'Sila muat naik fail imej',
|
||||
file_too_large: 'Fail melebihi 10MB',
|
||||
status_pending: 'Memproses',
|
||||
status_approved: 'Diluluskan',
|
||||
status_rejected: 'Ditolak',
|
||||
no_orders: 'Tiada rekod topup',
|
||||
credited: 'Dikreditkan',
|
||||
reject_reason: 'Sebab penolakan',
|
||||
apply_time: 'Masa permohonan',
|
||||
review_time: 'Masa semakan',
|
||||
remark: 'Catatan',
|
||||
},
|
||||
cashback: {
|
||||
title: 'Butiran Rebat',
|
||||
list_title: 'Butiran pembayaran',
|
||||
total_received: 'Jumlah rebat',
|
||||
record_count: '{n} rekod',
|
||||
period: 'Tempoh',
|
||||
effective_stake: 'Pertaruhan sah',
|
||||
bet_count: '{n} pertaruhan',
|
||||
empty: 'Tiada rekod rebat',
|
||||
empty_hint: 'Rebat dikeluarkan oleh platform mengikut kitaran penyelesaian.',
|
||||
ledger_hint: 'Sepadan dengan entri dompet di penapis Rebat; jumlah adalah sama.',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: 'Slip Pertaruhan',
|
||||
stake: 'Jumlah',
|
||||
place_bet: 'Letak Pertaruhan',
|
||||
place_bet_short: 'Pertaruhan',
|
||||
parlay: 'Berganda',
|
||||
tab_matches: 'Perlawanan',
|
||||
tab_outright: 'Juara',
|
||||
tab_parlay: 'Berganda',
|
||||
tab_today: 'Hari Ini',
|
||||
tab_early: 'Awal',
|
||||
show_open_only: 'Buka sahaja',
|
||||
show_all_matches: 'Tunjuk semua',
|
||||
today: 'Hari Ini',
|
||||
loading: 'Memuatkan…',
|
||||
no_matches: 'Tiada perlawanan',
|
||||
outright_coming: 'Pasaran juara akan datang',
|
||||
outright_enter_stake: 'Masukkan jumlah',
|
||||
outright_balance: 'Baki',
|
||||
outright_stake_amount: 'Jumlah pertaruhan',
|
||||
outright_success: 'Pertaruhan berjaya',
|
||||
outright_done: 'Selesai',
|
||||
outright_bet_failed: 'Pertaruhan gagal',
|
||||
outright_insufficient: 'Baki tidak mencukupi',
|
||||
stake_label: 'Jumlah',
|
||||
stake_placeholder: 'Masukkan jumlah',
|
||||
stake_max: 'Maks',
|
||||
placing: 'Memproses…',
|
||||
no_outright: 'Tiada pasaran juara',
|
||||
no_outright_hint: 'Log masuk sebagai pemain. Jika kosong, minta admin terbitkan acara juara.',
|
||||
outright_events_summary: '{events} acara juara · {teams} pasukan',
|
||||
outright_teams_count: '{n} pasukan',
|
||||
outright_load_failed: 'Gagal memuatkan pasaran juara',
|
||||
outright_player_only: 'Log masuk pemain diperlukan',
|
||||
outright_shown_count: '{shown} / {total} pasukan dipaparkan',
|
||||
outright_load_more: 'Muat lagi',
|
||||
cancel: 'Batal',
|
||||
parlay_title: 'Pertaruhan Berganda',
|
||||
parlay_guide_title: 'Cara parlay',
|
||||
parlay_guide_help: 'Bantuan parlay',
|
||||
parlay_desc: 'Gabung 2–5 perlawanan pra-perlawanan (2 hingga 5 liputan). Tiada live, outright atau suku bola HDP/O-U.',
|
||||
parlay_guide_1: 'Ketik odds dalam senarai; pilihan dipilih ada sempadan emas. Ketik lagi untuk batal',
|
||||
parlay_guide_2: 'Pilih 2–5 pilihan dari perlawanan berbeza. Tiada outright atau suku bola HDP/O-U',
|
||||
parlay_guide_3: 'Ketik Sahkan pesanan di bawah, isi pegangan dalam slip, dan hantar',
|
||||
parlay_max_legs: 'Maksimum 5 pilihan parlay',
|
||||
parlay_block_outright: 'Outright tidak boleh parlay',
|
||||
parlay_block_quarter: 'HDP/O-U suku bola tidak boleh parlay',
|
||||
parlay_block_not_allowed: 'Pasaran ini tidak boleh parlay',
|
||||
parlay_filter_all: 'Semua',
|
||||
parlay_empty: 'Tiada perlawanan untuk pertaruhan berganda',
|
||||
parlay_same_match: 'Perlawanan sama tidak boleh berganda',
|
||||
parlay_same_match_singles: '{n} pilihan → {n} pertaruhan tunggal berasingan',
|
||||
parlay_confirm_singles: 'Sahkan {n} pertaruhan tunggal',
|
||||
parlay_confirm_parlay: 'Sahkan parlay',
|
||||
parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
|
||||
back: 'Kembali',
|
||||
refresh: 'Muat semula',
|
||||
download: 'Muat turun',
|
||||
reward_active: 'Ganjaran aktif!',
|
||||
market_closed: 'Belum dibuka',
|
||||
match_phase_closed_pending: 'Ditutup menunggu',
|
||||
match_phase_settled: 'Selesai',
|
||||
view_match: 'Lihat perlawanan',
|
||||
expand_market: 'Kembang',
|
||||
collapse_market: 'Tutup',
|
||||
market_cs: 'Skor Tepat',
|
||||
market_ht_cs: 'Skor Tepat PB1',
|
||||
market_sh_cs: 'Skor Tepat PB2',
|
||||
market_ft_handicap: 'Handicap Penuh',
|
||||
market_ft_ou: 'Atas/Bawah Penuh',
|
||||
market_ft_1x2: '1X2 Penuh',
|
||||
market_ft_oe: 'Ganjil/Genap Penuh',
|
||||
market_ht_handicap: 'Handicap Separuh',
|
||||
market_ht_ou: 'Atas/Bawah Separuh',
|
||||
market_ht_1x2: '1X2 Separuh',
|
||||
parlay_lbl_handicap: 'Handicap',
|
||||
parlay_lbl_ou: 'Atas/Bawah',
|
||||
parlay_lbl_1x2: '1X2',
|
||||
parlay_lbl_oe: 'Ganjil/Genap',
|
||||
parlay_sel_home: 'R',
|
||||
parlay_sel_away: 'P',
|
||||
parlay_sel_draw: 'S',
|
||||
parlay_sel_over: 'Atas',
|
||||
parlay_sel_under: 'Bwh',
|
||||
parlay_sel_odd: 'G',
|
||||
parlay_sel_even: 'Gn',
|
||||
cs_other_home: 'Menang rumah (skor lain)',
|
||||
cs_other_draw: 'Seri (skor lain)',
|
||||
cs_other_away: 'Menang pelawat (skor lain)',
|
||||
col_home: 'Home',
|
||||
col_draw: 'Seri',
|
||||
col_away: 'Away',
|
||||
cs_stake_required: 'Masukkan jumlah pada sekurang-kurangnya satu skor',
|
||||
cs_confirm_title: 'Sahkan pertaruhan skor tepat',
|
||||
cs_confirm_count: '{n} pertaruhan',
|
||||
cs_confirm_total_stake: 'Jumlah pertaruhan',
|
||||
cs_place_success: 'Pertaruhan berjaya',
|
||||
cs_place_failed: 'Pertaruhan gagal',
|
||||
kickoff_time: 'Masa mula: ',
|
||||
guide_title: 'Cara pertaruhan',
|
||||
guide_help_aria: 'Bantuan pertaruhan',
|
||||
guide_got_it: 'Faham',
|
||||
guide_flow_normal: 'Handicap / O-U / 1X2',
|
||||
guide_normal_1: 'Ketik Kembang untuk lihat odds',
|
||||
guide_normal_2: 'Pilih satu odds (sisi emas); ketik lagi untuk batal',
|
||||
guide_normal_3: 'Ketik Sahkan pesanan di bawah pasaran, isi jumlah dan sahkan',
|
||||
guide_flow_cs: 'Skor tepat',
|
||||
guide_cs_1: 'Kembang dan isi jumlah setiap skor',
|
||||
guide_cs_2: 'Isi jumlah, kemudian Sahkan pesanan di bawah pasaran itu',
|
||||
guide_cs_3: 'Beberapa skor = beberapa pertaruhan',
|
||||
guide_flow_parlay: 'Parlay (2–5 perlawanan)',
|
||||
guide_parlay_1: 'Halaman ini untuk tunggal dan skor tepat. Parlay: ketik Pertaruhan di nav bawah, kemudian tab Parlay di bahagian atas halaman, pilih 2–5 perlawanan berbeza, hantar dari slip.',
|
||||
guide_rules_link: 'Peraturan penuh: Profil → Peraturan Pertaruhan.',
|
||||
mode_cs_tag: 'Pertaruhan di sini',
|
||||
mode_slip_tag: 'Tambah ke slip',
|
||||
cs_confirm_btn: 'Sahkan pertaruhan',
|
||||
cs_confirm_cell: 'Sahkan pesanan',
|
||||
cs_panel_hint: 'Isi jumlah di bawah, kemudian Sahkan di atas',
|
||||
slip_panel_hint: 'Ketik odds; guna bar bawah apabila siap',
|
||||
slip_pick_hint: 'Ketik untuk tambah/buang; sisi emas = dipilih',
|
||||
picked_tag: 'Dipilih',
|
||||
pick_added: 'Ditambah ke slip',
|
||||
pick_removed: 'Dikeluarkan dari slip',
|
||||
slip_bar_ready: '1 pilihan',
|
||||
slip_bar_go: 'Buka slip',
|
||||
cs_top_hint: '① Isi jumlah ② Ketik Sahkan di atas',
|
||||
slip_empty_hint: 'Ketik odds untuk tambah ke slip',
|
||||
slip_remove: 'Buang',
|
||||
slip_singles_hint: '{n} pertaruhan tunggal. Parlay: halaman Pertaruhan → tab Berganda di atas.',
|
||||
slip_stake_per_bet: 'Jumlah setiap pertaruhan',
|
||||
slip_est_return: 'Anggaran pulangan',
|
||||
slip_parlay_odds: 'Odds gabungan {odds}',
|
||||
place_success: 'Pertaruhan berjaya',
|
||||
place_failed: 'Pertaruhan gagal',
|
||||
},
|
||||
profile: {
|
||||
edit: 'Edit Profil',
|
||||
language: 'Bahasa',
|
||||
avatar: 'Avatar',
|
||||
avatar_change: 'Tukar avatar',
|
||||
avatar_confirm: 'Sahkan',
|
||||
section_contact: 'Maklumat hubungan',
|
||||
section_account: 'Akaun',
|
||||
change_password: 'Tukar kata laluan',
|
||||
show_password: 'Lihat',
|
||||
hide_password: 'Sembunyi',
|
||||
password_unavailable: '••••••••',
|
||||
password_unavailable_hint: 'Kata laluan tidak tersedia; hubungi sokongan',
|
||||
section_password: 'Tukar kata laluan (pilihan)',
|
||||
avatar_hint: 'Pilih dari potret pemain terbina',
|
||||
avatar_search: 'Cari pemain, posisi atau negara',
|
||||
avatar_empty: 'Tiada pemain dijumpai',
|
||||
phone: 'Telefon',
|
||||
email: 'E-mel',
|
||||
phone_placeholder: 'Nombor telefon',
|
||||
email_placeholder: 'Alamat e-mel',
|
||||
save: 'Simpan',
|
||||
password_optional_hint: 'Biarkan kosong jika tidak mahu tukar kata laluan',
|
||||
old_password_placeholder: 'Biarkan kosong untuk langkau',
|
||||
new_password_placeholder: 'Biarkan kosong untuk langkau',
|
||||
confirm_password_placeholder: 'Biarkan kosong untuk langkau',
|
||||
old_password: 'Kata laluan semasa',
|
||||
new_password: 'Kata laluan baharu',
|
||||
confirm_password: 'Sahkan kata laluan',
|
||||
back: 'Kembali',
|
||||
saved: 'Hubungan disimpan',
|
||||
save_failed: 'Gagal simpan',
|
||||
password_changed: 'Kata laluan dikemas kini',
|
||||
password_failed: 'Gagal tukar kata laluan',
|
||||
password_mismatch: 'Kata laluan tidak sepadan',
|
||||
password_incomplete: 'Isi kata laluan semasa, baharu dan pengesahan untuk menukar',
|
||||
username_placeholder: 'Nama log masuk',
|
||||
username_readonly_hint: 'Nama akaun diurus admin; hubungi sokongan untuk ubah',
|
||||
username_updated: 'Nama akaun dikemas kini',
|
||||
password_disabled: 'Akaun ini tidak dibenarkan tukar kata laluan; hubungi sokongan',
|
||||
rules_title: 'Peraturan Pertaruhan',
|
||||
rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.',
|
||||
rules_p2: 'Parlay 2–5 pilihan, satu pilihan setiap perlawanan. Outright dan suku bola HDP/O-U tidak boleh parlay.',
|
||||
rules_p3: 'Keputusan berdasarkan skor separuh masa/penuh yang dimasukkan admin; bayaran selepas pratonton disahkan.',
|
||||
rules_p4: 'Jika bercanggah dengan notis laman, ikut notis terkini dan peraturan pasaran.',
|
||||
rules_p5: 'Langkah operasi: buka butiran perlawanan, ketik ikon ? di atas kanan.',
|
||||
},
|
||||
},
|
||||
[initialLocale]: initialMessages as Record<string, string>,
|
||||
},
|
||||
});
|
||||
|
||||
markLocaleLoaded(initialLocale);
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app');
|
||||
|
||||
const loader = document.getElementById('app-loading');
|
||||
@@ -1233,3 +34,6 @@ if (loader) {
|
||||
loader.style.transition = 'opacity 0.3s ease';
|
||||
setTimeout(() => loader.remove(), 350);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -38,6 +38,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
async function register(
|
||||
username: string,
|
||||
phone: string,
|
||||
countryCode: string,
|
||||
password: string,
|
||||
@@ -49,6 +50,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const code = inviteCode?.trim();
|
||||
const dial = countryCode.replace(/\D/g, '');
|
||||
const { data } = await api.post('/player/auth/register', {
|
||||
username: username.trim(),
|
||||
phone,
|
||||
countryCode: dial,
|
||||
password,
|
||||
|
||||
@@ -192,13 +192,16 @@ body,
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.15'/%3E%3C/svg%3E"),
|
||||
linear-gradient(rgba(0, 0, 0, 0.95), rgba(0, 0, 0, 0.95)),
|
||||
radial-gradient(ellipse 120% 60% at 50% -10%, rgba(212, 175, 55, 0.14), transparent 55%),
|
||||
url('./assets/images/bg.png') no-repeat center center fixed;
|
||||
background-size: 150px, cover, cover, cover;
|
||||
/* 保留 bg 位图;用 scroll 替代 fixed,减轻移动端滚动重绘(后续可换 WebP/AVIF) */
|
||||
background-color: var(--tertiary);
|
||||
background-image:
|
||||
radial-gradient(ellipse 120% 60% at 50% -10%, rgba(212, 175, 55, 0.14), transparent 55%),
|
||||
linear-gradient(rgba(0, 0, 0, 0.88), rgba(0, 0, 0, 0.88)),
|
||||
url('./assets/images/bg.png');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-attachment: scroll;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -294,8 +297,7 @@ input:-webkit-autofill:active {
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
background: rgba(26, 26, 26, 0.92);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import { usePlayerMatches } from '../composables/usePlayerMatches';
|
||||
import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
|
||||
import OutrightPanel from '../components/outright/OutrightPanel.vue';
|
||||
import ParlayPanel from '../components/parlay/ParlayPanel.vue';
|
||||
@@ -55,21 +55,16 @@ const slip = useBetSlipStore();
|
||||
const mainTab = ref<MainTab>('matches');
|
||||
const timeTab = ref<TimeTab>('early');
|
||||
const showAll = ref(false);
|
||||
const matches = ref<Match[]>([]);
|
||||
const loading = ref(true);
|
||||
const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches();
|
||||
const matches = summaryMatches;
|
||||
const loading = summaryLoading;
|
||||
const expandedLeagues = ref<Set<string>>(new Set());
|
||||
|
||||
async function loadMatches() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/matches');
|
||||
matches.value = data.data ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
await loadSummary(true);
|
||||
}
|
||||
|
||||
useOnLocaleChange(loadMatches);
|
||||
useOnLocaleChange(() => loadSummary(true));
|
||||
|
||||
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: async () => { await loadMatches(); },
|
||||
@@ -141,6 +136,10 @@ watch(leagueGroups, (groups) => {
|
||||
if (!groups.some((g) => g.leagueId === id)) ids.delete(id);
|
||||
}
|
||||
if (ids.size !== expandedLeagues.value.size) expandedLeagues.value = ids;
|
||||
// 默认只展开第一个联赛,减少首屏 DOM
|
||||
if (groups.length > 0 && expandedLeagues.value.size === 0) {
|
||||
expandedLeagues.value = new Set([groups[0].leagueId]);
|
||||
}
|
||||
});
|
||||
|
||||
function isLeagueExpanded(leagueId: string) {
|
||||
|
||||
@@ -54,9 +54,10 @@ function formatKickoff(startTime: string) {
|
||||
|
||||
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
|
||||
<div
|
||||
v-for="match in hotMatches"
|
||||
v-for="(match, index) in hotMatches"
|
||||
:key="match.id"
|
||||
class="match-card"
|
||||
:class="{ 'match-card--live-anim': index < 3 }"
|
||||
@click="goMatch(match.id)"
|
||||
>
|
||||
<div class="match-info">
|
||||
@@ -215,17 +216,17 @@ function formatKickoff(startTime: string) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hz-path-main {
|
||||
.match-card--live-anim .hz-path-main {
|
||||
animation: hz-strike-main 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hz-path-sub {
|
||||
.match-card--live-anim .hz-path-sub {
|
||||
stroke-width: 1.6;
|
||||
animation: hz-strike-sub 2.6s ease-in-out infinite;
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
|
||||
.hz-beam {
|
||||
.match-card--live-anim .hz-beam {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@@ -247,12 +248,20 @@ function formatKickoff(startTime: string) {
|
||||
animation: hz-beam-flash 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hz-beam {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vs-img {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 48px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
|
||||
.match-card--live-anim .vs-img {
|
||||
animation: vs-glow 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,23 +2,36 @@
|
||||
import { ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defaultPhoneIsoForLocale, getPhoneDialFromIso } from '@thebet365/shared';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
||||
import PhoneCountrySelect from '../components/PhoneCountrySelect.vue';
|
||||
import RobotVerify from '../components/RobotVerify.vue';
|
||||
import loginBg from '../assets/images/h5bg.png';
|
||||
|
||||
const { t } = useI18n();
|
||||
type LoginMode = 'account' | 'phone';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { syncLocaleToBackend } = useAppLocale();
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const captchaRef = ref<InstanceType<typeof RobotVerify> | null>(null);
|
||||
const username = ref('');
|
||||
const loginMode = ref<LoginMode>('account');
|
||||
const account = ref('');
|
||||
const phone = ref('');
|
||||
const countryIso = ref(defaultPhoneIsoForLocale(locale.value));
|
||||
const password = ref('');
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
function switchLoginMode(mode: LoginMode) {
|
||||
if (loginMode.value === mode) return;
|
||||
loginMode.value = mode;
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!captchaRef.value?.validate()) {
|
||||
error.value = t('auth.captcha_wrong');
|
||||
@@ -28,7 +41,10 @@ async function submit() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await auth.login(username.value, password.value);
|
||||
const isPhone = loginMode.value === 'phone';
|
||||
const loginId = isPhone ? phone.value : account.value;
|
||||
const countryCode = isPhone ? getPhoneDialFromIso(countryIso.value) : undefined;
|
||||
await auth.login(loginId, password.value, countryCode);
|
||||
await syncLocaleToBackend();
|
||||
const redirectTo = (route.query.redirect as string) || '/';
|
||||
router.push(redirectTo);
|
||||
@@ -47,7 +63,6 @@ function isGuestBrowsablePath(path: string): boolean {
|
||||
|
||||
function continueBrowsing() {
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '';
|
||||
// redirect 是登录成功后的目标;跳过登录时只能去公开页,避免跳回需登录页形成死循环
|
||||
const target = isGuestBrowsablePath(redirect) ? redirect || '/' : '/';
|
||||
router.replace(target);
|
||||
}
|
||||
@@ -66,20 +81,42 @@ function goRegister() {
|
||||
<LocaleSwitcher compact />
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
||||
<div class="field">
|
||||
<div v-if="loginMode === 'account'" class="field">
|
||||
<label>{{ t('auth.username') }}</label>
|
||||
<input
|
||||
v-model="username"
|
||||
v-model="account"
|
||||
class="field-input"
|
||||
required
|
||||
autocomplete="username"
|
||||
:placeholder="t('auth.login_username_placeholder')"
|
||||
maxlength="32"
|
||||
:placeholder="t('auth.username_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="field">
|
||||
<label>{{ t('auth.phone') }}</label>
|
||||
<div class="inline-row">
|
||||
<PhoneCountrySelect v-model="countryIso" />
|
||||
<input
|
||||
v-model="phone"
|
||||
class="field-input"
|
||||
type="tel"
|
||||
required
|
||||
autocomplete="tel-national"
|
||||
:placeholder="t('auth.phone_local_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ t('auth.password') }}</label>
|
||||
<input v-model="password" class="field-input" type="password" required />
|
||||
<input v-model="password" class="field-input" type="password" required autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-mode-switch" @click="switchLoginMode(loginMode === 'account' ? 'phone' : 'account')">
|
||||
{{ loginMode === 'account' ? t('auth.login_by_phone') : t('auth.login_by_account') }}
|
||||
</button>
|
||||
|
||||
<RobotVerify ref="captchaRef" />
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
|
||||
@@ -169,6 +206,22 @@ label {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.btn-mode-switch {
|
||||
align-self: flex-start;
|
||||
margin: -2px 0 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(240, 216, 117, 0.75);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-mode-switch:active {
|
||||
color: rgba(240, 216, 117, 0.95);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
margin-top: 4px;
|
||||
padding: 10px 14px;
|
||||
|
||||
@@ -105,12 +105,10 @@ async function loadMyBets() {
|
||||
if (!match.value || !auth.token) return;
|
||||
loadingMyBets.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/bets?page=1');
|
||||
const items = (data.data?.items ?? data.data ?? []) as MyBet[];
|
||||
const matchTitle = `${match.value.homeTeamName} vs ${match.value.awayTeamName}`;
|
||||
myBets.value = items.filter(
|
||||
(b) => b.matchTitle === matchTitle || b.matchTitle === `${match.value!.awayTeamName} vs ${match.value!.homeTeamName}`,
|
||||
);
|
||||
const { data } = await api.get('/player/bets', {
|
||||
params: { page: 1, matchId: match.value.id },
|
||||
});
|
||||
myBets.value = (data.data?.items ?? data.data ?? []) as MyBet[];
|
||||
} catch {
|
||||
myBets.value = [];
|
||||
} finally {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defaultPhoneIsoForLocale, getPhoneDialFromIso } from '@thebet365/shared';
|
||||
import { defaultPhoneIsoForLocale, getPhoneDialFromIso, isValidPlayerUsername } from '@thebet365/shared';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
import { useSmsCode } from '../composables/useSmsCode';
|
||||
@@ -16,6 +16,7 @@ const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const account = ref('');
|
||||
const phone = ref('');
|
||||
const countryIso = ref(defaultPhoneIsoForLocale(locale.value));
|
||||
const password = ref('');
|
||||
@@ -32,6 +33,14 @@ async function sendCode() {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!account.value.trim()) {
|
||||
error.value = t('auth.username_required');
|
||||
return;
|
||||
}
|
||||
if (!isValidPlayerUsername(account.value)) {
|
||||
error.value = t('auth.username_format_invalid');
|
||||
return;
|
||||
}
|
||||
if (!sessionId.value) {
|
||||
error.value = t('auth.sms_required');
|
||||
return;
|
||||
@@ -48,6 +57,7 @@ async function submit() {
|
||||
error.value = '';
|
||||
try {
|
||||
await auth.register(
|
||||
account.value,
|
||||
phone.value,
|
||||
getPhoneDialFromIso(countryIso.value),
|
||||
password.value,
|
||||
@@ -97,6 +107,19 @@ const fieldError = () => {
|
||||
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
||||
<h2 class="form-title">{{ t('auth.register') }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ t('auth.username') }}</label>
|
||||
<input
|
||||
v-model="account"
|
||||
class="field-input"
|
||||
required
|
||||
autocomplete="username"
|
||||
maxlength="32"
|
||||
minlength="7"
|
||||
:placeholder="t('auth.username_register_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ t('auth.phone') }}</label>
|
||||
<div class="inline-row">
|
||||
|
||||
8
apps/player/src/vite-env.d.ts
vendored
8
apps/player/src/vite-env.d.ts
vendored
@@ -1,3 +1,11 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_CUSTOMER_SERVICE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<object, object, unknown>;
|
||||
|
||||
@@ -4,6 +4,27 @@ import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) {
|
||||
if (id.includes('/src/i18n/zh-CN')) return 'i18n-zh-CN';
|
||||
if (id.includes('/src/i18n/en-US')) return 'i18n-en-US';
|
||||
if (id.includes('/src/i18n/ms-MY')) return 'i18n-ms-MY';
|
||||
return undefined;
|
||||
}
|
||||
if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
|
||||
return 'vue-vendor';
|
||||
}
|
||||
if (id.includes('vue-i18n')) return 'i18n-vendor';
|
||||
if (id.includes('axios')) return 'axios';
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 600,
|
||||
},
|
||||
resolve: {
|
||||
// 避免删除 src 内过期 .js 后仍优先请求 index.js 导致 404
|
||||
extensions: ['.ts', '.tsx', '.mjs', '.js', '.mts', '.jsx', '.json', '.vue'],
|
||||
|
||||
40
docs/player-mobile-performance.md
Normal file
40
docs/player-mobile-performance.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 玩家端手机浏览器性能验收
|
||||
|
||||
本文档用于在真机上建立基线并验收性能优化(弱网、滚动、内存)。
|
||||
|
||||
## 环境
|
||||
|
||||
- 设备:iOS Safari + Android Chrome(至少各一台中低端机型)
|
||||
- 网络:Chrome DevTools → Network → Fast 3G(或 Safari 网络链路调节器)
|
||||
- 构建:`pnpm --filter @thebet365/player build` 后 `pnpm --filter @thebet365/player preview`
|
||||
|
||||
## 关键指标
|
||||
|
||||
| 指标 | 工具 | 目标(优化后) |
|
||||
|------|------|----------------|
|
||||
| LCP | Lighthouse Mobile | 弱网 < 3s |
|
||||
| `/api/player/matches` 体积 | Network 面板 | 列表(无 markets)显著小于串关 `scope=parlay` |
|
||||
| `/api/player/home` | Network | 单次 `listPublished`,无重复全量查询 |
|
||||
| 主 JS chunk | `dist/assets/*.js` | 首屏仅当前语言 i18n chunk |
|
||||
| 滚动长任务 | Performance 录制 | 投注页滚动时少见 > 50ms 块 |
|
||||
|
||||
## 必测路径
|
||||
|
||||
1. 冷启动首页(未登录 / 已登录)
|
||||
2. 投注 → 展开多个联赛 → 纵向滚动
|
||||
3. 投注 → 串关 Tab(应请求 `?scope=parlay`,且与赛事 Tab 共享缓存)
|
||||
4. 赛事详情 → 展开波胆 → 多次点选
|
||||
5. 弱网下首页 / 投注页下拉刷新
|
||||
|
||||
## 构建体积快照(本地)
|
||||
|
||||
```bash
|
||||
pnpm --filter @thebet365/player build
|
||||
# 查看 dist/assets 下 index、i18n-*、vue-vendor 等 chunk 大小
|
||||
```
|
||||
|
||||
## API 契约变更
|
||||
|
||||
- `GET /player/matches`:默认返回**无 markets** 的赛事摘要
|
||||
- `GET /player/matches?scope=parlay`:仅串关玩法 markets,且过滤无盘口赛事
|
||||
- `GET /player/bets?matchId=`:按赛事筛选注单(详情页使用)
|
||||
@@ -410,9 +410,9 @@ export const API_ERROR_MESSAGES = {
|
||||
'ms-MY': 'Kadar rebat tidak sah; mesti nombor bukan negatif',
|
||||
},
|
||||
USERNAME_FORMAT_INVALID: {
|
||||
'zh-CN': '玩家用户名仅可使用英文字母和数字(3–32 位),不可含中文或特殊符号',
|
||||
'en-US': 'Username must be 3–32 letters or digits only',
|
||||
'ms-MY': 'Nama pengguna mesti 3–32 huruf atau digit sahaja',
|
||||
'zh-CN': '玩家用户名仅可使用英文字母和数字(7–32 位),不可含中文或特殊符号',
|
||||
'en-US': 'Username must be 7–32 letters or digits only',
|
||||
'ms-MY': 'Nama pengguna mesti 7–32 huruf atau digit sahaja',
|
||||
},
|
||||
PASSWORD_MIN_LENGTH: {
|
||||
'zh-CN': '密码至少 8 位',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** 玩家用户名:仅英文字母与数字,3–32 位 */
|
||||
export const PLAYER_USERNAME_PATTERN = /^[a-zA-Z0-9]{3,32}$/;
|
||||
/** 玩家用户名:仅英文字母与数字,7–32 位 */
|
||||
export const PLAYER_USERNAME_PATTERN = /^[a-zA-Z0-9]{7,32}$/;
|
||||
|
||||
export const PLAYER_USERNAME_RULE_MESSAGE =
|
||||
'玩家用户名仅可使用英文字母和数字(3–32 位),不可含中文或特殊符号';
|
||||
'玩家用户名仅可使用英文字母和数字(7–32 位),不可含中文或特殊符号';
|
||||
|
||||
export function isValidPlayerUsername(username: string): boolean {
|
||||
return PLAYER_USERNAME_PATTERN.test(username.trim());
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -174,9 +174,6 @@ importers:
|
||||
vue-router:
|
||||
specifier: ^4.5.0
|
||||
version: 4.6.4(vue@3.5.35(typescript@5.7.3))
|
||||
vue3-slide-verify:
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(typescript@5.7.3)
|
||||
devDependencies:
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.1
|
||||
@@ -3672,9 +3669,6 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
vue3-slide-verify@1.1.8:
|
||||
resolution: {integrity: sha512-qsGXM0w0pkPnI10p58neHQNAkMiGdX8ijGkvRo6eIYbFn3+6fQE05t8afdQsCY9llgG1chDrRD7H0XxV3Ts9Cw==}
|
||||
|
||||
vue@3.5.35:
|
||||
resolution: {integrity: sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==}
|
||||
peerDependencies:
|
||||
@@ -7480,12 +7474,6 @@ snapshots:
|
||||
'@vue/language-core': 2.2.0(typescript@5.7.3)
|
||||
typescript: 5.7.3
|
||||
|
||||
vue3-slide-verify@1.1.8(typescript@5.7.3):
|
||||
dependencies:
|
||||
vue: 3.5.35(typescript@5.7.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
vue@3.5.35(typescript@5.7.3):
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.35
|
||||
|
||||
Reference in New Issue
Block a user