refactor(constants): 提取常量并优化国际化配置

- 创建API相关常量文件,包括响应码、HTTP状态码、请求头等
- 将认证相关常量从auth模块提取到独立的常量文件
- 在API客户端中使用新定义的常量替换硬编码值
- 更新认证API和服务中对常量的引用
- 在国际化配置中创建统一的文案常量以减少重复
- 将认证表单验证规则改为使用常量配置
This commit is contained in:
JiaJun
2026-06-02 12:03:29 +08:00
parent 68cf8c0be2
commit 901ad1c30b
30 changed files with 707 additions and 273 deletions

24
src/constants/api.ts Normal file
View File

@@ -0,0 +1,24 @@
/** @description 业务响应成功状态码,响应 envelope.code 等于该值代表成功。 */
export const API_SUCCESS_CODE = 1
/** @description 接口层统一引用的常用 HTTP 状态码集合。 */
export const HTTP_STATUS = {
noContent: 204,
requestTimeout: 408,
unauthorized: 401,
} as const
/** @description 接口请求头字段名集合。 */
export const REQUEST_HEADERS = {
accept: 'Accept',
authToken: 'auth-token',
authorization: 'Authorization',
lang: 'lang',
userToken: 'user-token',
} as const
/** @description Authorization 请求头的 Bearer 前缀。 */
export const AUTHORIZATION_BEARER_PREFIX = 'Bearer '
/** @description JSON 内容类型标识,用于响应体内容类型判定。 */
export const CONTENT_TYPE_JSON = 'application/json'

View File

@@ -34,3 +34,27 @@ export const AUTH_TOKEN_CACHE_SKEW_MS = 30_000
/** @description 认证错误翻译 key 的统一前缀。 */
export const AUTH_ERROR_KEY_PREFIX = 'auth.'
/** @description 密码最小长度,用于登录与注册表单校验。 */
export const PASSWORD_MIN_LENGTH = 6
/** @description 密码最大长度,用于登录与注册表单校验。 */
export const PASSWORD_MAX_LENGTH = 32
/** @description 邀请码最大长度,用于注册表单校验。 */
export const INVITE_CODE_MAX_LENGTH = 32
/** @description 短信验证码重发倒计时兜底秒数,后端未返回有效 expiresIn 时使用。 */
export const SMS_CODE_COOLDOWN_FALLBACK_SECONDS = 60
/** @description 发送短信验证码的业务事件类型,当前固定为注册场景。 */
export const SMS_SEND_EVENT_REGISTER = 'user_register'
/** @description 注册邀请码默认值URL 未携带邀请码参数时兜底。 */
export const DEFAULT_REGISTER_INVITE_CODE = 'D97DBC16'
/** @description 注册邀请码在 URL 查询参数中的字段名。 */
export const REGISTER_INVITE_CODE_QUERY_PARAM = 'registerInviteCode'
/** @description 触发登录提示(弹窗/Toast的最小去重间隔单位为毫秒。 */
export const LOGIN_PROMPT_DEDUP_MS = 1200

View File

@@ -240,6 +240,36 @@ export const ENTRY_NOTICE_CONFIRM_INTERVAL_MS = 24 * 60 * 60 * 1000
/** @description 游戏投注记录每页加载条数。 */
export const GAME_HISTORY_PAGE_SIZE = 20
/** @description 列表类接口默认分页条数,用于财务、钱包、公告、投注单等列表。 */
export const DEFAULT_LIST_PAGE_SIZE = 20
/** @description 财务配置类查询(充值提现配置、充值档位)缓存新鲜时间,单位为毫秒。 */
export const FINANCE_CONFIG_QUERY_STALE_TIME_MS = 5 * 60 * 1000
/** @description 提现表单默认币种代码。 */
export const DEFAULT_CURRENCY_CODE = 'MYR'
/** @description 自动托管「单次中奖超过」停止规则的默认阈值。 */
export const AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD = 50_000
/** @description 全站大奖播报最大保留条数。 */
export const MAX_JACKPOT_BROADCAST_COUNT = 20
/** @description 实时连接延迟信号「优」阈值,单位为毫秒。 */
export const CONNECTION_LATENCY_GOOD_MS = 80
/** @description 实时连接延迟信号「良」阈值,同时用于判定连接是否健康,单位为毫秒。 */
export const CONNECTION_LATENCY_FAIR_MS = 150
/** @description 实时连接延迟信号「中」阈值,单位为毫秒。 */
export const CONNECTION_LATENCY_POOR_MS = 300
/** @description 提现收款邮箱校验正则。 */
export const WITHDRAW_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/** @description 提现收款手机号校验正则6-20 位数字,允许前导 +。 */
export const WITHDRAW_PHONE_PATTERN = /^\+?\d{6,20}$/
/** @description 提现页快捷法币金额选项。 */
export const QUICK_FIAT_AMOUNTS = [3, 30, 50, 100, 200, 500] as const

View File

@@ -1,3 +1,4 @@
export * from './api'
export * from './auth'
export * from './game'
export * from './system'

View File

