feat(i18n): 添加马来语支持并重构多语言功能

This commit is contained in:
JiaJun
2026-04-21 14:25:50 +08:00
parent 052ade8bab
commit 236ae4b37e
7 changed files with 231 additions and 23 deletions

View File

@@ -27,7 +27,7 @@ function GoodsImage({
const showFallback = !imageUrl || hasError
return (
<div className="relative h-[116px] w-full overflow-hidden rounded-t-[10px] sm:h-[128px]">
<div className="relative w-full overflow-hidden rounded-t-[10px] bg-white/6 aspect-[3/2] min-h-[144px] max-h-[196px] sm:min-h-[156px] sm:max-h-[208px]">
{showFallback ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[radial-gradient(circle_at_top,rgba(250,106,0,0.18),transparent_58%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-[12px] text-center">
<div className="h-[30px] w-[30px] rounded-[10px] border border-white/10 bg-white/6"></div>
@@ -86,10 +86,10 @@ export function GoodsCategoryList({
{Array.from({length: 4}).map((_, index) => (
<div
key={`${categoryId}-${index}`}
className="liquid-glass-bg flex min-h-[260px] w-full flex-col items-stretch justify-start overflow-hidden"
className="liquid-glass-bg flex min-h-[296px] w-full flex-col items-stretch justify-start overflow-hidden sm:min-h-[312px]"
>
<div className="h-[116px] w-full bg-white/6 sm:h-[128px]"></div>
<div className="flex flex-1 flex-col items-start justify-between gap-[12px] p-[12px] sm:p-[14px]">
<div className="aspect-[3/2] min-h-[144px] max-h-[196px] w-full bg-white/6 sm:min-h-[156px] sm:max-h-[208px]"></div>
<div className="flex flex-1 flex-col items-start justify-between gap-[14px] p-[14px] sm:p-[16px]">
<div className="w-full space-y-[8px]">
<div className="h-[18px] w-[72%] rounded-full bg-white/10"></div>
<div className="h-[14px] w-[48%] rounded-full bg-white/8"></div>
@@ -148,13 +148,13 @@ export function GoodsCategoryList({
{category.items.map((product) => (
<div
key={product.id}
className="liquid-glass-bg pc-hover-float flex min-h-[260px] w-full flex-col items-stretch justify-start overflow-hidden"
className="liquid-glass-bg pc-hover-float flex min-h-[296px] w-full flex-col items-stretch justify-start overflow-hidden sm:min-h-[312px]"
>
<GoodsImage imageUrl={product.imageUrl}/>
<div
className="flex flex-1 flex-col items-start justify-between gap-[12px] p-[12px] sm:p-[14px]"
className="flex flex-1 flex-col items-start justify-between gap-[14px] p-[14px] sm:p-[16px]"
>
<div className="space-y-[4px]">
<div className="w-full space-y-[4px]">
<div className="text-[16px] font-medium text-white">{product.title}</div>
<div className="text-[13px] text-white/52">{product.subtitle}</div>
</div>

View File

@@ -1,18 +1,35 @@
import i18n from 'i18next'
import {initReactI18next} from 'react-i18next'
import en from '@/message/en'
import zh from '@/message/zh'
import en from '@/locale/en'
import ms from '@/locale/ms'
import zh from '@/locale/zh'
import {useUserStore} from '@/store/user.ts'
export function normalizeLanguage(language?: string) {
export const SUPPORTED_LANGUAGES = ['zh', 'en', 'ms'] as const
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
export function normalizeLanguage(language?: string): AppLanguage {
const value = language?.trim().toLowerCase()
if (!value) {
return 'zh'
}
return value.startsWith('zh') ? 'zh' : 'en'
if (value.startsWith('zh')) {
return 'zh'
}
if (value.startsWith('ms')) {
return 'ms'
}
return 'en'
}
export function getRequestLanguageKey(language?: string) {
return normalizeLanguage(language).toUpperCase() as Uppercase<AppLanguage>
}
const initialLanguage = normalizeLanguage(useUserStore.getState().language)
@@ -23,6 +40,7 @@ void i18n
resources: {
zh: {translation: zh},
en: {translation: en},
ms: {translation: ms},
},
lng: initialLanguage,
fallbackLng: 'zh',

View File

@@ -2,7 +2,7 @@ import ky, {HTTPError, type AfterResponseHook, type Input, type KyInstance, type
import {notifyError, resolveToastMessage} from '@/features/notifications'
import i18n from '@/lib/i18n'
import {normalizeLanguage} from '@/lib/i18n'
import {getRequestLanguageKey} from '@/lib/i18n'
import {useUserStore} from '@/store/user.ts'
import type {ValidateTokenData} from '@/types/auth.type.ts'
import {objectToFormData} from './tool'
@@ -68,7 +68,7 @@ export const setAccessTokenFormatter = (formatter?: TokenFormatter) => {
accessTokenFormatter = formatter ?? ((token) => `Bearer ${token}`)
}
const getRequestLanguage = () => normalizeLanguage(useUserStore.getState().language)
const getRequestLanguage = () => getRequestLanguageKey(useUserStore.getState().language)
const authRefreshClient = ky.create({
baseUrl: API_BASE_URL,

View File

@@ -13,6 +13,7 @@ const en = {
language: 'Language',
chinese: 'Chinese',
english: 'English',
malay: 'Malay',
},
auth: {
waitingForHostContext: 'Waiting for host context...',
@@ -23,8 +24,9 @@ const en = {
nav: {
record: 'Record',
account: 'Account',
switchToChinese: '中文',
switchToEnglish: 'EN',
switchToChinese: 'Chinese',
switchToEnglish: 'English',
switchToMalay: 'Malay',
},
home: {
claimablePoints: 'Claimable Points',

157
src/locale/ms.ts Normal file
View File

@@ -0,0 +1,157 @@
const ms = {
common: {
back: 'Kembali',
loading: 'Memuatkan...',
loadingPage: 'Sedang memuatkan halaman...',
processing: 'Sedang diproses...',
cancel: 'Batal',
confirm: 'Sahkan',
close: 'Tutup',
noData: 'Tiada data',
points: 'Mata',
more: 'Lagi',
language: 'Bahasa',
chinese: 'Bahasa Cina',
english: 'Bahasa Inggeris',
malay: 'Bahasa Melayu',
},
auth: {
waitingForHostContext: 'Menunggu konteks hos...',
loadingAccountData: 'Sedang memuatkan data akaun...',
authenticationFailed: 'Pengesahan gagal',
refreshAndTryAgain: 'Sila muat semula dan cuba lagi.',
},
nav: {
record: 'Rekod',
account: 'Akaun',
switchToChinese: 'Cina',
switchToEnglish: 'Inggeris',
switchToMalay: 'Melayu',
},
home: {
claimablePoints: 'Mata Boleh Dituntut',
claimDescription: 'Kerugian semalam telah ditukar kepada mata. Tuntut untuk digunakan bagi penebusan atau pengeluaran.',
dailyClaimLimit: 'Had Tuntutan Harian',
claimed: 'Telah Dituntut',
availableForWithdrawal: 'Tersedia untuk Pengeluaran (Tunai)',
availablePoints: 'Mata Tersedia',
cashUnit: 'RM',
claimNow: 'Tuntut Sekarang',
syncBalance: 'Segerak Baki',
syncing: 'Sedang disegerakkan...',
confirmClaim: 'Sahkan Tuntutan',
confirmClaimDescription: 'Selepas mata yang belum dituntut ditukar menjadi mata yang boleh digunakan, ia boleh digunakan untuk penebusan atau pengeluaran. Adakah anda pasti mahu menuntut?',
balanceSyncedSuccessfully: 'Baki berjaya disegerakkan.',
claimSubmittedSuccessfully: 'Tuntutan berjaya dihantar.',
noClaimablePointsAvailable: 'Tiada mata yang boleh dituntut.',
},
goods: {
categories: {
WITHDRAW: 'Pindah ke Platform',
BONUS: 'Bonus Permainan',
PHYSICAL: 'Hadiah Fizikal',
},
actions: {
WITHDRAW: 'Pindah Sekarang',
BONUS: 'Tebus Bonus',
PHYSICAL: 'Tuntut Hadiah',
},
noImage: 'TIADA GAMBAR',
loading: 'Memuatkan...',
noGoodsAvailableYet: 'Tiada barangan untuk ditebus buat masa ini.',
noGoodsForCategory: 'Tiada barangan dalam kategori ini.',
confirmWithdrawal: 'Sahkan Pengeluaran',
confirmBonusRedemption: 'Sahkan Penebusan Bonus',
confirmPhysicalReward: 'Sahkan Hadiah Fizikal',
addShippingAddress: 'Tambah Alamat Penghantaran',
editShippingAddress: 'Edit Alamat Penghantaran',
saveChanges: 'Simpan Perubahan',
addressInfo: 'Maklumat Alamat',
defaultAddress: 'Alamat Lalai',
name: 'Nama',
phoneNumber: 'Nombor Telefon',
detailedAddress: 'Alamat Terperinci',
enterReceiverName: 'Masukkan nama penuh penerima',
enterReachablePhone: 'Masukkan nombor telefon yang boleh dihubungi',
enterDetailAddress: 'Masukkan alamat terperinci',
withdrawalAmount: 'Jumlah Pengeluaran',
pointsRequired: 'Mata Diperlukan',
turnoverRequirement: 'Syarat Pusingan',
submitWithdrawalRequest: 'Hantar permohonan pengeluaran?',
item: 'Barangan',
pointsCost: 'Kos Mata',
pleaseSelectAddressInfo: 'Sila pilih dan isi maklumat alamat.',
loadingAddressList: 'Sedang memuatkan senarai alamat...',
addAddress: 'Tambah Alamat',
selectShippingAddress: 'Sila pilih alamat penghantaran.',
addressAddedSuccessfully: 'Alamat berjaya ditambah.',
redeemRequestSubmittedSuccessfully: 'Permohonan penebusan berjaya dihantar.',
bonusRedeemSubmittedSuccessfully: 'Permohonan penebusan bonus berjaya dihantar.',
physicalPrize: 'Hadiah Fizikal',
},
account: {
title: 'Akaun',
myShippingAddress: 'Alamat Penghantaran Saya',
addAddress: 'Tambah Alamat',
loadingAddressList: 'Sedang memuatkan senarai alamat...',
noShippingAddressFound: 'Tiada alamat penghantaran ditemui. Sila tambah dahulu.',
address: 'Alamat',
edit: 'Edit',
delete: 'Padam',
default: 'Lalai',
optional: 'Bukan Lalai',
addressUpdatedSuccessfully: 'Alamat berjaya dikemas kini.',
addressDeletedSuccessfully: 'Alamat berjaya dipadam.',
deleteAddress: 'Padam Alamat',
deleteAddressFor: 'Padam alamat untuk {{name}}?',
},
record: {
title: 'Rekod',
myOrders: 'Pesanan Saya',
pointsRecord: 'Rekod Mata',
loading: 'Memuatkan...',
noData: 'Tiada data',
checkDetails: 'Lihat butiran',
trackingNumber: 'Nombor Penjejakan',
orderDetails: 'Butiran Pesanan',
orderNumber: 'Nombor Pesanan',
orderTime: 'Masa Pesanan',
orderType: 'Jenis Pesanan',
itemName: 'Nama Barangan',
points: 'Mata',
status: 'Status',
untitledOrder: 'Pesanan Tanpa Tajuk',
pointsRecordFallback: 'Rekod Mata',
categories: {
bonus: 'Bonus Permainan',
physical: 'Hadiah Fizikal',
withdraw: 'Pindah ke Platform',
order: 'Pesanan',
},
statusLabel: {
pending: 'Menunggu',
completed: 'Selesai',
shipped: 'Dihantar',
rejected: 'Ditolak',
},
},
validation: {
sessionExpired: 'Sesi telah tamat. Sila log masuk semula.',
noProductSelected: 'Tiada barangan dipilih.',
pleaseSelectShippingAddress: 'Sila pilih alamat penghantaran.',
pleaseCompleteAddressFields: 'Sila lengkapkan semua maklumat alamat.',
pleaseEnterReceiverName: 'Sila masukkan nama penerima.',
pleaseEnterReachablePhone: 'Sila masukkan nombor telefon yang boleh dihubungi.',
pleaseEnterDetailedAddress: 'Sila masukkan alamat terperinci.',
},
errors: {
unauthorized: 'Tidak dibenarkan',
requestFailed: 'Permintaan gagal',
networkRequestFailed: 'Permintaan rangkaian gagal',
},
app: {
loading: 'Memuatkan...',
},
}
export default ms

View File

@@ -13,6 +13,7 @@ const zh = {
language: '语言',
chinese: '中文',
english: '英文',
malay: '马来文',
},
auth: {
waitingForHostContext: '等待宿主上下文...',
@@ -24,7 +25,8 @@ const zh = {
record: '记录',
account: '我的',
switchToChinese: '中文',
switchToEnglish: 'EN',
switchToEnglish: '英文',
switchToMalay: '马来文',
},
home: {
claimablePoints: '待领取积分',

View File

@@ -1,7 +1,7 @@
import {useState} from 'react'
import {useMutation} from '@tanstack/react-query'
import {Languages} from 'lucide-react'
import {Check, Languages} from 'lucide-react'
import {useTranslation} from 'react-i18next'
import PageLayout from '@/components/layout'
@@ -33,7 +33,13 @@ import {claim} from '@/api/business.ts'
import {notifyError, notifySuccess} from '@/features/notifications'
import {MotionButton, MotionLink, tapMotionProps} from '@/lib/motion'
import {useUserStore} from "@/store/user.ts";
import {normalizeLanguage} from '@/lib/i18n'
import {type AppLanguage, normalizeLanguage} from '@/lib/i18n'
const LANGUAGE_OPTIONS: Array<{value: AppLanguage; labelKey: 'switchToChinese' | 'switchToEnglish' | 'switchToMalay'}> = [
{value: 'zh', labelKey: 'switchToChinese'},
{value: 'en', labelKey: 'switchToEnglish'},
{value: 'ms', labelKey: 'switchToMalay'},
]
function QuickNavCard({icon: Icon, label, to}: QuickNavCardProps) {
return (
@@ -65,6 +71,7 @@ function getProgressPercent(current = 0, total = 0) {
function HomePage() {
const {t} = useTranslation()
const [claimModalOpen, setClaimModalOpen] = useState(false)
const [languageModalOpen, setLanguageModalOpen] = useState(false)
const navigate = useNavigate()
const authInfo = useUserStore(state => state.authInfo)
const language = useUserStore((state) => state.language)
@@ -87,6 +94,7 @@ function HomePage() {
const {assetsInfo} = useAssetsQuery()
const claimProgress = getProgressPercent(assetsInfo?.today_claimed, assetsInfo?.today_limit)
const isClaimAvailable = (assetsInfo?.locked_points ?? 0) > 0
const currentLanguage = normalizeLanguage(language)
const previewCategories: ProductCategory[] = productCategories.map((category) => ({
...category,
items: category.items.slice(0, 4),
@@ -136,9 +144,9 @@ function HomePage() {
navigate(`/goods?type=${type}`)
}
const handleToggleLanguage = () => {
const currentLanguage = normalizeLanguage(language)
setLanguage(currentLanguage === 'zh' ? 'en' : 'zh')
const handleSelectLanguage = (nextLanguage: AppLanguage) => {
setLanguage(nextLanguage)
setLanguageModalOpen(false)
}
return (
@@ -150,7 +158,7 @@ function HomePage() {
<MotionButton
type="button"
className="liquid-glass-bg flex items-center justify-between gap-[12px] rounded-[12px] px-[12px] py-[8px] text-[13px] text-white/88 transition-colors hover:bg-white/28 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={handleToggleLanguage}
onClick={() => setLanguageModalOpen(true)}
{...tapMotionProps}
>
<div className="flex min-w-0 items-center gap-[10px]">
@@ -159,7 +167,7 @@ function HomePage() {
<Languages className="h-[16px] w-[16px] shrink-0" aria-hidden="true"/>
</div>
<div
className="truncate font-medium">{normalizeLanguage(language) === 'zh' ? t('nav.switchToEnglish') : t('nav.switchToChinese')}</div>
className="truncate font-medium">{t(`nav.${LANGUAGE_OPTIONS.find((option) => option.value === currentLanguage)?.labelKey ?? 'switchToChinese'}`)}</div>
</div>
<ChevronRight className="h-[16px] w-[16px] shrink-0 text-white/70" aria-hidden="true"/>
</MotionButton>
@@ -303,6 +311,27 @@ function HomePage() {
onChangeAddressForm={redeem.changeAddressForm}
/>
<Modal
open={languageModalOpen}
title={t('common.language')}
onClose={() => setLanguageModalOpen(false)}
className="max-w-[420px]"
bodyClassName="space-y-3"
>
{LANGUAGE_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={option.value === currentLanguage ? 'orange' : 'gray'}
className="flex h-[46px] w-full items-center justify-between px-[14px] text-[14px]"
onClick={() => handleSelectLanguage(option.value)}
>
<span>{t(`nav.${option.labelKey}`)}</span>
{option.value === currentLanguage ? <Check className="h-[16px] w-[16px] shrink-0"/> : null}
</Button>
))}
</Modal>
<Modal
open={claimModalOpen}
title={t('home.confirmClaim')}