This commit is contained in:
wchino
2026-06-13 17:38:25 +08:00
parent e7e938f261
commit 7b33d9f9fa
190 changed files with 23222 additions and 4336 deletions

View File

@@ -0,0 +1,1002 @@
const DEFAULT_LOCALE = 'zh-CN';
const SUPPORTED_LOCALES = ['zh-CN', 'ms-MY', 'en-US'];
/** 后端错误码 → 三语文案({param} 占位) */
export const API_ERROR_MESSAGES = {
INTERNAL_SERVER_ERROR: {
'zh-CN': '服务器内部错误',
'en-US': 'Internal server error',
'ms-MY': 'Ralat pelayan dalaman',
},
INVALID_CREDENTIALS: {
'zh-CN': '用户名或密码错误',
'en-US': 'Invalid username or password',
'ms-MY': 'Nama pengguna atau kata laluan tidak sah',
},
ACCOUNT_DISABLED: {
'zh-CN': '账号已停用',
'en-US': 'Account disabled',
'ms-MY': 'Akaun telah dinyahaktifkan',
},
ACCOUNT_SUSPENDED: {
'zh-CN': '账号已冻结',
'en-US': 'Account suspended',
'ms-MY': 'Akaun digantung',
},
AGENT_ACCOUNT_SUSPENDED: {
'zh-CN': '代理账号已停用',
'en-US': 'Agent account suspended',
'ms-MY': 'Akaun ejen digantung',
},
PARENT_AGENT_SUSPENDED: {
'zh-CN': '上级代理已停用,暂无法登录',
'en-US': 'Parent agent is suspended; login unavailable',
'ms-MY': 'Ejen induk digantung; log masuk tidak tersedia',
},
ACCOUNT_LOCKED: {
'zh-CN': '账号已锁定,请稍后再试',
'en-US': 'Account locked, try again later',
'ms-MY': 'Akaun dikunci, cuba lagi nanti',
},
USER_NOT_FOUND: {
'zh-CN': '用户不存在',
'en-US': 'User not found',
'ms-MY': 'Pengguna tidak dijumpai',
},
PASSWORD_CHANGE_DISABLED: {
'zh-CN': '当前平台未开放玩家自行修改密码',
'en-US': 'Password change is disabled for players',
'ms-MY': 'Pertukaran kata laluan pemain tidak dibenarkan',
},
INVALID_OLD_PASSWORD: {
'zh-CN': '当前密码不正确',
'en-US': 'Current password is incorrect',
'ms-MY': 'Kata laluan semasa tidak betul',
},
ADMIN_ACCESS_REQUIRED: {
'zh-CN': '需要管理员权限',
'en-US': 'Admin access required',
'ms-MY': 'Akses pentadbir diperlukan',
},
INSUFFICIENT_PERMISSIONS: {
'zh-CN': '权限不足',
'en-US': 'Insufficient permissions',
'ms-MY': 'Kebenaran tidak mencukupi',
},
ACCESS_DENIED_PORTAL: {
'zh-CN': '无权访问该门户',
'en-US': 'Access denied for this portal',
'ms-MY': 'Akses portal ditolak',
},
PLAYER_ACCESS_ONLY: {
'zh-CN': '仅玩家可访问',
'en-US': 'Player access only',
'ms-MY': 'Akses pemain sahaja',
},
ADMIN_ACCESS_ONLY: {
'zh-CN': '仅管理员可访问',
'en-US': 'Admin access only',
'ms-MY': 'Akses pentadbir sahaja',
},
AGENT_ACCESS_ONLY: {
'zh-CN': '仅代理可访问',
'en-US': 'Agent access only',
'ms-MY': 'Akses ejen sahaja',
},
LEAGUE_NAME_REQUIRED: {
'zh-CN': '请填写联赛名称(中文、英文或马来文至少一项)',
'en-US': 'League name required (Chinese, English or Malay)',
'ms-MY': 'Nama liga diperlukan (Cina, Inggeris atau Melayu)',
},
LEAGUE_NOT_FOUND: {
'zh-CN': '联赛不存在',
'en-US': 'League not found',
'ms-MY': 'Liga tidak dijumpai',
},
LEAGUE_UNPUBLISH_SETTLED: {
'zh-CN': '联赛冠军盘已结算,不可下架',
'en-US': 'Cannot unpublish league after outright market is settled',
'ms-MY': 'Liga tidak boleh ditarik selepas pasaran juara diselesaikan',
},
MATCH_UNPUBLISH_FORBIDDEN: {
'zh-CN': '当前状态不可下架',
'en-US': 'Match cannot be unpublished in current status',
'ms-MY': 'Perlawanan tidak boleh ditarik dalam status semasa',
},
TEAM_CODE_REQUIRED: {
'zh-CN': '请填写球队代码',
'en-US': 'Team code is required',
'ms-MY': 'Kod pasukan diperlukan',
},
TEAMS_NAME_REQUIRED: {
'zh-CN': '请填写主客队名称(中文、英文或马来文至少一项)',
'en-US': 'Home and away team names required',
'ms-MY': 'Nama pasukan home/away diperlukan',
},
TEAMS_SAME: {
'zh-CN': '主客队不能相同,请填写不同的队名',
'en-US': 'Home and away teams must be different',
'ms-MY': 'Pasukan home dan away mesti berbeza',
},
MATCH_NOT_FOUND: {
'zh-CN': '赛事不存在',
'en-US': 'Match not found',
'ms-MY': 'Perlawanan tidak dijumpai',
},
OUTRIGHT_EDIT_VIA_MARKETS: {
'zh-CN': '冠军盘请通过盘口管理维护',
'en-US': 'Edit outright markets in the markets page',
'ms-MY': 'Urus outright melalui halaman pasaran',
},
MATCH_NOT_EDITABLE: {
'zh-CN': '当前状态不可编辑',
'en-US': 'Match cannot be edited in current status',
'ms-MY': 'Perlawanan tidak boleh diedit dalam status semasa',
},
MATCH_NOT_REOPENABLE: {
'zh-CN': '当前状态不可解除封盘',
'en-US': 'Match cannot be reopened in current status',
'ms-MY': 'Perlawanan tidak boleh dibuka semula dalam status semasa',
},
MATCH_NOT_SETTLEABLE: {
'zh-CN': '当前状态不可确认结算',
'en-US': 'Match cannot be settled in current status',
'ms-MY': 'Perlawanan tidak boleh diselesaikan dalam status semasa',
},
MATCH_MUST_CLOSE_FOR_SETTLEMENT: {
'zh-CN': '请先封盘后再结算',
'en-US': 'Close betting before settlement',
'ms-MY': 'Tutup pertaruhan sebelum penyelesaian',
},
MATCH_REOPEN_KICKOFF_REQUIRED: {
'zh-CN': '开赛时间已过,请设置新的未来开赛时间',
'en-US': 'Kickoff has passed; set a new future start time',
'ms-MY': 'Masa mula telah berlalu; tetapkan masa mula baharu pada masa hadapan',
},
MATCH_START_TIME_INVALID: {
'zh-CN': '开赛时间格式无效',
'en-US': 'Invalid kickoff time',
'ms-MY': 'Format masa mula tidak sah',
},
OUTRIGHT_DELETE_FORBIDDEN: {
'zh-CN': '冠军盘不可删除',
'en-US': 'Outright events cannot be deleted',
'ms-MY': 'Acara outright tidak boleh dipadam',
},
MATCH_DELETE_DRAFT_ONLY: {
'zh-CN': '仅草稿状态可删除',
'en-US': 'Only draft matches can be deleted',
'ms-MY': 'Hanya draf boleh dipadam',
},
MATCH_HAS_BETS: {
'zh-CN': '该赛事已有注单关联,无法删除',
'en-US': 'Match has bets and cannot be deleted',
'ms-MY': 'Perlawanan mempunyai pertaruhan dan tidak boleh dipadam',
},
ARCHIVE_BLOCKED: {
'zh-CN': '存在未结注单或未结算状态,需确认强制删除',
'en-US': 'Unsettled bets or match state require forced archive',
'ms-MY': 'Pertaruhan belum selesai atau status perlawanan memerlukan arkib paksa',
},
LEAGUE_ARCHIVE_NOT_READY: {
'zh-CN': '联赛下仍有未结算赛事或未结注单,无法删除',
'en-US': 'League still has unsettled fixtures or pending bets',
'ms-MY': 'Liga masih mempunyai perlawanan belum selesai atau pertaruhan tertunda',
},
ALREADY_ARCHIVED: {
'zh-CN': '已删除或已隐藏',
'en-US': 'Already archived',
'ms-MY': 'Sudah diarkibkan',
},
MATCHES_ARRAY_REQUIRED: {
'zh-CN': '请提供 matches 数组',
'en-US': 'matches array is required',
'ms-MY': 'Tatasusunan matches diperlukan',
},
SELECTION_NOT_FOUND: {
'zh-CN': '投注选项不存在',
'en-US': 'Selection not found',
'ms-MY': 'Pilihan tidak dijumpai',
},
SELECTION_CLOSED: {
'zh-CN': '投注选项已关闭',
'en-US': 'Selection closed',
'ms-MY': 'Pilihan ditutup',
},
MARKET_CLOSED: {
'zh-CN': '盘口已关闭',
'en-US': 'Market closed',
'ms-MY': 'Pasaran ditutup',
},
MATCH_NOT_BETTING: {
'zh-CN': '该赛事暂不可投注',
'en-US': 'Match not available for betting',
'ms-MY': 'Perlawanan tidak tersedia untuk pertaruhan',
},
FOOTBALL_ONLY: {
'zh-CN': '仅支持足球投注',
'en-US': 'Only football betting is supported',
'ms-MY': 'Hanya pertaruhan bola sepak disokong',
},
PRE_MATCH_ONLY: {
'zh-CN': '仅支持赛前投注,比赛已开始',
'en-US': 'Pre-match betting only; match has started',
'ms-MY': 'Pertaruhan pra-perlawanan sahaja; perlawanan telah bermula',
},
ODDS_CHANGED: {
'zh-CN': '赔率已变更,请重新确认',
'en-US': 'Odds changed, please confirm again',
'ms-MY': 'Odds berubah, sila sahkan semula',
},
INVALID_STAKE: {
'zh-CN': '投注金额无效',
'en-US': 'Invalid stake',
'ms-MY': 'Stake tidak sah',
},
PARLAY_LEG_COUNT_INVALID: {
'zh-CN': '串关场次须在 {min}{max} 场之间',
'en-US': 'Parlay must have {min}{max} legs',
'ms-MY': 'Parlay mesti {min}{max} pilihan',
},
PARLAY_OUTRIGHT_FORBIDDEN: {
'zh-CN': '冠军盘不可加入串关',
'en-US': 'Outright cannot be in parlay',
'ms-MY': 'Outright tidak boleh dalam parlay',
},
PARLAY_QUARTER_LINE_FORBIDDEN: {
'zh-CN': '四分之一盘口不可加入串关',
'en-US': 'Quarter line markets cannot be in parlay',
'ms-MY': 'Pasaran suku baris tidak boleh dalam parlay',
},
PARLAY_MARKET_NOT_ALLOWED: {
'zh-CN': '该盘口不可加入串关',
'en-US': 'Market not allowed in parlay',
'ms-MY': 'Pasaran tidak dibenarkan dalam parlay',
},
SINGLE_MARKET_NOT_ALLOWED: {
'zh-CN': '该盘口不可单关投注',
'en-US': 'Market not allowed for single bets',
'ms-MY': 'Pasaran ini tidak dibenarkan untuk taruhan tunggal',
},
PARLAY_SAME_MATCH_FORBIDDEN: {
'zh-CN': '同一场比赛不能串关',
'en-US': 'Cannot parlay selections from the same match',
'ms-MY': 'Perlawanan sama tidak boleh berganda',
},
WALLET_FROZEN_INSUFFICIENT: {
'zh-CN': '注单冻结金额不足,无法结算',
'en-US': 'Frozen bet funds are insufficient for settlement',
'ms-MY': 'Dana beku pertaruhan tidak mencukupi untuk penyelesaian',
},
BET_NOT_FOUND: {
'zh-CN': '注单不存在',
'en-US': 'Bet not found',
'ms-MY': 'Pertaruhan tidak dijumpai',
},
MIN_STAKE: {
'zh-CN': '最低投注额为 {minStake}',
'en-US': 'Minimum stake is {minStake}',
'ms-MY': 'Stake minimum ialah {minStake}',
},
MAX_STAKE: {
'zh-CN': '最高投注额为 {maxStake}',
'en-US': 'Maximum stake is {maxStake}',
'ms-MY': 'Stake maksimum ialah {maxStake}',
},
MAX_PAYOUT: {
'zh-CN': '潜在派彩超过限额 {maxPayout}',
'en-US': 'Potential return exceeds limit of {maxPayout}',
'ms-MY': 'Bayaran melebihi had {maxPayout}',
},
DAILY_STAKE_LIMIT: {
'zh-CN': '已超过日投注限额 {limit}',
'en-US': 'Daily stake limit of {limit} exceeded',
'ms-MY': 'Had stake harian {limit} telah melebihi',
},
WALLET_NOT_FOUND: {
'zh-CN': '钱包不存在',
'en-US': 'Wallet not found',
'ms-MY': 'Dompet tidak dijumpai',
},
AMOUNT_MUST_BE_POSITIVE: {
'zh-CN': '金额须大于 0',
'en-US': 'Amount must be positive',
'ms-MY': 'Jumlah mesti positif',
},
INSUFFICIENT_BALANCE: {
'zh-CN': '余额不足',
'en-US': 'Insufficient balance',
'ms-MY': 'Baki tidak mencukupi',
},
AGENT_PROFILE_NOT_FOUND: {
'zh-CN': '代理资料不存在',
'en-US': 'Agent profile not found',
'ms-MY': 'Profil ejen tidak dijumpai',
},
AGENT_NOT_FOUND: {
'zh-CN': '代理不存在',
'en-US': 'Agent not found',
'ms-MY': 'Ejen tidak dijumpai',
},
CREDIT_LIMIT_NEGATIVE: {
'zh-CN': '授信额度不能为负',
'en-US': 'Credit limit cannot be negative',
'ms-MY': 'Had kredit tidak boleh negatif',
},
PLAYER_NOT_FOUND: {
'zh-CN': '玩家不存在',
'en-US': 'Player not found',
'ms-MY': 'Pemain tidak dijumpai',
},
NOT_PLAYER: {
'zh-CN': '该用户不是玩家',
'en-US': 'User is not a player',
'ms-MY': 'Pengguna bukan pemain',
},
PLAYER_HAS_PENDING_BETS: {
'zh-CN': '玩家仍有未结算注单,无法删除',
'en-US': 'Player has pending bets and cannot be deleted',
'ms-MY': 'Pemain masih ada pertaruhan belum selesai dan tidak boleh dipadam',
},
PLAYER_HAS_BALANCE: {
'zh-CN': '玩家钱包仍有余额,无法删除',
'en-US': 'Player wallet still has balance and cannot be deleted',
'ms-MY': 'Dompet pemain masih ada baki dan tidak boleh dipadam',
},
MANAGE_DIRECT_PLAYERS_ONLY: {
'zh-CN': '仅可管理直属玩家',
'en-US': 'Can only manage direct players',
'ms-MY': 'Hanya pemain terus boleh diurus',
},
PARENT_AGENT_NOT_FOUND: {
'zh-CN': '上级代理不存在',
'en-US': 'Parent agent not found',
'ms-MY': 'Ejen induk tidak dijumpai',
},
CREDIT_EXCEEDS_PARENT: {
'zh-CN': '下级代理授信不能超过上级授信额度',
'en-US': 'Sub-agent credit cannot exceed parent limit',
'ms-MY': 'Kredit sub-ejen tidak boleh melebihi induk',
},
CASHBACK_RATE_NEGATIVE: {
'zh-CN': '回水比例不能为负',
'en-US': 'Cashback rate cannot be negative',
'ms-MY': 'Kadar rebat tidak boleh negatif',
},
CASHBACK_RATE_EXCEEDS_PARENT: {
'zh-CN': '下级代理回水比例不能超过上级',
'en-US': 'Sub-agent cashback rate cannot exceed parent',
'ms-MY': 'Kadar rebat sub-ejen tidak boleh melebihi induk',
},
BET_LIMIT_EXCEEDS_PARENT: {
'zh-CN': '下级代理单笔限额不能超过上级',
'en-US': 'Sub-agent bet limit cannot exceed parent',
'ms-MY': 'Had pertaruhan sub-ejen tidak boleh melebihi induk',
},
BET_LIMIT_NEGATIVE: {
'zh-CN': '单笔限额不能为负',
'en-US': 'Bet limit cannot be negative',
'ms-MY': 'Had pertaruhan tidak boleh negatif',
},
DAILY_LIMIT_EXCEEDS_PARENT: {
'zh-CN': '下级代理日限额不能超过上级',
'en-US': 'Sub-agent daily limit cannot exceed parent',
'ms-MY': 'Had harian sub-ejen tidak boleh melebihi induk',
},
DAILY_LIMIT_NEGATIVE: {
'zh-CN': '日限额不能为负',
'en-US': 'Daily limit cannot be negative',
'ms-MY': 'Had harian tidak boleh negatif',
},
CREDIT_TOPUP_EXCEEDED: {
'zh-CN': '超过玩家上级代理可用授信,无法上分',
'en-US': 'Exceeds parent agent available credit',
'ms-MY': 'Melebihi kredit ejen induk yang tersedia',
},
AGENT_SINGLE_TOPUP_LIMIT: {
'zh-CN': '超过代理单笔上分限额',
'en-US': 'Exceeds agent single top-up limit',
'ms-MY': 'Melebihi had top-up tunggal ejen',
},
AGENT_DAILY_TOPUP_LIMIT: {
'zh-CN': '超过代理日上分限额',
'en-US': 'Exceeds agent daily top-up limit',
'ms-MY': 'Melebihi had top-up harian ejen',
},
INSUFFICIENT_AGENT_CREDIT: {
'zh-CN': '代理可用授信不足',
'en-US': 'Insufficient agent credit',
'ms-MY': 'Kredit ejen tidak mencukupi',
},
INVALID_STATUS: {
'zh-CN': '无效状态',
'en-US': 'Invalid status',
'ms-MY': 'Status tidak sah',
},
USERNAME_REQUIRED: {
'zh-CN': '账号名称不能为空',
'en-US': 'Username is required',
'ms-MY': 'Nama pengguna diperlukan',
},
USERNAME_TAKEN: {
'zh-CN': '账号名称已被占用',
'en-US': 'Username already taken',
'ms-MY': 'Nama pengguna sudah digunakan',
},
INVITE_CODE_REQUIRED: {
'zh-CN': '请填写邀请码',
'en-US': 'Invitation code is required',
'ms-MY': 'Kod jemputan diperlukan',
},
INVITE_CODE_INVALID: {
'zh-CN': '邀请码无效或已失效',
'en-US': 'Invalid or inactive invitation code',
'ms-MY': 'Kod jemputan tidak sah atau tidak aktif',
},
INVITE_CODE_NOT_AVAILABLE: {
'zh-CN': '该邀请码暂不可用于注册',
'en-US': 'This invitation code is not available for registration',
'ms-MY': 'Kod jemputan ini tidak tersedia untuk pendaftaran',
},
INVITE_NOT_FOUND: {
'zh-CN': '邀请码记录不存在',
'en-US': 'Invitation record not found',
'ms-MY': 'Rekod jemputan tidak dijumpai',
},
INVITE_MUST_REVOKE_FIRST: {
'zh-CN': '请先作废该邀请码后再删除',
'en-US': 'Revoke the invitation code before deleting',
'ms-MY': 'Batalkan kod jemputan dahulu sebelum padam',
},
INVITE_CODE_ALREADY_USED: {
'zh-CN': '该邀请码已被使用,每个邀请码仅可注册一名玩家',
'en-US': 'This invitation code has already been used; each code allows one registration only',
'ms-MY': 'Kod jemputan ini telah digunakan; setiap kod hanya untuk satu pendaftaran',
},
INVITE_CANNOT_DELETE_USED: {
'zh-CN': '已使用的邀请码不可删除',
'en-US': 'Used invitation codes cannot be deleted',
'ms-MY': 'Kod jemputan yang telah digunakan tidak boleh dipadam',
},
INVITE_CASHBACK_RATE_INVALID: {
'zh-CN': '返水比例无效,请输入非负数',
'en-US': 'Invalid cashback rate; must be a non-negative number',
'ms-MY': 'Kadar rebat tidak sah; mesti nombor bukan negatif',
},
USERNAME_FORMAT_INVALID: {
'zh-CN': '玩家用户名仅可使用英文字母和数字732 位),不可含中文或特殊符号',
'en-US': 'Username must be 732 letters or digits only',
'ms-MY': 'Nama pengguna mesti 732 huruf atau digit sahaja',
},
PASSWORD_MIN_LENGTH: {
'zh-CN': '密码至少 8 位',
'en-US': 'Password must be at least 8 characters',
'ms-MY': 'Kata laluan sekurang-kurangnya 8 aksara',
},
AUTH_INFO_MISSING: {
'zh-CN': '账号认证信息缺失',
'en-US': 'Account auth info missing',
'ms-MY': 'Maklumat auth akaun tiada',
},
USERNAME_CHANGE_DISABLED: {
'zh-CN': '当前平台未开放玩家自行修改账号名称',
'en-US': 'Username change is disabled for players',
'ms-MY': 'Pertukaran nama pengguna pemain tidak dibenarkan',
},
INVALID_AVATAR: {
'zh-CN': '无效头像',
'en-US': 'Invalid avatar',
'ms-MY': 'Avatar tidak sah',
},
UNSUPPORTED_LOCALE: {
'zh-CN': '不支持的语言',
'en-US': 'Unsupported locale',
'ms-MY': 'Locale tidak disokong',
},
NOT_SUB_AGENT: {
'zh-CN': '非您的下级代理',
'en-US': 'Not your sub-agent',
'ms-MY': 'Bukan sub-ejen anda',
},
PROMOTE_PLAYER_ONLY: {
'zh-CN': '仅玩家账号可设为代理',
'en-US': 'Only player accounts can be promoted to agent',
'ms-MY': 'Hanya pemain boleh dinaikkan ke ejen',
},
ALREADY_AGENT: {
'zh-CN': '该用户已是代理',
'en-US': 'User is already an agent',
'ms-MY': 'Pengguna sudah menjadi ejen',
},
AGENT_LEVEL_INVALID: {
'zh-CN': '代理级别无效',
'en-US': 'Invalid agent level',
'ms-MY': 'Tahap ejen tidak sah',
},
AGENT_MAX_LEVEL_REACHED: {
'zh-CN': '已达到最大代理层级,无法继续创建下级',
'en-US': 'Maximum agent level reached; cannot create sub-agents',
'ms-MY': 'Tahap ejen maksimum dicapai; tidak boleh cipta sub-ejen',
},
AGENT_PARENT_LEVEL_MISMATCH: {
'zh-CN': '上级代理层级与目标层级不匹配',
'en-US': 'Parent agent level does not match target level',
'ms-MY': 'Tahap ejen induk tidak sepadan dengan tahap sasaran',
},
AGENT_LEVEL_ROOT_INVALID: {
'zh-CN': '一级代理不可指定上级',
'en-US': 'Root-level agents cannot have a parent',
'ms-MY': 'Ejen peringkat akar tidak boleh ada induk',
},
LEVEL2_REQUIRES_PARENT: {
'zh-CN': '二级代理必须指定上级代理',
'en-US': 'Level 2 agent requires parent',
'ms-MY': 'Ejen peringkat 2 memerlukan induk',
},
TIER1_NO_PARENT_PLAYER: {
'zh-CN': '一级代理不可设置上级玩家',
'en-US': 'Tier-1 agents cannot have a parent player',
'ms-MY': 'Ejen peringkat 1 tidak boleh ada pemain induk',
},
PROMOTE_USE_CREDIT_NOT_BALANCE: {
'zh-CN': '设为代理时请使用授信额度,勿填玩家初始余额',
'en-US': 'Use credit limit when promoting to agent, not initial balance',
'ms-MY': 'Guna had kredit apabila naik taraf ke ejen, bukan baki awal',
},
INITIAL_DEPOSIT_REMARK_REQUIRED: {
'zh-CN': '有初始余额时必须选择上分流水说明',
'en-US': 'Ledger note is required when initial balance > 0',
'ms-MY': 'Nota ledger diperlukan apabila baki awal > 0',
},
INITIAL_DEPOSIT_REMARK_CUSTOM_INVALID: {
'zh-CN': '自定义流水说明至少 2 个字符',
'en-US': 'Custom ledger note must be at least 2 characters',
'ms-MY': 'Nota ledger tersuai mesti sekurang-kurangnya 2 aksara',
},
TIER2_REQUIRES_PARENT_AGENT: {
'zh-CN': '二级代理必须指定上级代理',
'en-US': 'Tier-2 agent must specify parent agent',
'ms-MY': 'Ejen peringkat 2 mesti nyatakan ejen induk',
},
PARENT_MUST_BE_AGENT: {
'zh-CN': '上级必须为代理账号',
'en-US': 'Parent must be an agent account',
'ms-MY': 'Induk mesti akaun ejen',
},
CREATE_DIRECT_PLAYERS_ONLY: {
'zh-CN': '仅可创建直属玩家',
'en-US': 'Can only create direct players',
'ms-MY': 'Hanya pemain terus boleh dicipta',
},
DB_RESET_FORBIDDEN: {
'zh-CN': '生产环境禁止重置数据库(需设置 ALLOW_DB_RESET=true',
'en-US': 'Database reset forbidden in production (set ALLOW_DB_RESET=true)',
'ms-MY': 'Reset DB dilarang di produksi (set ALLOW_DB_RESET=true)',
},
SMOKE_TESTS_FORBIDDEN: {
'zh-CN': '生产环境已禁用自动化测试',
'en-US': 'Smoke tests are disabled in production',
'ms-MY': 'Ujian asap dilumpuhkan di produksi',
},
CASHBACK_DATE_RANGE_INVALID: {
'zh-CN': '开始日期不能晚于结束日期',
'en-US': 'Start date cannot be after end date',
'ms-MY': 'Tarikh mula tidak boleh selepas tarikh tamat',
},
CASHBACK_ALREADY_ISSUED: {
'zh-CN': '该统计周期已发放返水,不可重复生成预览',
'en-US': 'Cashback already issued for this period',
'ms-MY': 'Rebat telah dikeluarkan untuk tempoh ini',
},
CASHBACK_NO_ELIGIBLE_BETS: {
'zh-CN': '该周期内无符合条件的返水,无法生成预览',
'en-US': 'No eligible bets in period for cashback preview',
'ms-MY': 'Tiada pertaruhan layak untuk pratonton rebat',
},
CASHBACK_BETS_IN_OTHER_BATCH: {
'zh-CN': '该周期内的有效注单均已计入其他返水批次,无法生成预览',
'en-US': 'Eligible bets already in another cashback batch',
'ms-MY': 'Pertaruhan layak sudah dalam batch rebat lain',
},
CASHBACK_BATCH_NOT_FOUND: {
'zh-CN': '返水批次不存在',
'en-US': 'Cashback batch not found',
'ms-MY': 'Batch rebat tidak dijumpai',
},
CASHBACK_BATCH_NOT_ISSUABLE: {
'zh-CN': '该批次不可发放',
'en-US': 'Batch cannot be issued',
'ms-MY': 'Batch tidak boleh dikeluarkan',
},
CASHBACK_NO_AMOUNT: {
'zh-CN': '批次无有效返水金额',
'en-US': 'Batch has no valid cashback amount',
'ms-MY': 'Batch tiada jumlah rebat sah',
},
CASHBACK_PERIOD_ALREADY_ISSUED: {
'zh-CN': '该统计周期已发放返水',
'en-US': 'Cashback already issued for this period',
'ms-MY': 'Rebat tempoh ini telah dikeluarkan',
},
CASHBACK_BETS_ALREADY_PAID: {
'zh-CN': '部分注单已在其他批次发放返水,请作废本预览后重新生成',
'en-US': 'Some bets paid in another batch; void preview and regenerate',
'ms-MY': 'Sebahagian pertaruhan dibayar dalam batch lain; batalkan pratonton',
},
CASHBACK_PREVIEW_ONLY_VOID: {
'zh-CN': '只能作废待发放批次',
'en-US': 'Only preview batches can be voided',
'ms-MY': 'Hanya batch pratonton boleh dibatalkan',
},
UNKNOWN_MARKET_TYPE: {
'zh-CN': '未知盘口类型:{marketType}',
'en-US': 'Unknown market type: {marketType}',
'ms-MY': 'Jenis pasaran tidak diketahui: {marketType}',
},
ODDS_MIN: {
'zh-CN': '赔率须大于 1.00',
'en-US': 'Odds must be greater than 1.00',
'ms-MY': 'Odds mesti lebih daripada 1.00',
},
MARKET_NOT_FOUND: {
'zh-CN': '盘口不存在',
'en-US': 'Market not found',
'ms-MY': 'Pasaran tidak dijumpai',
},
MARKET_TEMPLATE_NOT_FOUND: {
'zh-CN': '盘口模板不存在',
'en-US': 'Market template not found',
'ms-MY': 'Templat pasaran tidak dijumpai',
},
MARKET_LINE_REQUIRED: {
'zh-CN': '该盘口类型必须设置盘口线',
'en-US': 'This market type requires a line value',
'ms-MY': 'Jenis pasaran ini memerlukan nilai garisan',
},
MARKET_LINE_NOT_ALLOWED: {
'zh-CN': '该盘口类型不允许设置盘口线',
'en-US': 'This market type does not allow a line value',
'ms-MY': 'Jenis pasaran ini tidak membenarkan nilai garisan',
},
OPERATOR_REQUIRED: {
'zh-CN': '修改赔率须指定操作员',
'en-US': 'Operator required for odds update',
'ms-MY': 'Operator diperlukan untuk kemas kini odds',
},
OUTRIGHT_SELECTION_EXISTS: {
'zh-CN': '该球队代码已存在选项',
'en-US': 'Selection already exists for this team code',
'ms-MY': 'Pilihan untuk kod pasukan ini sudah wujud',
},
OUTRIGHT_TEAMS_REQUIRED: {
'zh-CN': '至少添加一支球队',
'en-US': 'At least one team required',
'ms-MY': 'Sekurang-kurangnya satu pasukan diperlukan',
},
OUTRIGHT_SELECTION_INVALID: {
'zh-CN': '无效的夺冠选项',
'en-US': 'Invalid selection for this outright event',
'ms-MY': 'Pilihan tidak sah untuk outright ini',
},
OUTRIGHT_LEAGUE_FIXTURES_UNSETTLED: {
'zh-CN': '该联赛仍有未结算的单场赛事,请先完成单场结算后再结算冠军盘',
'en-US': 'This league still has unsettled fixture matches. Settle them before settling the outright market.',
'ms-MY': 'Liga ini masih ada perlawanan belum diselesaikan. Selesaikan dahulu sebelum juara.',
},
OUTRIGHT_EVENT_NOT_FOUND: {
'zh-CN': '冠军盘赛事不存在',
'en-US': 'Outright event not found',
'ms-MY': 'Acara outright tidak dijumpai',
},
SETTLEMENT_WINNER_REQUIRED: {
'zh-CN': '冠军盘结算需指定获胜球队',
'en-US': 'Outright settlement requires winner team',
'ms-MY': 'Penyelesaian outright memerlukan pasukan pemenang',
},
SETTLEMENT_WINNER_NOT_FOUND: {
'zh-CN': '获胜球队不存在',
'en-US': 'Winner team not found',
'ms-MY': 'Pasukan pemenang tidak dijumpai',
},
SETTLEMENT_WINNER_NOT_IN_MARKET: {
'zh-CN': '该球队不在本冠军盘选项中',
'en-US': 'Team is not in this outright market',
'ms-MY': 'Pasukan tiada dalam pasaran outright ini',
},
SCORE_NOT_RECORDED: {
'zh-CN': '尚未录入比分',
'en-US': 'Score not recorded',
'ms-MY': 'Skor belum direkod',
},
SETTLEMENT_FACTS_REQUIRED: {
'zh-CN': '缺少比赛统计事实:{fields}',
'en-US': 'Missing match settlement facts: {fields}',
'ms-MY': 'Fakta penyelesaian perlawanan belum lengkap: {fields}',
},
SETTLEMENT_BATCH_NOT_FOUND: {
'zh-CN': '结算批次不存在',
'en-US': 'Settlement batch not found',
'ms-MY': 'Batch penyelesaian tidak dijumpai',
},
SETTLEMENT_BATCH_NOT_PREVIEW: {
'zh-CN': '结算批次不在预览状态',
'en-US': 'Batch is not in preview status',
'ms-MY': 'Batch bukan dalam status pratonton',
},
SETTLEMENT_BATCH_ALREADY_CONFIRMED: {
'zh-CN': '结算批次已确认',
'en-US': 'Batch already confirmed',
'ms-MY': 'Batch sudah disahkan',
},
SCORE_NOT_FOUND: {
'zh-CN': '比分不存在',
'en-US': 'Score not found',
'ms-MY': 'Skor tidak dijumpai',
},
PARLAY_UNSETTLED_LEGS: {
'zh-CN': '串关注单 {betId} 仍有未结算场次',
'en-US': 'Parlay bet {betId} has unsettled legs',
'ms-MY': 'Parlay {betId} masih ada pilihan belum selesai',
},
RESETTLE_SETTLED_ONLY: {
'zh-CN': '仅已结算赛事可重结算',
'en-US': 'Only settled matches can be resettled',
'ms-MY': 'Hanya perlawanan selesai boleh diselesaikan semula',
},
RESETTLE_BATCH_ONLY: {
'zh-CN': '非重结算批次',
'en-US': 'Not a resettle batch',
'ms-MY': 'Bukan batch penyelesaian semula',
},
UPLOAD_CATEGORY_UNSUPPORTED: {
'zh-CN': '不支持的上传分类',
'en-US': 'Unsupported upload category',
'ms-MY': 'Kategori muat naik tidak disokong',
},
UPLOAD_IMAGE_REQUIRED: {
'zh-CN': '请选择图片文件',
'en-US': 'Image file is required',
'ms-MY': 'Fail imej diperlukan',
},
UPLOAD_IMAGE_TYPE_INVALID: {
'zh-CN': '仅支持 PNG、JPG、WEBP、GIF 或 SVG 图片',
'en-US': 'Only PNG, JPG, WEBP, GIF or SVG images are allowed',
'ms-MY': 'Hanya PNG, JPG, WEBP, GIF atau SVG dibenarkan',
},
UPLOAD_SVG_UNSAFE: {
'zh-CN': 'SVG 内容不安全,已拒绝',
'en-US': 'Unsafe SVG content is not allowed',
'ms-MY': 'Kandungan SVG tidak selamat',
},
DB_RESET_PHRASE_INVALID: {
'zh-CN': '确认短语不正确,请输入 RESET',
'en-US': 'Invalid confirmation phrase; type RESET',
'ms-MY': 'Frasa pengesahan salah; taip RESET',
},
IMPORT_MATCHES_REQUIRED: {
'zh-CN': '导入数据无效:需要 matches[]',
'en-US': 'Invalid import payload: matches[] required',
'ms-MY': 'Payload import tidak sah: matches[] diperlukan',
},
WC_OUTRIGHT_NOT_FOUND: {
'zh-CN': '未找到 WC2026 冠军盘,请先导入',
'en-US': 'WC2026 outright not found — run import',
'ms-MY': 'Outright WC2026 tidak dijumpai — jalankan import',
},
FILE_NOT_FOUND: {
'zh-CN': '文件不存在',
'en-US': 'File not found',
'ms-MY': 'Fail tidak dijumpai',
},
URL_REQUIRED: {
'zh-CN': '请提供 url 参数',
'en-US': 'url is required',
'ms-MY': 'url diperlukan',
},
CONTENT_TYPE_INVALID: {
'zh-CN': '无效内容类型:{type}',
'en-US': 'Invalid contentType: {type}',
'ms-MY': 'Jenis kandungan tidak sah: {type}',
},
CONTENT_STATUS_INVALID: {
'zh-CN': '无效状态:{status}',
'en-US': 'Invalid status: {status}',
'ms-MY': 'Status tidak sah: {status}',
},
CONTENT_END_BEFORE_START: {
'zh-CN': '结束时间须晚于开始时间',
'en-US': 'endTime must be after startTime',
'ms-MY': 'endTime mesti selepas startTime',
},
CONTENT_TRANSLATION_REQUIRED: {
'zh-CN': '至少填写一条翻译',
'en-US': 'At least one translation required',
'ms-MY': 'Sekurang-kurangnya satu terjemahan diperlukan',
},
CONTENT_LOCALE_REQUIRED: {
'zh-CN': '翻译语言不能为空',
'en-US': 'Translation locale required',
'ms-MY': 'Locale terjemahan diperlukan',
},
CONTENT_LOCALE_DUPLICATE: {
'zh-CN': '重复的语言:{locale}',
'en-US': 'Duplicate locale: {locale}',
'ms-MY': 'Locale pendua: {locale}',
},
CONTENT_LINK_TYPE_INVALID: {
'zh-CN': '无效链接类型:{linkType}',
'en-US': 'Invalid linkType: {linkType}',
'ms-MY': 'Jenis pautan tidak sah: {linkType}',
},
CONTENT_LINK_TARGET_REQUIRED: {
'zh-CN': '设置链接类型时须填写 linkTarget',
'en-US': 'linkTarget required when linkType is set',
'ms-MY': 'linkTarget diperlukan apabila linkType ditetapkan',
},
CONTENT_NOT_FOUND: {
'zh-CN': '内容不存在',
'en-US': 'Content not found',
'ms-MY': 'Kandungan tidak dijumpai',
},
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',
'ms-MY': 'Banner aktif memerlukan imageUrl sekurang-kurangnya satu locale',
},
CONTENT_ACTIVE_NOTICE_INCOMPLETE: {
'zh-CN': '启用公告须至少一种语言填写标题或正文',
'en-US': 'ACTIVE notice requires title or body in at least one locale',
'ms-MY': 'Notis aktif memerlukan tajuk atau kandungan',
},
CONTENT_ACTIVE_TICKER_INCOMPLETE: {
'zh-CN': '启用滚动公告须至少一种语言填写正文',
'en-US': 'ACTIVE ticker requires body in at least one locale',
'ms-MY': 'Ticker aktif memerlukan kandungan',
},
INVALID_METHOD_TYPE: {
'zh-CN': '无效的收款方式类型',
'en-US': 'Invalid payment method type',
'ms-MY': 'Jenis kaedah pembayaran tidak sah',
},
PAYMENT_METHOD_NOT_FOUND: {
'zh-CN': '收款方式不存在或已停用',
'en-US': 'Payment method not found or inactive',
'ms-MY': 'Kaedah pembayaran tidak dijumpai atau tidak aktif',
},
ORDER_NOT_FOUND: {
'zh-CN': '充值订单不存在',
'en-US': 'Deposit order not found',
'ms-MY': 'Pesanan deposit tidak dijumpai',
},
ORDER_NOT_PENDING: {
'zh-CN': '订单已被审核或不是待审核状态',
'en-US': 'Order is not in pending status',
'ms-MY': 'Pesanan bukan dalam status menunggu',
},
ORDER_ALREADY_PENDING: {
'zh-CN': '订单已是待审核状态',
'en-US': 'Order is already pending review',
'ms-MY': 'Pesanan sudah menunggu semakan',
},
ORDER_NOT_APPROVED: {
'zh-CN': '仅已通过的充值订单可撤销',
'en-US': 'Only approved deposit orders can be revoked',
'ms-MY': 'Hanya pesanan deposit yang diluluskan boleh dibatalkan',
},
DEPOSIT_REVOKE_WINDOW_EXPIRED: {
'zh-CN': '批准已超过 5 分钟,无法撤回',
'en-US': 'Approval was more than 5 minutes ago; revoke is no longer allowed',
'ms-MY': 'Kelulusan melebihi 5 minit; pembatalan tidak dibenarkan',
},
DEPOSIT_REVOKE_SETTLED_BETS: {
'zh-CN': '批准后已有投注,无法直接撤回;请走冲正流程',
'en-US': 'Bets were placed after approval; use an adjustment flow instead',
'ms-MY': 'Terdapat pertaruhan selepas kelulusan; sila gunakan aliran pelarasan',
},
DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN: {
'zh-CN': '已入账充值订单不能删除,请走撤回或冲正流程',
'en-US': 'Funded deposit orders cannot be deleted; use revoke or adjustment flow',
'ms-MY': 'Pesanan deposit yang telah dikreditkan tidak boleh dipadam; gunakan pembatalan atau pelarasan',
},
REASON_REQUIRED: {
'zh-CN': '请填写拒绝原因',
'en-US': 'Rejection reason is required',
'ms-MY': 'Sebab penolakan diperlukan',
},
SCREENSHOT_REQUIRED: {
'zh-CN': '请上传转账截图',
'en-US': 'Screenshot is required',
'ms-MY': 'Screenshot diperlukan',
},
FILE_MUST_BE_IMAGE: {
'zh-CN': '请上传图片文件',
'en-US': 'File must be an image',
'ms-MY': 'Fail mesti imej',
},
INVALID_AMOUNT: {
'zh-CN': '金额无效',
'en-US': 'Invalid amount',
'ms-MY': 'Jumlah tidak sah',
},
PAYMENT_METHOD_REQUIRED: {
'zh-CN': '请选择收款方式',
'en-US': 'Payment method is required',
'ms-MY': 'Kaedah pembayaran diperlukan',
},
PHONE_REQUIRED: {
'zh-CN': '请填写手机号',
'en-US': 'Phone number is required',
'ms-MY': 'Nombor telefon diperlukan',
},
PHONE_INVALID: {
'zh-CN': '手机号格式无效',
'en-US': 'Invalid phone number',
'ms-MY': 'Nombor telefon tidak sah',
},
PHONE_TAKEN: {
'zh-CN': '该手机号已注册',
'en-US': 'This phone number is already registered',
'ms-MY': 'Nombor telefon ini sudah didaftarkan',
},
PHONE_NOT_REGISTERED: {
'zh-CN': '该手机号未注册',
'en-US': 'This phone number is not registered',
'ms-MY': 'Nombor telefon ini belum didaftarkan',
},
SMS_CODE_REQUIRED: {
'zh-CN': '请填写短信验证码',
'en-US': 'SMS verification code is required',
'ms-MY': 'Kod pengesahan SMS diperlukan',
},
SMS_CODE_INVALID: {
'zh-CN': '验证码错误',
'en-US': 'Incorrect verification code',
'ms-MY': 'Kod pengesahan salah',
},
SMS_CODE_EXPIRED: {
'zh-CN': '验证码已过期,请重新获取',
'en-US': 'Verification code expired, please request a new one',
'ms-MY': 'Kod pengesahan tamat tempoh, sila minta yang baharu',
},
SMS_RATE_LIMIT: {
'zh-CN': '发送太频繁请60秒后再试',
'en-US': 'Too many requests, please try again in 60 seconds',
'ms-MY': 'Terlalu kerap, sila cuba lagi dalam 60 saat',
},
SMS_SEND_FAILED: {
'zh-CN': '短信发送失败,请稍后重试',
'en-US': 'Failed to send SMS, please try again later',
'ms-MY': 'Gagal menghantar SMS, sila cuba lagi',
},
PHONE_COUNTRY_UNSUPPORTED: {
'zh-CN': '暂不支持该国家/地区',
'en-US': 'This country or region is not supported',
'ms-MY': 'Negara atau wilayah ini tidak disokong',
},
};
export function normalizeLocale(input) {
const raw = String(input ?? '').trim();
if (!raw)
return DEFAULT_LOCALE;
const lower = raw.toLowerCase();
if (lower.startsWith('zh'))
return 'zh-CN';
if (lower.startsWith('ms') || lower.startsWith('my'))
return 'ms-MY';
if (lower.startsWith('en'))
return 'en-US';
if (SUPPORTED_LOCALES.includes(raw))
return raw;
return DEFAULT_LOCALE;
}
export function formatApiErrorMessage(code, localeInput, params) {
const locale = normalizeLocale(localeInput);
const template = API_ERROR_MESSAGES[code]?.[locale] ??
API_ERROR_MESSAGES[code]?.[DEFAULT_LOCALE] ??
API_ERROR_MESSAGES.INTERNAL_SERVER_ERROR[locale];
if (!params)
return template;
return template.replace(/\{(\w+)\}/g, (_match, key) => String(params[key] ?? `{${key}}`));
}
export function isApiErrorCode(value) {
return typeof value === 'string' && value in API_ERROR_MESSAGES;
}