@@ -48,6 +48,9 @@ export const QUERY_RETRYABLE_STATUS_CODES = [
/** @description 桌面端布局切换起始断点,单位为像素。 */
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
/** @description 移动端布局判定断点(最大宽度),单位为像素。 */
export const MOBILE_LAYOUT_BREAKPOINT_PX = 768
/** @description 应用支持的语言代码列表。 */
export const SUPPORTED_LANGUAGES = ['zh-CN', 'en-US', 'ms-MY', 'id-ID'] as const

View File

@@ -1,4 +1,9 @@
import { AUTH_ENDPOINTS, AUTH_SKIP_REFRESH_CONTEXT_KEY } from '@/constants'
import {
API_SUCCESS_CODE,
AUTH_ENDPOINTS,
AUTH_SKIP_REFRESH_CONTEXT_KEY,
SMS_SEND_EVENT_REGISTER,
} from '@/constants'
import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error'
import type { AuthSessionInput } from '@/store/auth'
@@ -34,7 +39,7 @@ function unwrapEnvelope<T>(
response: ApiResponse<T>,
fallbackErrorKey = 'auth.errors.requestFailed',
) {
if (response.code === 1) {
if (response.code === API_SUCCESS_CODE) {
return response.data
}
@@ -172,7 +177,7 @@ export async function sendSmsCode(
AUTH_ENDPOINTS.sendSmsCode,
{
json: {
event: 'user_register',
event: SMS_SEND_EVENT_REGISTER,
mobile: payload.mobile,
},
},

View File

@@ -1,3 +1,4 @@
import type { SMS_SEND_EVENT_REGISTER } from '@/constants'
import type { AuthSessionInput, AuthUser } from '@/store/auth'
export interface AuthApiEnvelope<T> {
@@ -70,7 +71,7 @@ export interface RegisterRequestDto {
}
export interface SendSmsCodeRequestDto {
event: 'user_register'
event: typeof SMS_SEND_EVENT_REGISTER
mobile: string
}

View File

@@ -1,5 +1,9 @@
import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import {
DEFAULT_REGISTER_INVITE_CODE,
REGISTER_INVITE_CODE_QUERY_PARAM,
} from '@/constants'
import i18n from '@/i18n'
import { notify } from '@/lib/notify'
import { useAuthStore } from '@/store/auth'
@@ -15,9 +19,6 @@ interface UseRegisterFormOptions {
onSuccess?: () => void
}
const REGISTER_INVITE_CODE_QUERY_PARAM = 'registerInviteCode'
const DEFAULT_REGISTER_INVITE_CODE = 'D97DBC16'
function getInitialRegisterInviteCode() {
if (typeof window === 'undefined') {
return DEFAULT_REGISTER_INVITE_CODE

View File

@@ -1,12 +1,11 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { SMS_CODE_COOLDOWN_FALLBACK_SECONDS } from '@/constants'
import i18n from '@/i18n'
import { notify } from '@/lib/notify'
import { sendSmsCode } from '../api/auth-api'
import { toAuthSubmitErrorKey } from './auth-error-key'
const FALLBACK_SMS_CODE_COOLDOWN_SECONDS = 60
export function useSendSmsCode() {
const [remainingSeconds, setRemainingSeconds] = useState(0)
const mutation = useMutation({
@@ -24,7 +23,7 @@ export function useSendSmsCode() {
setRemainingSeconds(
result.expiresIn > 0
? result.expiresIn
: FALLBACK_SMS_CODE_COOLDOWN_SECONDS,
: SMS_CODE_COOLDOWN_FALLBACK_SECONDS,
)
notify.success(i18n.t('auth.register.sms.success'))
},

View File

@@ -1,5 +1,11 @@
import { z } from 'zod'
import {
INVITE_CODE_MAX_LENGTH,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
} from '@/constants'
const usernameSchema = z
.string()
.trim()
@@ -12,8 +18,8 @@ const captchaSchema = z
const passwordSchema = z
.string()
.min(6, 'auth.validation.password.min')
.max(32, 'auth.validation.password.max')
.min(PASSWORD_MIN_LENGTH, 'auth.validation.password.min')
.max(PASSWORD_MAX_LENGTH, 'auth.validation.password.max')
export const loginFormSchema = z.object({
password: passwordSchema,
@@ -28,7 +34,7 @@ export const registerFormSchema = z
.string()
.trim()
.min(1, 'auth.validation.inviteCode.required')
.max(32, 'auth.validation.inviteCode.max'),
.max(INVITE_CODE_MAX_LENGTH, 'auth.validation.inviteCode.max'),
password: passwordSchema,
mobile: usernameSchema,
})

View File

@@ -1,4 +1,8 @@
import { FINANCE_API_ENDPOINTS } from '@/constants'
import {
API_SUCCESS_CODE,
DEFAULT_LIST_PAGE_SIZE,
FINANCE_API_ENDPOINTS,
} from '@/constants'
import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type'
@@ -30,7 +34,7 @@ function unwrapFinanceEnvelope<T>(
response: ApiResponse<T>,
fallbackMessage = 'Finance request failed',
) {
if (response.code === 1) {
if (response.code === API_SUCCESS_CODE) {
return response.data
}
@@ -194,7 +198,7 @@ function normalizeFinanceOrderList(dto: FinanceOrderListDto): FinanceOrderList {
list: (dto.list ?? []).map(normalizeFinanceOrderItem),
pagination: {
page: dto.pagination?.page ?? 1,
page_size: dto.pagination?.page_size ?? 20,
page_size: dto.pagination?.page_size ?? DEFAULT_LIST_PAGE_SIZE,
total: dto.pagination?.total ?? 0,
},
}
@@ -237,7 +241,8 @@ function normalizeWalletRecordList(dto: WalletRecordListDto): WalletRecordList {
list: (dto.list ?? []).map(normalizeWalletRecordItem),
pagination: {
page: dto.pagination?.page ?? dto.page ?? 1,
page_size: dto.pagination?.page_size ?? dto.page_size ?? 20,
page_size:
dto.pagination?.page_size ?? dto.page_size ?? DEFAULT_LIST_PAGE_SIZE,
total: dto.pagination?.total ?? dto.total ?? 0,
},
}
@@ -301,7 +306,7 @@ export async function getDepositOrderList(params?: {
{
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? 20),
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
},
},
)
@@ -322,7 +327,7 @@ export async function getWithdrawOrderList(params?: {
{
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? 20),
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
},
},
)
@@ -344,7 +349,7 @@ export async function getWalletRecordList(params?: {
{
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? 20),
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
type: params?.type ?? 'payout',
},
},

View File

@@ -1,4 +1,8 @@
import { GAME_API_ENDPOINTS } from '@/constants'
import {
API_SUCCESS_CODE,
DEFAULT_LIST_PAGE_SIZE,
GAME_API_ENDPOINTS,
} from '@/constants'
import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type'
@@ -51,7 +55,7 @@ function unwrapGameEnvelope<T>(
response: ApiResponse<T>,
fallbackMessage = 'Game request failed',
) {
if (response.code === 1) {
if (response.code === API_SUCCESS_CODE) {
return response.data
}
@@ -424,7 +428,7 @@ export async function getNoticeList(params?: {
const response = await api.get<NoticeListDto>(GAME_API_ENDPOINTS.noticeList, {
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? 20),
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
},
})
const dto = unwrapGameEnvelope(
@@ -478,7 +482,7 @@ export async function getGameBetMyOrders(params: {
{
json: {
page: params.page ?? 1,
page_size: params.pageSize ?? 20,
page_size: params.pageSize ?? DEFAULT_LIST_PAGE_SIZE,
},
},
)

View File

@@ -1,4 +1,4 @@
import { GAME_API_ENDPOINTS } from '@/constants'
import { API_SUCCESS_CODE, GAME_API_ENDPOINTS } from '@/constants'
import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type'
@@ -16,7 +16,7 @@ interface GamePeriodHistoryDto {
function unwrapPeriodHistoryEnvelope(
response: ApiResponse<GamePeriodHistoryDto>,
) {
if (response.code === 1) {
if (response.code === API_SUCCESS_CODE) {
return response.data
}

View File

@@ -1,6 +1,7 @@
import { startTransition, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MOBILE_LAYOUT_BREAKPOINT_PX } from '@/constants'
import { getGameLobbyInit } from '@/features/game'
import { EntryNoticeGateModal } from '@/features/game/components'
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
@@ -74,7 +75,8 @@ export function EntryPage() {
return false
}
return window.matchMedia('(max-width: 768px)').matches
return window.matchMedia(`(max-width: ${MOBILE_LAYOUT_BREAKPOINT_PX}px)`)
.matches
})
useDocumentMetadata({
@@ -189,7 +191,9 @@ export function EntryPage() {
return
}
const mediaQuery = window.matchMedia('(max-width: 768px)')
const mediaQuery = window.matchMedia(
`(max-width: ${MOBILE_LAYOUT_BREAKPOINT_PX}px)`,
)
const syncLayout = (event?: MediaQueryListEvent) => {
setIsMobile(event?.matches ?? mediaQuery.matches)
}

View File

@@ -1,7 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { DEFAULT_APP_LANGUAGE } from '@/constants'
import {
DEFAULT_APP_LANGUAGE,
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
} from '@/constants'
import { getDepositTierList } from '@/features/game/api'
export function useDepositTierList() {
@@ -12,6 +15,6 @@ export function useDepositTierList() {
return useQuery({
queryKey: ['finance', 'deposit-tier-list', language],
queryFn: () => getDepositTierList(),
staleTime: 5 * 60 * 1000,
staleTime: FINANCE_CONFIG_QUERY_STALE_TIME_MS,
})
}

View File

@@ -1,7 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { DEFAULT_APP_LANGUAGE } from '@/constants'
import {
DEFAULT_APP_LANGUAGE,
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
} from '@/constants'
import { getDepositWithdrawConfig } from '@/features/game/api'
export function useDepositWithdrawConfig() {
@@ -12,6 +15,6 @@ export function useDepositWithdrawConfig() {
return useQuery({
queryKey: ['finance', 'deposit-withdraw-config', language],
queryFn: () => getDepositWithdrawConfig(),
staleTime: 5 * 60 * 1000,
staleTime: FINANCE_CONFIG_QUERY_STALE_TIME_MS,
})
}

View File

@@ -2,12 +2,11 @@ import { useInfiniteQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
import { getDepositOrderList, getWithdrawOrderList } from '@/features/game/api'
export type FinanceRecordType = 'deposit' | 'withdraw'
const FINANCE_RECORD_PAGE_SIZE = 20
const FINANCE_RECORD_TYPE_OPTIONS: Array<{
key: FinanceRecordType
labelKey: string
@@ -46,11 +45,11 @@ export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
recordType === 'deposit'
? getDepositOrderList({
page: pageParam,
pageSize: FINANCE_RECORD_PAGE_SIZE,
pageSize: DEFAULT_LIST_PAGE_SIZE,
})
: getWithdrawOrderList({
page: pageParam,
pageSize: FINANCE_RECORD_PAGE_SIZE,
pageSize: DEFAULT_LIST_PAGE_SIZE,
}),
enabled,
getNextPageParam: (lastPage) => {

View File

@@ -1,4 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import {
CONNECTION_LATENCY_FAIR_MS,
CONNECTION_LATENCY_GOOD_MS,
CONNECTION_LATENCY_POOR_MS,
} from '@/constants'
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
import {
isDesktopFullscreen,
@@ -74,7 +79,7 @@ function resolveSignalPresentation(input: {
} satisfies SignalPresentation
}
if (input.latencyMs <= 80) {
if (input.latencyMs <= CONNECTION_LATENCY_GOOD_MS) {
return {
activeBars: 4,
latencyLabel: String(input.latencyMs),
@@ -82,7 +87,7 @@ function resolveSignalPresentation(input: {
} satisfies SignalPresentation
}
if (input.latencyMs <= 150) {
if (input.latencyMs <= CONNECTION_LATENCY_FAIR_MS) {
return {
activeBars: 3,
latencyLabel: String(input.latencyMs),
@@ -90,7 +95,7 @@ function resolveSignalPresentation(input: {
} satisfies SignalPresentation
}
if (input.latencyMs <= 300) {
if (input.latencyMs <= CONNECTION_LATENCY_POOR_MS) {
return {
activeBars: 2,
latencyLabel: String(input.latencyMs),

View File

@@ -3,9 +3,9 @@ import dayjs from 'dayjs'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
import { getWalletRecordList } from '@/features/game/api'
const WALLET_RECORD_PAGE_SIZE = 20
const WALLET_RECORD_TYPE = 'payout'
function formatWalletAmount(value: string, locale: string) {
@@ -47,7 +47,7 @@ export function useWalletRecordsVm({ enabled }: { enabled: boolean }) {
queryFn: ({ pageParam }) =>
getWalletRecordList({
page: pageParam,
pageSize: WALLET_RECORD_PAGE_SIZE,
pageSize: DEFAULT_LIST_PAGE_SIZE,
type: WALLET_RECORD_TYPE,
}),
enabled,

View File

@@ -1,7 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DEFAULT_WITHDRAW_CONFIG, QUICK_FIAT_AMOUNTS } from '@/constants'
import {
DEFAULT_CURRENCY_CODE,
DEFAULT_WITHDRAW_CONFIG,
QUICK_FIAT_AMOUNTS,
WITHDRAW_EMAIL_PATTERN,
WITHDRAW_PHONE_PATTERN,
} from '@/constants'
import type { DepositWithdrawConfig } from '@/features/game/api'
import { useDepositWithdrawConfig } from '@/features/game/hooks/use-deposit-withdraw-config'
import { useAuthStore } from '@/store'
@@ -47,7 +53,7 @@ function isValidEmail(value: string) {
return false
}
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
return WITHDRAW_EMAIL_PATTERN.test(value.trim())
}
function isValidPhone(value: string) {
@@ -57,7 +63,7 @@ function isValidPhone(value: string) {
return false
}
return /^\+?\d{6,20}$/.test(normalized)
return WITHDRAW_PHONE_PATTERN.test(normalized)
}
export function useWithdrawVm() {
@@ -88,7 +94,7 @@ export function useWithdrawVm() {
const [amount, setAmountState] = useState(0)
const [hasInitializedAmount, setHasInitializedAmount] = useState(false)
const [currencyCode, setCurrencyCode] = useState(
config.currencies[0]?.code ?? 'MYR',
config.currencies[0]?.code ?? DEFAULT_CURRENCY_CODE,
)
const [paymentChannelCode, setPaymentChannelCode] = useState('')
const [bankCode, setBankCode] = useState('')
@@ -207,7 +213,7 @@ export function useWithdrawVm() {
}, [locale, maxWithdrawAmount, selectedCurrency.code, selectedRate])
const resetForm = useCallback(() => {
const nextCurrencyCode = config.currencies[0]?.code ?? 'MYR'
const nextCurrencyCode = config.currencies[0]?.code ?? DEFAULT_CURRENCY_CODE
const nextCurrency = getActiveCurrencyCode(
config.currencies,
nextCurrencyCode,

View File

@@ -5,6 +5,7 @@ import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { Input } from '@/components/ui/input.tsx'
import { Switch } from '@/components/ui/switch.tsx'
import { AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD } from '@/constants'
import { notify } from '@/lib/notify'
import { useModalStore } from '@/store'
import { useAuthStore } from '@/store/auth'
@@ -51,7 +52,9 @@ function DesktopAutoSettingModal() {
const [balanceLimitEnabled, setBalanceLimitEnabled] = useState(false)
const [balanceLimitValue, setBalanceLimitValue] = useState('0')
const [singleWinLimitEnabled, setSingleWinLimitEnabled] = useState(false)
const [singleWinLimitValue, setSingleWinLimitValue] = useState('50000')
const [singleWinLimitValue, setSingleWinLimitValue] = useState(
String(AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD),
)
const [jackpotStopEnabled, setJackpotStopEnabled] = useState(false)
function handleClose() {

View File

@@ -15,6 +15,7 @@ import userInfoBg from '@/assets/system/userInfo-bg.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { REGISTER_INVITE_CODE_QUERY_PARAM } from '@/constants'
import { logoutWithPassword } from '@/features/auth/api/auth-api'
import DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab'
import DesktopWalletRecordsTab from '@/features/game/modal/desktop/desktop-wallet-records-tab'
@@ -47,8 +48,6 @@ const USER_INFO_TABS: Array<{
},
]
const REGISTER_INVITE_CODE_QUERY_PARAM = 'registerInviteCode'
function createRegisterInviteUrl(inviteCode: string) {
const url = new URL(window.location.href)

View File

@@ -2,14 +2,19 @@ import ky, { HTTPError, type Options, TimeoutError } from 'ky'
import {
ACCESS_TOKEN_REFRESH_SKEW_MS,
API_ERROR_MESSAGES,
API_SUCCESS_CODE,
AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY,
AUTH_REFRESH_ENDPOINT,
AUTH_RELOGIN_REQUIRED_CODES,
AUTH_SKIP_REFRESH_CONTEXT_KEY,
AUTH_TOKEN_CACHE_SKEW_MS,
AUTH_TOKEN_ENDPOINT,
AUTHORIZATION_BEARER_PREFIX,
CONTENT_TYPE_JSON,
DEFAULT_REQUEST_ACCEPT_HEADER,
DEFAULT_REQUEST_TIMEOUT_MS,
HTTP_STATUS,
REQUEST_HEADERS,
} from '@/constants'
import type { AuthTokenDto } from '@/features/auth/api/types'
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
@@ -65,13 +70,13 @@ function normalizeApiBaseUrl(baseUrl: string | undefined) {
}
async function parseResponseBody(response: Response) {
if (response.status === 204) {
if (response.status === HTTP_STATUS.noContent) {
return null
}
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
if (contentType.includes(CONTENT_TYPE_JSON)) {
return response.json()
}
@@ -110,7 +115,7 @@ async function toApiError(error: unknown) {
if (error instanceof TimeoutError) {
return new ApiError({
message: API_ERROR_MESSAGES.timeout,
status: 408,
status: HTTP_STATUS.requestTimeout,
})
}
@@ -143,14 +148,20 @@ const apiClient = ky.create({
hooks: {
beforeRequest: [
({ request }) => {
request.headers.set('Accept', DEFAULT_REQUEST_ACCEPT_HEADER)
request.headers.set('lang', getRequestLanguage())
request.headers.set(
REQUEST_HEADERS.accept,
DEFAULT_REQUEST_ACCEPT_HEADER,
)
request.headers.set(REQUEST_HEADERS.lang, getRequestLanguage())
const token = useAuthStore.getState().accessToken
if (token) {
request.headers.set('Authorization', `Bearer ${token}`)
request.headers.set('user-token', token)
request.headers.set(
REQUEST_HEADERS.authorization,
`${AUTHORIZATION_BEARER_PREFIX}${token}`,
)
request.headers.set(REQUEST_HEADERS.userToken, token)
}
if (shouldLogRequests) {
@@ -222,14 +233,14 @@ function assertValidAuthEnvelope(data: unknown) {
throw new ApiError({
data,
message: getApiEnvelopeMessage(data),
status: 401,
status: HTTP_STATUS.unauthorized,
})
}
function unwrapEnvelopeData<T>(response: ApiResponse<T>) {
assertValidAuthEnvelope(response)
if (response.code === 1) {
if (response.code === API_SUCCESS_CODE) {
return response.data
}
@@ -333,8 +344,8 @@ function createHeaders(headersInit?: Options['headers']) {
async function buildRequestOptions(input: string, options?: Options) {
const headers = createHeaders(options?.headers)
if (shouldAttachAuthToken(input) && !headers.has('auth-token')) {
headers.set('auth-token', await fetchAuthToken())
if (shouldAttachAuthToken(input) && !headers.has(REQUEST_HEADERS.authToken)) {
headers.set(REQUEST_HEADERS.authToken, await fetchAuthToken())
}
return {
@@ -361,7 +372,7 @@ async function request<TResponse>(input: string, options?: Options) {
} catch (error) {
if (
error instanceof HTTPError &&
error.response.status === 401 &&
error.response.status === HTTP_STATUS.unauthorized &&
input !== AUTH_REFRESH_ENDPOINT &&
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] !== true &&
options?.context?.[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY] !== true
@@ -379,7 +390,10 @@ async function request<TResponse>(input: string, options?: Options) {
}
}
if (error instanceof HTTPError && error.response.status === 401) {
if (
error instanceof HTTPError &&
error.response.status === HTTP_STATUS.unauthorized
) {
handleUnauthorizedSession()
}

View File

@@ -1,3 +1,4 @@
import { LOGIN_PROMPT_DEDUP_MS } from '@/constants'
import i18n from '@/i18n'
import { notify } from '@/lib/notify'
import { queryClient } from '@/lib/query/query-client'
@@ -16,8 +17,6 @@ let authInitializationPromise: Promise<void> | null = null
let refreshSessionPromise: Promise<boolean> | null = null
let lastLoginPromptAt = 0
const LOGIN_PROMPT_DEDUP_MS = 1200
interface ClearAuthenticatedSessionOptions {
clearBrowserStorage?: boolean
clearQueryCache?: boolean

View File

@@ -1,3 +1,76 @@
/* 以下为多语言中重复出现的文案,统一声明一次后在下方各 key 复用,避免同一文案多处声明。 */
/** @description 登录的统一文案。 */
const TEXT_LOGIN = 'Login'
/** @description 注册的统一文案。 */
const TEXT_REGISTER = 'Register'
/** @description “是”的统一文案。 */
const TEXT_YES = 'Yes'
/** @description “否”的统一文案。 */
const TEXT_NO = 'No'
/** @description “查看”的统一文案。 */
const TEXT_VIEW = 'View'
/** @description 自动托管的统一文案。 */
const TEXT_AUTO_HOSTING = 'Auto Spin'
/** @description 钱包流水的统一文案。 */
const TEXT_WALLET_RECORDS = 'Wallet Records'
/** @description 站内消息的统一文案。 */
const TEXT_SITE_MESSAGES = 'Messages'
/** @description 分页“第x页/共x条”的统一文案。 */
const TEXT_PAGE_INDICATOR = 'Page {{page}} / {{total}} total'
/** @description 上一页的统一文案。 */
const TEXT_PREV_PAGE = 'Previous'
/** @description 下一页的统一文案。 */
const TEXT_NEXT_PAGE = 'Next'
/** @description “时间”的统一文案。 */
const TEXT_TIME = 'Time'
/** @description 超过单次投注限额的统一文案。 */
const TEXT_BET_LIMIT_EXCEEDED = 'Single bet limit exceeded'
/** @description “提交中...”的统一文案。 */
const TEXT_SUBMITTING = 'Submitting...'
/** @description 手机号字段标签的统一文案。 */
const TEXT_MOBILE_LABEL = 'Mobile:'
/** @description 手机号输入占位的统一文案。 */
const TEXT_MOBILE_PLACEHOLDER = 'Enter mobile number'
/** @description 密码字段标签的统一文案。 */
const TEXT_PASSWORD_LABEL = 'Password:'
/** @description 密码输入占位的统一文案。 */
const TEXT_PASSWORD_PLACEHOLDER = 'Enter password'
/** @description “确认”按钮的统一文案。 */
const TEXT_CONFIRM = 'Confirm'
/** @description “加载中...”的统一文案。 */
const TEXT_LOADING = 'Loading...'
/** @description 货币类型的统一文案。 */
const TEXT_CURRENCY_TYPE = 'Currency Type'
/** @description 支付渠道的统一文案。 */
const TEXT_PAYMENT_CHANNEL = 'Payment Channel'
/** @description “已封盘”的统一文案。 */
const TEXT_LOCKED = 'Locked'
/** @description “已结算”的统一文案。 */
const TEXT_SETTLED = 'Settled'
export default {
nav: {
home: 'Home',
@@ -88,8 +161,8 @@ export default {
},
phases: {
betting: 'Betting',
locked: 'Locked',
settled: 'Settled',
locked: TEXT_LOCKED,
settled: TEXT_SETTLED,
},
roundBettingStart: {
title: 'Round {{roundId}}',
@@ -99,8 +172,8 @@ export default {
unifiedBetHint: 'Unified bet',
totalBet: 'Total bet',
canBet: 'Can bet',
yes: 'Yes',
no: 'No',
yes: TEXT_YES,
no: TEXT_NO,
quickBet: 'Quick bet 08',
clearPending: 'Clear pending',
autoModeDemo: 'Auto mode demo',
@@ -108,16 +181,16 @@ export default {
},
modals: {
login: {
title: 'Login',
title: TEXT_LOGIN,
},
register: {
title: 'Register',
title: TEXT_REGISTER,
},
notice: {
title: 'Event Notice',
content:
'This area will later load the real event announcement body, rich media, and a longer scrollable message. The current version focuses on shared multilingual modal wiring.',
check: 'View',
check: TEXT_VIEW,
},
entryNotice: {
title: 'Site Notice',
@@ -142,7 +215,7 @@ export default {
topup: 'Top Up',
},
autoSetting: {
title: 'Auto Spin',
title: TEXT_AUTO_HOSTING,
startAutoSpin: 'Start Auto Spin',
rows: {
stopIfBalanceLowerThan: 'Stop if balance is lower than',
@@ -155,8 +228,8 @@ export default {
tabs: {
profile: 'Profile',
financeRecords: 'Top Up / Withdraw Records',
walletRecords: 'Wallet Records',
message: 'Messages',
walletRecords: TEXT_WALLET_RECORDS,
message: TEXT_SITE_MESSAGES,
},
profile: {
name: 'Name',
@@ -169,7 +242,7 @@ export default {
'My signature is as unique as my personality. This area will later display the real profile summary.',
},
message: {
title: 'Messages',
title: TEXT_SITE_MESSAGES,
back: 'Back',
loading: 'Loading messages...',
loadFailed: 'Failed to load messages. Please try again later.',
@@ -178,7 +251,7 @@ export default {
unread: 'Unread',
eventBonus:
'[Top-up Bonus Event] From October 1 to October 7, 2026, claim your rebate rewards...',
check: 'View',
check: TEXT_VIEW,
deleteRecords: 'Delete records',
},
financeRecords: {
@@ -190,9 +263,9 @@ export default {
loading: 'Loading records...',
loadFailed: 'Failed to load records. Please try again later.',
empty: 'No records yet',
page: 'Page {{page}} / {{total}} total',
previous: 'Previous',
next: 'Next',
page: TEXT_PAGE_INDICATOR,
previous: TEXT_PREV_PAGE,
next: TEXT_NEXT_PAGE,
},
walletRecords: {
amount: 'Amount',
@@ -201,12 +274,12 @@ export default {
empty: 'No wallet records yet',
loadFailed: 'Failed to load wallet records. Please try again later.',
loading: 'Loading wallet records...',
next: 'Next',
page: 'Page {{page}} / {{total}} total',
previous: 'Previous',
next: TEXT_NEXT_PAGE,
page: TEXT_PAGE_INDICATOR,
previous: TEXT_PREV_PAGE,
remark: 'Remark',
time: 'Time',
type: 'Wallet Records',
time: TEXT_TIME,
type: TEXT_WALLET_RECORDS,
},
},
withdrawTopup: {
@@ -238,8 +311,8 @@ export default {
dialog: {
close: 'Close alert',
confirm: 'OK',
no: 'No',
yes: 'Yes',
no: TEXT_NO,
yes: TEXT_YES,
},
modal: {
close: 'Close modal',
@@ -256,7 +329,7 @@ export default {
inviteLinkCopyFailed:
'Failed to copy invite link. Please copy it manually.',
insufficientBalance: 'Insufficient balance. Please adjust your bet.',
betLimitExceeded: 'Single bet limit exceeded',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
betUnavailable: 'Betting is not available for this round',
betPlaced: 'Bet placed successfully',
noRecentSuccessfulBet:
@@ -282,7 +355,7 @@ export default {
common: {
arrowIconAlt: 'Arrow',
actions: {
submitting: 'Submitting...',
submitting: TEXT_SUBMITTING,
},
passwordVisibility: {
hide: 'Hide password',
@@ -295,12 +368,12 @@ export default {
},
fields: {
username: {
label: 'Mobile:',
placeholder: 'Enter mobile number',
label: TEXT_MOBILE_LABEL,
placeholder: TEXT_MOBILE_PLACEHOLDER,
},
password: {
label: 'Password:',
placeholder: 'Enter password',
label: TEXT_PASSWORD_LABEL,
placeholder: TEXT_PASSWORD_PLACEHOLDER,
},
},
footer: {
@@ -314,20 +387,20 @@ export default {
},
register: {
actions: {
submit: 'Register',
submit: TEXT_REGISTER,
},
fields: {
mobile: {
label: 'Mobile:',
placeholder: 'Enter mobile number',
label: TEXT_MOBILE_LABEL,
placeholder: TEXT_MOBILE_PLACEHOLDER,
},
captcha: {
label: 'Code:',
placeholder: 'Enter verification code',
},
password: {
label: 'Password:',
placeholder: 'Enter password',
label: TEXT_PASSWORD_LABEL,
placeholder: TEXT_PASSWORD_PLACEHOLDER,
},
confirmPassword: {
label: 'Confirm Password:',
@@ -393,23 +466,23 @@ export default {
bgm: 'BGM',
id: 'ID',
fullscreen: 'Full Screen',
login: 'Login',
register: 'Register',
login: TEXT_LOGIN,
register: TEXT_REGISTER,
},
control: {
trend: 'Trend',
map: 'Map',
selected: 'Selected',
totalBet: 'Total Bet',
confirm: 'Confirm',
confirm: TEXT_CONFIRM,
selectNumbers: 'Select Numbers',
insufficientBalance: 'Insufficient Balance',
betLimitExceeded: 'Limit Exceeded',
submitting: 'Submitting...',
submitting: TEXT_SUBMITTING,
actions: {
clear: 'Clear',
repeat: 'Repeat',
'auto-spin': 'Auto Spin',
'auto-spin': TEXT_AUTO_HOSTING,
},
},
status: {
@@ -423,7 +496,7 @@ export default {
description: '(Accepting Bets)',
},
locked: {
label: 'Locked',
label: TEXT_LOCKED,
description: '(Betting Closed)',
},
revealing: {
@@ -445,7 +518,7 @@ export default {
},
animal: {
insufficientBalanceRecharge: 'Insufficient balance, please top up',
betLimitExceeded: 'Single bet limit exceeded',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Loading',
selectionLimitReached: 'Selection limit exceeded',
tapToEnter: 'Tap To Enter',
@@ -460,29 +533,29 @@ export default {
orderNo: 'Order No.',
roundId: 'Round ID',
numbers: 'Bet Numbers',
createdAt: 'Time',
createdAt: TEXT_TIME,
settledAt: 'Settled At',
totalPoolAmount: 'Bet Amount',
winningResult: 'Winning Result',
payout: 'Win Amount',
empty: 'No history yet',
end: 'No more records',
loading: 'Loading...',
settled: 'Settled',
loading: TEXT_LOADING,
settled: TEXT_SETTLED,
},
periodHistory: {
title: 'Draw Result History',
close: 'Close draw result history',
empty: 'No draw results yet',
failed: 'Failed to load draw results',
loading: 'Loading...',
loading: TEXT_LOADING,
retry: 'Retry',
},
topup: {
title: 'Top-up Config',
platformCoinLabel: 'Platform Coin',
currencyLabel: 'Currency Type',
channelLabel: 'Payment Channel',
currencyLabel: TEXT_CURRENCY_TYPE,
channelLabel: TEXT_PAYMENT_CHANNEL,
rateHint:
'Exchange rates are for reference only. The final amount follows the top-up-time rate.',
tier: {
@@ -524,13 +597,13 @@ export default {
feeNotice:
'Transactions between RM10 and RM99.99 will be charged a minimum withdrawal fee of RM 1.',
cancel: 'Cancel',
confirm: 'Confirm',
confirm: TEXT_CONFIRM,
submitSuccess: 'Withdrawal request submitted',
withdrawal: 'Withdrawal',
fields: {
diamondAmount: 'Withdrawal Diamond Amount',
currencyType: 'Currency Type',
paymentChannel: 'Payment Channel',
currencyType: TEXT_CURRENCY_TYPE,
paymentChannel: TEXT_PAYMENT_CHANNEL,
bankCode: 'Bank Code',
cardHolderName: 'Card Holder Name',
bankAccountNumber: 'Bank Account Number',

View File

@@ -1,3 +1,76 @@
/* 以下为多语言中重复出现的文案,统一声明一次后在下方各 key 复用,避免同一文案多处声明。 */
/** @description 登录的统一文案。 */
const TEXT_LOGIN = 'Masuk'
/** @description 注册的统一文案。 */
const TEXT_REGISTER = 'Daftar'
/** @description “是”的统一文案。 */
const TEXT_YES = 'Ya'
/** @description “否”的统一文案。 */
const TEXT_NO = 'Tidak'
/** @description “查看”的统一文案。 */
const TEXT_VIEW = 'Lihat'
/** @description 自动托管的统一文案。 */
const TEXT_AUTO_HOSTING = 'Auto Spin'
/** @description 钱包流水的统一文案。 */
const TEXT_WALLET_RECORDS = 'Riwayat Dompet'
/** @description 站内消息的统一文案。 */
const TEXT_SITE_MESSAGES = 'Pesan'
/** @description 分页“第x页/共x条”的统一文案。 */
const TEXT_PAGE_INDICATOR = 'Halaman {{page}} / total {{total}}'
/** @description 上一页的统一文案。 */
const TEXT_PREV_PAGE = 'Sebelumnya'
/** @description 下一页的统一文案。 */
const TEXT_NEXT_PAGE = 'Berikutnya'
/** @description “时间”的统一文案。 */
const TEXT_TIME = 'Waktu'
/** @description 超过单次投注限额的统一文案。 */
const TEXT_BET_LIMIT_EXCEEDED = 'Melebihi batas taruhan tunggal'
/** @description “提交中...”的统一文案。 */
const TEXT_SUBMITTING = 'Mengirim...'
/** @description 手机号字段标签的统一文案。 */
const TEXT_MOBILE_LABEL = 'Nomor Ponsel:'
/** @description 手机号输入占位的统一文案。 */
const TEXT_MOBILE_PLACEHOLDER = 'Masukkan nomor ponsel'
/** @description 密码字段标签的统一文案。 */
const TEXT_PASSWORD_LABEL = 'Kata Sandi:'
/** @description 密码输入占位的统一文案。 */
const TEXT_PASSWORD_PLACEHOLDER = 'Masukkan kata sandi'
/** @description “确认”按钮的统一文案。 */
const TEXT_CONFIRM = 'Konfirmasi'
/** @description “加载中...”的统一文案。 */
const TEXT_LOADING = 'Memuat...'
/** @description 货币类型的统一文案。 */
const TEXT_CURRENCY_TYPE = 'Jenis Mata Uang'
/** @description 支付渠道的统一文案。 */
const TEXT_PAYMENT_CHANNEL = 'Saluran Pembayaran'
/** @description “已封盘”的统一文案。 */
const TEXT_LOCKED = 'Terkunci'
/** @description “已结算”的统一文案。 */
const TEXT_SETTLED = 'Selesai'
export default {
nav: {
home: 'Beranda',
@@ -87,8 +160,8 @@ export default {
},
phases: {
betting: 'Betting',
locked: 'Terkunci',
settled: 'Selesai',
locked: TEXT_LOCKED,
settled: TEXT_SETTLED,
},
roundBettingStart: {
title: 'Ronde {{roundId}}',
@@ -98,8 +171,8 @@ export default {
unifiedBetHint: 'Bet seragam',
totalBet: 'Total bet',
canBet: 'Bisa bet',
yes: 'Ya',
no: 'Tidak',
yes: TEXT_YES,
no: TEXT_NO,
quickBet: 'Quick bet 08',
clearPending: 'Hapus pending',
autoModeDemo: 'Demo mode auto',
@@ -107,16 +180,16 @@ export default {
},
modals: {
login: {
title: 'Masuk',
title: TEXT_LOGIN,
},
register: {
title: 'Daftar',
title: TEXT_REGISTER,
},
notice: {
title: 'Pengumuman Acara',
content:
'Bagian ini nantinya akan memuat konten pengumuman acara yang sebenarnya, materi visual, dan pesan panjang yang dapat digulir. Versi saat ini fokus pada sambungan modal multibahasa.',
check: 'Lihat',
check: TEXT_VIEW,
},
entryNotice: {
title: 'Pengumuman Situs',
@@ -141,7 +214,7 @@ export default {
topup: 'Isi Ulang',
},
autoSetting: {
title: 'Auto Spin',
title: TEXT_AUTO_HOSTING,
startAutoSpin: 'Mulai Auto Spin',
rows: {
stopIfBalanceLowerThan: 'Berhenti jika saldo lebih rendah dari',
@@ -154,8 +227,8 @@ export default {
tabs: {
profile: 'Profil',
financeRecords: 'Riwayat Isi Ulang / Penarikan',
walletRecords: 'Riwayat Dompet',
message: 'Pesan',
walletRecords: TEXT_WALLET_RECORDS,
message: TEXT_SITE_MESSAGES,
},
profile: {
name: 'Nama',
@@ -168,7 +241,7 @@ export default {
'Tanda tanganku seunik diriku. Bagian ini nantinya akan menampilkan ringkasan profil yang sebenarnya.',
},
message: {
title: 'Pesan',
title: TEXT_SITE_MESSAGES,
back: 'Kembali',
loading: 'Memuat pesan...',
loadFailed: 'Gagal memuat pesan. Silakan coba lagi nanti.',
@@ -177,7 +250,7 @@ export default {
unread: 'Belum dibaca',
eventBonus:
'[Event Bonus Isi Ulang] Dari 1 Oktober hingga 7 Oktober 2026, klaim hadiah rebate kamu...',
check: 'Lihat',
check: TEXT_VIEW,
deleteRecords: 'Hapus riwayat',
},
financeRecords: {
@@ -189,9 +262,9 @@ export default {
loading: 'Memuat riwayat...',
loadFailed: 'Gagal memuat riwayat. Silakan coba lagi nanti.',
empty: 'Belum ada riwayat',
page: 'Halaman {{page}} / total {{total}}',
previous: 'Sebelumnya',
next: 'Berikutnya',
page: TEXT_PAGE_INDICATOR,
previous: TEXT_PREV_PAGE,
next: TEXT_NEXT_PAGE,
},
walletRecords: {
amount: 'Jumlah',
@@ -200,12 +273,12 @@ export default {
empty: 'Belum ada riwayat dompet',
loadFailed: 'Gagal memuat riwayat dompet. Silakan coba lagi nanti.',
loading: 'Memuat riwayat dompet...',
next: 'Berikutnya',
page: 'Halaman {{page}} / total {{total}}',
previous: 'Sebelumnya',
next: TEXT_NEXT_PAGE,
page: TEXT_PAGE_INDICATOR,
previous: TEXT_PREV_PAGE,
remark: 'Catatan',
time: 'Waktu',
type: 'Riwayat Dompet',
time: TEXT_TIME,
type: TEXT_WALLET_RECORDS,
},
},
withdrawTopup: {
@@ -237,8 +310,8 @@ export default {
dialog: {
close: 'Tutup notifikasi',
confirm: 'OK',
no: 'Tidak',
yes: 'Ya',
no: TEXT_NO,
yes: TEXT_YES,
},
modal: {
close: 'Tutup modal',
@@ -255,7 +328,7 @@ export default {
inviteLinkCopyFailed:
'Gagal menyalin tautan undangan. Silakan salin secara manual.',
insufficientBalance: 'Saldo tidak cukup. Silakan sesuaikan taruhan.',
betLimitExceeded: 'Melebihi batas taruhan tunggal',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
betUnavailable: 'Taruhan tidak tersedia untuk ronde ini',
betPlaced: 'Taruhan berhasil dikirim',
noRecentSuccessfulBet:
@@ -282,7 +355,7 @@ export default {
common: {
arrowIconAlt: 'Panah',
actions: {
submitting: 'Mengirim...',
submitting: TEXT_SUBMITTING,
},
passwordVisibility: {
hide: 'Sembunyikan kata sandi',
@@ -291,16 +364,16 @@ export default {
},
login: {
actions: {
submit: 'Masuk',
submit: TEXT_LOGIN,
},
fields: {
username: {
label: 'Nomor Ponsel:',
placeholder: 'Masukkan nomor ponsel',
label: TEXT_MOBILE_LABEL,
placeholder: TEXT_MOBILE_PLACEHOLDER,
},
password: {
label: 'Kata Sandi:',
placeholder: 'Masukkan kata sandi',
label: TEXT_PASSWORD_LABEL,
placeholder: TEXT_PASSWORD_PLACEHOLDER,
},
},
footer: {
@@ -314,20 +387,20 @@ export default {
},
register: {
actions: {
submit: 'Daftar',
submit: TEXT_REGISTER,
},
fields: {
mobile: {
label: 'Nomor Ponsel:',
placeholder: 'Masukkan nomor ponsel',
label: TEXT_MOBILE_LABEL,
placeholder: TEXT_MOBILE_PLACEHOLDER,
},
captcha: {
label: 'Kode:',
placeholder: 'Masukkan kode verifikasi',
},
password: {
label: 'Kata Sandi:',
placeholder: 'Masukkan kata sandi',
label: TEXT_PASSWORD_LABEL,
placeholder: TEXT_PASSWORD_PLACEHOLDER,
},
confirmPassword: {
label: 'Konfirmasi Kata Sandi:',
@@ -352,7 +425,7 @@ export default {
submitFailed: 'Gagal mengirim kode. Silakan coba lagi nanti.',
},
send: 'Ambil kode',
sending: 'Mengirim...',
sending: TEXT_SUBMITTING,
success: 'Kode verifikasi telah dikirim.',
},
},
@@ -389,27 +462,27 @@ export default {
header: {
systemTime: 'Waktu Sistem',
rules: 'Aturan',
message: 'Pesan',
message: TEXT_SITE_MESSAGES,
bgm: 'BGM',
id: 'ID',
fullscreen: 'Layar',
login: 'Masuk',
register: 'Daftar',
login: TEXT_LOGIN,
register: TEXT_REGISTER,
},
control: {
trend: 'Tren',
map: 'Peta',
selected: 'Dipilih',
totalBet: 'Total Bet',
confirm: 'Konfirmasi',
confirm: TEXT_CONFIRM,
selectNumbers: 'Pilih Nombor',
insufficientBalance: 'Saldo Tidak Cukup',
betLimitExceeded: 'Batas Terlampaui',
submitting: 'Mengirim...',
submitting: TEXT_SUBMITTING,
actions: {
clear: 'Hapus',
repeat: 'Ulang',
'auto-spin': 'Auto Spin',
'auto-spin': TEXT_AUTO_HOSTING,
},
},
status: {
@@ -423,7 +496,7 @@ export default {
description: '(Menerima Bet)',
},
locked: {
label: 'Terkunci',
label: TEXT_LOCKED,
description: '(Bet Ditutup)',
},
revealing: {
@@ -445,7 +518,7 @@ export default {
},
animal: {
insufficientBalanceRecharge: 'Saldo tidak cukup, silakan isi ulang',
betLimitExceeded: 'Melebihi batas taruhan tunggal',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Memuat',
selectionLimitReached: 'Melebihi pilihan yang diizinkan',
tapToEnter: 'Ketuk Untuk Masuk',
@@ -460,29 +533,29 @@ export default {
orderNo: 'No. Order',
roundId: 'ID Ronde',
numbers: 'Nomor Taruhan',
createdAt: 'Waktu',
createdAt: TEXT_TIME,
settledAt: 'Waktu Selesai',
totalPoolAmount: 'Jumlah Taruhan',
winningResult: 'Hasil Menang',
payout: 'Jumlah Menang',
empty: 'Belum ada riwayat',
end: 'Tidak ada catatan lagi',
loading: 'Memuat...',
settled: 'Selesai',
loading: TEXT_LOADING,
settled: TEXT_SETTLED,
},
periodHistory: {
title: 'Riwayat Hasil Undian',
close: 'Tutup riwayat hasil undian',
empty: 'Belum ada hasil undian',
failed: 'Gagal memuat hasil undian',
loading: 'Memuat...',
loading: TEXT_LOADING,
retry: 'Coba lagi',
},
topup: {
title: 'Konfigurasi Isi Ulang',
platformCoinLabel: 'Koin Platform',
currencyLabel: 'Jenis Mata Uang',
channelLabel: 'Saluran Pembayaran',
currencyLabel: TEXT_CURRENCY_TYPE,
channelLabel: TEXT_PAYMENT_CHANNEL,
rateHint:
'Kurs hanya sebagai referensi. Jumlah akhir mengikuti kurs saat isi ulang.',
tier: {
@@ -525,13 +598,13 @@ export default {
feeNotice:
'Transaksi antara RM10 dan RM99.99 akan dikenakan biaya penarikan minimum RM 1.',
cancel: 'Batal',
confirm: 'Konfirmasi',
confirm: TEXT_CONFIRM,
submitSuccess: 'Permintaan penarikan berhasil dikirim',
withdrawal: 'Penarikan',
fields: {
diamondAmount: 'Jumlah Berlian Penarikan',
currencyType: 'Jenis Mata Uang',
paymentChannel: 'Saluran Pembayaran',
currencyType: TEXT_CURRENCY_TYPE,
paymentChannel: TEXT_PAYMENT_CHANNEL,
bankCode: 'Kode Bank',
cardHolderName: 'Nama Pemilik Rekening',
bankAccountNumber: 'Nomor Rekening Bank',

View File

@@ -1,3 +1,76 @@
/* 以下为多语言中重复出现的文案,统一声明一次后在下方各 key 复用,避免同一文案多处声明。 */
/** @description 登录的统一文案。 */
const TEXT_LOGIN = 'Log Masuk'
/** @description 注册的统一文案。 */
const TEXT_REGISTER = 'Daftar'
/** @description “是”的统一文案。 */
const TEXT_YES = 'Ya'
/** @description “否”的统一文案。 */
const TEXT_NO = 'Tidak'
/** @description “查看”的统一文案。 */
const TEXT_VIEW = 'Semak'
/** @description 自动托管的统一文案。 */
const TEXT_AUTO_HOSTING = 'Putaran Auto'
/** @description 钱包流水的统一文案。 */
const TEXT_WALLET_RECORDS = 'Rekod Dompet'
/** @description 站内消息的统一文案。 */
const TEXT_SITE_MESSAGES = 'Mesej'
/** @description 分页“第x页/共x条”的统一文案。 */
const TEXT_PAGE_INDICATOR = 'Halaman {{page}} / jumlah {{total}}'
/** @description 上一页的统一文案。 */
const TEXT_PREV_PAGE = 'Sebelumnya'
/** @description 下一页的统一文案。 */
const TEXT_NEXT_PAGE = 'Seterusnya'
/** @description “时间”的统一文案。 */
const TEXT_TIME = 'Masa'
/** @description 超过单次投注限额的统一文案。 */
const TEXT_BET_LIMIT_EXCEEDED = 'Melebihi had taruhan tunggal'
/** @description “提交中...”的统一文案。 */
const TEXT_SUBMITTING = 'Menghantar...'
/** @description 手机号字段标签的统一文案。 */
const TEXT_MOBILE_LABEL = 'Nombor Telefon:'
/** @description 手机号输入占位的统一文案。 */
const TEXT_MOBILE_PLACEHOLDER = 'Masukkan nombor telefon'
/** @description 密码字段标签的统一文案。 */
const TEXT_PASSWORD_LABEL = 'Kata Laluan:'
/** @description 密码输入占位的统一文案。 */
const TEXT_PASSWORD_PLACEHOLDER = 'Masukkan kata laluan'
/** @description “确认”按钮的统一文案。 */
const TEXT_CONFIRM = 'Sahkan'
/** @description “加载中...”的统一文案。 */
const TEXT_LOADING = 'Memuatkan...'
/** @description 货币类型的统一文案。 */
const TEXT_CURRENCY_TYPE = 'Jenis Mata Wang'
/** @description 支付渠道的统一文案。 */
const TEXT_PAYMENT_CHANNEL = 'Saluran Pembayaran'
/** @description “已封盘”的统一文案。 */
const TEXT_LOCKED = 'Dikunci'
/** @description “已结算”的统一文案。 */
const TEXT_SETTLED = 'Selesai'
export default {
nav: {
home: 'Laman Utama',
@@ -90,8 +163,8 @@ export default {
},
phases: {
betting: 'Taruhan',
locked: 'Dikunci',
settled: 'Selesai',
locked: TEXT_LOCKED,
settled: TEXT_SETTLED,
},
roundBettingStart: {
title: 'Pusingan {{roundId}}',
@@ -101,8 +174,8 @@ export default {
unifiedBetHint: 'Taruhan seragam',
totalBet: 'Jumlah taruhan',
canBet: 'Boleh taruhan',
yes: 'Ya',
no: 'Tidak',
yes: TEXT_YES,
no: TEXT_NO,
quickBet: 'Taruhan cepat 08',
clearPending: 'Kosongkan belum sah',
autoModeDemo: 'Demo mod auto',
@@ -110,16 +183,16 @@ export default {
},
modals: {
login: {
title: 'Log Masuk',
title: TEXT_LOGIN,
},
register: {
title: 'Daftar',
title: TEXT_REGISTER,
},
notice: {
title: 'Notis Acara',
content:
'Bahagian ini akan memuatkan kandungan notis acara sebenar, bahan visual, dan mesej boleh skrol yang lebih panjang. Versi semasa memfokuskan sambungan modal pelbagai bahasa.',
check: 'Semak',
check: TEXT_VIEW,
},
entryNotice: {
title: 'Notis Laman',
@@ -144,7 +217,7 @@ export default {
topup: 'Tambah Nilai',
},
autoSetting: {
title: 'Putaran Auto',
title: TEXT_AUTO_HOSTING,
startAutoSpin: 'Mula Putaran Auto',
rows: {
stopIfBalanceLowerThan: 'Henti jika baki lebih rendah daripada',
@@ -157,8 +230,8 @@ export default {
tabs: {
profile: 'Profil',
financeRecords: 'Rekod Tambah Nilai / Pengeluaran',
walletRecords: 'Rekod Dompet',
message: 'Mesej',
walletRecords: TEXT_WALLET_RECORDS,
message: TEXT_SITE_MESSAGES,
},
profile: {
name: 'Nama',
@@ -171,7 +244,7 @@ export default {
'Tandatangan saya unik seperti personaliti saya. Bahagian ini akan memaparkan ringkasan profil sebenar kemudian.',
},
message: {
title: 'Mesej',
title: TEXT_SITE_MESSAGES,
back: 'Kembali',
loading: 'Memuatkan mesej...',
loadFailed: 'Gagal memuatkan mesej. Sila cuba lagi kemudian.',
@@ -180,7 +253,7 @@ export default {
unread: 'Belum dibaca',
eventBonus:
'[Acara Bonus Tambah Nilai] Dari 1 Oktober hingga 7 Oktober 2026, tuntut ganjaran rebat anda...',
check: 'Semak',
check: TEXT_VIEW,
deleteRecords: 'Padam rekod',
},
financeRecords: {
@@ -192,9 +265,9 @@ export default {
loading: 'Memuatkan rekod...',
loadFailed: 'Gagal memuatkan rekod. Sila cuba lagi kemudian.',
empty: 'Belum ada rekod',
page: 'Halaman {{page}} / jumlah {{total}}',
previous: 'Sebelumnya',
next: 'Seterusnya',
page: TEXT_PAGE_INDICATOR,
previous: TEXT_PREV_PAGE,
next: TEXT_NEXT_PAGE,
},
walletRecords: {
amount: 'Jumlah',
@@ -203,12 +276,12 @@ export default {
empty: 'Belum ada rekod dompet',
loadFailed: 'Gagal memuatkan rekod dompet. Sila cuba lagi kemudian.',
loading: 'Memuatkan rekod dompet...',
next: 'Seterusnya',
page: 'Halaman {{page}} / jumlah {{total}}',
previous: 'Sebelumnya',
next: TEXT_NEXT_PAGE,
page: TEXT_PAGE_INDICATOR,
previous: TEXT_PREV_PAGE,
remark: 'Catatan',
time: 'Masa',
type: 'Rekod Dompet',
time: TEXT_TIME,
type: TEXT_WALLET_RECORDS,
},
},
withdrawTopup: {
@@ -240,8 +313,8 @@ export default {
dialog: {
close: 'Tutup notifikasi',
confirm: 'OK',
no: 'Tidak',
yes: 'Ya',
no: TEXT_NO,
yes: TEXT_YES,
},
modal: {
close: 'Tutup modal',
@@ -259,7 +332,7 @@ export default {
inviteLinkCopyFailed:
'Gagal menyalin pautan jemputan. Sila salin secara manual.',
insufficientBalance: 'Baki tidak mencukupi. Sila laraskan taruhan.',
betLimitExceeded: 'Melebihi had taruhan tunggal',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
betUnavailable: 'Taruhan tidak tersedia untuk pusingan ini',
betPlaced: 'Taruhan berjaya dihantar',
noRecentSuccessfulBet:
@@ -287,7 +360,7 @@ export default {
common: {
arrowIconAlt: 'Anak panah',
actions: {
submitting: 'Menghantar...',
submitting: TEXT_SUBMITTING,
},
passwordVisibility: {
hide: 'Sembunyikan kata laluan',
@@ -296,16 +369,16 @@ export default {
},
login: {
actions: {
submit: 'Log Masuk',
submit: TEXT_LOGIN,
},
fields: {
username: {
label: 'Nombor Telefon:',
placeholder: 'Masukkan nombor telefon',
label: TEXT_MOBILE_LABEL,
placeholder: TEXT_MOBILE_PLACEHOLDER,
},
password: {
label: 'Kata Laluan:',
placeholder: 'Masukkan kata laluan',
label: TEXT_PASSWORD_LABEL,
placeholder: TEXT_PASSWORD_PLACEHOLDER,
},
},
footer: {
@@ -319,20 +392,20 @@ export default {
},
register: {
actions: {
submit: 'Daftar',
submit: TEXT_REGISTER,
},
fields: {
mobile: {
label: 'Nombor Telefon:',
placeholder: 'Masukkan nombor telefon',
label: TEXT_MOBILE_LABEL,
placeholder: TEXT_MOBILE_PLACEHOLDER,
},
captcha: {
label: 'Kod:',
placeholder: 'Masukkan kod pengesahan',
},
password: {
label: 'Kata Laluan:',
placeholder: 'Masukkan kata laluan',
label: TEXT_PASSWORD_LABEL,
placeholder: TEXT_PASSWORD_PLACEHOLDER,
},
confirmPassword: {
label: 'Sahkan Kata Laluan:',
@@ -357,7 +430,7 @@ export default {
submitFailed: 'Gagal menghantar kod. Sila cuba lagi kemudian.',
},
send: 'Dapatkan kod',
sending: 'Menghantar...',
sending: TEXT_SUBMITTING,
success: 'Kod pengesahan telah dihantar.',
},
},
@@ -394,27 +467,27 @@ export default {
header: {
systemTime: 'Masa Sistem',
rules: 'Peraturan',
message: 'Mesej',
message: TEXT_SITE_MESSAGES,
bgm: 'BGM',
id: 'ID',
fullscreen: 'Skrin',
login: 'Log Masuk',
register: 'Daftar',
login: TEXT_LOGIN,
register: TEXT_REGISTER,
},
control: {
trend: 'Trend',
map: 'Peta',
selected: 'Dipilih',
totalBet: 'Jumlah Taruhan',
confirm: 'Sahkan',
confirm: TEXT_CONFIRM,
selectNumbers: 'Pilih Nombor',
insufficientBalance: 'Baki Tidak Mencukupi',
betLimitExceeded: 'Melebihi Had',
submitting: 'Menghantar...',
submitting: TEXT_SUBMITTING,
actions: {
clear: 'Kosongkan',
repeat: 'Ulang',
'auto-spin': 'Putaran Auto',
'auto-spin': TEXT_AUTO_HOSTING,
},
},
status: {
@@ -428,7 +501,7 @@ export default {
description: '(Menerima Taruhan)',
},
locked: {
label: 'Dikunci',
label: TEXT_LOCKED,
description: '(Taruhan Ditutup)',
},
revealing: {
@@ -450,7 +523,7 @@ export default {
},
animal: {
insufficientBalanceRecharge: 'Baki tidak mencukupi, sila tambah nilai',
betLimitExceeded: 'Melebihi had taruhan tunggal',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Memuatkan',
selectionLimitReached: 'Melebihi pilihan aksara yang dibenarkan',
tapToEnter: 'Ketik Untuk Masuk',
@@ -465,29 +538,29 @@ export default {
orderNo: 'No. Pesanan',
roundId: 'ID Pusingan',
numbers: 'Nombor Pertaruhan',
createdAt: 'Masa',
createdAt: TEXT_TIME,
settledAt: 'Masa Selesai',
totalPoolAmount: 'Jumlah Pertaruhan',
winningResult: 'Keputusan Menang',
payout: 'Jumlah Menang',
empty: 'Belum ada sejarah',
end: 'Tiada lagi rekod',
loading: 'Memuatkan...',
settled: 'Selesai',
loading: TEXT_LOADING,
settled: TEXT_SETTLED,
},
periodHistory: {
title: 'Sejarah Keputusan Cabutan',
close: 'Tutup sejarah keputusan cabutan',
empty: 'Belum ada keputusan cabutan',
failed: 'Gagal memuatkan keputusan cabutan',
loading: 'Memuatkan...',
loading: TEXT_LOADING,
retry: 'Cuba lagi',
},
topup: {
title: 'Konfigurasi Tambah Nilai',
platformCoinLabel: 'Syiling Platform',
currencyLabel: 'Jenis Mata Wang',
channelLabel: 'Saluran Pembayaran',
currencyLabel: TEXT_CURRENCY_TYPE,
channelLabel: TEXT_PAYMENT_CHANNEL,
rateHint:
'Kadar pertukaran hanya untuk rujukan. Jumlah akhir tertakluk kepada kadar semasa tambah nilai.',
tier: {
@@ -529,13 +602,13 @@ export default {
feeNotice:
'Transaksi antara RM10 dan RM99.99 akan dikenakan yuran pengeluaran minimum RM 1.',
cancel: 'Batal',
confirm: 'Sahkan',
confirm: TEXT_CONFIRM,
submitSuccess: 'Permohonan pengeluaran telah dihantar',
withdrawal: 'Pengeluaran',
fields: {
diamondAmount: 'Jumlah Berlian Pengeluaran',
currencyType: 'Jenis Mata Wang',
paymentChannel: 'Saluran Pembayaran',
currencyType: TEXT_CURRENCY_TYPE,
paymentChannel: TEXT_PAYMENT_CHANNEL,
bankCode: 'Kod Bank',
cardHolderName: 'Nama Pemegang Kad',
bankAccountNumber: 'Nombor Akaun Bank',

View File

@@ -1,3 +1,76 @@
/* 以下为多语言中重复出现的文案,统一声明一次后在下方各 key 复用,避免同一文案多处声明。 */
/** @description 登录的统一文案。 */
const TEXT_LOGIN = '登录'
/** @description 注册的统一文案。 */
const TEXT_REGISTER = '注册'
/** @description “是”的统一文案。 */
const TEXT_YES = '是'
/** @description “否”的统一文案。 */
const TEXT_NO = '否'
/** @description “查看”的统一文案。 */
const TEXT_VIEW = '查看'
/** @description 自动托管的统一文案。 */
const TEXT_AUTO_HOSTING = '自动托管'
/** @description 钱包流水的统一文案。 */
const TEXT_WALLET_RECORDS = '钱包流水'
/** @description 站内消息的统一文案。 */
const TEXT_SITE_MESSAGES = '站内消息'
/** @description 分页“第x页/共x条”的统一文案。 */
const TEXT_PAGE_INDICATOR = '第 {{page}} 页 / 共 {{total}} 条'
/** @description 上一页的统一文案。 */
const TEXT_PREV_PAGE = '上一页'
/** @description 下一页的统一文案。 */
const TEXT_NEXT_PAGE = '下一页'
/** @description “时间”的统一文案。 */
const TEXT_TIME = '时间'
/** @description 超过单次投注限额的统一文案。 */
const TEXT_BET_LIMIT_EXCEEDED = '超过单次投注限额'
/** @description “提交中...”的统一文案。 */
const TEXT_SUBMITTING = '提交中...'
/** @description 手机号字段标签的统一文案。 */
const TEXT_MOBILE_LABEL = '手机号:'
/** @description 手机号输入占位的统一文案。 */
const TEXT_MOBILE_PLACEHOLDER = '请输入手机号'
/** @description 密码字段标签的统一文案。 */
const TEXT_PASSWORD_LABEL = '密码:'
/** @description 密码输入占位的统一文案。 */
const TEXT_PASSWORD_PLACEHOLDER = '请输入密码'
/** @description “确认”按钮的统一文案。 */
const TEXT_CONFIRM = '确认'
/** @description “加载中...”的统一文案。 */
const TEXT_LOADING = '加载中...'
/** @description 货币类型的统一文案。 */
const TEXT_CURRENCY_TYPE = '货币类型'
/** @description 支付渠道的统一文案。 */
const TEXT_PAYMENT_CHANNEL = '支付渠道'
/** @description “已封盘”的统一文案。 */
const TEXT_LOCKED = '已封盘'
/** @description “已结算”的统一文案。 */
const TEXT_SETTLED = '已结算'
export default {
nav: {
home: '首页',
@@ -86,8 +159,8 @@ export default {
},
phases: {
betting: '下注中',
locked: '已封盘',
settled: '已结算',
locked: TEXT_LOCKED,
settled: TEXT_SETTLED,
},
roundBettingStart: {
title: '{{roundId}}期',
@@ -97,8 +170,8 @@ export default {
unifiedBetHint: '统一下注额',
totalBet: '总下注',
canBet: '可下注',
yes: '是',
no: '否',
yes: TEXT_YES,
no: TEXT_NO,
quickBet: '快速选中 08',
clearPending: '清空未确认',
autoModeDemo: '自动托管演示',
@@ -106,16 +179,16 @@ export default {
},
modals: {
login: {
title: '登录',
title: TEXT_LOGIN,
},
register: {
title: '注册',
title: TEXT_REGISTER,
},
notice: {
title: '活动公告',
content:
'这里后续将接入真实的活动公告内容、图文素材和滚动阅读区域。当前版本先用多语言结构完成桌面端公告弹窗能力。',
check: '查看',
check: TEXT_VIEW,
},
entryNotice: {
title: '网站公告',
@@ -139,7 +212,7 @@ export default {
topup: '充值',
},
autoSetting: {
title: '自动托管',
title: TEXT_AUTO_HOSTING,
startAutoSpin: '开始自动托管',
rows: {
stopIfBalanceLowerThan: '余额低于时停止',
@@ -152,8 +225,8 @@ export default {
tabs: {
profile: '个人信息',
financeRecords: '充值/提现记录',
walletRecords: '钱包流水',
message: '站内消息',
walletRecords: TEXT_WALLET_RECORDS,
message: TEXT_SITE_MESSAGES,
},
profile: {
name: '用户名',
@@ -165,7 +238,7 @@ export default {
signature: '我的个性签名和灵魂一样独特,后续这里会接真实资料字段。',
},
message: {
title: '站内消息',
title: TEXT_SITE_MESSAGES,
back: '返回',
loading: '消息加载中...',
loadFailed: '消息加载失败,请稍后重试',
@@ -173,7 +246,7 @@ export default {
read: '已读',
unread: '未读',
eventBonus: '[充值活动] 10 月 1 日至 10 月 7 日期间可获得返利奖励……',
check: '查看',
check: TEXT_VIEW,
deleteRecords: '删除记录',
},
financeRecords: {
@@ -185,9 +258,9 @@ export default {
loading: '记录加载中...',
loadFailed: '记录加载失败,请稍后重试',
empty: '暂无记录',
page: '第 {{page}} 页 / 共 {{total}} 条',
previous: '上一页',
next: '下一页',
page: TEXT_PAGE_INDICATOR,
previous: TEXT_PREV_PAGE,
next: TEXT_NEXT_PAGE,
},
walletRecords: {
amount: '变动金额',
@@ -196,12 +269,12 @@ export default {
empty: '暂无钱包流水',
loadFailed: '钱包流水加载失败,请稍后重试',
loading: '钱包流水加载中...',
next: '下一页',
page: '第 {{page}} 页 / 共 {{total}} 条',
previous: '上一页',
next: TEXT_NEXT_PAGE,
page: TEXT_PAGE_INDICATOR,
previous: TEXT_PREV_PAGE,
remark: '备注',
time: '时间',
type: '钱包流水',
time: TEXT_TIME,
type: TEXT_WALLET_RECORDS,
},
},
withdrawTopup: {
@@ -210,7 +283,7 @@ export default {
},
},
autoSpin: {
eyebrow: '自动托管',
eyebrow: TEXT_AUTO_HOSTING,
title: '自动托管运行中',
description: '托管态会覆盖主盘面,但目标格子和进度信息仍然保留可见。',
runningRounds: '游戏托管中,已经进行 {{count}} 局',
@@ -232,8 +305,8 @@ export default {
dialog: {
close: '关闭提示',
confirm: '知道了',
no: '否',
yes: '是',
no: TEXT_NO,
yes: TEXT_YES,
},
modal: {
close: '关闭弹窗',
@@ -249,7 +322,7 @@ export default {
inviteLinkCopied: '邀请链接已复制',
inviteLinkCopyFailed: '邀请链接复制失败,请手动复制',
insufficientBalance: '余额不足,请调整下注金额',
betLimitExceeded: '超过单次投注限额',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
betUnavailable: '当前期不可下注',
betPlaced: '下注成功',
noRecentSuccessfulBet: '暂无上一局成功下注记录',
@@ -270,7 +343,7 @@ export default {
common: {
arrowIconAlt: '箭头',
actions: {
submitting: '提交中...',
submitting: TEXT_SUBMITTING,
},
passwordVisibility: {
hide: '隐藏密码',
@@ -279,16 +352,16 @@ export default {
},
login: {
actions: {
submit: '登录',
submit: TEXT_LOGIN,
},
fields: {
username: {
label: '手机号:',
placeholder: '请输入手机号',
label: TEXT_MOBILE_LABEL,
placeholder: TEXT_MOBILE_PLACEHOLDER,
},
password: {
label: '密码:',
placeholder: '请输入密码',
label: TEXT_PASSWORD_LABEL,
placeholder: TEXT_PASSWORD_PLACEHOLDER,
},
},
footer: {
@@ -302,20 +375,20 @@ export default {
},
register: {
actions: {
submit: '注册',
submit: TEXT_REGISTER,
},
fields: {
mobile: {
label: '手机号:',
placeholder: '请输入手机号',
label: TEXT_MOBILE_LABEL,
placeholder: TEXT_MOBILE_PLACEHOLDER,
},
captcha: {
label: '验证码:',
placeholder: '请输入验证码',
},
password: {
label: '密码:',
placeholder: '请输入密码',
label: TEXT_PASSWORD_LABEL,
placeholder: TEXT_PASSWORD_PLACEHOLDER,
},
confirmPassword: {
label: '确认密码:',
@@ -349,7 +422,7 @@ export default {
required: '请输入验证码',
},
username: {
required: '请输入手机号',
required: TEXT_MOBILE_PLACEHOLDER,
invalidPhone: '请输入正确的手机号',
},
password: {
@@ -379,23 +452,23 @@ export default {
bgm: '音乐',
id: '编号',
fullscreen: '全屏',
login: '登录',
register: '注册',
login: TEXT_LOGIN,
register: TEXT_REGISTER,
},
control: {
trend: '走势',
map: '地图',
selected: '已选',
totalBet: '总下注',
confirm: '确认',
confirm: TEXT_CONFIRM,
selectNumbers: '请选择号码',
insufficientBalance: '余额不足',
betLimitExceeded: '超过限额',
submitting: '提交中...',
submitting: TEXT_SUBMITTING,
actions: {
clear: '清空',
repeat: '重复',
'auto-spin': '自动托管',
'auto-spin': TEXT_AUTO_HOSTING,
},
},
status: {
@@ -409,7 +482,7 @@ export default {
description: '(接受下注)',
},
locked: {
label: '已封盘',
label: TEXT_LOCKED,
description: '(停止下注)',
},
revealing: {
@@ -431,7 +504,7 @@ export default {
},
animal: {
insufficientBalanceRecharge: '余额不足,请充值',
betLimitExceeded: '超过单次投注限额',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: '加载中',
selectionLimitReached: '超过可选择字花',
tapToEnter: '点击进入',
@@ -446,29 +519,29 @@ export default {
orderNo: '订单号',
roundId: '期号',
numbers: '下注号码',
createdAt: '时间',
createdAt: TEXT_TIME,
settledAt: '结算时间',
totalPoolAmount: '下注金额',
winningResult: '中奖字花',
payout: '中奖金额',
empty: '暂无历史记录',
end: '没有更多记录了',
loading: '加载中...',
settled: '已结算',
loading: TEXT_LOADING,
settled: TEXT_SETTLED,
},
periodHistory: {
title: '开奖结果历史',
close: '关闭开奖结果历史',
empty: '暂无开奖结果',
failed: '开奖结果加载失败',
loading: '加载中...',
loading: TEXT_LOADING,
retry: '重试',
},
topup: {
title: '充值配置',
platformCoinLabel: '平台币',
currencyLabel: '货币类型',
channelLabel: '支付渠道',
currencyLabel: TEXT_CURRENCY_TYPE,
channelLabel: TEXT_PAYMENT_CHANNEL,
rateHint: '汇率为参考价格,实际以充值时为准。',
tier: {
bonus: '赠送',
@@ -506,13 +579,13 @@ export default {
notice: '注意',
feeNotice: 'RM10 - RM99.99 之间的交易将收取最低RM 1的提现手续费',
cancel: '取消',
confirm: '确认',
confirm: TEXT_CONFIRM,
submitSuccess: '提现申请已提交',
withdrawal: '提现',
fields: {
diamondAmount: '提现钻石数量',
currencyType: '货币类型',
paymentChannel: '支付渠道',
currencyType: TEXT_CURRENCY_TYPE,
paymentChannel: TEXT_PAYMENT_CHANNEL,
bankCode: '银行代码',
cardHolderName: '持卡人姓名',
bankAccountNumber: '银行账号',

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD } from '@/constants'
import type { BetSelection } from '@/features/game/shared'
export interface AutoHostingStopRules {
@@ -44,7 +45,7 @@ const DEFAULT_AUTO_HOSTING_RULES: AutoHostingStopRules = {
enabled: false,
},
stopIfSingleWinAbove: {
amount: 50_000,
amount: AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD,
enabled: false,
},
stopOnJackpot: false,

View File

@@ -1,5 +1,9 @@
import { create } from 'zustand'
import {
CONNECTION_LATENCY_FAIR_MS,
MAX_JACKPOT_BROADCAST_COUNT,
} from '@/constants'
import type {
AnnouncementState,
ConnectionState,
@@ -18,8 +22,6 @@ type GameSessionSlice = Pick<
'announcements' | 'connection' | 'dashboard'
>
const MAX_JACKPOT_BROADCAST_COUNT = 20
export interface JackpotBroadcastItem {
id: string
message: string
@@ -173,7 +175,8 @@ export const selectActiveAnnouncement = (state: GameSessionStoreState) =>
export const selectIsConnectionHealthy = (state: GameSessionStoreState) =>
state.connection.status === 'connected' &&
(state.connection.latencyMs === null || state.connection.latencyMs < 150)
(state.connection.latencyMs === null ||
state.connection.latencyMs < CONNECTION_LATENCY_FAIR_MS)
export const selectUnreadAnnouncementCount = (state: GameSessionStoreState) =>
getUnreadAnnouncementCount(state.announcements)