View File

@@ -154,6 +154,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Kickoff has passed; set a new future start time',
'ms-MY': 'Masa mula telah berlalu; tetapkan masa mula baharu pada masa hadapan',
},
MATCH_START_TIME_INVALID: {
'zh-CN': '开赛时间格式无效',
'en-US': 'Invalid kickoff time',
'ms-MY': 'Format masa mula tidak sah',
},
OUTRIGHT_DELETE_FORBIDDEN: {
'zh-CN': '冠军盘不可删除',
'en-US': 'Outright events cannot be deleted',
@@ -219,11 +224,6 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Pre-match betting only; match has started',
'ms-MY': 'Pertaruhan pra-perlawanan sahaja; perlawanan telah bermula',
},
CORRECT_SCORE_DISABLED: {
'zh-CN': '该赛事未开放波胆投注',
'en-US': 'Correct score betting is disabled for this match',
'ms-MY': 'Pertaruhan skor tepat tidak dibuka untuk perlawanan ini',
},
ODDS_CHANGED: {
'zh-CN': '赔率已变更,请重新确认',
'en-US': 'Odds changed, please confirm again',
@@ -254,11 +254,21 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Market not allowed in parlay',
'ms-MY': 'Pasaran tidak dibenarkan dalam parlay',
},
SINGLE_MARKET_NOT_ALLOWED: {
'zh-CN': '该盘口不可单关投注',
'en-US': 'Market not allowed for single bets',
'ms-MY': 'Pasaran ini tidak dibenarkan untuk taruhan tunggal',
},
PARLAY_SAME_MATCH_FORBIDDEN: {
'zh-CN': '同一场比赛不能串关',
'en-US': 'Cannot parlay selections from the same match',
'ms-MY': 'Perlawanan sama tidak boleh berganda',
},
WALLET_FROZEN_INSUFFICIENT: {
'zh-CN': '注单冻结金额不足,无法结算',
'en-US': 'Frozen bet funds are insufficient for settlement',
'ms-MY': 'Dana beku pertaruhan tidak mencukupi untuk penyelesaian',
},
BET_NOT_FOUND: {
'zh-CN': '注单不存在',
'en-US': 'Bet not found',
@@ -634,6 +644,21 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Market not found',
'ms-MY': 'Pasaran tidak dijumpai',
},
MARKET_TEMPLATE_NOT_FOUND: {
'zh-CN': '盘口模板不存在',
'en-US': 'Market template not found',
'ms-MY': 'Templat pasaran tidak dijumpai',
},
MARKET_LINE_REQUIRED: {
'zh-CN': '该盘口类型必须设置盘口线',
'en-US': 'This market type requires a line value',
'ms-MY': 'Jenis pasaran ini memerlukan nilai garisan',
},
MARKET_LINE_NOT_ALLOWED: {
'zh-CN': '该盘口类型不允许设置盘口线',
'en-US': 'This market type does not allow a line value',
'ms-MY': 'Jenis pasaran ini tidak membenarkan nilai garisan',
},
OPERATOR_REQUIRED: {
'zh-CN': '修改赔率须指定操作员',
'en-US': 'Operator required for odds update',
@@ -684,6 +709,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Score not recorded',
'ms-MY': 'Skor belum direkod',
},
SETTLEMENT_FACTS_REQUIRED: {
'zh-CN': '缺少比赛统计事实:{fields}',
'en-US': 'Missing match settlement facts: {fields}',
'ms-MY': 'Fakta penyelesaian perlawanan belum lengkap: {fields}',
},
SETTLEMENT_BATCH_NOT_FOUND: {
'zh-CN': '结算批次不存在',
'en-US': 'Settlement batch not found',
@@ -860,9 +890,14 @@ export const API_ERROR_MESSAGES = {
'ms-MY': 'Kelulusan melebihi 5 minit; pembatalan tidak dibenarkan',
},
DEPOSIT_REVOKE_SETTLED_BETS: {
'zh-CN': '批准后有注单已结算,无法撤回;请先处理相关注单',
'en-US': 'Bets placed after approval have already settled; revoke is blocked',
'ms-MY': 'Terdapat pertaruhan selepas kelulusan yang telah diselesaikan; pembatalan disekat',
'zh-CN': '批准后已有投注,无法直接撤回;请走冲正流程',
'en-US': 'Bets were placed after approval; use an adjustment flow instead',
'ms-MY': 'Terdapat pertaruhan selepas kelulusan; sila gunakan aliran pelarasan',
},
DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN: {
'zh-CN': '已入账充值订单不能删除,请走撤回或冲正流程',
'en-US': 'Funded deposit orders cannot be deleted; use revoke or adjustment flow',
'ms-MY': 'Pesanan deposit yang telah dikreditkan tidak boleh dipadam; gunakan pembatalan atau pelarasan',
},
REASON_REQUIRED: {
'zh-CN': '请填写拒绝原因',

View File

@@ -0,0 +1,44 @@
import { DEFAULT_MARKET_TYPES } from './market-catalog';
/** 第一版仅足球;字段预留其他 sportType */
export const SPORT_TYPE_FOOTBALL = 'FOOTBALL';
/** 常规赛事发布时生成的赛前盘口(手动维护,不含冠军盘) */
export const STANDARD_PREMATCH_MARKET_TYPES = DEFAULT_MARKET_TYPES;
export const HANDICAP_TOTAL_MARKET_TYPES = [
'FT_HANDICAP',
'HT_HANDICAP',
'FT_OVER_UNDER',
'HT_OVER_UNDER',
'FT_TEAM_TOTAL_HOME',
'FT_TEAM_TOTAL_AWAY',
'FT_CORNERS_HANDICAP',
'FT_CORNERS_OVER_UNDER',
'FT_CARDS_OVER_UNDER',
];
export function isQuarterLine(line) {
if (line == null || Number.isNaN(line))
return false;
const frac = Math.abs(line % 1);
return Math.abs(frac - 0.25) < 0.001 || Math.abs(frac - 0.75) < 0.001;
}
export function isQuarterHandicapOrTotal(line) {
return isQuarterLine(line);
}
export function canSelectForParlay(params) {
if (params.marketType === 'OUTRIGHT_WINNER' || params.isOutright) {
return { ok: false, reason: 'OUTRIGHT' };
}
if (params.allowParlay === false) {
return { ok: false, reason: 'NOT_ALLOWED' };
}
if (HANDICAP_TOTAL_MARKET_TYPES.includes(params.marketType) &&
isQuarterHandicapOrTotal(params.lineValue ?? null)) {
return { ok: false, reason: 'QUARTER_LINE' };
}
return { ok: true };
}
export function isPreMatchKickoff(startTime) {
return new Date() < new Date(startTime);
}
export function isSupportedSport(sportType) {
return (sportType ?? SPORT_TYPE_FOOTBALL) === SPORT_TYPE_FOOTBALL;
}

View File

@@ -1,41 +1,24 @@
import { DEFAULT_MARKET_TYPES } from './market-catalog';
/** 第一版仅足球;字段预留其他 sportType */
export const SPORT_TYPE_FOOTBALL = 'FOOTBALL';
/** 常规赛事发布时生成的赛前盘口(手动维护,不含冠军盘) */
export const STANDARD_PREMATCH_MARKET_TYPES = [
'FT_1X2',
'FT_HANDICAP',
'FT_OVER_UNDER',
'FT_ODD_EVEN',
'HT_1X2',
'HT_HANDICAP',
'HT_OVER_UNDER',
'FT_CORRECT_SCORE',
'HT_CORRECT_SCORE',
'SH_CORRECT_SCORE',
] as const;
export const STANDARD_PREMATCH_MARKET_TYPES = DEFAULT_MARKET_TYPES;
export const HANDICAP_TOTAL_MARKET_TYPES = [
'FT_HANDICAP',
'HT_HANDICAP',
'FT_OVER_UNDER',
'HT_OVER_UNDER',
'FT_TEAM_TOTAL_HOME',
'FT_TEAM_TOTAL_AWAY',
'FT_CORNERS_HANDICAP',
'FT_CORNERS_OVER_UNDER',
'FT_CARDS_OVER_UNDER',
] as const;
export type ParlayRejectReason = 'OUTRIGHT' | 'NOT_ALLOWED' | 'QUARTER_LINE' | 'SAME_MATCH';
export function hasDuplicateParlayMatch(
matchIds: Array<string | bigint | null | undefined>,
): boolean {
const seen = new Set<string>();
for (const id of matchIds) {
if (id == null) continue;
const key = String(id);
if (seen.has(key)) return true;
seen.add(key);
}
return false;
}
export type ParlayRejectReason = 'OUTRIGHT' | 'NOT_ALLOWED' | 'QUARTER_LINE';
export function isQuarterLine(line: number | null | undefined): boolean {
if (line == null || Number.isNaN(line)) return false;

View File

@@ -0,0 +1,97 @@
const BUILTIN_PLAYER_LEGACY_FILENAMES = [
'何塞·曼努埃尔·洛佩斯-前锋-阿根廷.jpg',
'佩德里-中场-西班牙.jpg',
'卢卡·莫德里奇-中场-克罗地亚.jpg',
'华金·皮克雷斯-中场-乌拉圭.jpg',
'乔治亚·德·阿拉斯凯塔-中场-乌拉圭.jpg',
'乌古尔坎·卡基尔-守門員-土耳其.jpg',
'亚历杭德罗·曾德哈斯-前锋-美国.jpg',
'恩德里克-前锋-巴西.jpg',
'克里斯蒂亚诺·罗纳尔多-前锋-葡萄牙.jpg',
'克里斯蒂安·罗梅罗-后卫-阿根廷.jpg',
'内马尔-前锋-巴西.jpg',
'凯南·耶尔德兹-前锋-土耳其.jpg',
'卡塞米罗-中场-巴西.jpg',
'卢卡斯·帕奎塔-中场-巴西.jpg',
'基利安·姆巴佩-前锋-法国.jpg',
'孟菲斯·德派-前锋-荷兰.jpg',
'奥斯曼·登贝莱-前锋-法国.jpg',
'布鲁诺·吉马良斯-中场-巴西.jpg',
'布鲁诺·费尔南德斯-中场-葡萄牙.jpg',
'布卡约·萨卡-前锋-英格兰.jpg',
'德尼兹·居尔-前锋-土耳其.jpg',
'德尼兹·温达夫-前锋-德国.jpg',
'拉斐尔·迪亚斯·贝洛利-前锋-巴西.jpg',
'拉明·亚马尔-前锋-西班牙.jpg',
'朱利安·阿尔瓦雷斯-前锋-阿根廷.jpg',
'梅西-前锋-阿根廷.jpg',
'迈克尔·奥利塞-前锋-法国.jpg',
'穆罕默德·萨拉赫-前锋-埃及.jpg',
'维尼修斯·儒尼奥尔-前锋-巴西.jpg',
'维克托·哲凯赖什-前锋-瑞典.jpg',
'圣地亚哥·吉梅内斯-前锋-墨西哥.jpg',
'埃德森·阿尔瓦雷斯-后卫-墨西哥.jpg',
'埃贝雷奇·埃泽-中场-英格兰.jpg',
'埃尔林·哈兰德-前锋-挪威.jpg',
'蒂博·库尔图瓦-守門員-比利时.jpg',
'曼努埃尔·诺伊尔-守門員-德国.jpg',
'祖德·贝林厄姆-中场-英格兰.jpg',
'伦纳特·卡尔-中场-德国.jpg',
'费德里科·巴尔韦德-中场-乌拉圭.jpg',
'贾马尔·慕斯拉-中场-德国.jpg',
'路易斯·迪亚斯-前锋-哥伦比亚.jpg',
'阿什拉夫·哈基米-后卫-摩洛哥.jpg',
'阿利松·贝克尔-守門員-巴西.jpg',
'阿尔达·居莱尔-前锋-土耳其.jpg',
'马西斯·拉扬·切尔基-中场-法国.jpg',
'马库斯·拉什福德-前锋-英格兰.jpg',
'哈里·凯恩-前锋-英格兰.jpg',
'尼科·威廉斯-前锋-西班牙.jpg',
'巴勃罗·加维-中场-西班牙.jpg',
'吉列尔莫·奥乔亚-守門員-墨西哥.jpg',
];
function parsePlayerFilename(filename) {
const base = filename.replace(/\.jpg$/i, '');
const parts = base.split('-');
const country = parts.pop() ?? '';
const position = parts.pop() ?? '';
const name = parts.join('-');
return { id: base, name, position, country, filename };
}
export const BUILTIN_PLAYERS = BUILTIN_PLAYER_LEGACY_FILENAMES.map((legacy, index) => ({
...parsePlayerFilename(legacy),
filename: `player-${index}.jpg`,
}));
const AVATAR_KEY_SET = new Set(BUILTIN_PLAYERS.map((p) => p.id));
export function isValidAvatarKey(key) {
if (!key)
return true;
return AVATAR_KEY_SET.has(key);
}
export function playerAvatarUrl(key) {
if (!key)
return null;
const player = BUILTIN_PLAYERS.find((p) => p.id === key);
if (!player)
return null;
return `/players/${player.filename}`;
}
export function getBuiltinPlayer(key) {
if (!key)
return null;
return BUILTIN_PLAYERS.find((p) => p.id === key) ?? null;
}
/** 按 seed 稳定随机,无 seed 时完全随机 */
export function randomAvatarKey(seed) {
if (!BUILTIN_PLAYERS.length)
return '';
if (seed === undefined || seed === null || seed === '') {
return BUILTIN_PLAYERS[Math.floor(Math.random() * BUILTIN_PLAYERS.length)].id;
}
const text = String(seed);
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
}
return BUILTIN_PLAYERS[hash % BUILTIN_PLAYERS.length].id;
}

View File

@@ -0,0 +1,129 @@
// User & Auth
export var UserType;
(function (UserType) {
UserType["PLAYER"] = "PLAYER";
UserType["AGENT"] = "AGENT";
UserType["ADMIN"] = "ADMIN";
})(UserType || (UserType = {}));
export var UserStatus;
(function (UserStatus) {
UserStatus["ACTIVE"] = "ACTIVE";
UserStatus["DISABLED"] = "DISABLED";
UserStatus["LOCKED"] = "LOCKED";
})(UserStatus || (UserStatus = {}));
/** Minimum agent tier (root agents). Actual levels are unbounded integers when `agent.max_level` is 0. */
export const MIN_AGENT_LEVEL = 1;
/**
* Legacy enum for the original two-tier demo. Production code should use numeric `agentLevel` / `AgentProfile.level`.
*/
export var AgentLevel;
(function (AgentLevel) {
AgentLevel[AgentLevel["LEVEL_1"] = 1] = "LEVEL_1";
AgentLevel[AgentLevel["LEVEL_2"] = 2] = "LEVEL_2";
})(AgentLevel || (AgentLevel = {}));
// Match
export var MatchStatus;
(function (MatchStatus) {
MatchStatus["DRAFT"] = "DRAFT";
MatchStatus["PUBLISHED"] = "PUBLISHED";
MatchStatus["CLOSED"] = "CLOSED";
MatchStatus["PENDING_SETTLEMENT"] = "PENDING_SETTLEMENT";
MatchStatus["SETTLED"] = "SETTLED";
MatchStatus["CANCELLED"] = "CANCELLED";
MatchStatus["VOID"] = "VOID";
})(MatchStatus || (MatchStatus = {}));
export var MarketStatus;
(function (MarketStatus) {
MarketStatus["OPEN"] = "OPEN";
MarketStatus["SUSPENDED"] = "SUSPENDED";
MarketStatus["CLOSED"] = "CLOSED";
})(MarketStatus || (MarketStatus = {}));
export var MarketType;
(function (MarketType) {
MarketType["FT_CORRECT_SCORE"] = "FT_CORRECT_SCORE";
MarketType["HT_CORRECT_SCORE"] = "HT_CORRECT_SCORE";
MarketType["SH_CORRECT_SCORE"] = "SH_CORRECT_SCORE";
MarketType["FT_HANDICAP"] = "FT_HANDICAP";
MarketType["FT_OVER_UNDER"] = "FT_OVER_UNDER";
MarketType["FT_1X2"] = "FT_1X2";
MarketType["FT_ODD_EVEN"] = "FT_ODD_EVEN";
MarketType["HT_HANDICAP"] = "HT_HANDICAP";
MarketType["HT_OVER_UNDER"] = "HT_OVER_UNDER";
MarketType["HT_1X2"] = "HT_1X2";
MarketType["OUTRIGHT_WINNER"] = "OUTRIGHT_WINNER";
})(MarketType || (MarketType = {}));
export var Period;
(function (Period) {
Period["FT"] = "FT";
Period["HT"] = "HT";
Period["SH"] = "SH";
Period["OUTRIGHT"] = "OUTRIGHT";
})(Period || (Period = {}));
// Bet
export var BetType;
(function (BetType) {
BetType["SINGLE"] = "SINGLE";
BetType["PARLAY"] = "PARLAY";
})(BetType || (BetType = {}));
export var BetStatus;
(function (BetStatus) {
BetStatus["PENDING"] = "PENDING";
BetStatus["WON"] = "WON";
BetStatus["LOST"] = "LOST";
BetStatus["PUSH"] = "PUSH";
BetStatus["VOID"] = "VOID";
BetStatus["CANCELLED"] = "CANCELLED";
BetStatus["SETTLED"] = "SETTLED";
})(BetStatus || (BetStatus = {}));
export var SelectionResult;
(function (SelectionResult) {
SelectionResult["WIN"] = "WIN";
SelectionResult["HALF_WIN"] = "HALF_WIN";
SelectionResult["PUSH"] = "PUSH";
SelectionResult["HALF_LOSE"] = "HALF_LOSE";
SelectionResult["LOSE"] = "LOSE";
SelectionResult["VOID"] = "VOID";
})(SelectionResult || (SelectionResult = {}));
// Wallet
export var WalletTransactionType;
(function (WalletTransactionType) {
WalletTransactionType["MANUAL_DEPOSIT"] = "MANUAL_DEPOSIT";
WalletTransactionType["MANUAL_WITHDRAW"] = "MANUAL_WITHDRAW";
WalletTransactionType["BET_FREEZE"] = "BET_FREEZE";
WalletTransactionType["BET_SETTLE_WIN"] = "BET_SETTLE_WIN";
WalletTransactionType["BET_SETTLE_LOSE"] = "BET_SETTLE_LOSE";
WalletTransactionType["BET_SETTLE_PUSH"] = "BET_SETTLE_PUSH";
WalletTransactionType["BET_VOID_REFUND"] = "BET_VOID_REFUND";
WalletTransactionType["CASHBACK"] = "CASHBACK";
WalletTransactionType["RESETTLE_REVERSE"] = "RESETTLE_REVERSE";
WalletTransactionType["MANUAL_ADJUST"] = "MANUAL_ADJUST";
})(WalletTransactionType || (WalletTransactionType = {}));
export var WalletStatus;
(function (WalletStatus) {
WalletStatus["ACTIVE"] = "ACTIVE";
WalletStatus["FROZEN"] = "FROZEN";
WalletStatus["DISABLED"] = "DISABLED";
})(WalletStatus || (WalletStatus = {}));
// Locale
export const SUPPORTED_LOCALES = ['zh-CN', 'ms-MY', 'en-US'];
export const DEFAULT_LOCALE = 'zh-CN';
// Admin roles
export var AdminRole;
(function (AdminRole) {
AdminRole["SUPER_ADMIN"] = "SUPER_ADMIN";
AdminRole["MATCH_ADMIN"] = "MATCH_ADMIN";
AdminRole["FINANCE_ADMIN"] = "FINANCE_ADMIN";
AdminRole["SUPPORT"] = "SUPPORT";
})(AdminRole || (AdminRole = {}));
export const PARLAY_MIN_LEGS = 2;
export const PARLAY_MAX_LEGS = 5;
export * from './betting-rules';
export * from './market-catalog';
export * from './locale';
export * from './builtinPlayers';
export * from './playerLocale';
export * from './playerUsername';
export * from './initial-depositRemark';
export * from './phone-countries';
export * from './match-time';
export * from './api-errors';

View File

@@ -122,12 +122,14 @@ export const PARLAY_MIN_LEGS = 2;
export const PARLAY_MAX_LEGS = 5;
export * from './betting-rules';
export * from './market-catalog';
export * from './locale';
export * from './builtinPlayers';
export * from './playerLocale';
export * from './playerUsername';
export * from './initial-depositRemark';
export * from './phone-countries';
export * from './match-time';
export interface ApiResponse<T = unknown> {
success: boolean;

View File

@@ -0,0 +1,31 @@
export const OPENING_BONUS_REMARK = '开户初始余额';
export const ADMIN_DAILY_DEPOSIT_REMARK = '管理员上分';
export const AGENT_DAILY_DEPOSIT_REMARK = '代理上分';
export function dailyDepositRemark(operator) {
return operator === 'admin' ? ADMIN_DAILY_DEPOSIT_REMARK : AGENT_DAILY_DEPOSIT_REMARK;
}
export function resolveInitialDepositRemark(kind, custom, operator) {
if (kind === 'daily')
return dailyDepositRemark(operator);
if (kind === 'opening_bonus')
return OPENING_BONUS_REMARK;
const trimmed = custom?.trim() ?? '';
if (trimmed.length < 2) {
throw new Error('INITIAL_DEPOSIT_REMARK_CUSTOM_INVALID');
}
return trimmed;
}
export function validateInitialDepositRemark(initialDeposit, remark, operator) {
if (initialDeposit <= 0) {
return { ok: true, remark: remark?.trim() ?? '' };
}
const r = remark?.trim();
if (!r)
return { ok: false, code: 'INITIAL_DEPOSIT_REMARK_REQUIRED' };
const daily = dailyDepositRemark(operator);
if (r === daily || r === OPENING_BONUS_REMARK)
return { ok: true, remark: r };
if (r.length < 2)
return { ok: false, code: 'INITIAL_DEPOSIT_REMARK_CUSTOM_INVALID' };
return { ok: true, remark: r };
}

View File

@@ -0,0 +1,26 @@
/** 内容翻译 fallback当前语言 → 英文 → 中文 → 马来文 */
export function resolveTranslationFallback(map, locale) {
const chain = [locale, 'en-US', 'zh-CN', 'ms-MY'];
const seen = new Set();
for (const loc of chain) {
if (seen.has(loc))
continue;
seen.add(loc);
const v = map[loc];
if (v != null && String(v).trim() !== '')
return String(v).trim();
}
for (const v of Object.values(map)) {
if (v != null && String(v).trim() !== '')
return String(v).trim();
}
return '';
}
/** 前台语言显示名PRD 3.1 */
export const LOCALE_UI_LABELS = {
'zh-CN': '中文',
'ms-MY': 'Bahasa Melayu',
'en-US': 'English',
};
/** vue-i18n 缺 key 时en-US → zh-CN */
export const VUE_I18N_FALLBACK_LOCALES = ['en-US', 'zh-CN'];

View File

@@ -0,0 +1,544 @@
import { resolveTranslationFallback } from './locale';
export const MARKET_LOCALES = ['zh-CN', 'en-US', 'ms-MY'];
export const FT_CORRECT_SCORE_TEMPLATE = [
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2',
'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3',
'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY',
];
export const HT_CORRECT_SCORE_TEMPLATE = [
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW',
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY',
];
function text(zh, en, ms) {
return { 'zh-CN': zh, 'en-US': en, 'ms-MY': ms };
}
function selection(code, zh, en, ms, odds) {
const nameI18n = text(zh, en, ms);
return { code, name: zh, nameI18n, odds };
}
function handicapName(side, line, half = false) {
const sideLabel = side === 'home' ? '主队' : '客队';
const value = side === 'home' ? line : -line;
const lineText = value > 0 ? `+${value}` : `${value}`;
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
}
function totalName(side, line, half = false) {
const sideLabel = side === 'over' ? '大' : '小';
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
}
function scoreName(code) {
if (code === 'OTHER_HOME')
return '其它主胜';
if (code === 'OTHER_DRAW')
return '其它和局';
if (code === 'OTHER_AWAY')
return '其它客胜';
return code.replace('SCORE_', '').replace('_', '-');
}
function scoreSelection(code, odds) {
const display = scoreName(code);
const en = code === 'OTHER_HOME'
? 'Any Other Home Win'
: code === 'OTHER_DRAW'
? 'Any Other Draw'
: code === 'OTHER_AWAY'
? 'Any Other Away Win'
: display;
const ms = code === 'OTHER_HOME'
? 'Lain-lain Menang Tuan Rumah'
: code === 'OTHER_DRAW'
? 'Lain-lain Seri'
: code === 'OTHER_AWAY'
? 'Lain-lain Menang Pelawat'
: display;
return selection(code, display, en, ms, odds);
}
const ftCorrectScoreSelections = FT_CORRECT_SCORE_TEMPLATE.map((code) => scoreSelection(code, 8));
const htCorrectScoreSelections = HT_CORRECT_SCORE_TEMPLATE.map((code) => scoreSelection(code, 6));
const teamTotalSelections = [
selection('OVER', totalName('over', 1.5), 'Over', 'Atas', 1.85),
selection('UNDER', totalName('under', 1.5), 'Under', 'Bawah', 1.95),
];
const htFtSelections = [
selection('HOME_HOME', '主/主', 'Home/Home', 'Tuan Rumah/Tuan Rumah', 2.9),
selection('HOME_DRAW', '主/和', 'Home/Draw', 'Tuan Rumah/Seri', 15),
selection('HOME_AWAY', '主/客', 'Home/Away', 'Tuan Rumah/Pelawat', 26),
selection('DRAW_HOME', '和/主', 'Draw/Home', 'Seri/Tuan Rumah', 4.8),
selection('DRAW_DRAW', '和/和', 'Draw/Draw', 'Seri/Seri', 4.5),
selection('DRAW_AWAY', '和/客', 'Draw/Away', 'Seri/Pelawat', 6.8),
selection('AWAY_HOME', '客/主', 'Away/Home', 'Pelawat/Tuan Rumah', 21),
selection('AWAY_DRAW', '客/和', 'Away/Draw', 'Pelawat/Seri', 15),
selection('AWAY_AWAY', '客/客', 'Away/Away', 'Pelawat/Pelawat', 4.6),
];
const totalGoalsSelections = [
selection('TG_0_1', '0-1', '0-1', '0-1', 3.8),
selection('TG_2_3', '2-3', '2-3', '2-3', 2.2),
selection('TG_4_6', '4-6', '4-6', '4-6', 2.8),
selection('TG_7_PLUS', '7+', '7+', '7+', 12),
];
const cornersHandicapSelections = [
selection('HOME', '主队角球', 'Home Corners', 'Sudut Tuan Rumah', 1.9),
selection('AWAY', '客队角球', 'Away Corners', 'Sudut Pelawat', 1.9),
];
const cornersTotalSelections = [
selection('OVER', '大 8.5', 'Over', 'Atas', 1.85),
selection('UNDER', '小 8.5', 'Under', 'Bawah', 1.95),
];
const cardsTotalSelections = [
selection('OVER', '大 3.5', 'Over', 'Atas', 1.85),
selection('UNDER', '小 3.5', 'Under', 'Bawah', 1.95),
];
const unsupported = {
allowSingle: true,
allowParlay: false,
showOnPlayer: false,
settlementSupported: false,
settlementKind: 'UNSUPPORTED',
renderType: 'unsupported',
detailOrder: null,
parlayOrder: null,
selectionTemplate: [],
};
export const FOOTBALL_MARKET_CATALOG = {
FT_1X2: {
marketKey: 'FT_1X2',
period: 'FT',
sortOrder: 1,
defaultLineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'SCORE',
renderType: 'standard',
detailOrder: 5,
parlayOrder: 3,
i18nKey: 'bet.market_ft_1x2',
adminI18nKey: 'matchEditor.market.FT_1X2',
nameI18n: text('全场 1X2', 'FT 1X2', '1X2 Penuh'),
selectionTemplate: [
selection('HOME', '主', 'Home', 'Tuan Rumah', 2.5),
selection('DRAW', '和', 'Draw', 'Seri', 3.2),
selection('AWAY', '客', 'Away', 'Pelawat', 2.8),
],
},
FT_HANDICAP: {
marketKey: 'FT_HANDICAP',
period: 'FT',
sortOrder: 2,
defaultLineValue: -0.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'HANDICAP',
renderType: 'standard',
detailOrder: 3,
parlayOrder: 1,
i18nKey: 'bet.market_ft_handicap',
adminI18nKey: 'matchEditor.market.FT_HANDICAP',
nameI18n: text('全场让球', 'FT Handicap', 'Handicap Penuh'),
selectionTemplate: [
selection('HOME', handicapName('home', -0.5), 'Home', 'Tuan Rumah', 1.9),
selection('AWAY', handicapName('away', -0.5), 'Away', 'Pelawat', 1.9),
],
},
FT_OVER_UNDER: {
marketKey: 'FT_OVER_UNDER',
period: 'FT',
sortOrder: 3,
defaultLineValue: 2.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'TOTAL',
renderType: 'standard',
detailOrder: 4,
parlayOrder: 2,
i18nKey: 'bet.market_ft_ou',
adminI18nKey: 'matchEditor.market.FT_OVER_UNDER',
nameI18n: text('全场大小', 'FT Over/Under', 'Atas/Bawah Penuh'),
selectionTemplate: [
selection('OVER', totalName('over', 2.5), 'Over', 'Atas', 1.85),
selection('UNDER', totalName('under', 2.5), 'Under', 'Bawah', 1.95),
],
},
FT_ODD_EVEN: {
marketKey: 'FT_ODD_EVEN',
period: 'FT',
sortOrder: 4,
defaultLineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'ODD_EVEN',
renderType: 'standard',
detailOrder: 6,
parlayOrder: 4,
i18nKey: 'bet.market_ft_oe',
adminI18nKey: 'matchEditor.market.FT_ODD_EVEN',
nameI18n: text('全场单双', 'FT Odd/Even', 'Ganjil/Genap Penuh'),
selectionTemplate: [
selection('ODD', '单', 'Odd', 'Ganjil', 1.9),
selection('EVEN', '双', 'Even', 'Genap', 1.9),
],
},
HT_1X2: {
marketKey: 'HT_1X2',
period: 'HT',
sortOrder: 5,
defaultLineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'SCORE',
renderType: 'standard',
detailOrder: 9,
parlayOrder: 7,
i18nKey: 'bet.market_ht_1x2',
adminI18nKey: 'matchEditor.market.HT_1X2',
nameI18n: text('半场 1X2', 'HT 1X2', '1X2 Separuh'),
selectionTemplate: [
selection('HOME', '主', 'Home', 'Tuan Rumah', 3),
selection('DRAW', '和', 'Draw', 'Seri', 2),
selection('AWAY', '客', 'Away', 'Pelawat', 3.5),
],
},
HT_HANDICAP: {
marketKey: 'HT_HANDICAP',
period: 'HT',
sortOrder: 6,
defaultLineValue: -0.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'HANDICAP',
renderType: 'standard',
detailOrder: 7,
parlayOrder: 5,
i18nKey: 'bet.market_ht_handicap',
adminI18nKey: 'matchEditor.market.HT_HANDICAP',
nameI18n: text('半场让球', 'HT Handicap', 'Handicap Separuh'),
selectionTemplate: [
selection('HOME', handicapName('home', -0.5, true), 'Home', 'Tuan Rumah', 1.9),
selection('AWAY', handicapName('away', -0.5, true), 'Away', 'Pelawat', 1.9),
],
},
HT_OVER_UNDER: {
marketKey: 'HT_OVER_UNDER',
period: 'HT',
sortOrder: 7,
defaultLineValue: 1.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'TOTAL',
renderType: 'standard',
detailOrder: 8,
parlayOrder: 6,
i18nKey: 'bet.market_ht_ou',
adminI18nKey: 'matchEditor.market.HT_OVER_UNDER',
nameI18n: text('半场大小', 'HT Over/Under', 'Atas/Bawah Separuh'),
selectionTemplate: [
selection('OVER', totalName('over', 1.5, true), 'Over', 'Atas', 2),
selection('UNDER', totalName('under', 1.5, true), 'Under', 'Bawah', 1.75),
],
},
FT_CORRECT_SCORE: {
marketKey: 'FT_CORRECT_SCORE',
period: 'FT',
sortOrder: 8,
defaultLineValue: null,
allowSingle: true,
allowParlay: false,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'CORRECT_SCORE',
renderType: 'correctScore',
detailOrder: 0,
parlayOrder: null,
i18nKey: 'bet.market_cs',
adminI18nKey: 'matchEditor.market.FT_CORRECT_SCORE',
nameI18n: text('全场波胆', 'FT Correct Score', 'Skor Tepat Penuh'),
selectionTemplate: ftCorrectScoreSelections,
},
HT_CORRECT_SCORE: {
marketKey: 'HT_CORRECT_SCORE',
period: 'HT',
sortOrder: 9,
defaultLineValue: null,
allowSingle: true,
allowParlay: false,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'CORRECT_SCORE',
renderType: 'correctScore',
detailOrder: 1,
parlayOrder: null,
i18nKey: 'bet.market_ht_cs',
adminI18nKey: 'matchEditor.market.HT_CORRECT_SCORE',
nameI18n: text('上半场波胆', '1H Correct Score', 'Skor Tepat Separuh Pertama'),
selectionTemplate: htCorrectScoreSelections,
},
SH_CORRECT_SCORE: {
marketKey: 'SH_CORRECT_SCORE',
period: 'SH',
sortOrder: 10,
defaultLineValue: null,
allowSingle: true,
allowParlay: false,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'CORRECT_SCORE',
renderType: 'correctScore',
detailOrder: 2,
parlayOrder: null,
i18nKey: 'bet.market_sh_cs',
adminI18nKey: 'matchEditor.market.SH_CORRECT_SCORE',
nameI18n: text('下半场波胆', '2H Correct Score', 'Skor Tepat Separuh Kedua'),
selectionTemplate: htCorrectScoreSelections,
},
OUTRIGHT_WINNER: {
marketKey: 'OUTRIGHT_WINNER',
period: 'OUTRIGHT',
sortOrder: 1,
defaultLineValue: null,
allowSingle: true,
allowParlay: false,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'OUTRIGHT',
renderType: 'standard',
detailOrder: null,
parlayOrder: null,
i18nKey: 'bet.market_outright_winner',
adminI18nKey: 'matchEditor.market.OUTRIGHT_WINNER',
nameI18n: text('冠军', 'Outright Winner', 'Juara'),
selectionTemplate: [],
},
FT_TEAM_TOTAL_HOME: {
marketKey: 'FT_TEAM_TOTAL_HOME',
period: 'FT',
sortOrder: 40,
defaultLineValue: 1.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'TOTAL',
renderType: 'standard',
detailOrder: 10,
parlayOrder: 8,
i18nKey: 'bet.market_ft_team_total_home',
adminI18nKey: 'matchEditor.market.FT_TEAM_TOTAL_HOME',
nameI18n: text('主队进球大小', 'Home Team Total Goals', 'Jumlah Gol Tuan Rumah'),
selectionTemplate: teamTotalSelections,
},
FT_TEAM_TOTAL_AWAY: {
marketKey: 'FT_TEAM_TOTAL_AWAY',
period: 'FT',
sortOrder: 41,
defaultLineValue: 1.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'TOTAL',
renderType: 'standard',
detailOrder: 11,
parlayOrder: 9,
i18nKey: 'bet.market_ft_team_total_away',
adminI18nKey: 'matchEditor.market.FT_TEAM_TOTAL_AWAY',
nameI18n: text('客队进球大小', 'Away Team Total Goals', 'Jumlah Gol Pelawat'),
selectionTemplate: teamTotalSelections,
},
HT_FT: {
marketKey: 'HT_FT',
period: 'FT',
sortOrder: 42,
defaultLineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'SCORE',
renderType: 'standard',
detailOrder: 12,
parlayOrder: 10,
i18nKey: 'bet.market_ht_ft',
adminI18nKey: 'matchEditor.market.HT_FT',
nameI18n: text('半全场', 'Half Time / Full Time', 'Separuh Masa / Penuh Masa'),
selectionTemplate: htFtSelections,
},
FT_TOTAL_GOALS: {
marketKey: 'FT_TOTAL_GOALS',
period: 'FT',
sortOrder: 43,
defaultLineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'SCORE',
renderType: 'standard',
detailOrder: 13,
parlayOrder: 11,
i18nKey: 'bet.market_ft_total_goals',
adminI18nKey: 'matchEditor.market.FT_TOTAL_GOALS',
nameI18n: text('总进球数', 'Total Goals', 'Jumlah Gol'),
selectionTemplate: totalGoalsSelections,
},
FT_CORNERS_HANDICAP: {
marketKey: 'FT_CORNERS_HANDICAP',
period: 'FT',
sortOrder: 60,
defaultLineValue: -0.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'MANUAL_STATS',
renderType: 'standard',
detailOrder: 14,
parlayOrder: 12,
i18nKey: 'bet.market_ft_corners_handicap',
adminI18nKey: 'matchEditor.market.FT_CORNERS_HANDICAP',
nameI18n: text('全场角球让球', 'FT Corners Handicap', 'Handicap Sepakan Sudut Penuh'),
selectionTemplate: cornersHandicapSelections,
},
FT_CORNERS_OVER_UNDER: {
marketKey: 'FT_CORNERS_OVER_UNDER',
period: 'FT',
sortOrder: 61,
defaultLineValue: 8.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'MANUAL_STATS',
renderType: 'standard',
detailOrder: 15,
parlayOrder: 13,
i18nKey: 'bet.market_ft_corners_ou',
adminI18nKey: 'matchEditor.market.FT_CORNERS_OVER_UNDER',
nameI18n: text('全场角球大小', 'FT Corners Over/Under', 'Atas/Bawah Sudut Penuh'),
selectionTemplate: cornersTotalSelections,
},
FT_CARDS_OVER_UNDER: {
marketKey: 'FT_CARDS_OVER_UNDER',
period: 'FT',
sortOrder: 70,
defaultLineValue: 3.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'MANUAL_STATS',
renderType: 'standard',
detailOrder: 16,
parlayOrder: 14,
i18nKey: 'bet.market_ft_cards_ou',
adminI18nKey: 'matchEditor.market.FT_CARDS_OVER_UNDER',
nameI18n: text('全场罚牌大小', 'FT Cards Over/Under', 'Atas/Bawah Kad Penuh'),
selectionTemplate: cardsTotalSelections,
},
};
export const FEATURED_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG)
.filter(([, entry]) => entry.detailOrder !== null && entry.detailOrder < 3)
.sort(([, a], [, b]) => (a.detailOrder ?? 0) - (b.detailOrder ?? 0))
.map(([marketType]) => marketType);
export const DETAIL_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG)
.filter(([, entry]) => entry.detailOrder !== null)
.sort(([, a], [, b]) => (a.detailOrder ?? 0) - (b.detailOrder ?? 0))
.map(([marketType]) => marketType);
export const GRID_MARKET_TYPES = DETAIL_MARKET_TYPES.filter((marketType) => !FEATURED_MARKET_TYPES.includes(marketType));
export const PARLAY_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG)
.filter(([, entry]) => entry.parlayOrder !== null && entry.allowParlay)
.sort(([, a], [, b]) => (a.parlayOrder ?? 0) - (b.parlayOrder ?? 0))
.map(([marketType]) => marketType);
export const DEFAULT_MARKET_EXCLUDED_TYPES = [
'HT_CORRECT_SCORE',
'SH_CORRECT_SCORE',
];
export const DEFAULT_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG)
.filter(([marketType, entry]) => entry.showOnPlayer &&
entry.marketKey !== 'OUTRIGHT_WINNER' &&
!DEFAULT_MARKET_EXCLUDED_TYPES.includes(marketType))
.sort(([, a], [, b]) => a.sortOrder - b.sortOrder)
.map(([marketType]) => marketType);
export const MARKET_I18N_KEY = Object.fromEntries(Object.entries(FOOTBALL_MARKET_CATALOG).map(([marketType, entry]) => [
marketType,
entry.i18nKey,
]));
export function isFootballMarketType(marketType) {
return marketType in FOOTBALL_MARKET_CATALOG;
}
export function isCorrectScoreMarketType(marketType) {
return ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'].includes(marketType);
}
export function isSettlementSupportedMarketType(marketType) {
return isFootballMarketType(marketType) && FOOTBALL_MARKET_CATALOG[marketType].settlementSupported;
}
export function marketUsesLineValue(marketType) {
return isFootballMarketType(marketType) && FOOTBALL_MARKET_CATALOG[marketType].defaultLineValue !== null;
}
export function sanitizeLocalizedText(input) {
if (!input || typeof input !== 'object')
return {};
const out = {};
for (const locale of MARKET_LOCALES) {
const raw = input[locale];
if (raw == null)
continue;
const value = String(raw).trim();
if (value)
out[locale] = value;
}
return out;
}
export function resolveMarketText(input, locale, fallback) {
const map = sanitizeLocalizedText(input);
if (fallback && typeof fallback === 'object') {
Object.assign(map, Object.fromEntries(Object.entries(fallback).filter(([key]) => map[key] == null)));
}
const resolved = resolveTranslationFallback(map, locale);
if (resolved)
return resolved;
if (typeof fallback === 'string')
return fallback;
return '';
}
export function defaultMarketName(marketType, locale = 'zh-CN') {
if (!isFootballMarketType(marketType))
return marketType;
return resolveMarketText(FOOTBALL_MARKET_CATALOG[marketType].nameI18n, locale, marketType);
}
export function defaultSelectionName(marketType, selectionCode, locale = 'zh-CN') {
if (!isFootballMarketType(marketType))
return selectionCode;
const hit = FOOTBALL_MARKET_CATALOG[marketType].selectionTemplate.find((s) => s.code === selectionCode);
return hit ? resolveMarketText(hit.nameI18n, locale, hit.name) : selectionCode;
}
export function buildMarketLineKey(marketType, lineValue, params) {
const line = lineValue == null || lineValue === '' ? 'none' : Number(lineValue).toFixed(2);
const paramPairs = Object.entries(params ?? {})
.filter(([, value]) => value != null && value !== '')
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${String(value)}`)
.join('|');
return paramPairs ? `${marketType}:${line}:${paramPairs}` : `${marketType}:${line}`;
}
export function buildMarketTemplate(marketType) {
if (!isFootballMarketType(marketType)) {
throw new Error(`Unknown football market type: ${marketType}`);
}
return FOOTBALL_MARKET_CATALOG[marketType];
}

View File

@@ -0,0 +1,634 @@
import { resolveTranslationFallback } from './locale';
export const MARKET_LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
export type MarketLocale = (typeof MARKET_LOCALES)[number];
export type LocalizedText = Partial<Record<MarketLocale, string>>;
export type FootballMarketPeriod = 'FT' | 'HT' | 'SH' | 'OUTRIGHT';
export type MarketRenderType = 'standard' | 'correctScore' | 'unsupported';
export type MarketSettlementKind =
| 'SCORE'
| 'HANDICAP'
| 'TOTAL'
| 'ODD_EVEN'
| 'CORRECT_SCORE'
| 'OUTRIGHT'
| 'MANUAL_STATS'
| 'UNSUPPORTED';
export type MarketSelectionTemplate = {
code: string;
name: string;
nameI18n: LocalizedText;
odds: number;
};
export type FootballMarketCatalogEntry = {
marketKey: string;
period: FootballMarketPeriod;
sortOrder: number;
defaultLineValue: number | null;
defaultParams?: Record<string, string | number | boolean | null>;
allowSingle: boolean;
allowParlay: boolean;
showOnPlayer: boolean;
settlementSupported: boolean;
settlementKind: MarketSettlementKind;
renderType: MarketRenderType;
detailOrder: number | null;
parlayOrder: number | null;
i18nKey: string;
adminI18nKey: string;
nameI18n: LocalizedText;
selectionTemplate: readonly MarketSelectionTemplate[];
};
export const FT_CORRECT_SCORE_TEMPLATE = [
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2',
'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3',
'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY',
] as const;
export const HT_CORRECT_SCORE_TEMPLATE = [
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW',
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY',
] as const;
function text(zh: string, en: string, ms: string): LocalizedText {
return { 'zh-CN': zh, 'en-US': en, 'ms-MY': ms };
}
function selection(code: string, zh: string, en: string, ms: string, odds: number): MarketSelectionTemplate {
const nameI18n = text(zh, en, ms);
return { code, name: zh, nameI18n, odds };
}
function handicapName(side: 'home' | 'away', line: number, half = false) {
const sideLabel = side === 'home' ? '主队' : '客队';
const value = side === 'home' ? line : -line;
const lineText = value > 0 ? `+${value}` : `${value}`;
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
}
function totalName(side: 'over' | 'under', line: number, half = false) {
const sideLabel = side === 'over' ? '大' : '小';
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
}
function scoreName(code: string) {
if (code === 'OTHER_HOME') return '其它主胜';
if (code === 'OTHER_DRAW') return '其它和局';
if (code === 'OTHER_AWAY') return '其它客胜';
return code.replace('SCORE_', '').replace('_', '-');
}
function scoreSelection(code: string, odds: number): MarketSelectionTemplate {
const display = scoreName(code);
const en =
code === 'OTHER_HOME'
? 'Any Other Home Win'
: code === 'OTHER_DRAW'
? 'Any Other Draw'
: code === 'OTHER_AWAY'
? 'Any Other Away Win'
: display;
const ms =
code === 'OTHER_HOME'
? 'Lain-lain Menang Tuan Rumah'
: code === 'OTHER_DRAW'
? 'Lain-lain Seri'
: code === 'OTHER_AWAY'
? 'Lain-lain Menang Pelawat'
: display;
return selection(code, display, en, ms, odds);
}
const ftCorrectScoreSelections = FT_CORRECT_SCORE_TEMPLATE.map((code) => scoreSelection(code, 8));
const htCorrectScoreSelections = HT_CORRECT_SCORE_TEMPLATE.map((code) => scoreSelection(code, 6));
const teamTotalSelections = [
selection('OVER', totalName('over', 1.5), 'Over', 'Atas', 1.85),
selection('UNDER', totalName('under', 1.5), 'Under', 'Bawah', 1.95),
] as const;
const htFtSelections = [
selection('HOME_HOME', '主/主', 'Home/Home', 'Tuan Rumah/Tuan Rumah', 2.9),
selection('HOME_DRAW', '主/和', 'Home/Draw', 'Tuan Rumah/Seri', 15),
selection('HOME_AWAY', '主/客', 'Home/Away', 'Tuan Rumah/Pelawat', 26),
selection('DRAW_HOME', '和/主', 'Draw/Home', 'Seri/Tuan Rumah', 4.8),
selection('DRAW_DRAW', '和/和', 'Draw/Draw', 'Seri/Seri', 4.5),
selection('DRAW_AWAY', '和/客', 'Draw/Away', 'Seri/Pelawat', 6.8),
selection('AWAY_HOME', '客/主', 'Away/Home', 'Pelawat/Tuan Rumah', 21),
selection('AWAY_DRAW', '客/和', 'Away/Draw', 'Pelawat/Seri', 15),
selection('AWAY_AWAY', '客/客', 'Away/Away', 'Pelawat/Pelawat', 4.6),
] as const;
const totalGoalsSelections = [
selection('TG_0_1', '0-1', '0-1', '0-1', 3.8),
selection('TG_2_3', '2-3', '2-3', '2-3', 2.2),
selection('TG_4_6', '4-6', '4-6', '4-6', 2.8),
selection('TG_7_PLUS', '7+', '7+', '7+', 12),
] as const;
const cornersHandicapSelections = [
selection('HOME', '主队角球', 'Home Corners', 'Sudut Tuan Rumah', 1.9),
selection('AWAY', '客队角球', 'Away Corners', 'Sudut Pelawat', 1.9),
] as const;
const cornersTotalSelections = [
selection('OVER', '大 8.5', 'Over', 'Atas', 1.85),
selection('UNDER', '小 8.5', 'Under', 'Bawah', 1.95),
] as const;
const cardsTotalSelections = [
selection('OVER', '大 3.5', 'Over', 'Atas', 1.85),
selection('UNDER', '小 3.5', 'Under', 'Bawah', 1.95),
] as const;
const unsupported = {
allowSingle: true,
allowParlay: false,
showOnPlayer: false,
settlementSupported: false,
settlementKind: 'UNSUPPORTED' as const,
renderType: 'unsupported' as const,
detailOrder: null,
parlayOrder: null,
selectionTemplate: [] as readonly MarketSelectionTemplate[],
};
export const FOOTBALL_MARKET_CATALOG = {
FT_1X2: {
marketKey: 'FT_1X2',
period: 'FT',
sortOrder: 1,
defaultLineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'SCORE',
renderType: 'standard',
detailOrder: 5,
parlayOrder: 3,
i18nKey: 'bet.market_ft_1x2',
adminI18nKey: 'matchEditor.market.FT_1X2',
nameI18n: text('全场 1X2', 'FT 1X2', '1X2 Penuh'),
selectionTemplate: [
selection('HOME', '主', 'Home', 'Tuan Rumah', 2.5),
selection('DRAW', '和', 'Draw', 'Seri', 3.2),
selection('AWAY', '客', 'Away', 'Pelawat', 2.8),
],
},
FT_HANDICAP: {
marketKey: 'FT_HANDICAP',
period: 'FT',
sortOrder: 2,
defaultLineValue: -0.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'HANDICAP',
renderType: 'standard',
detailOrder: 3,
parlayOrder: 1,
i18nKey: 'bet.market_ft_handicap',
adminI18nKey: 'matchEditor.market.FT_HANDICAP',
nameI18n: text('全场让球', 'FT Handicap', 'Handicap Penuh'),
selectionTemplate: [
selection('HOME', handicapName('home', -0.5), 'Home', 'Tuan Rumah', 1.9),
selection('AWAY', handicapName('away', -0.5), 'Away', 'Pelawat', 1.9),
],
},
FT_OVER_UNDER: {
marketKey: 'FT_OVER_UNDER',
period: 'FT',
sortOrder: 3,
defaultLineValue: 2.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'TOTAL',
renderType: 'standard',
detailOrder: 4,
parlayOrder: 2,
i18nKey: 'bet.market_ft_ou',
adminI18nKey: 'matchEditor.market.FT_OVER_UNDER',
nameI18n: text('全场大小', 'FT Over/Under', 'Atas/Bawah Penuh'),
selectionTemplate: [
selection('OVER', totalName('over', 2.5), 'Over', 'Atas', 1.85),
selection('UNDER', totalName('under', 2.5), 'Under', 'Bawah', 1.95),
],
},
FT_ODD_EVEN: {
marketKey: 'FT_ODD_EVEN',
period: 'FT',
sortOrder: 4,
defaultLineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'ODD_EVEN',
renderType: 'standard',
detailOrder: 6,
parlayOrder: 4,
i18nKey: 'bet.market_ft_oe',
adminI18nKey: 'matchEditor.market.FT_ODD_EVEN',
nameI18n: text('全场单双', 'FT Odd/Even', 'Ganjil/Genap Penuh'),
selectionTemplate: [
selection('ODD', '单', 'Odd', 'Ganjil', 1.9),
selection('EVEN', '双', 'Even', 'Genap', 1.9),
],
},
HT_1X2: {
marketKey: 'HT_1X2',
period: 'HT',
sortOrder: 5,
defaultLineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'SCORE',
renderType: 'standard',
detailOrder: 9,
parlayOrder: 7,
i18nKey: 'bet.market_ht_1x2',
adminI18nKey: 'matchEditor.market.HT_1X2',
nameI18n: text('半场 1X2', 'HT 1X2', '1X2 Separuh'),
selectionTemplate: [
selection('HOME', '主', 'Home', 'Tuan Rumah', 3),
selection('DRAW', '和', 'Draw', 'Seri', 2),
selection('AWAY', '客', 'Away', 'Pelawat', 3.5),
],
},
HT_HANDICAP: {
marketKey: 'HT_HANDICAP',
period: 'HT',
sortOrder: 6,
defaultLineValue: -0.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'HANDICAP',
renderType: 'standard',
detailOrder: 7,
parlayOrder: 5,
i18nKey: 'bet.market_ht_handicap',
adminI18nKey: 'matchEditor.market.HT_HANDICAP',
nameI18n: text('半场让球', 'HT Handicap', 'Handicap Separuh'),
selectionTemplate: [
selection('HOME', handicapName('home', -0.5, true), 'Home', 'Tuan Rumah', 1.9),
selection('AWAY', handicapName('away', -0.5, true), 'Away', 'Pelawat', 1.9),
],
},
HT_OVER_UNDER: {
marketKey: 'HT_OVER_UNDER',
period: 'HT',
sortOrder: 7,
defaultLineValue: 1.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'TOTAL',
renderType: 'standard',
detailOrder: 8,
parlayOrder: 6,
i18nKey: 'bet.market_ht_ou',
adminI18nKey: 'matchEditor.market.HT_OVER_UNDER',
nameI18n: text('半场大小', 'HT Over/Under', 'Atas/Bawah Separuh'),
selectionTemplate: [
selection('OVER', totalName('over', 1.5, true), 'Over', 'Atas', 2),
selection('UNDER', totalName('under', 1.5, true), 'Under', 'Bawah', 1.75),
],
},
FT_CORRECT_SCORE: {
marketKey: 'FT_CORRECT_SCORE',
period: 'FT',
sortOrder: 8,
defaultLineValue: null,
allowSingle: true,
allowParlay: false,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'CORRECT_SCORE',
renderType: 'correctScore',
detailOrder: 0,
parlayOrder: null,
i18nKey: 'bet.market_cs',
adminI18nKey: 'matchEditor.market.FT_CORRECT_SCORE',
nameI18n: text('全场波胆', 'FT Correct Score', 'Skor Tepat Penuh'),
selectionTemplate: ftCorrectScoreSelections,
},
HT_CORRECT_SCORE: {
marketKey: 'HT_CORRECT_SCORE',
period: 'HT',
sortOrder: 9,
defaultLineValue: null,
allowSingle: true,
allowParlay: false,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'CORRECT_SCORE',
renderType: 'correctScore',
detailOrder: 1,
parlayOrder: null,
i18nKey: 'bet.market_ht_cs',
adminI18nKey: 'matchEditor.market.HT_CORRECT_SCORE',
nameI18n: text('上半场波胆', '1H Correct Score', 'Skor Tepat Separuh Pertama'),
selectionTemplate: htCorrectScoreSelections,
},
SH_CORRECT_SCORE: {
marketKey: 'SH_CORRECT_SCORE',
period: 'SH',
sortOrder: 10,
defaultLineValue: null,
allowSingle: true,
allowParlay: false,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'CORRECT_SCORE',
renderType: 'correctScore',
detailOrder: 2,
parlayOrder: null,
i18nKey: 'bet.market_sh_cs',
adminI18nKey: 'matchEditor.market.SH_CORRECT_SCORE',
nameI18n: text('下半场波胆', '2H Correct Score', 'Skor Tepat Separuh Kedua'),
selectionTemplate: htCorrectScoreSelections,
},
OUTRIGHT_WINNER: {
marketKey: 'OUTRIGHT_WINNER',
period: 'OUTRIGHT',
sortOrder: 1,
defaultLineValue: null,
allowSingle: true,
allowParlay: false,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'OUTRIGHT',
renderType: 'standard',
detailOrder: null,
parlayOrder: null,
i18nKey: 'bet.market_outright_winner',
adminI18nKey: 'matchEditor.market.OUTRIGHT_WINNER',
nameI18n: text('冠军', 'Outright Winner', 'Juara'),
selectionTemplate: [],
},
FT_TEAM_TOTAL_HOME: {
marketKey: 'FT_TEAM_TOTAL_HOME',
period: 'FT',
sortOrder: 40,
defaultLineValue: 1.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'TOTAL',
renderType: 'standard',
detailOrder: 10,
parlayOrder: 8,
i18nKey: 'bet.market_ft_team_total_home',
adminI18nKey: 'matchEditor.market.FT_TEAM_TOTAL_HOME',
nameI18n: text('主队进球大小', 'Home Team Total Goals', 'Jumlah Gol Tuan Rumah'),
selectionTemplate: teamTotalSelections,
},
FT_TEAM_TOTAL_AWAY: {
marketKey: 'FT_TEAM_TOTAL_AWAY',
period: 'FT',
sortOrder: 41,
defaultLineValue: 1.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'TOTAL',
renderType: 'standard',
detailOrder: 11,
parlayOrder: 9,
i18nKey: 'bet.market_ft_team_total_away',
adminI18nKey: 'matchEditor.market.FT_TEAM_TOTAL_AWAY',
nameI18n: text('客队进球大小', 'Away Team Total Goals', 'Jumlah Gol Pelawat'),
selectionTemplate: teamTotalSelections,
},
HT_FT: {
marketKey: 'HT_FT',
period: 'FT',
sortOrder: 42,
defaultLineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'SCORE',
renderType: 'standard',
detailOrder: 12,
parlayOrder: 10,
i18nKey: 'bet.market_ht_ft',
adminI18nKey: 'matchEditor.market.HT_FT',
nameI18n: text('半全场', 'Half Time / Full Time', 'Separuh Masa / Penuh Masa'),
selectionTemplate: htFtSelections,
},
FT_TOTAL_GOALS: {
marketKey: 'FT_TOTAL_GOALS',
period: 'FT',
sortOrder: 43,
defaultLineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'SCORE',
renderType: 'standard',
detailOrder: 13,
parlayOrder: 11,
i18nKey: 'bet.market_ft_total_goals',
adminI18nKey: 'matchEditor.market.FT_TOTAL_GOALS',
nameI18n: text('总进球数', 'Total Goals', 'Jumlah Gol'),
selectionTemplate: totalGoalsSelections,
},
FT_CORNERS_HANDICAP: {
marketKey: 'FT_CORNERS_HANDICAP',
period: 'FT',
sortOrder: 60,
defaultLineValue: -0.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'MANUAL_STATS',
renderType: 'standard',
detailOrder: 14,
parlayOrder: 12,
i18nKey: 'bet.market_ft_corners_handicap',
adminI18nKey: 'matchEditor.market.FT_CORNERS_HANDICAP',
nameI18n: text('全场角球让球', 'FT Corners Handicap', 'Handicap Sepakan Sudut Penuh'),
selectionTemplate: cornersHandicapSelections,
},
FT_CORNERS_OVER_UNDER: {
marketKey: 'FT_CORNERS_OVER_UNDER',
period: 'FT',
sortOrder: 61,
defaultLineValue: 8.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'MANUAL_STATS',
renderType: 'standard',
detailOrder: 15,
parlayOrder: 13,
i18nKey: 'bet.market_ft_corners_ou',
adminI18nKey: 'matchEditor.market.FT_CORNERS_OVER_UNDER',
nameI18n: text('全场角球大小', 'FT Corners Over/Under', 'Atas/Bawah Sudut Penuh'),
selectionTemplate: cornersTotalSelections,
},
FT_CARDS_OVER_UNDER: {
marketKey: 'FT_CARDS_OVER_UNDER',
period: 'FT',
sortOrder: 70,
defaultLineValue: 3.5,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
settlementSupported: true,
settlementKind: 'MANUAL_STATS',
renderType: 'standard',
detailOrder: 16,
parlayOrder: 14,
i18nKey: 'bet.market_ft_cards_ou',
adminI18nKey: 'matchEditor.market.FT_CARDS_OVER_UNDER',
nameI18n: text('全场罚牌大小', 'FT Cards Over/Under', 'Atas/Bawah Kad Penuh'),
selectionTemplate: cardsTotalSelections,
},
} as const satisfies Record<string, FootballMarketCatalogEntry>;
export type FootballMarketType = keyof typeof FOOTBALL_MARKET_CATALOG;
export const FEATURED_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG)
.filter(([, entry]) => entry.detailOrder !== null && entry.detailOrder < 3)
.sort(([, a], [, b]) => (a.detailOrder ?? 0) - (b.detailOrder ?? 0))
.map(([marketType]) => marketType) as FootballMarketType[];
export const DETAIL_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG)
.filter(([, entry]) => entry.detailOrder !== null)
.sort(([, a], [, b]) => (a.detailOrder ?? 0) - (b.detailOrder ?? 0))
.map(([marketType]) => marketType) as FootballMarketType[];
export const GRID_MARKET_TYPES = DETAIL_MARKET_TYPES.filter(
(marketType) => !FEATURED_MARKET_TYPES.includes(marketType),
);
export const PARLAY_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG)
.filter(([, entry]) => entry.parlayOrder !== null && entry.allowParlay)
.sort(([, a], [, b]) => (a.parlayOrder ?? 0) - (b.parlayOrder ?? 0))
.map(([marketType]) => marketType) as FootballMarketType[];
export const DEFAULT_MARKET_EXCLUDED_TYPES = [
'HT_CORRECT_SCORE',
'SH_CORRECT_SCORE',
] as const satisfies readonly FootballMarketType[];
export const DEFAULT_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG)
.filter(
([marketType, entry]) =>
entry.showOnPlayer &&
entry.marketKey !== 'OUTRIGHT_WINNER' &&
!(DEFAULT_MARKET_EXCLUDED_TYPES as readonly string[]).includes(marketType),
)
.sort(([, a], [, b]) => a.sortOrder - b.sortOrder)
.map(([marketType]) => marketType) as FootballMarketType[];
export const MARKET_I18N_KEY: Record<string, string> = Object.fromEntries(
Object.entries(FOOTBALL_MARKET_CATALOG).map(([marketType, entry]) => [
marketType,
entry.i18nKey,
]),
);
export function isFootballMarketType(marketType: string): marketType is FootballMarketType {
return marketType in FOOTBALL_MARKET_CATALOG;
}
export function isCorrectScoreMarketType(marketType: string) {
return ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'].includes(marketType);
}
export function isSettlementSupportedMarketType(marketType: string) {
return isFootballMarketType(marketType) && FOOTBALL_MARKET_CATALOG[marketType].settlementSupported;
}
export function marketUsesLineValue(marketType: string) {
return isFootballMarketType(marketType) && FOOTBALL_MARKET_CATALOG[marketType].defaultLineValue !== null;
}
export function sanitizeLocalizedText(input: unknown): LocalizedText {
if (!input || typeof input !== 'object') return {};
const out: LocalizedText = {};
for (const locale of MARKET_LOCALES) {
const raw = (input as Record<string, unknown>)[locale];
if (raw == null) continue;
const value = String(raw).trim();
if (value) out[locale] = value;
}
return out;
}
export function resolveMarketText(input: unknown, locale: string, fallback?: LocalizedText | string | null) {
const map = sanitizeLocalizedText(input);
if (fallback && typeof fallback === 'object') {
Object.assign(map, Object.fromEntries(
Object.entries(fallback).filter(([key]) => map[key as MarketLocale] == null),
));
}
const resolved = resolveTranslationFallback(map, locale);
if (resolved) return resolved;
if (typeof fallback === 'string') return fallback;
return '';
}
export function defaultMarketName(marketType: string, locale = 'zh-CN') {
if (!isFootballMarketType(marketType)) return marketType;
return resolveMarketText(FOOTBALL_MARKET_CATALOG[marketType].nameI18n, locale, marketType);
}
export function defaultSelectionName(
marketType: string,
selectionCode: string,
locale = 'zh-CN',
) {
if (!isFootballMarketType(marketType)) return selectionCode;
const hit = FOOTBALL_MARKET_CATALOG[marketType].selectionTemplate.find(
(s) => s.code === selectionCode,
);
return hit ? resolveMarketText(hit.nameI18n, locale, hit.name) : selectionCode;
}
export function buildMarketLineKey(
marketType: string,
lineValue?: number | string | null,
params?: Record<string, unknown> | null,
) {
const line = lineValue == null || lineValue === '' ? 'none' : Number(lineValue).toFixed(2);
const paramPairs = Object.entries(params ?? {})
.filter(([, value]) => value != null && value !== '')
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${String(value)}`)
.join('|');
return paramPairs ? `${marketType}:${line}:${paramPairs}` : `${marketType}:${line}`;
}
export function buildMarketTemplate(marketType: string): FootballMarketCatalogEntry {
if (!isFootballMarketType(marketType)) {
throw new Error(`Unknown football market type: ${marketType}`);
}
return FOOTBALL_MARKET_CATALOG[marketType];
}

View File

@@ -0,0 +1,153 @@
export const PLATFORM_TIME_ZONE = 'Asia/Kuala_Lumpur';
export const PLATFORM_TIME_ZONE_OFFSET_MINUTES = 8 * 60;
export const PLATFORM_TIME_ZONE_OFFSET_LABEL = 'UTC+8';
const PICKER_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
function pad2(value) {
return String(value).padStart(2, '0');
}
function validDate(value) {
if (value == null)
return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDateTime(date, locale, options) {
try {
return new Intl.DateTimeFormat(locale || 'en-US', options).format(date);
}
catch {
return new Intl.DateTimeFormat('en-US', options).format(date);
}
}
function parsePickerParts(value) {
const match = PICKER_DATETIME_RE.exec(value.trim());
if (!match)
return null;
const [, y, mo, d, h, mi, s = '00'] = match;
const parts = {
year: Number(y),
month: Number(mo),
day: Number(d),
hour: Number(h),
minute: Number(mi),
second: Number(s),
};
const wallAsUtc = new Date(Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second));
if (wallAsUtc.getUTCFullYear() !== parts.year ||
wallAsUtc.getUTCMonth() !== parts.month - 1 ||
wallAsUtc.getUTCDate() !== parts.day ||
wallAsUtc.getUTCHours() !== parts.hour ||
wallAsUtc.getUTCMinutes() !== parts.minute ||
wallAsUtc.getUTCSeconds() !== parts.second) {
return null;
}
return parts;
}
export function platformPickerDateTimeToIso(value) {
const trimmed = value.trim();
if (!trimmed)
return '';
const parts = parsePickerParts(trimmed);
if (!parts)
return trimmed;
const platformWallMs = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
return new Date(platformWallMs - PLATFORM_TIME_ZONE_OFFSET_MINUTES * 60 * 1000).toISOString();
}
export function isoToPlatformPickerDateTime(value) {
if (!value?.trim())
return '';
const date = validDate(value);
if (!date)
return value.slice(0, 19);
const platform = new Date(date.getTime() + PLATFORM_TIME_ZONE_OFFSET_MINUTES * 60 * 1000);
return [
platform.getUTCFullYear(),
pad2(platform.getUTCMonth() + 1),
pad2(platform.getUTCDate()),
].join('-') + `T${pad2(platform.getUTCHours())}:${pad2(platform.getUTCMinutes())}:${pad2(platform.getUTCSeconds())}`;
}
export function isValidIsoDateTime(value) {
return validDate(value) !== null;
}
export function formatPlatformMatchDateTime(value, locale = 'en-US') {
const date = validDate(value);
if (!date)
return '';
const formatted = formatDateTime(date, locale, {
timeZone: PLATFORM_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
return `${formatted} ${PLATFORM_TIME_ZONE_OFFSET_LABEL}`;
}
export function getLocalGmtOffsetLabel(value = new Date()) {
const date = validDate(value) ?? new Date();
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? '+' : '-';
const abs = Math.abs(offsetMinutes);
const hours = Math.floor(abs / 60);
const minutes = abs % 60;
return minutes === 0 ? `GMT${sign}${hours}` : `GMT${sign}${hours}:${pad2(minutes)}`;
}
export function isSameLocalCalendarDay(value, now = new Date()) {
const date = validDate(value);
if (!date)
return false;
return (date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate());
}
export function isInLocalToday(value, now = new Date()) {
const date = validDate(value);
if (!date)
return false;
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return date >= start && date < end;
}
export function isAfterLocalToday(value, now = new Date()) {
const date = validDate(value);
if (!date)
return false;
const end = new Date(now);
end.setHours(0, 0, 0, 0);
end.setDate(end.getDate() + 1);
return date >= end;
}
export function formatLocalMatchDateTime(value, locale = 'en-US', options = {}) {
const date = validDate(value);
if (!date)
return '';
const variant = options.variant ?? 'compact';
const time = formatDateTime(date, locale, {
hour: '2-digit',
minute: '2-digit',
...(options.includeSeconds ? { second: '2-digit' } : {}),
});
let text;
if (variant === 'compact') {
if (options.todayLabel && isSameLocalCalendarDay(date)) {
text = `${options.todayLabel} ${time}`;
}
else {
const day = formatDateTime(date, locale, { month: 'numeric', day: 'numeric' });
text = `${day} ${time}`;
}
}
else {
const day = formatDateTime(date, locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
text = `${day} ${time}`;
}
if (options.includeTimeZone === false)
return text;
return `${text} ${getLocalGmtOffsetLabel(date)}`;
}

View File

@@ -0,0 +1,236 @@
export const PLATFORM_TIME_ZONE = 'Asia/Kuala_Lumpur';
export const PLATFORM_TIME_ZONE_OFFSET_MINUTES = 8 * 60;
export const PLATFORM_TIME_ZONE_OFFSET_LABEL = 'UTC+8';
const PICKER_DATETIME_RE =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function validDate(value: string | Date | null | undefined): Date | null {
if (value == null) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDateTime(
date: Date,
locale: string,
options: Intl.DateTimeFormatOptions,
): string {
try {
return new Intl.DateTimeFormat(locale || 'en-US', options).format(date);
} catch {
return new Intl.DateTimeFormat('en-US', options).format(date);
}
}
function timeZoneParts(date: Date, timeZone: string) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(date);
return new Map(parts.map((part) => [part.type, part.value]));
}
function dayKeyInTimeZone(date: Date, timeZone: string): string {
const parts = timeZoneParts(date, timeZone);
return `${parts.get('year')}-${parts.get('month')}-${parts.get('day')}`;
}
function offsetMinutesInTimeZone(date: Date, timeZone: string): number {
const parts = timeZoneParts(date, timeZone);
const asUtc = Date.UTC(
Number(parts.get('year')),
Number(parts.get('month')) - 1,
Number(parts.get('day')),
Number(parts.get('hour')),
Number(parts.get('minute')),
Number(parts.get('second')),
);
return Math.round((asUtc - date.getTime()) / 60000);
}
function parsePickerParts(value: string) {
const match = PICKER_DATETIME_RE.exec(value.trim());
if (!match) return null;
const [, y, mo, d, h, mi, s = '00'] = match;
const parts = {
year: Number(y),
month: Number(mo),
day: Number(d),
hour: Number(h),
minute: Number(mi),
second: Number(s),
};
const wallAsUtc = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second),
);
if (
wallAsUtc.getUTCFullYear() !== parts.year ||
wallAsUtc.getUTCMonth() !== parts.month - 1 ||
wallAsUtc.getUTCDate() !== parts.day ||
wallAsUtc.getUTCHours() !== parts.hour ||
wallAsUtc.getUTCMinutes() !== parts.minute ||
wallAsUtc.getUTCSeconds() !== parts.second
) {
return null;
}
return parts;
}
export function platformPickerDateTimeToIso(value: string): string {
const trimmed = value.trim();
if (!trimmed) return '';
const parts = parsePickerParts(trimmed);
if (!parts) return trimmed;
const platformWallMs = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
return new Date(platformWallMs - PLATFORM_TIME_ZONE_OFFSET_MINUTES * 60 * 1000).toISOString();
}
export function isoToPlatformPickerDateTime(value?: string | null): string {
if (!value?.trim()) return '';
const date = validDate(value);
if (!date) return value.slice(0, 19);
const platform = new Date(date.getTime() + PLATFORM_TIME_ZONE_OFFSET_MINUTES * 60 * 1000);
return [
platform.getUTCFullYear(),
pad2(platform.getUTCMonth() + 1),
pad2(platform.getUTCDate()),
].join('-') + `T${pad2(platform.getUTCHours())}:${pad2(platform.getUTCMinutes())}:${pad2(platform.getUTCSeconds())}`;
}
export function isValidIsoDateTime(value: string | Date | null | undefined): boolean {
return validDate(value) !== null;
}
export function formatPlatformMatchDateTime(
value: string | Date | null | undefined,
locale = 'en-US',
): string {
const date = validDate(value);
if (!date) return '';
const formatted = formatDateTime(date, locale, {
timeZone: PLATFORM_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
return `${formatted} ${PLATFORM_TIME_ZONE_OFFSET_LABEL}`;
}
export function getLocalGmtOffsetLabel(value: string | Date = new Date(), timeZone?: string): string {
const date = validDate(value) ?? new Date();
const offsetMinutes = timeZone
? offsetMinutesInTimeZone(date, timeZone)
: -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? '+' : '-';
const abs = Math.abs(offsetMinutes);
const hours = Math.floor(abs / 60);
const minutes = abs % 60;
return minutes === 0 ? `GMT${sign}${hours}` : `GMT${sign}${hours}:${pad2(minutes)}`;
}
export function isSameLocalCalendarDay(
value: string | Date,
now = new Date(),
timeZone?: string,
): boolean {
const date = validDate(value);
if (!date) return false;
if (timeZone) {
return dayKeyInTimeZone(date, timeZone) === dayKeyInTimeZone(now, timeZone);
}
return (
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
);
}
export function isInLocalToday(value: string | Date, now = new Date(), timeZone?: string): boolean {
const date = validDate(value);
if (!date) return false;
if (timeZone) {
return dayKeyInTimeZone(date, timeZone) === dayKeyInTimeZone(now, timeZone);
}
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return date >= start && date < end;
}
export function isAfterLocalToday(value: string | Date, now = new Date(), timeZone?: string): boolean {
const date = validDate(value);
if (!date) return false;
if (timeZone) {
return dayKeyInTimeZone(date, timeZone) > dayKeyInTimeZone(now, timeZone);
}
const end = new Date(now);
end.setHours(0, 0, 0, 0);
end.setDate(end.getDate() + 1);
return date >= end;
}
export function formatLocalMatchDateTime(
value: string | Date | null | undefined,
locale = 'en-US',
options: {
variant?: 'compact' | 'full';
todayLabel?: string;
includeSeconds?: boolean;
includeTimeZone?: boolean;
timeZone?: string;
} = {},
): string {
const date = validDate(value);
if (!date) return '';
const variant = options.variant ?? 'compact';
const time = formatDateTime(date, locale, {
...(options.timeZone ? { timeZone: options.timeZone } : {}),
hour: '2-digit',
minute: '2-digit',
...(options.includeSeconds ? { second: '2-digit' as const } : {}),
});
let text: string;
if (variant === 'compact') {
if (options.todayLabel && isSameLocalCalendarDay(date, new Date(), options.timeZone)) {
text = `${options.todayLabel} ${time}`;
} else {
const day = formatDateTime(date, locale, {
...(options.timeZone ? { timeZone: options.timeZone } : {}),
month: 'numeric',
day: 'numeric',
});
text = `${day} ${time}`;
}
} else {
const day = formatDateTime(date, locale, {
...(options.timeZone ? { timeZone: options.timeZone } : {}),
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
text = `${day} ${time}`;
}
if (options.includeTimeZone === false) return text;
return `${text} ${getLocalGmtOffsetLabel(date, options.timeZone)}`;
}

View File

@@ -0,0 +1,78 @@
import { normalizeLocale } from './api-errors';
import dialCodesJson from './phone-dial-codes.json';
/** 平台开放注册/短信的国家ISO 3166-1 alpha-2顺序即下拉展示顺序 */
export const ALLOWED_PHONE_ISO = ['MY', 'SG', 'IN', 'AU', 'TH', 'VN', 'BD', 'TW'];
const ALLOWED_SET = new Set(ALLOWED_PHONE_ISO);
const ZH_LABELS = {
MY: '马来西亚',
SG: '新加坡',
IN: '印度',
AU: '澳洲',
TH: '泰国',
VN: '越南',
BD: '孟加拉国',
TW: '台湾',
};
const allCountries = dialCodesJson;
/** 开放国家列表(从 ITU 全量数据中筛选) */
export const PHONE_COUNTRIES = ALLOWED_PHONE_ISO.map((iso) => {
const found = allCountries.find((c) => c.iso === iso);
if (!found) {
throw new Error(`Missing phone country data for ISO: ${iso}`);
}
return found;
});
const DIAL_SET = new Set(PHONE_COUNTRIES.map((c) => c.dial));
export function isSupportedPhoneDial(dial) {
return DIAL_SET.has(dial.replace(/\D/g, ''));
}
export function isAllowedPhoneIso(iso) {
return ALLOWED_SET.has(iso.toUpperCase());
}
export function defaultPhoneDialForLocale(localeInput) {
return findPhoneCountryByIso(defaultPhoneIsoForLocale(localeInput))?.dial ?? '60';
}
export function defaultPhoneIsoForLocale(localeInput) {
const locale = normalizeLocale(localeInput);
if (locale === 'zh-CN')
return 'TW';
if (locale === 'ms-MY')
return 'MY';
return 'SG';
}
export function getPhoneDialFromIso(iso) {
return findPhoneCountryByIso(iso)?.dial ?? '';
}
export function getPhoneCountryLabel(country, localeInput) {
const locale = normalizeLocale(localeInput);
if (locale === 'zh-CN' && isAllowedPhoneIso(country.iso)) {
return ZH_LABELS[country.iso];
}
if (locale === 'zh-CN') {
return country.nameLocal || country.nameEn;
}
return country.nameEn;
}
export function findPhoneCountryByDial(dial) {
const normalized = dial.replace(/\D/g, '');
return PHONE_COUNTRIES.find((c) => c.dial === normalized);
}
export function findPhoneCountryByIso(iso) {
const upper = iso.toUpperCase();
if (!isAllowedPhoneIso(upper))
return undefined;
return PHONE_COUNTRIES.find((c) => c.iso === upper);
}
export function searchPhoneCountries(query, localeInput) {
const q = query.trim().toLowerCase();
if (!q)
return PHONE_COUNTRIES;
return PHONE_COUNTRIES.filter((country) => {
const label = getPhoneCountryLabel(country, localeInput).toLowerCase();
return (country.iso.toLowerCase().includes(q)
|| country.dial.includes(q.replace(/^\+/, ''))
|| country.nameEn.toLowerCase().includes(q)
|| label.includes(q)
|| `+${country.dial}`.includes(q));
});
}

View File

@@ -0,0 +1,335 @@
import { resolveTranslationFallback } from './locale';
export const PLAYER_POSITION_LABELS = {
FW: { 'zh-CN': '前锋', 'en-US': 'Forward', 'ms-MY': 'Penyerang' },
MF: { 'zh-CN': '中场', 'en-US': 'Midfielder', 'ms-MY': 'Pemain Tengah' },
DF: { 'zh-CN': '后卫', 'en-US': 'Defender', 'ms-MY': 'Pertahan' },
GK: { 'zh-CN': '守门员', 'en-US': 'Goalkeeper', 'ms-MY': 'Penjaga Gol' },
};
export const PLAYER_COUNTRY_LABELS = {
ARG: { 'zh-CN': '阿根廷', 'en-US': 'Argentina', 'ms-MY': 'Argentina' },
ESP: { 'zh-CN': '西班牙', 'en-US': 'Spain', 'ms-MY': 'Sepanyol' },
CRO: { 'zh-CN': '克罗地亚', 'en-US': 'Croatia', 'ms-MY': 'Croatia' },
URU: { 'zh-CN': '乌拉圭', 'en-US': 'Uruguay', 'ms-MY': 'Uruguay' },
TUR: { 'zh-CN': '土耳其', 'en-US': 'Turkey', 'ms-MY': 'Turki' },
USA: { 'zh-CN': '美国', 'en-US': 'USA', 'ms-MY': 'Amerika Syarikat' },
BRA: { 'zh-CN': '巴西', 'en-US': 'Brazil', 'ms-MY': 'Brazil' },
POR: { 'zh-CN': '葡萄牙', 'en-US': 'Portugal', 'ms-MY': 'Portugal' },
ENG: { 'zh-CN': '英格兰', 'en-US': 'England', 'ms-MY': 'England' },
FRA: { 'zh-CN': '法国', 'en-US': 'France', 'ms-MY': 'Perancis' },
NED: { 'zh-CN': '荷兰', 'en-US': 'Netherlands', 'ms-MY': 'Belanda' },
GER: { 'zh-CN': '德国', 'en-US': 'Germany', 'ms-MY': 'Jerman' },
NOR: { 'zh-CN': '挪威', 'en-US': 'Norway', 'ms-MY': 'Norway' },
MEX: { 'zh-CN': '墨西哥', 'en-US': 'Mexico', 'ms-MY': 'Mexico' },
EGY: { 'zh-CN': '埃及', 'en-US': 'Egypt', 'ms-MY': 'Mesir' },
SWE: { 'zh-CN': '瑞典', 'en-US': 'Sweden', 'ms-MY': 'Sweden' },
COL: { 'zh-CN': '哥伦比亚', 'en-US': 'Colombia', 'ms-MY': 'Colombia' },
MAR: { 'zh-CN': '摩洛哥', 'en-US': 'Morocco', 'ms-MY': 'Maghribi' },
BEL: { 'zh-CN': '比利时', 'en-US': 'Belgium', 'ms-MY': 'Belgium' },
};
/** key = BuiltinPlayer.id文件名去掉 .jpg */
export const PLAYER_LOCALE_META = {
'何塞·曼努埃尔·洛佩斯-前锋-阿根廷': {
name: { 'zh-CN': '何塞·曼努埃尔·洛佩斯', 'en-US': 'José Manuel López', 'ms-MY': 'José Manuel López' },
position: 'FW',
country: 'ARG',
},
'佩德里-中场-西班牙': {
name: { 'zh-CN': '佩德里', 'en-US': 'Pedri', 'ms-MY': 'Pedri' },
position: 'MF',
country: 'ESP',
},
'卢卡·莫德里奇-中场-克罗地亚': {
name: { 'zh-CN': '卢卡·莫德里奇', 'en-US': 'Luka Modrić', 'ms-MY': 'Luka Modrić' },
position: 'MF',
country: 'CRO',
},
'华金·皮克雷斯-中场-乌拉圭': {
name: { 'zh-CN': '华金·皮克雷斯', 'en-US': 'Joaquín Piquerez', 'ms-MY': 'Joaquín Piquerez' },
position: 'MF',
country: 'URU',
},
'乔治亚·德·阿拉斯凯塔-中场-乌拉圭': {
name: { 'zh-CN': '乔治亚·德·阿拉斯凯塔', 'en-US': 'Giorgian de Arrascaeta', 'ms-MY': 'Giorgian de Arrascaeta' },
position: 'MF',
country: 'URU',
},
'乌古尔坎·卡基尔-守門員-土耳其': {
name: { 'zh-CN': '乌古尔坎·卡基尔', 'en-US': 'Uğurcan Çakır', 'ms-MY': 'Uğurcan Çakır' },
position: 'GK',
country: 'TUR',
},
'亚历杭德罗·曾德哈斯-前锋-美国': {
name: { 'zh-CN': '亚历杭德罗·曾德哈斯', 'en-US': 'Alejandro Zendejas', 'ms-MY': 'Alejandro Zendejas' },
position: 'FW',
country: 'USA',
},
'恩德里克-前锋-巴西': {
name: { 'zh-CN': '恩德里克', 'en-US': 'Endrick', 'ms-MY': 'Endrick' },
position: 'FW',
country: 'BRA',
},
'克里斯蒂亚诺·罗纳尔多-前锋-葡萄牙': {
name: { 'zh-CN': '克里斯蒂亚诺·罗纳尔多', 'en-US': 'Cristiano Ronaldo', 'ms-MY': 'Cristiano Ronaldo' },
position: 'FW',
country: 'POR',
},
'克里斯蒂安·罗梅罗-后卫-阿根廷': {
name: { 'zh-CN': '克里斯蒂安·罗梅罗', 'en-US': 'Cristian Romero', 'ms-MY': 'Cristian Romero' },
position: 'DF',
country: 'ARG',
},
'内马尔-前锋-巴西': {
name: { 'zh-CN': '内马尔', 'en-US': 'Neymar', 'ms-MY': 'Neymar' },
position: 'FW',
country: 'BRA',
},
'凯南·耶尔德兹-前锋-土耳其': {
name: { 'zh-CN': '凯南·耶尔德兹', 'en-US': 'Kenan Yıldız', 'ms-MY': 'Kenan Yıldız' },
position: 'FW',
country: 'TUR',
},
'卡塞米罗-中场-巴西': {
name: { 'zh-CN': '卡塞米罗', 'en-US': 'Casemiro', 'ms-MY': 'Casemiro' },
position: 'MF',
country: 'BRA',
},
'卢卡斯·帕奎塔-中场-巴西': {
name: { 'zh-CN': '卢卡斯·帕奎塔', 'en-US': 'Lucas Paquetá', 'ms-MY': 'Lucas Paquetá' },
position: 'MF',
country: 'BRA',
},
'基利安·姆巴佩-前锋-法国': {
name: { 'zh-CN': '基利安·姆巴佩', 'en-US': 'Kylian Mbappé', 'ms-MY': 'Kylian Mbappé' },
position: 'FW',
country: 'FRA',
},
'孟菲斯·德派-前锋-荷兰': {
name: { 'zh-CN': '孟菲斯·德派', 'en-US': 'Memphis Depay', 'ms-MY': 'Memphis Depay' },
position: 'FW',
country: 'NED',
},
'奥斯曼·登贝莱-前锋-法国': {
name: { 'zh-CN': '奥斯曼·登贝莱', 'en-US': 'Ousmane Dembélé', 'ms-MY': 'Ousmane Dembélé' },
position: 'FW',
country: 'FRA',
},
'布鲁诺·吉马良斯-中场-巴西': {
name: { 'zh-CN': '布鲁诺·吉马良斯', 'en-US': 'Bruno Guimarães', 'ms-MY': 'Bruno Guimarães' },
position: 'MF',
country: 'BRA',
},
'布鲁诺·费尔南德斯-中场-葡萄牙': {
name: { 'zh-CN': '布鲁诺·费尔南德斯', 'en-US': 'Bruno Fernandes', 'ms-MY': 'Bruno Fernandes' },
position: 'MF',
country: 'POR',
},
'布卡约·萨卡-前锋-英格兰': {
name: { 'zh-CN': '布卡约·萨卡', 'en-US': 'Bukayo Saka', 'ms-MY': 'Bukayo Saka' },
position: 'FW',
country: 'ENG',
},
'德尼兹·居尔-前锋-土耳其': {
name: { 'zh-CN': '德尼兹·居尔', 'en-US': 'Deniz Gül', 'ms-MY': 'Deniz Gül' },
position: 'FW',
country: 'TUR',
},
'德尼兹·温达夫-前锋-德国': {
name: { 'zh-CN': '德尼兹·温达夫', 'en-US': 'Deniz Undav', 'ms-MY': 'Deniz Undav' },
position: 'FW',
country: 'GER',
},
'拉斐尔·迪亚斯·贝洛利-前锋-巴西': {
name: { 'zh-CN': '拉斐尔·迪亚斯·贝洛利', 'en-US': 'Raphinha', 'ms-MY': 'Raphinha' },
position: 'FW',
country: 'BRA',
},
'拉明·亚马尔-前锋-西班牙': {
name: { 'zh-CN': '拉明·亚马尔', 'en-US': 'Lamine Yamal', 'ms-MY': 'Lamine Yamal' },
position: 'FW',
country: 'ESP',
},
'朱利安·阿尔瓦雷斯-前锋-阿根廷': {
name: { 'zh-CN': '朱利安·阿尔瓦雷斯', 'en-US': 'Julián Álvarez', 'ms-MY': 'Julián Álvarez' },
position: 'FW',
country: 'ARG',
},
'梅西-前锋-阿根廷': {
name: { 'zh-CN': '梅西', 'en-US': 'Lionel Messi', 'ms-MY': 'Lionel Messi' },
position: 'FW',
country: 'ARG',
},
'迈克尔·奥利塞-前锋-法国': {
name: { 'zh-CN': '迈克尔·奥利塞', 'en-US': 'Michael Olise', 'ms-MY': 'Michael Olise' },
position: 'FW',
country: 'FRA',
},
'穆罕默德·萨拉赫-前锋-埃及': {
name: { 'zh-CN': '穆罕默德·萨拉赫', 'en-US': 'Mohamed Salah', 'ms-MY': 'Mohamed Salah' },
position: 'FW',
country: 'EGY',
},
'维尼修斯·儒尼奥尔-前锋-巴西': {
name: { 'zh-CN': '维尼修斯·儒尼奥尔', 'en-US': 'Vinícius Júnior', 'ms-MY': 'Vinícius Júnior' },
position: 'FW',
country: 'BRA',
},
'维克托·哲凯赖什-前锋-瑞典': {
name: { 'zh-CN': '维克托·哲凯赖什', 'en-US': 'Viktor Gyökeres', 'ms-MY': 'Viktor Gyökeres' },
position: 'FW',
country: 'SWE',
},
'圣地亚哥·吉梅内斯-前锋-墨西哥': {
name: { 'zh-CN': '圣地亚哥·吉梅内斯', 'en-US': 'Santiago Giménez', 'ms-MY': 'Santiago Giménez' },
position: 'FW',
country: 'MEX',
},
'埃德森·阿尔瓦雷斯-后卫-墨西哥': {
name: { 'zh-CN': '埃德森·阿尔瓦雷斯', 'en-US': 'Edson Álvarez', 'ms-MY': 'Edson Álvarez' },
position: 'DF',
country: 'MEX',
},
'埃贝雷奇·埃泽-中场-英格兰': {
name: { 'zh-CN': '埃贝雷奇·埃泽', 'en-US': 'Eberechi Eze', 'ms-MY': 'Eberechi Eze' },
position: 'MF',
country: 'ENG',
},
'埃尔林·哈兰德-前锋-挪威': {
name: { 'zh-CN': '埃尔林·哈兰德', 'en-US': 'Erling Haaland', 'ms-MY': 'Erling Haaland' },
position: 'FW',
country: 'NOR',
},
'蒂博·库尔图瓦-守門員-比利时': {
name: { 'zh-CN': '蒂博·库尔图瓦', 'en-US': 'Thibaut Courtois', 'ms-MY': 'Thibaut Courtois' },
position: 'GK',
country: 'BEL',
},
'曼努埃尔·诺伊尔-守門員-德国': {
name: { 'zh-CN': '曼努埃尔·诺伊尔', 'en-US': 'Manuel Neuer', 'ms-MY': 'Manuel Neuer' },
position: 'GK',
country: 'GER',
},
'祖德·贝林厄姆-中场-英格兰': {
name: { 'zh-CN': '祖德·贝林厄姆', 'en-US': 'Jude Bellingham', 'ms-MY': 'Jude Bellingham' },
position: 'MF',
country: 'ENG',
},
'伦纳特·卡尔-中场-德国': {
name: { 'zh-CN': '伦纳特·卡尔', 'en-US': 'Lennart Karl', 'ms-MY': 'Lennart Karl' },
position: 'MF',
country: 'GER',
},
'费德里科·巴尔韦德-中场-乌拉圭': {
name: { 'zh-CN': '费德里科·巴尔韦德', 'en-US': 'Federico Valverde', 'ms-MY': 'Federico Valverde' },
position: 'MF',
country: 'URU',
},
'贾马尔·慕斯拉-中场-德国': {
name: { 'zh-CN': '贾马尔·慕斯拉', 'en-US': 'Jamal Musiala', 'ms-MY': 'Jamal Musiala' },
position: 'MF',
country: 'GER',
},
'路易斯·迪亚斯-前锋-哥伦比亚': {
name: { 'zh-CN': '路易斯·迪亚斯', 'en-US': 'Luis Díaz', 'ms-MY': 'Luis Díaz' },
position: 'FW',
country: 'COL',
},
'阿什拉夫·哈基米-后卫-摩洛哥': {
name: { 'zh-CN': '阿什拉夫·哈基米', 'en-US': 'Achraf Hakimi', 'ms-MY': 'Achraf Hakimi' },
position: 'DF',
country: 'MAR',
},
'阿利松·贝克尔-守門員-巴西': {
name: { 'zh-CN': '阿利松·贝克尔', 'en-US': 'Alisson Becker', 'ms-MY': 'Alisson Becker' },
position: 'GK',
country: 'BRA',
},
'阿尔达·居莱尔-前锋-土耳其': {
name: { 'zh-CN': '阿尔达·居莱尔', 'en-US': 'Arda Güler', 'ms-MY': 'Arda Güler' },
position: 'FW',
country: 'TUR',
},
'马西斯·拉扬·切尔基-中场-法国': {
name: { 'zh-CN': '马西斯·拉扬·切尔基', 'en-US': 'Rayan Cherki', 'ms-MY': 'Rayan Cherki' },
position: 'MF',
country: 'FRA',
},
'马库斯·拉什福德-前锋-英格兰': {
name: { 'zh-CN': '马库斯·拉什福德', 'en-US': 'Marcus Rashford', 'ms-MY': 'Marcus Rashford' },
position: 'FW',
country: 'ENG',
},
'哈里·凯恩-前锋-英格兰': {
name: { 'zh-CN': '哈里·凯恩', 'en-US': 'Harry Kane', 'ms-MY': 'Harry Kane' },
position: 'FW',
country: 'ENG',
},
'尼科·威廉斯-前锋-西班牙': {
name: { 'zh-CN': '尼科·威廉斯', 'en-US': 'Nico Williams', 'ms-MY': 'Nico Williams' },
position: 'FW',
country: 'ESP',
},
'巴勃罗·加维-中场-西班牙': {
name: { 'zh-CN': '巴勃罗·加维', 'en-US': 'Pablo Gavi', 'ms-MY': 'Pablo Gavi' },
position: 'MF',
country: 'ESP',
},
'吉列尔莫·奥乔亚-守門員-墨西哥': {
name: { 'zh-CN': '吉列尔莫·奥乔亚', 'en-US': 'Guillermo Ochoa', 'ms-MY': 'Guillermo Ochoa' },
position: 'GK',
country: 'MEX',
},
};
function mapPositionFromZh(position) {
if (position.includes('守') || position.includes('门') || position.includes('門'))
return 'GK';
if (position.includes('后') || position.includes('後'))
return 'DF';
if (position.includes('中'))
return 'MF';
return 'FW';
}
export function getPlayerLocaleMeta(player) {
const meta = PLAYER_LOCALE_META[player.id];
if (meta)
return meta;
return {
name: { 'zh-CN': player.name },
position: mapPositionFromZh(player.position),
country: '',
};
}
export function getPlayerDisplayName(player, locale) {
const meta = getPlayerLocaleMeta(player);
return resolveTranslationFallback(meta.name, locale) || player.name;
}
export function getPlayerPositionLabel(player, locale) {
const meta = getPlayerLocaleMeta(player);
const labels = PLAYER_POSITION_LABELS[meta.position];
return resolveTranslationFallback(labels, locale) || player.position;
}
export function getPlayerCountryLabel(player, locale) {
const meta = getPlayerLocaleMeta(player);
if (!meta.country)
return player.country;
const labels = PLAYER_COUNTRY_LABELS[meta.country];
return resolveTranslationFallback(labels ?? {}, locale) || player.country;
}
export function getPlayerSearchTokens(player) {
const meta = getPlayerLocaleMeta(player);
const tokens = new Set();
for (const value of Object.values(meta.name)) {
if (value)
tokens.add(value.toLowerCase());
}
for (const loc of ['zh-CN', 'en-US', 'ms-MY']) {
tokens.add(getPlayerPositionLabel(player, loc).toLowerCase());
tokens.add(getPlayerCountryLabel(player, loc).toLowerCase());
}
tokens.add(player.name.toLowerCase());
tokens.add(player.position.toLowerCase());
tokens.add(player.country.toLowerCase());
return [...tokens];
}
export function formatPlayerMeta(player, locale) {
return `${getPlayerPositionLabel(player, locale)} · ${getPlayerCountryLabel(player, locale)}`;
}

View File

@@ -0,0 +1,15 @@
/** 玩家用户名仅英文字母与数字732 位 */
export const PLAYER_USERNAME_PATTERN = /^[a-zA-Z0-9]{7,32}$/;
export const PLAYER_USERNAME_RULE_MESSAGE = '玩家用户名仅可使用英文字母和数字732 位),不可含中文或特殊符号';
export function isValidPlayerUsername(username) {
return PLAYER_USERNAME_PATTERN.test(username.trim());
}
export function assertPlayerUsername(username) {
const trimmed = username.trim();
if (!trimmed) {
throw new Error('玩家用户名不能为空');
}
if (!isValidPlayerUsername(trimmed)) {
throw new Error(PLAYER_USERNAME_RULE_MESSAGE);
}
}