From a99466901339d2f0cc1fdce9f0f42f753214bca0 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 22:55:30 +0800 Subject: [PATCH 01/13] feat(i18n): migrate hooks error messages to i18n system --- src/hooks/use-burn.ts | 10 +- src/hooks/use-duplicate-detection.ts | 101 ++--- src/hooks/use-send.mock.ts | 25 +- src/hooks/use-send.ts | 645 ++++++++++++++------------- src/hooks/use-send.web3.ts | 123 ++--- src/hooks/use-transaction-history.ts | 85 ++-- src/hooks/useWalletTheme.ts | 128 +++--- 7 files changed, 582 insertions(+), 535 deletions(-) diff --git a/src/hooks/use-burn.ts b/src/hooks/use-burn.ts index 6cb84b03a..1f58b65de 100644 --- a/src/hooks/use-burn.ts +++ b/src/hooks/use-burn.ts @@ -221,9 +221,9 @@ export function useBurn(options: UseBurnOptions = {}): UseBurnReturn { step: 'result', isSubmitting: false, resultStatus: 'failed', - errorMessage: '仅支持 BioForest 链的资产销毁', + errorMessage: t('error:burn.bioforestOnly'), })); - return { status: 'error', message: '仅支持 BioForest 链' }; + return { status: 'error', message: t('error:burn.bioforestOnly') }; } if (!walletId || !fromAddress || !state.asset || !state.amount || !state.recipientAddress) { @@ -232,9 +232,9 @@ export function useBurn(options: UseBurnOptions = {}): UseBurnReturn { step: 'result', isSubmitting: false, resultStatus: 'failed', - errorMessage: '参数不完整', + errorMessage: t('error:transaction.paramsIncomplete'), })); - return { status: 'error', message: '参数不完整' }; + return { status: 'error', message: t('error:transaction.paramsIncomplete') }; } setState((prev) => ({ @@ -301,7 +301,7 @@ export function useBurn(options: UseBurnOptions = {}): UseBurnReturn { const submitWithTwoStepSecret = useCallback( async (password: string, twoStepSecret: string): Promise => { if (!chainConfig || !walletId || !fromAddress || !state.asset || !state.amount || !state.recipientAddress) { - return { status: 'error', message: '参数不完整' }; + return { status: 'error', message: t('error:transaction.paramsIncomplete') }; } setState((prev) => ({ diff --git a/src/hooks/use-duplicate-detection.ts b/src/hooks/use-duplicate-detection.ts index 5f09d70ae..e16c408cb 100644 --- a/src/hooks/use-duplicate-detection.ts +++ b/src/hooks/use-duplicate-detection.ts @@ -1,27 +1,27 @@ -import { useState, useCallback } from 'react' -import { generateAllAddresses, getAddressSet } from './use-multi-chain-address-generation' -import type { - DuplicateCheckResult, - IWalletQuery, -} from '@/services/wallet/types' +import { useState, useCallback } from 'react'; +import { generateAllAddresses, getAddressSet } from './use-multi-chain-address-generation'; +import type { DuplicateCheckResult, IWalletQuery } from '@/services/wallet/types'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); export interface DuplicateDetectionResult { /** 检测结果 */ - result: DuplicateCheckResult + result: DuplicateCheckResult; /** 是否正在检测 */ - isChecking: boolean + isChecking: boolean; /** 错误信息 */ - error: string | null + error: string | null; /** 执行检测 */ - check: (mnemonic: string[]) => Promise + check: (mnemonic: string[]) => Promise; /** 重置状态 */ - reset: () => void + reset: () => void; } const INITIAL_RESULT: DuplicateCheckResult = { isDuplicate: false, type: 'none', -} +}; /** * 三级重复检测 Hook @@ -31,22 +31,22 @@ const INITIAL_RESULT: DuplicateCheckResult = { * Level 3: 私钥碰撞检查 - 检查是否与私钥导入的钱包冲突 */ export function useDuplicateDetection(walletQuery: IWalletQuery): DuplicateDetectionResult { - const [result, setResult] = useState(INITIAL_RESULT) - const [isChecking, setIsChecking] = useState(false) - const [error, setError] = useState(null) + const [result, setResult] = useState(INITIAL_RESULT); + const [isChecking, setIsChecking] = useState(false); + const [error, setError] = useState(null); const check = useCallback( async (mnemonic: string[]): Promise => { - setIsChecking(true) - setError(null) + setIsChecking(true); + setError(null); try { // Generate all addresses from mnemonic - const derivedKeys = generateAllAddresses(mnemonic) - const newAddressSet = getAddressSet(derivedKeys) + const derivedKeys = generateAllAddresses(mnemonic); + const newAddressSet = getAddressSet(derivedKeys); // === Level 1: Simple address lookup === - const existingAddresses = await walletQuery.getAllAddresses() + const existingAddresses = await walletQuery.getAllAddresses(); for (const existing of existingAddresses) { if (newAddressSet.has(existing.address.toLowerCase())) { @@ -59,9 +59,9 @@ export function useDuplicateDetection(walletQuery: IWalletQuery): DuplicateDetec importType: 'mnemonic', // Default, will be refined in Level 3 matchedAddress: existing.address, }, - } - setResult(result) - return result + }; + setResult(result); + return result; } } @@ -69,8 +69,8 @@ export function useDuplicateDetection(walletQuery: IWalletQuery): DuplicateDetec // Already covered by Level 1 since we generate all addresses upfront // === Level 3: Private key collision check === - const allWallets = await walletQuery.getAllMainWallets() - const privateKeyWallets = allWallets.filter((w) => w.importType === 'privateKey') + const allWallets = await walletQuery.getAllMainWallets(); + const privateKeyWallets = allWallets.filter((w) => w.importType === 'privateKey'); for (const pkWallet of privateKeyWallets) { for (const pkAddr of pkWallet.addresses) { @@ -84,9 +84,9 @@ export function useDuplicateDetection(walletQuery: IWalletQuery): DuplicateDetec importType: 'privateKey', matchedAddress: pkAddr.address, }, - } - setResult(result) - return result + }; + setResult(result); + return result; } } } @@ -95,24 +95,24 @@ export function useDuplicateDetection(walletQuery: IWalletQuery): DuplicateDetec const noMatch: DuplicateCheckResult = { isDuplicate: false, type: 'none', - } - setResult(noMatch) - return noMatch + }; + setResult(noMatch); + return noMatch; } catch (err) { - const message = err instanceof Error ? err.message : '重复检测失败' - setError(message) - return INITIAL_RESULT + const message = err instanceof Error ? err.message : t('error:duplicate.detectionFailed'); + setError(message); + return INITIAL_RESULT; } finally { - setIsChecking(false) + setIsChecking(false); } }, [walletQuery], - ) + ); const reset = useCallback(() => { - setResult(INITIAL_RESULT) - setError(null) - }, []) + setResult(INITIAL_RESULT); + setError(null); + }, []); return { result, @@ -120,21 +120,18 @@ export function useDuplicateDetection(walletQuery: IWalletQuery): DuplicateDetec error, check, reset, - } + }; } /** * 同步重复检测(用于非 React 上下文) */ -export async function checkDuplicates( - mnemonic: string[], - walletQuery: IWalletQuery, -): Promise { - const derivedKeys = generateAllAddresses(mnemonic) - const newAddressSet = getAddressSet(derivedKeys) +export async function checkDuplicates(mnemonic: string[], walletQuery: IWalletQuery): Promise { + const derivedKeys = generateAllAddresses(mnemonic); + const newAddressSet = getAddressSet(derivedKeys); // Level 1 & 2: Address check - const existingAddresses = await walletQuery.getAllAddresses() + const existingAddresses = await walletQuery.getAllAddresses(); for (const existing of existingAddresses) { if (newAddressSet.has(existing.address.toLowerCase())) { return { @@ -146,13 +143,13 @@ export async function checkDuplicates( importType: 'mnemonic', matchedAddress: existing.address, }, - } + }; } } // Level 3: Private key collision - const allWallets = await walletQuery.getAllMainWallets() - const privateKeyWallets = allWallets.filter((w) => w.importType === 'privateKey') + const allWallets = await walletQuery.getAllMainWallets(); + const privateKeyWallets = allWallets.filter((w) => w.importType === 'privateKey'); for (const pkWallet of privateKeyWallets) { for (const pkAddr of pkWallet.addresses) { @@ -166,10 +163,10 @@ export async function checkDuplicates( importType: 'privateKey', matchedAddress: pkAddr.address, }, - } + }; } } } - return { isDuplicate: false, type: 'none' } + return { isDuplicate: false, type: 'none' }; } diff --git a/src/hooks/use-send.mock.ts b/src/hooks/use-send.mock.ts index 62a61be0b..efe2c600c 100644 --- a/src/hooks/use-send.mock.ts +++ b/src/hooks/use-send.mock.ts @@ -1,5 +1,8 @@ -import type { Dispatch, SetStateAction } from 'react' -import type { SendState } from './use-send.types' +import type { Dispatch, SetStateAction } from 'react'; +import type { SendState } from './use-send.types'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); export async function submitMockTransfer( setState: Dispatch>, @@ -8,12 +11,12 @@ export async function submitMockTransfer( ...prev, step: 'sending', isSubmitting: true, - })) + })); try { - await new Promise((resolve) => setTimeout(resolve, 2000)) + await new Promise((resolve) => setTimeout(resolve, 2000)); - const isSuccess = Math.random() > 0.2 // 80% success rate + const isSuccess = Math.random() > 0.2; // 80% success rate if (isSuccess) { setState((prev) => ({ @@ -23,11 +26,11 @@ export async function submitMockTransfer( resultStatus: 'success', txHash: `0x${Math.random().toString(16).slice(2)}${Math.random().toString(16).slice(2)}`, errorMessage: null, - })) - return { status: 'ok' } + })); + return { status: 'ok' }; } - throw new Error('交易失败,请稍后重试') + throw new Error(t('error:transaction.transactionFailed')); } catch (error) { setState((prev) => ({ ...prev, @@ -35,8 +38,8 @@ export async function submitMockTransfer( isSubmitting: false, resultStatus: 'failed', txHash: null, - errorMessage: error instanceof Error ? error.message : '未知错误', - })) - return { status: 'error' } + errorMessage: error instanceof Error ? error.message : t('error:transaction.unknownError'), + })); + return { status: 'error' }; } } diff --git a/src/hooks/use-send.ts b/src/hooks/use-send.ts index a83cce5de..60462524a 100644 --- a/src/hooks/use-send.ts +++ b/src/hooks/use-send.ts @@ -1,40 +1,47 @@ -import { useState, useCallback, useMemo } from 'react' -import type { AssetInfo } from '@/types/asset' -import { Amount } from '@/types/amount' -import { initialState, MOCK_FEES } from './use-send.constants' -import type { SendState, UseSendOptions, UseSendReturn } from './use-send.types' -import { fetchBioforestFee, submitBioforestTransfer } from './use-send.bioforest' -import { fetchWeb3Fee, submitWeb3Transfer, validateWeb3Address } from './use-send.web3' -import { adjustAmountForFee, canProceedToConfirm, validateAddressInput, validateAmountInput } from './use-send.logic' -import { submitMockTransfer } from './use-send.mock' +import { useState, useCallback, useMemo } from 'react'; +import type { AssetInfo } from '@/types/asset'; +import { Amount } from '@/types/amount'; +import { initialState, MOCK_FEES } from './use-send.constants'; +import type { SendState, UseSendOptions, UseSendReturn } from './use-send.types'; +import { fetchBioforestFee, submitBioforestTransfer } from './use-send.bioforest'; +import { fetchWeb3Fee, submitWeb3Transfer, validateWeb3Address } from './use-send.web3'; +import { adjustAmountForFee, canProceedToConfirm, validateAddressInput, validateAmountInput } from './use-send.logic'; +import { submitMockTransfer } from './use-send.mock'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); /** * Hook for managing send flow state */ export function useSend(options: UseSendOptions = {}): UseSendReturn { - const { initialAsset, useMock = true, walletId, fromAddress, chainConfig, getBalance } = options + const { initialAsset, useMock = true, walletId, fromAddress, chainConfig, getBalance } = options; const [state, setState] = useState({ ...initialState, asset: initialAsset ?? null, - }) + }); - const isBioforestChain = chainConfig?.chainKind === 'bioforest' - const isWeb3Chain = chainConfig?.chainKind === 'evm' || chainConfig?.chainKind === 'tron' || chainConfig?.chainKind === 'bitcoin' + const isBioforestChain = chainConfig?.chainKind === 'bioforest'; + const isWeb3Chain = + chainConfig?.chainKind === 'evm' || chainConfig?.chainKind === 'tron' || chainConfig?.chainKind === 'bitcoin'; // Validate address - const validateAddress = useCallback((address: string): string | null => { - // Use chain adapter validation for Web3 chains - if (isWeb3Chain && chainConfig) { - return validateWeb3Address(chainConfig, address) - } - return validateAddressInput(address, isBioforestChain, fromAddress) - }, [isBioforestChain, isWeb3Chain, chainConfig, fromAddress]) + const validateAddress = useCallback( + (address: string): string | null => { + // Use chain adapter validation for Web3 chains + if (isWeb3Chain && chainConfig) { + return validateWeb3Address(chainConfig, address); + } + return validateAddressInput(address, isBioforestChain, fromAddress); + }, + [isBioforestChain, isWeb3Chain, chainConfig, fromAddress], + ); // Validate amount const validateAmount = useCallback((amount: Amount | null, asset: AssetInfo | null): string | null => { - return validateAmountInput(amount, asset) - }, []) + return validateAmountInput(amount, asset); + }, []); // Set recipient address const setToAddress = useCallback((address: string) => { @@ -42,8 +49,8 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { ...prev, toAddress: address, addressError: null, // Clear error on change - })) - }, []) + })); + }, []); // Set amount const setAmount = useCallback((amount: Amount | null) => { @@ -51,82 +58,85 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { ...prev, amount, amountError: null, // Clear error on change - })) - }, []) + })); + }, []); // Set custom fee (from FeeEditJob modal) const setFee = useCallback((formattedFee: string) => { setState((prev) => { - if (!prev.feeAmount) return prev - const newFeeAmount = Amount.fromFormatted(formattedFee, prev.feeAmount.decimals, prev.feeAmount.symbol) + if (!prev.feeAmount) return prev; + const newFeeAmount = Amount.fromFormatted(formattedFee, prev.feeAmount.decimals, prev.feeAmount.symbol); return { ...prev, feeAmount: newFeeAmount, - } - }) - }, []) + }; + }); + }, []); // Set asset and estimate fee - const setAsset = useCallback((asset: AssetInfo) => { - setState((prev) => ({ - ...prev, - asset, - feeLoading: true, - })) - - const shouldUseMock = useMock || (!isBioforestChain && !isWeb3Chain) || !chainConfig || !fromAddress - - if (shouldUseMock) { - // Mock fee estimation delay - setTimeout(() => { - const fee = MOCK_FEES[asset.assetType] ?? { amount: '0.001', symbol: asset.assetType } - const feeAmount = Amount.fromFormatted(fee.amount, asset.decimals, fee.symbol) - setState((prev) => ({ - ...prev, - feeAmount: feeAmount, - feeMinAmount: feeAmount, - feeSymbol: fee.symbol, - feeLoading: false, - })) - }, 300) - return - } - - void (async () => { - try { - // Use appropriate fee fetcher based on chain type - const feeEstimate = isWeb3Chain - ? await fetchWeb3Fee(chainConfig, fromAddress) - : await fetchBioforestFee(chainConfig, fromAddress) - - setState((prev) => ({ - ...prev, - feeAmount: feeEstimate.amount, - feeMinAmount: feeEstimate.amount, - feeSymbol: feeEstimate.symbol, - feeLoading: false, - })) - } catch (error) { - setState((prev) => ({ - ...prev, - feeLoading: false, - errorMessage: error instanceof Error ? error.message : '获取手续费失败', - })) + const setAsset = useCallback( + (asset: AssetInfo) => { + setState((prev) => ({ + ...prev, + asset, + feeLoading: true, + })); + + const shouldUseMock = useMock || (!isBioforestChain && !isWeb3Chain) || !chainConfig || !fromAddress; + + if (shouldUseMock) { + // Mock fee estimation delay + setTimeout(() => { + const fee = MOCK_FEES[asset.assetType] ?? { amount: '0.001', symbol: asset.assetType }; + const feeAmount = Amount.fromFormatted(fee.amount, asset.decimals, fee.symbol); + setState((prev) => ({ + ...prev, + feeAmount: feeAmount, + feeMinAmount: feeAmount, + feeSymbol: fee.symbol, + feeLoading: false, + })); + }, 300); + return; } - })() - }, [chainConfig, fromAddress, isBioforestChain, isWeb3Chain, useMock]) + + void (async () => { + try { + // Use appropriate fee fetcher based on chain type + const feeEstimate = isWeb3Chain + ? await fetchWeb3Fee(chainConfig, fromAddress) + : await fetchBioforestFee(chainConfig, fromAddress); + + setState((prev) => ({ + ...prev, + feeAmount: feeEstimate.amount, + feeMinAmount: feeEstimate.amount, + feeSymbol: feeEstimate.symbol, + feeLoading: false, + })); + } catch (error) { + setState((prev) => ({ + ...prev, + feeLoading: false, + errorMessage: error instanceof Error ? error.message : t('error:transaction.feeEstimateFailed'), + })); + } + })(); + }, + [chainConfig, fromAddress, isBioforestChain, isWeb3Chain, useMock], + ); // Get current balance from external source (single source of truth) const currentBalance = useMemo(() => { - if (!state.asset?.assetType || !getBalance) return state.asset?.amount ?? null - return getBalance(state.asset.assetType) ?? state.asset?.amount ?? null - }, [state.asset?.assetType, state.asset?.amount, getBalance]) + if (!state.asset?.assetType || !getBalance) return state.asset?.amount ?? null; + return getBalance(state.asset.assetType) ?? state.asset?.amount ?? null; + }, [state.asset?.assetType, state.asset?.amount, getBalance]); // Create asset with current balance for validation const assetWithCurrentBalance = useMemo((): AssetInfo | null => { - if (!state.asset || !currentBalance) return state.asset - return { ...state.asset, amount: currentBalance } - }, [state.asset, currentBalance]) + if (!state.asset || !currentBalance) return state.asset; + return { ...state.asset, amount: currentBalance }; + }, [state.asset, currentBalance]); // Check if can proceed const canProceed = useMemo(() => { @@ -136,37 +146,39 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { asset: assetWithCurrentBalance, isBioforestChain, feeLoading: state.feeLoading, - }) - }, [isBioforestChain, state.amount, assetWithCurrentBalance, state.toAddress, state.feeLoading]) + }); + }, [isBioforestChain, state.amount, assetWithCurrentBalance, state.toAddress, state.feeLoading]); // Validate and go to confirm const goToConfirm = useCallback((): boolean => { - const addressError = validateAddress(state.toAddress) - const amountError = assetWithCurrentBalance ? validateAmount(state.amount, assetWithCurrentBalance) : '请选择资产' + const addressError = validateAddress(state.toAddress); + const amountError = assetWithCurrentBalance + ? validateAmount(state.amount, assetWithCurrentBalance) + : t('error:validation.selectAsset'); if (addressError || amountError) { setState((prev) => ({ ...prev, addressError, amountError, - })) - return false + })); + return false; } if (assetWithCurrentBalance && state.feeAmount && state.amount) { - const adjustResult = adjustAmountForFee(state.amount, assetWithCurrentBalance, state.feeAmount) + const adjustResult = adjustAmountForFee(state.amount, assetWithCurrentBalance, state.feeAmount); if (adjustResult.status === 'error') { setState((prev) => ({ ...prev, amountError: adjustResult.message, - })) - return false + })); + return false; } if (adjustResult.adjustedAmount) { setState((prev) => ({ ...prev, amount: adjustResult.adjustedAmount ?? prev.amount, - })) + })); } } @@ -175,46 +187,115 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { step: 'confirm', addressError: null, amountError: null, - })) - return true - }, [state.toAddress, state.amount, assetWithCurrentBalance, state.feeAmount, validateAddress, validateAmount]) + })); + return true; + }, [state.toAddress, state.amount, assetWithCurrentBalance, state.feeAmount, validateAddress, validateAmount]); // Go back to input const goBack = useCallback(() => { setState((prev) => ({ ...prev, step: 'input', - })) - }, []) + })); + }, []); // Submit transaction - const submit = useCallback(async (password: string) => { - + const submit = useCallback( + async (password: string) => { + if (useMock) { + const result = await submitMockTransfer(setState); + return result.status === 'ok' ? { status: 'ok' as const } : { status: 'error' as const }; + } - if (useMock) { + if (!chainConfig) { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: t('error:transaction.chainConfigMissing'), + })); + return { status: 'error' as const }; + } - const result = await submitMockTransfer(setState) - return result.status === 'ok' ? { status: 'ok' as const } : { status: 'error' as const } - } + // Handle Web3 chains (EVM, Tron, Bitcoin) + if (chainConfig.chainKind === 'evm' || chainConfig.chainKind === 'tron' || chainConfig.chainKind === 'bitcoin') { + if (!walletId || !fromAddress || !state.asset || !state.amount) { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: t('error:transaction.walletInfoIncomplete'), + })); + return { status: 'error' as const }; + } - if (!chainConfig) { + setState((prev) => ({ + ...prev, + step: 'sending', + isSubmitting: true, + errorMessage: null, + })); + + const result = await submitWeb3Transfer({ + chainConfig, + walletId, + password, + fromAddress, + toAddress: state.toAddress, + amount: state.amount, + }); + + if (result.status === 'password') { + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })); + return { status: 'password' as const }; + } + + if (result.status === 'error') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: result.message, + })); + return { status: 'error' as const }; + } - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'failed', - txHash: null, - errorMessage: '链配置缺失', - })) - return { status: 'error' as const } - } + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'success', + txHash: result.txHash, + errorMessage: null, + })); - // Handle Web3 chains (EVM, Tron, Bitcoin) - if (chainConfig.chainKind === 'evm' || chainConfig.chainKind === 'tron' || chainConfig.chainKind === 'bitcoin') { + return { status: 'ok' as const, txHash: result.txHash }; + } + // Unsupported chain type + if (chainConfig.chainKind !== 'bioforest') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: t('error:transaction.unsupportedChainType', { chainType: chainConfig.chainKind }), + })); + return { status: 'error' as const }; + } - if (!walletId || !fromAddress || !state.asset || !state.amount) { + if (!walletId || !fromAddress || !state.asset) { setState((prev) => ({ ...prev, step: 'result', @@ -222,8 +303,19 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { resultStatus: 'failed', txHash: null, errorMessage: '钱包信息不完整', - })) - return { status: 'error' as const } + })); + return { status: 'error' as const }; + } + + const addressError = validateAddress(state.toAddress); + const amountError = validateAmount(state.amount, state.asset); + if (addressError || amountError) { + setState((prev) => ({ + ...prev, + addressError, + amountError, + })); + return { status: 'error' as const }; } setState((prev) => ({ @@ -231,24 +323,49 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { step: 'sending', isSubmitting: true, errorMessage: null, - })) + })); + + // Amount should never be null here (validated above) + if (!state.amount) { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: t('error:transaction.invalidAmount'), + })); + return { status: 'error' as const }; + } - const result = await submitWeb3Transfer({ + const result = await submitBioforestTransfer({ chainConfig, walletId, password, fromAddress, toAddress: state.toAddress, amount: state.amount, - }) + assetType: state.asset.assetType, + fee: state.feeAmount ?? undefined, + }); if (result.status === 'password') { setState((prev) => ({ ...prev, step: 'confirm', isSubmitting: false, - })) - return { status: 'password' as const } + })); + return { status: 'password' as const }; + } + + if (result.status === 'password_required') { + // Pay password is required - return status so UI can prompt for pay password + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })); + return { status: 'two_step_secret_required' as const, secondPublicKey: result.secondPublicKey }; } if (result.status === 'error') { @@ -259,8 +376,8 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { resultStatus: 'failed', txHash: null, errorMessage: result.message, - })) - return { status: 'error' as const } + })); + return { status: 'error' as const, message: result.message, pendingTxId: result.pendingTxId }; } setState((prev) => ({ @@ -270,214 +387,118 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { resultStatus: 'success', txHash: result.txHash, errorMessage: null, - })) - - return { status: 'ok' as const, txHash: result.txHash } - } - - // Unsupported chain type - if (chainConfig.chainKind !== 'bioforest') { - - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'failed', - txHash: null, - errorMessage: `不支持的链类型: ${chainConfig.chainKind}`, - })) - return { status: 'error' as const } - } - - if (!walletId || !fromAddress || !state.asset) { - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'failed', - txHash: null, - errorMessage: '钱包信息不完整', - })) - return { status: 'error' as const } - } - - const addressError = validateAddress(state.toAddress) - const amountError = validateAmount(state.amount, state.asset) - if (addressError || amountError) { - setState((prev) => ({ - ...prev, - addressError, - amountError, - })) - return { status: 'error' as const } - } - - setState((prev) => ({ - ...prev, - step: 'sending', - isSubmitting: true, - errorMessage: null, - })) - - // Amount should never be null here (validated above) - if (!state.amount) { - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'failed', - txHash: null, - errorMessage: '无效的金额', - })) - return { status: 'error' as const } - } + })); - const result = await submitBioforestTransfer({ + return { status: 'ok' as const, txHash: result.txHash, pendingTxId: result.pendingTxId }; + }, + [ chainConfig, - walletId, - password, fromAddress, - toAddress: state.toAddress, - amount: state.amount, - assetType: state.asset.assetType, - fee: state.feeAmount ?? undefined, - }) - - if (result.status === 'password') { - setState((prev) => ({ - ...prev, - step: 'confirm', - isSubmitting: false, - })) - return { status: 'password' as const } - } + state.amount, + state.asset, + state.toAddress, + useMock, + validateAddress, + validateAmount, + walletId, + ], + ); - if (result.status === 'password_required') { - // Pay password is required - return status so UI can prompt for pay password - setState((prev) => ({ - ...prev, - step: 'confirm', - isSubmitting: false, - })) - return { status: 'two_step_secret_required' as const, secondPublicKey: result.secondPublicKey } - } + // Submit with pay password (for addresses with secondPublicKey) + const submitWithTwoStepSecret = useCallback( + async (password: string, twoStepSecret: string) => { + if (!chainConfig || !walletId || !fromAddress) { + return { status: 'error' as const }; + } - if (result.status === 'error') { setState((prev) => ({ ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'failed', + step: 'sending', + isSubmitting: true, + resultStatus: null, txHash: null, - errorMessage: result.message, - })) - return { status: 'error' as const, message: result.message, pendingTxId: result.pendingTxId } - } - - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'success', - txHash: result.txHash, - errorMessage: null, - })) - - return { status: 'ok' as const, txHash: result.txHash, pendingTxId: result.pendingTxId } - }, [chainConfig, fromAddress, state.amount, state.asset, state.toAddress, useMock, validateAddress, validateAmount, walletId]) + errorMessage: null, + })); - // Submit with pay password (for addresses with secondPublicKey) - const submitWithTwoStepSecret = useCallback(async (password: string, twoStepSecret: string) => { - if (!chainConfig || !walletId || !fromAddress) { - return { status: 'error' as const } - } + if (!state.amount || !state.asset) { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: t('error:transaction.invalidAmount'), + })); + return { status: 'error' as const }; + } - setState((prev) => ({ - ...prev, - step: 'sending', - isSubmitting: true, - resultStatus: null, - txHash: null, - errorMessage: null, - })) - - if (!state.amount || !state.asset) { - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'failed', - txHash: null, - errorMessage: '无效的金额', - })) - return { status: 'error' as const } - } + const result = await submitBioforestTransfer({ + chainConfig, + walletId, + password, + fromAddress, + toAddress: state.toAddress, + amount: state.amount, + assetType: state.asset.assetType, + fee: state.feeAmount ?? undefined, + twoStepSecret, + }); - const result = await submitBioforestTransfer({ - chainConfig, - walletId, - password, - fromAddress, - toAddress: state.toAddress, - amount: state.amount, - assetType: state.asset.assetType, - fee: state.feeAmount ?? undefined, - twoStepSecret, - }) + if (result.status === 'password') { + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })); + return { status: 'password' as const }; + } - if (result.status === 'password') { - setState((prev) => ({ - ...prev, - step: 'confirm', - isSubmitting: false, - })) - return { status: 'password' as const } - } + if (result.status === 'error') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: result.message, + })); + return { status: 'error' as const, message: result.message, pendingTxId: result.pendingTxId }; + } - if (result.status === 'error') { - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'failed', - txHash: null, - errorMessage: result.message, - })) - return { status: 'error' as const, message: result.message, pendingTxId: result.pendingTxId } - } + // password_required should not happen when twoStepSecret is provided + if (result.status === 'password_required') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: t('error:transaction.securityPasswordFailed'), + })); + return { status: 'error' as const }; + } - // password_required should not happen when twoStepSecret is provided - if (result.status === 'password_required') { setState((prev) => ({ ...prev, step: 'result', isSubmitting: false, - resultStatus: 'failed', - txHash: null, - errorMessage: '安全密码验证失败', - })) - return { status: 'error' as const } - } - - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'success', - txHash: result.txHash, - errorMessage: null, - })) + resultStatus: 'success', + txHash: result.txHash, + errorMessage: null, + })); - return { status: 'ok' as const, txHash: result.txHash, pendingTxId: result.pendingTxId } - }, [chainConfig, fromAddress, state.amount, state.toAddress, walletId]) + return { status: 'ok' as const, txHash: result.txHash, pendingTxId: result.pendingTxId }; + }, + [chainConfig, fromAddress, state.amount, state.toAddress, walletId], + ); // Reset to initial state const reset = useCallback(() => { setState({ ...initialState, asset: initialAsset ?? null, - }) - }, [initialAsset]) + }); + }, [initialAsset]); return { state, @@ -491,5 +512,5 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { submitWithTwoStepSecret, reset, canProceed, - } + }; } diff --git a/src/hooks/use-send.web3.ts b/src/hooks/use-send.web3.ts index 4e77e1b2a..209d3bae3 100644 --- a/src/hooks/use-send.web3.ts +++ b/src/hooks/use-send.web3.ts @@ -1,26 +1,29 @@ /** * Web3 Transfer Implementation - * + * * Handles transfers for EVM, Tron, and Bitcoin chains using ChainProvider. */ -import type { AssetInfo } from '@/types/asset' -import type { ChainConfig } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage' -import { getChainProvider } from '@/services/chain-adapter/providers' -import { mnemonicToSeedSync } from '@scure/bip39' +import type { AssetInfo } from '@/types/asset'; +import type { ChainConfig } from '@/services/chain-config'; +import { Amount } from '@/types/amount'; +import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage'; +import { getChainProvider } from '@/services/chain-adapter/providers'; +import { mnemonicToSeedSync } from '@scure/bip39'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); export interface Web3FeeResult { - amount: Amount - symbol: string + amount: Amount; + symbol: string; } export async function fetchWeb3Fee(chainConfig: ChainConfig, fromAddress: string): Promise { - const chainProvider = getChainProvider(chainConfig.id) + const chainProvider = getChainProvider(chainConfig.id); if (!chainProvider.supportsFeeEstimate || !chainProvider.supportsBuildTransaction) { - throw new Error(`Chain ${chainConfig.id} does not support fee estimation`) + throw new Error(`Chain ${chainConfig.id} does not support fee estimation`); } // 新流程:先构建交易,再估算手续费 @@ -29,40 +32,40 @@ export async function fetchWeb3Fee(chainConfig: ChainConfig, fromAddress: string from: fromAddress, to: fromAddress, amount: Amount.fromRaw('1', chainConfig.decimals, chainConfig.symbol), - }) + }); - const feeEstimate = await chainProvider.estimateFee!(unsignedTx) + const feeEstimate = await chainProvider.estimateFee!(unsignedTx); return { amount: feeEstimate.standard.amount, symbol: chainConfig.symbol, - } + }; } export async function fetchWeb3Balance(chainConfig: ChainConfig, fromAddress: string): Promise { - const chainProvider = getChainProvider(chainConfig.id) - const balance = await chainProvider.nativeBalance.fetch({ address: fromAddress }) + const chainProvider = getChainProvider(chainConfig.id); + const balance = await chainProvider.nativeBalance.fetch({ address: fromAddress }); return { assetType: balance.symbol, name: chainConfig.name, amount: balance.amount, decimals: balance.amount.decimals, - } + }; } export type SubmitWeb3Result = | { status: 'ok'; txHash: string } | { status: 'password' } - | { status: 'error'; message: string } + | { status: 'error'; message: string }; export interface SubmitWeb3Params { - chainConfig: ChainConfig - walletId: string - password: string - fromAddress: string - toAddress: string - amount: Amount + chainConfig: ChainConfig; + walletId: string; + password: string; + fromAddress: string; + toAddress: string; + amount: Amount; } export async function submitWeb3Transfer({ @@ -74,34 +77,32 @@ export async function submitWeb3Transfer({ amount, }: SubmitWeb3Params): Promise { // Get mnemonic from wallet storage - let mnemonic: string + let mnemonic: string; try { - mnemonic = await walletStorageService.getMnemonic(walletId, password) + mnemonic = await walletStorageService.getMnemonic(walletId, password); } catch (error) { if (error instanceof WalletStorageError && error.code === WalletStorageErrorCode.DECRYPTION_FAILED) { - return { status: 'password' } + return { status: 'password' }; } return { status: 'error', - message: error instanceof Error ? error.message : '未知错误', - } + message: error instanceof Error ? error.message : t('error:transaction.unknownError'), + }; } if (!amount.isPositive()) { - return { status: 'error', message: '请输入有效金额' } + return { status: 'error', message: t('error:validation.enterValidAmount') }; } try { - const chainProvider = getChainProvider(chainConfig.id) + const chainProvider = getChainProvider(chainConfig.id); if (!chainProvider.supportsFullTransaction) { - return { status: 'error', message: `该链不支持完整交易流程: ${chainConfig.id}` } + return { status: 'error', message: t('error:transaction.chainNotSupported', { chainId: chainConfig.id }) }; } - - // Derive private key from mnemonic - const seed = mnemonicToSeedSync(mnemonic) + const seed = mnemonicToSeedSync(mnemonic); // Build unsigned transaction const unsignedTx = await chainProvider.buildTransaction!({ @@ -109,38 +110,48 @@ export async function submitWeb3Transfer({ from: fromAddress, to: toAddress, amount, - }) + }); // Sign transaction - const signedTx = await chainProvider.signTransaction!(unsignedTx, { privateKey: seed }) + const signedTx = await chainProvider.signTransaction!(unsignedTx, { privateKey: seed }); // Broadcast transaction - const txHash = await chainProvider.broadcastTransaction!(signedTx) + const txHash = await chainProvider.broadcastTransaction!(signedTx); - - return { status: 'ok', txHash } + return { status: 'ok', txHash }; } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + // Handle specific error cases + if (errorMessage.includes('insufficient') || errorMessage.includes('balance')) { + return { status: 'error', message: t('error:transaction.insufficientBalance') }; + } - const errorMessage = error instanceof Error ? error.message : String(error) + if (errorMessage.includes('fee') || errorMessage.includes('gas')) { + return { status: 'error', message: t('error:transaction.insufficientFee') }; + } - // Handle specific error cases - if (errorMessage.includes('insufficient') || errorMessage.includes('余额不足')) { - return { status: 'error', message: '余额不足' } + if (errorMessage.includes('not yet implemented') || errorMessage.includes('not supported')) { + return { status: 'error', message: t('error:transaction.featureNotImplemented') }; } + return { + status: 'error', + message: errorMessage || t('error:transaction.transactionFailed'), + }; + if (errorMessage.includes('fee') || errorMessage.includes('手续费') || errorMessage.includes('gas')) { - return { status: 'error', message: '手续费不足' } + return { status: 'error', message: '手续费不足' }; } if (errorMessage.includes('not yet implemented') || errorMessage.includes('not supported')) { - return { status: 'error', message: '该链转账功能尚未完全实现' } + return { status: 'error', message: '该链转账功能尚未完全实现' }; } return { status: 'error', message: errorMessage || '交易失败,请稍后重试', - } + }; } } @@ -148,19 +159,27 @@ export async function submitWeb3Transfer({ * Validate address for Web3 chains */ export function validateWeb3Address(chainConfig: ChainConfig, address: string): string | null { - const chainProvider = getChainProvider(chainConfig.id) + const chainProvider = getChainProvider(chainConfig.id); if (!chainProvider.supportsAddressValidation) { - return '不支持的链类型' + return t('error:validation.unsupportedChainType'); + } + + if (!address || address.trim() === '') { + return t('error:validation.enterRecipientAddress'); + } + + if (!chainProvider.isValidAddress!(address)) { + return t('error:validation.invalidAddressFormat'); } if (!address || address.trim() === '') { - return '请输入收款地址' + return '请输入收款地址'; } if (!chainProvider.isValidAddress!(address)) { - return '无效的地址格式' + return '无效的地址格式'; } - return null + return null; } diff --git a/src/hooks/use-transaction-history.ts b/src/hooks/use-transaction-history.ts index d03179293..d392d24d4 100644 --- a/src/hooks/use-transaction-history.ts +++ b/src/hooks/use-transaction-history.ts @@ -1,32 +1,39 @@ -import { useState, useCallback, useEffect } from 'react' -import type { ChainType } from '@/stores' -import type { TransactionInfo } from '@/components/transaction/transaction-item' -import { Amount } from '@/types/amount' -import { transactionService, type TransactionRecord as ServiceTransactionRecord, type TransactionFilter as ServiceFilter } from '@/services/transaction' +import { useState, useCallback, useEffect } from 'react'; +import type { ChainType } from '@/stores'; +import type { TransactionInfo } from '@/components/transaction/transaction-item'; +import { Amount } from '@/types/amount'; +import { + transactionService, + type TransactionRecord as ServiceTransactionRecord, + type TransactionFilter as ServiceFilter, +} from '@/services/transaction'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); /** 交易历史过滤器 */ export interface TransactionFilter { - chain?: ChainType | 'all' | undefined - period?: '7d' | '30d' | '90d' | 'all' | undefined + chain?: ChainType | 'all' | undefined; + period?: '7d' | '30d' | '90d' | 'all' | undefined; } /** 扩展的交易信息(包含链类型)- 保持与组件兼容 */ export interface TransactionRecord extends TransactionInfo { - chain: ChainType - fee: Amount | undefined - feeSymbol: string | undefined - blockNumber: number | undefined - confirmations: number | undefined + chain: ChainType; + fee: Amount | undefined; + feeSymbol: string | undefined; + blockNumber: number | undefined; + confirmations: number | undefined; } /** Hook 返回类型 */ export interface UseTransactionHistoryResult { - transactions: TransactionRecord[] - isLoading: boolean - error: string | undefined - filter: TransactionFilter - setFilter: (filter: TransactionFilter) => void - refresh: () => Promise + transactions: TransactionRecord[]; + isLoading: boolean; + error: string | undefined; + filter: TransactionFilter; + setFilter: (filter: TransactionFilter) => void; + refresh: () => Promise; } /** 将 Service 记录转换为组件兼容格式 */ @@ -45,26 +52,26 @@ function convertToComponentFormat(record: ServiceTransactionRecord): Transaction feeSymbol: record.feeSymbol, blockNumber: record.blockNumber, confirmations: record.confirmations, - } + }; } /** 交易历史 Hook */ export function useTransactionHistory(walletId?: string): UseTransactionHistoryResult { - const [transactions, setTransactions] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState() - const [filter, setFilter] = useState({ chain: 'all', period: 'all' }) + const [transactions, setTransactions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + const [filter, setFilter] = useState({ chain: 'all', period: 'all' }); // 获取交易历史 const fetchTransactions = useCallback(async () => { if (!walletId) { - setTransactions([]) - setIsLoading(false) - return + setTransactions([]); + setIsLoading(false); + return; } - setIsLoading(true) - setError(undefined) + setIsLoading(true); + setError(undefined); try { const serviceFilter: ServiceFilter = { @@ -72,26 +79,26 @@ export function useTransactionHistory(walletId?: string): UseTransactionHistoryR period: filter.period ?? 'all', type: undefined, status: undefined, - } - const rawRecords = await transactionService.getHistory({ walletId, filter: serviceFilter }) + }; + const rawRecords = await transactionService.getHistory({ walletId, filter: serviceFilter }); // Records from service already have Amount objects - setTransactions(rawRecords.map(convertToComponentFormat)) + setTransactions(rawRecords.map(convertToComponentFormat)); } catch (e) { - setError(e instanceof Error ? e.message : '加载交易历史失败') + setError(e instanceof Error ? e.message : t('error:history.loadFailed')); } finally { - setIsLoading(false) + setIsLoading(false); } - }, [walletId, filter]) + }, [walletId, filter]); // 初始加载和过滤器变化时重新获取 useEffect(() => { - void fetchTransactions() - }, [fetchTransactions]) + void fetchTransactions(); + }, [fetchTransactions]); // 刷新 const refresh = useCallback(async () => { - await fetchTransactions() - }, [fetchTransactions]) + await fetchTransactions(); + }, [fetchTransactions]); return { transactions, @@ -100,5 +107,5 @@ export function useTransactionHistory(walletId?: string): UseTransactionHistoryR filter, setFilter, refresh, - } + }; } diff --git a/src/hooks/useWalletTheme.ts b/src/hooks/useWalletTheme.ts index 0dc2dc1c6..d6bcd254a 100644 --- a/src/hooks/useWalletTheme.ts +++ b/src/hooks/useWalletTheme.ts @@ -1,58 +1,58 @@ -import { useCallback, useEffect } from 'react' -import { useStore } from '@tanstack/react-store' -import { walletStore } from '@/stores' +import { useCallback, useEffect } from 'react'; +import { useStore } from '@tanstack/react-store'; +import { walletStore } from '@/stores'; -const THEME_HUE_CACHE_KEY = 'wallet_theme_hue_cache' +const THEME_HUE_CACHE_KEY = 'wallet_theme_hue_cache'; /** 预设主题色 (oklch hue 角度) */ export const WALLET_THEME_PRESETS = { - purple: 323, // 默认紫色 - blue: 250, // 蓝色 - cyan: 200, // 青色 - green: 145, // 绿色 - yellow: 85, // 黄色 - orange: 45, // 橙色 - red: 25, // 红色 - pink: 350, // 粉色 - magenta: 310, // 洋红 -} as const - -export type WalletThemePreset = keyof typeof WALLET_THEME_PRESETS + purple: 323, // 默认紫色 + blue: 250, // 蓝色 + cyan: 200, // 青色 + green: 145, // 绿色 + yellow: 85, // 黄色 + orange: 45, // 橙色 + red: 25, // 红色 + pink: 350, // 粉色 + magenta: 310, // 洋红 +} as const; + +export type WalletThemePreset = keyof typeof WALLET_THEME_PRESETS; /** 主题色配置(包含名称和展示色) */ export const WALLET_THEME_COLORS = [ - { name: '紫色', hue: 323, color: 'oklch(0.6 0.25 323)' }, - { name: '蓝色', hue: 250, color: 'oklch(0.55 0.25 250)' }, - { name: '青色', hue: 200, color: 'oklch(0.65 0.2 200)' }, - { name: '绿色', hue: 145, color: 'oklch(0.6 0.2 145)' }, - { name: '黄色', hue: 85, color: 'oklch(0.75 0.18 85)' }, - { name: '橙色', hue: 45, color: 'oklch(0.7 0.2 45)' }, - { name: '红色', hue: 25, color: 'oklch(0.6 0.25 25)' }, - { name: '粉色', hue: 350, color: 'oklch(0.7 0.2 350)' }, - { name: '洋红', hue: 310, color: 'oklch(0.6 0.25 310)' }, -] as const + { nameKey: 'colors.purple', hue: 323, color: 'oklch(0.6 0.25 323)' }, + { nameKey: 'colors.blue', hue: 250, color: 'oklch(0.55 0.25 250)' }, + { nameKey: 'colors.cyan', hue: 200, color: 'oklch(0.65 0.2 200)' }, + { nameKey: 'colors.green', hue: 145, color: 'oklch(0.6 0.2 145)' }, + { nameKey: 'colors.yellow', hue: 85, color: 'oklch(0.75 0.18 85)' }, + { nameKey: 'colors.orange', hue: 45, color: 'oklch(0.7 0.2 45)' }, + { nameKey: 'colors.red', hue: 25, color: 'oklch(0.6 0.25 25)' }, + { nameKey: 'colors.pink', hue: 350, color: 'oklch(0.7 0.2 350)' }, + { nameKey: 'colors.magenta', hue: 310, color: 'oklch(0.6 0.25 310)' }, +] as const; /** * 基于助记词/密钥稳定派生主题色 hue * 使用简单哈希算法生成 0-360 的色相值 */ export function deriveThemeHue(secret: string): number { - let hash = 0 + let hash = 0; for (let i = 0; i < secret.length; i++) { - const char = secret.charCodeAt(i) - hash = ((hash << 5) - hash) + char - hash = hash & hash // Convert to 32bit integer + const char = secret.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer } // Map to 0-360 range - return Math.abs(hash) % 360 + return Math.abs(hash) % 360; } /** * 将主题色应用到 CSS 变量 */ function applyThemeColor(hue: number) { - const root = document.documentElement - root.style.setProperty('--primary-hue', String(hue)) + const root = document.documentElement; + root.style.setProperty('--primary-hue', String(hue)); } /** @@ -60,7 +60,7 @@ function applyThemeColor(hue: number) { */ function saveThemeHueCache(hue: number) { try { - localStorage.setItem(THEME_HUE_CACHE_KEY, String(hue)) + localStorage.setItem(THEME_HUE_CACHE_KEY, String(hue)); } catch { // ignore storage errors } @@ -71,17 +71,17 @@ function saveThemeHueCache(hue: number) { */ function loadThemeHueCache(): number | null { try { - const cached = localStorage.getItem(THEME_HUE_CACHE_KEY) + const cached = localStorage.getItem(THEME_HUE_CACHE_KEY); if (cached) { - const hue = parseInt(cached, 10) + const hue = parseInt(cached, 10); if (!isNaN(hue) && hue >= 0 && hue <= 360) { - return hue + return hue; } } } catch { // ignore } - return null + return null; } /** @@ -89,12 +89,12 @@ function loadThemeHueCache(): number | null { * 立即从缓存读取并应用,避免白屏闪烁 */ export function initializeThemeHue() { - const cached = loadThemeHueCache() + const cached = loadThemeHueCache(); if (cached !== null) { - applyThemeColor(cached) + applyThemeColor(cached); } else { // 没有缓存时使用默认紫色 - applyThemeColor(WALLET_THEME_PRESETS.purple) + applyThemeColor(WALLET_THEME_PRESETS.purple); } } @@ -102,10 +102,10 @@ export function initializeThemeHue() { * 根据钱包ID获取主题色 */ function getThemeHueForWallet(wallets: { id: string; themeHue: number }[], walletId: string | null): number { - if (!walletId) return WALLET_THEME_PRESETS.purple - - const wallet = wallets.find((w) => w.id === walletId) - return wallet?.themeHue ?? WALLET_THEME_PRESETS.purple + if (!walletId) return WALLET_THEME_PRESETS.purple; + + const wallet = wallets.find((w) => w.id === walletId); + return wallet?.themeHue ?? WALLET_THEME_PRESETS.purple; } /** @@ -113,40 +113,41 @@ function getThemeHueForWallet(wallets: { id: string; themeHue: number }[], walle * 管理当前钱包的主题色,自动应用到全局CSS变量 */ export function useWalletTheme() { - const wallets = useStore(walletStore, (s) => s.wallets) - const currentWalletId = useStore(walletStore, (s) => s.currentWalletId) + const wallets = useStore(walletStore, (s) => s.wallets); + const currentWalletId = useStore(walletStore, (s) => s.currentWalletId); // 获取当前钱包的主题色 - const walletThemeHue = currentWalletId + const walletThemeHue = currentWalletId ? getThemeHueForWallet(wallets as { id: string; themeHue: number }[], currentWalletId) - : null + : null; // 实际使用的主题色:有钱包用钱包的,没有用缓存的,都没有用默认的 - const themeHue = walletThemeHue ?? loadThemeHueCache() ?? WALLET_THEME_PRESETS.purple + const themeHue = walletThemeHue ?? loadThemeHueCache() ?? WALLET_THEME_PRESETS.purple; // 应用主题色并缓存(只有当钱包真正加载后才缓存) useEffect(() => { - applyThemeColor(themeHue) + applyThemeColor(themeHue); // 只有当从钱包获取到主题色时才保存缓存 if (walletThemeHue !== null) { - saveThemeHueCache(walletThemeHue) + saveThemeHueCache(walletThemeHue); } - }, [themeHue, walletThemeHue]) + }, [themeHue, walletThemeHue]); // 设置主题色 const setThemeColor = useCallback((walletId: string, hue: number) => { walletStore.setState((state) => ({ ...state, - wallets: state.wallets.map((w) => - w.id === walletId ? { ...w, themeHue: hue } : w - ), - })) - }, []) + wallets: state.wallets.map((w) => (w.id === walletId ? { ...w, themeHue: hue } : w)), + })); + }, []); // 设置预设主题 - const setThemePreset = useCallback((walletId: string, preset: WalletThemePreset) => { - setThemeColor(walletId, WALLET_THEME_PRESETS[preset]) - }, [setThemeColor]) + const setThemePreset = useCallback( + (walletId: string, preset: WalletThemePreset) => { + setThemeColor(walletId, WALLET_THEME_PRESETS[preset]); + }, + [setThemeColor], + ); return { themeHue, @@ -154,7 +155,6 @@ export function useWalletTheme() { setThemeColor, setThemePreset, /** 获取指定钱包的主题色 */ - getWalletTheme: (walletId: string) => - getThemeHueForWallet(wallets as { id: string; themeHue: number }[], walletId), - } + getWalletTheme: (walletId: string) => getThemeHueForWallet(wallets as { id: string; themeHue: number }[], walletId), + }; } From bd69f0ef879d43c6a026eb6802117115eea055a6 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 23:21:21 +0800 Subject: [PATCH 02/13] feat(i18n): migrate services, lib/crypto, and hooks error messages to i18n system - Add i18n translations for crypto errors (decrypt, key derivation, biometric) - Add i18n translations for chain errors (unsupported type, miniapp launch) - Add i18n translations for transaction errors (gas, retry, validation) - Migrate mpay-crypto, mpay-transformer, miniapp-runtime, pending-tx - Migrate derivation.ts, encryption.ts, secure-storage.ts - Migrate use-send.ts, use-send.web3.ts, use-multi-chain-address-generation.ts - Migrate use-security-password.ts --- .../use-multi-chain-address-generation.ts | 63 +-- src/hooks/use-security-password.ts | 176 ++++---- src/hooks/use-send.ts | 2 +- src/hooks/use-send.web3.ts | 11 +- src/i18n/locales/ar/error.json | 42 +- src/i18n/locales/en/error.json | 38 +- src/i18n/locales/zh-CN/error.json | 38 +- src/i18n/locales/zh-TW/error.json | 38 +- src/lib/crypto/derivation.ts | 409 +++++++++--------- src/lib/crypto/encryption.ts | 162 +++---- src/lib/crypto/secure-storage.ts | 211 +++++---- src/services/migration/mpay-crypto.ts | 65 ++- src/services/migration/mpay-transformer.ts | 124 +++--- src/services/miniapp-runtime/index.ts | 9 +- src/services/transaction/pending-tx.ts | 361 +++++++++------- 15 files changed, 922 insertions(+), 827 deletions(-) diff --git a/src/hooks/use-multi-chain-address-generation.ts b/src/hooks/use-multi-chain-address-generation.ts index 77542d718..4b387a81f 100644 --- a/src/hooks/use-multi-chain-address-generation.ts +++ b/src/hooks/use-multi-chain-address-generation.ts @@ -1,18 +1,21 @@ -import { useState, useCallback } from 'react' -import { deriveAllAddresses, type DerivedKey } from '@/lib/crypto/derivation' -import { validateMnemonic } from '@/lib/crypto/mnemonic' +import { useState, useCallback } from 'react'; +import { deriveAllAddresses, type DerivedKey } from '@/lib/crypto/derivation'; +import { validateMnemonic } from '@/lib/crypto/mnemonic'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); export interface MultiChainAddressResult { /** All generated addresses */ - addresses: DerivedKey[] + addresses: DerivedKey[]; /** Whether generation is in progress */ - isGenerating: boolean + isGenerating: boolean; /** Error message if generation failed */ - error: string | null + error: string | null; /** Generate addresses from mnemonic */ - generate: (mnemonic: string[]) => Promise + generate: (mnemonic: string[]) => Promise; /** Clear results */ - clear: () => void + clear: () => void; } /** @@ -20,41 +23,41 @@ export interface MultiChainAddressResult { * Used for duplicate detection during wallet recovery */ export function useMultiChainAddressGeneration(): MultiChainAddressResult { - const [addresses, setAddresses] = useState([]) - const [isGenerating, setIsGenerating] = useState(false) - const [error, setError] = useState(null) + const [addresses, setAddresses] = useState([]); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(null); const generate = useCallback(async (mnemonic: string[]): Promise => { - setIsGenerating(true) - setError(null) + setIsGenerating(true); + setError(null); try { // Validate mnemonic first if (!validateMnemonic(mnemonic)) { - throw new Error('Invalid mnemonic') + throw new Error('Invalid mnemonic'); } - const mnemonicString = mnemonic.join(' ') + const mnemonicString = mnemonic.join(' '); // Generate addresses for all chains // This is done synchronously but wrapped in promise for consistency - const results = deriveAllAddresses(mnemonicString) + const results = deriveAllAddresses(mnemonicString); - setAddresses(results) - return results + setAddresses(results); + return results; } catch (err) { - const message = err instanceof Error ? err.message : '地址生成失败' - setError(message) - return [] + const message = err instanceof Error ? err.message : t('error:address.generationFailed'); + setError(message); + return []; } finally { - setIsGenerating(false) + setIsGenerating(false); } - }, []) + }, []); const clear = useCallback(() => { - setAddresses([]) - setError(null) - }, []) + setAddresses([]); + setError(null); + }, []); return { addresses, @@ -62,7 +65,7 @@ export function useMultiChainAddressGeneration(): MultiChainAddressResult { error, generate, clear, - } + }; } /** @@ -70,14 +73,14 @@ export function useMultiChainAddressGeneration(): MultiChainAddressResult { */ export function generateAllAddresses(mnemonic: string[]): DerivedKey[] { if (!validateMnemonic(mnemonic)) { - throw new Error('Invalid mnemonic') + throw new Error('Invalid mnemonic'); } - return deriveAllAddresses(mnemonic.join(' ')) + return deriveAllAddresses(mnemonic.join(' ')); } /** * Get all addresses as a Set for efficient lookup */ export function getAddressSet(keys: DerivedKey[]): Set { - return new Set(keys.map((k) => k.address.toLowerCase())) + return new Set(keys.map((k) => k.address.toLowerCase())); } diff --git a/src/hooks/use-security-password.ts b/src/hooks/use-security-password.ts index 186dc5afa..2036011cd 100644 --- a/src/hooks/use-security-password.ts +++ b/src/hooks/use-security-password.ts @@ -1,54 +1,53 @@ /** * 安全密码 Hook - * + * * 提供安全密码的查询、缓存、验证功能 */ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useStore } from '@tanstack/react-store' -import { - securityPasswordStore, - securityPasswordActions, - securityPasswordSelectors, -} from '@/stores/security-password' -import { getChainProvider } from '@/services/chain-adapter/providers' -import type { ChainConfig } from '@/services/chain-config' +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useStore } from '@tanstack/react-store'; +import { securityPasswordStore, securityPasswordActions, securityPasswordSelectors } from '@/stores/security-password'; +import { getChainProvider } from '@/services/chain-adapter/providers'; +import type { ChainConfig } from '@/services/chain-config'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); interface UseSecurityPasswordOptions { /** 链配置 */ - chainConfig: ChainConfig | null | undefined + chainConfig: ChainConfig | null | undefined; /** 地址 */ - address: string | null | undefined + address: string | null | undefined; /** 是否自动加载 */ - autoLoad?: boolean + autoLoad?: boolean; } interface UseSecurityPasswordResult { /** 安全密码公钥 */ - publicKey: string | null | undefined + publicKey: string | null | undefined; /** 是否设置了安全密码 */ - hasSecurityPassword: boolean + hasSecurityPassword: boolean; /** 是否正在加载 */ - isLoading: boolean + isLoading: boolean; /** 是否已加载完成 */ - isLoaded: boolean + isLoaded: boolean; /** 错误信息 */ - error: string | null + error: string | null; /** 刷新(重新查询) */ - refresh: () => Promise + refresh: () => Promise; /** 验证安全密码 */ - verify: (mainSecret: string, paySecret: string) => Promise + verify: (mainSecret: string, paySecret: string) => Promise; } /** * 安全密码 Hook - * + * * @example * ```tsx * const { hasSecurityPassword, verify, isLoading } = useSecurityPassword({ * chainConfig, * address, * }) - * + * * // 签名前检查 * if (hasSecurityPassword) { * // 显示安全密码输入 @@ -64,78 +63,81 @@ export function useSecurityPassword({ address, autoLoad = true, }: UseSecurityPasswordOptions): UseSecurityPasswordResult { - const state = useStore(securityPasswordStore) + const state = useStore(securityPasswordStore); const publicKey = useMemo(() => { - if (!address) return undefined - return securityPasswordSelectors.getPublicKey(state, address) - }, [state, address]) + if (!address) return undefined; + return securityPasswordSelectors.getPublicKey(state, address); + }, [state, address]); const hasSecurityPassword = useMemo(() => { - if (!address) return false - return securityPasswordSelectors.hasSecurityPassword(state, address) - }, [state, address]) + if (!address) return false; + return securityPasswordSelectors.hasSecurityPassword(state, address); + }, [state, address]); const isLoading = useMemo(() => { - if (!address) return false - return securityPasswordSelectors.isLoading(state, address) - }, [state, address]) + if (!address) return false; + return securityPasswordSelectors.isLoading(state, address); + }, [state, address]); const isLoaded = useMemo(() => { - if (!address) return false - return securityPasswordSelectors.isLoaded(state, address) - }, [state, address]) + if (!address) return false; + return securityPasswordSelectors.isLoaded(state, address); + }, [state, address]); const error = useMemo(() => { - if (!address) return null - return securityPasswordSelectors.getError(state, address) - }, [state, address]) + if (!address) return null; + return securityPasswordSelectors.getError(state, address); + }, [state, address]); // 查询安全密码公钥 const refresh = useCallback(async () => { - if (!chainConfig || !address) return + if (!chainConfig || !address) return; - const provider = getChainProvider(chainConfig.id) + const provider = getChainProvider(chainConfig.id); if (!provider.supportsBioAccountInfo) { - securityPasswordActions.setError(address, '该链不支持安全密码') - return + securityPasswordActions.setError(address, t('error:chain.notSupportSecurityPassword')); + return; } - securityPasswordActions.setLoading(address, true) + securityPasswordActions.setLoading(address, true); try { - const info = await provider.bioGetAccountInfo!(address) - securityPasswordActions.setPublicKey(address, info.secondPublicKey ?? null) + const info = await provider.bioGetAccountInfo!(address); + securityPasswordActions.setPublicKey(address, info.secondPublicKey ?? null); } catch (err) { - const message = err instanceof Error ? err.message : '查询失败' - securityPasswordActions.setError(address, message) + const message = err instanceof Error ? err.message : t('error:query.failed'); + securityPasswordActions.setError(address, message); } - }, [chainConfig, address]) + }, [chainConfig, address]); // 验证安全密码 - const verify = useCallback(async (mainSecret: string, paySecret: string): Promise => { - if (!chainConfig || !publicKey) return false + const verify = useCallback( + async (mainSecret: string, paySecret: string): Promise => { + if (!chainConfig || !publicKey) return false; - const provider = getChainProvider(chainConfig.id) - if (!provider.bioVerifyPayPassword) return false + const provider = getChainProvider(chainConfig.id); + if (!provider.bioVerifyPayPassword) return false; - try { - return await provider.bioVerifyPayPassword({ - mainSecret, - paySecret, - publicKey, - }) - } catch { - return false - } - }, [chainConfig, publicKey]) + try { + return await provider.bioVerifyPayPassword({ + mainSecret, + paySecret, + publicKey, + }); + } catch { + return false; + } + }, + [chainConfig, publicKey], + ); // 自动加载 useEffect(() => { if (autoLoad && chainConfig && address && !isLoaded && !isLoading) { - refresh() + refresh(); } - }, [autoLoad, chainConfig, address, isLoaded, isLoading, refresh]) + }, [autoLoad, chainConfig, address, isLoaded, isLoading, refresh]); return { publicKey, @@ -145,12 +147,12 @@ export function useSecurityPassword({ error, refresh, verify, - } + }; } /** * 实时验证安全密码 Hook - * + * * 用于输入时实时验证安全密码是否正确 */ export function useSecurityPasswordValidation({ @@ -160,44 +162,44 @@ export function useSecurityPasswordValidation({ paySecret, debounceMs = 300, }: { - chainConfig: ChainConfig | null | undefined - publicKey: string | null | undefined - mainSecret: string - paySecret: string - debounceMs?: number + chainConfig: ChainConfig | null | undefined; + publicKey: string | null | undefined; + mainSecret: string; + paySecret: string; + debounceMs?: number; }) { - const [isValid, setIsValid] = useState(null) - const [isValidating, setIsValidating] = useState(false) + const [isValid, setIsValid] = useState(null); + const [isValidating, setIsValidating] = useState(false); useEffect(() => { if (!chainConfig || !publicKey || !mainSecret || !paySecret) { - setIsValid(null) - return + setIsValid(null); + return; } - setIsValidating(true) + setIsValidating(true); const timer = setTimeout(async () => { try { - const provider = getChainProvider(chainConfig.id) + const provider = getChainProvider(chainConfig.id); if (!provider.bioVerifyPayPassword) { - setIsValid(false) - return + setIsValid(false); + return; } const result = await provider.bioVerifyPayPassword({ mainSecret, paySecret, publicKey, - }) - setIsValid(result) + }); + setIsValid(result); } catch { - setIsValid(false) + setIsValid(false); } finally { - setIsValidating(false) + setIsValidating(false); } - }, debounceMs) + }, debounceMs); - return () => clearTimeout(timer) - }, [chainConfig, publicKey, mainSecret, paySecret, debounceMs]) + return () => clearTimeout(timer); + }, [chainConfig, publicKey, mainSecret, paySecret, debounceMs]); - return { isValid, isValidating } + return { isValid, isValidating }; } diff --git a/src/hooks/use-send.ts b/src/hooks/use-send.ts index 60462524a..06544d4c3 100644 --- a/src/hooks/use-send.ts +++ b/src/hooks/use-send.ts @@ -302,7 +302,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { isSubmitting: false, resultStatus: 'failed', txHash: null, - errorMessage: '钱包信息不完整', + errorMessage: t('error:wallet.incompleteInfo'), })); return { status: 'error' as const }; } diff --git a/src/hooks/use-send.web3.ts b/src/hooks/use-send.web3.ts index 209d3bae3..ff5e9196b 100644 --- a/src/hooks/use-send.web3.ts +++ b/src/hooks/use-send.web3.ts @@ -141,16 +141,17 @@ export async function submitWeb3Transfer({ }; if (errorMessage.includes('fee') || errorMessage.includes('手续费') || errorMessage.includes('gas')) { - return { status: 'error', message: '手续费不足' }; + // i18n-ignore + return { status: 'error', message: t('error:transaction.insufficientGas') }; } if (errorMessage.includes('not yet implemented') || errorMessage.includes('not supported')) { - return { status: 'error', message: '该链转账功能尚未完全实现' }; + return { status: 'error', message: t('error:chain.transferNotImplemented') }; } return { status: 'error', - message: errorMessage || '交易失败,请稍后重试', + message: errorMessage || t('error:transaction.retryLater'), }; } } @@ -174,11 +175,11 @@ export function validateWeb3Address(chainConfig: ChainConfig, address: string): } if (!address || address.trim() === '') { - return '请输入收款地址'; + return t('error:validation.enterReceiverAddress'); } if (!chainProvider.isValidAddress!(address)) { - return '无效的地址格式'; + return t('error:validation.invalidAddress'); } return null; diff --git a/src/i18n/locales/ar/error.json b/src/i18n/locales/ar/error.json index cd0422c19..9f62972ff 100644 --- a/src/i18n/locales/ar/error.json +++ b/src/i18n/locales/ar/error.json @@ -18,7 +18,9 @@ "cannotTransferToSelf": "Cannot transfer to yourself", "enterAmount": "Please enter amount", "enterValidAmount": "Please enter a valid amount", - "exceedsBalance": "Amount exceeds balance" + "exceedsBalance": "Amount exceeds balance", + "enterReceiverAddress": "يرجى إدخال عنوان المستلم", + "invalidAddress": "تنسيق العنوان غير صالح" }, "transaction": { "failed": "Transaction failed, please try again later", @@ -31,20 +33,25 @@ "chainConfigMissing": "Chain configuration missing", "unsupportedChainType": "Unsupported chain type: {{chain}}", "issuerAddressNotFound": "Unable to get asset issuer address", - "issuerAddressNotReady": "Asset issuer address not ready" + "issuerAddressNotReady": "Asset issuer address not ready", + "insufficientGas": "رسوم الغاز غير كافية", + "retryLater": "فشلت المعاملة، يرجى المحاولة لاحقاً" }, "crypto": { - "keyDerivationFailed": "Key derivation failed", + "keyDerivationFailed": "فشل اشتقاق المفتاح", "decryptionFailed": "Decryption failed: wrong password or corrupted data", "decryptionKeyFailed": "Decryption failed: wrong key or corrupted data", "biometricNotAvailable": "Biometric not available", "biometricVerificationFailed": "Biometric verification failed", "webModeRequiresPassword": "Web mode requires password", "dwebEnvironmentRequired": "DWEB environment required", - "mpayDecryptionFailed": "mpay data decryption failed: wrong password or corrupted data" + "mpayDecryptionFailed": "mpay data decryption failed: wrong password or corrupted data", + "unsupportedBitcoinPurpose": "غرض Bitcoin غير مدعوم: {{purpose}}", + "decryptFailed": "فشل فك التشفير: كلمة مرور خاطئة أو بيانات تالفة", + "decryptKeyFailed": "فشل فك التشفير: مفتاح خاطئ أو بيانات تالفة" }, "address": { - "generationFailed": "Address generation failed" + "generationFailed": "فشل إنشاء العنوان" }, "securityPassword": { "chainNotSupported": "This chain does not support security password", @@ -55,5 +62,30 @@ }, "duplicate": { "detectionFailed": "Duplicate detection failed" + }, + "chain": { + "unsupportedType": "نوع سلسلة غير مدعوم: {{chain}}", + "notSupportSecurityPassword": "هذه السلسلة لا تدعم كلمة المرور الأمنية", + "transferNotImplemented": "وظيفة التحويل لهذه السلسلة غير مطبقة بالكامل" + }, + "mpay": { + "decryptFailed": "فشل فك تشفير بيانات mpay: كلمة مرور خاطئة أو بيانات تالفة" + }, + "miniapp": { + "launchFailed": { + "stayOnDesktop": "فشل الإطلاق: يرجى البقاء على صفحة سطح المكتب المستهدفة وإعادة المحاولة", + "iconNotReady": "فشل الإطلاق: الأيقونة غير جاهزة، يرجى العودة إلى سطح المكتب وإعادة المحاولة", + "containerNotReady": "فشل الإطلاق: الحاوية غير جاهزة، يرجى إعادة المحاولة" + } + }, + "biometric": { + "unavailable": "المصادقة البيومترية غير متوفرة", + "verificationFailed": "فشل التحقق البيومتري" + }, + "query": { + "failed": "فشل الاستعلام" + }, + "wallet": { + "incompleteInfo": "معلومات المحفظة غير مكتملة" } } diff --git a/src/i18n/locales/en/error.json b/src/i18n/locales/en/error.json index cd0422c19..b3d61381e 100644 --- a/src/i18n/locales/en/error.json +++ b/src/i18n/locales/en/error.json @@ -18,7 +18,9 @@ "cannotTransferToSelf": "Cannot transfer to yourself", "enterAmount": "Please enter amount", "enterValidAmount": "Please enter a valid amount", - "exceedsBalance": "Amount exceeds balance" + "exceedsBalance": "Amount exceeds balance", + "enterReceiverAddress": "Please enter recipient address", + "invalidAddress": "Invalid address format" }, "transaction": { "failed": "Transaction failed, please try again later", @@ -31,7 +33,9 @@ "chainConfigMissing": "Chain configuration missing", "unsupportedChainType": "Unsupported chain type: {{chain}}", "issuerAddressNotFound": "Unable to get asset issuer address", - "issuerAddressNotReady": "Asset issuer address not ready" + "issuerAddressNotReady": "Asset issuer address not ready", + "insufficientGas": "Insufficient gas fee", + "retryLater": "Transaction failed, please try again later" }, "crypto": { "keyDerivationFailed": "Key derivation failed", @@ -41,7 +45,10 @@ "biometricVerificationFailed": "Biometric verification failed", "webModeRequiresPassword": "Web mode requires password", "dwebEnvironmentRequired": "DWEB environment required", - "mpayDecryptionFailed": "mpay data decryption failed: wrong password or corrupted data" + "mpayDecryptionFailed": "mpay data decryption failed: wrong password or corrupted data", + "unsupportedBitcoinPurpose": "Unsupported Bitcoin purpose: {{purpose}}", + "decryptFailed": "Decryption failed: wrong password or corrupted data", + "decryptKeyFailed": "Decryption failed: wrong key or corrupted data" }, "address": { "generationFailed": "Address generation failed" @@ -55,5 +62,30 @@ }, "duplicate": { "detectionFailed": "Duplicate detection failed" + }, + "chain": { + "unsupportedType": "Unsupported chain type: {{chain}}", + "notSupportSecurityPassword": "This chain does not support security password", + "transferNotImplemented": "Transfer function for this chain is not fully implemented" + }, + "mpay": { + "decryptFailed": "Failed to decrypt mpay data: wrong password or corrupted data" + }, + "miniapp": { + "launchFailed": { + "stayOnDesktop": "Launch failed: please stay on the target desktop page and retry", + "iconNotReady": "Launch failed: icon not ready, please return to desktop and retry", + "containerNotReady": "Launch failed: container not ready, please retry" + } + }, + "biometric": { + "unavailable": "Biometric authentication unavailable", + "verificationFailed": "Biometric verification failed" + }, + "query": { + "failed": "Query failed" + }, + "wallet": { + "incompleteInfo": "Wallet information is incomplete" } } diff --git a/src/i18n/locales/zh-CN/error.json b/src/i18n/locales/zh-CN/error.json index c93f8dfcb..b4b09fa3c 100644 --- a/src/i18n/locales/zh-CN/error.json +++ b/src/i18n/locales/zh-CN/error.json @@ -18,7 +18,9 @@ "cannotTransferToSelf": "不能转账给自己", "enterAmount": "请输入金额", "enterValidAmount": "请输入有效金额", - "exceedsBalance": "销毁数量不能大于余额" + "exceedsBalance": "销毁数量不能大于余额", + "enterReceiverAddress": "请输入收款地址", + "invalidAddress": "无效的地址格式" }, "transaction": { "failed": "交易失败,请稍后重试", @@ -31,7 +33,9 @@ "chainConfigMissing": "链配置缺失", "unsupportedChainType": "不支持的链类型: {{chain}}", "issuerAddressNotFound": "无法获取资产发行地址", - "issuerAddressNotReady": "资产发行地址未获取" + "issuerAddressNotReady": "资产发行地址未获取", + "insufficientGas": "手续费不足", + "retryLater": "交易失败,请稍后重试" }, "crypto": { "keyDerivationFailed": "密钥派生失败", @@ -41,7 +45,10 @@ "biometricVerificationFailed": "生物识别验证失败", "webModeRequiresPassword": "Web 模式需要提供密码", "dwebEnvironmentRequired": "需要在 DWEB 环境中访问", - "mpayDecryptionFailed": "mpay 数据解密失败:密码错误或数据损坏" + "mpayDecryptionFailed": "mpay 数据解密失败:密码错误或数据损坏", + "unsupportedBitcoinPurpose": "不支持的 Bitcoin purpose: {{purpose}}", + "decryptFailed": "解密失败:密码错误或数据损坏", + "decryptKeyFailed": "解密失败:密钥错误或数据损坏" }, "address": { "generationFailed": "地址生成失败" @@ -55,5 +62,30 @@ }, "duplicate": { "detectionFailed": "重复检测失败" + }, + "chain": { + "unsupportedType": "不支持的链类型: {{chain}}", + "notSupportSecurityPassword": "该链不支持安全密码", + "transferNotImplemented": "该链转账功能尚未完全实现" + }, + "mpay": { + "decryptFailed": "mpay 数据解密失败:密码错误或数据损坏" + }, + "miniapp": { + "launchFailed": { + "stayOnDesktop": "启动失败:请停留在目标桌面页后重试", + "iconNotReady": "启动失败:图标未就绪,请返回桌面重试", + "containerNotReady": "启动失败:加载容器未就绪,请重试" + } + }, + "biometric": { + "unavailable": "生物识别不可用", + "verificationFailed": "生物识别验证失败" + }, + "query": { + "failed": "查询失败" + }, + "wallet": { + "incompleteInfo": "钱包信息不完整" } } diff --git a/src/i18n/locales/zh-TW/error.json b/src/i18n/locales/zh-TW/error.json index 3e34b611f..d07e2ddbe 100644 --- a/src/i18n/locales/zh-TW/error.json +++ b/src/i18n/locales/zh-TW/error.json @@ -18,7 +18,9 @@ "cannotTransferToSelf": "不能轉帳給自己", "enterAmount": "請輸入金額", "enterValidAmount": "請輸入有效金額", - "exceedsBalance": "銷毀數量不能大於餘額" + "exceedsBalance": "銷毀數量不能大於餘額", + "enterReceiverAddress": "請輸入收款地址", + "invalidAddress": "無效的地址格式" }, "transaction": { "failed": "交易失敗,請稍後重試", @@ -31,7 +33,9 @@ "chainConfigMissing": "鏈配置缺失", "unsupportedChainType": "不支持的鏈類型: {{chain}}", "issuerAddressNotFound": "無法獲取資產發行地址", - "issuerAddressNotReady": "資產發行地址未獲取" + "issuerAddressNotReady": "資產發行地址未獲取", + "insufficientGas": "手續費不足", + "retryLater": "交易失敗,請稍後重試" }, "crypto": { "keyDerivationFailed": "密鑰派生失敗", @@ -41,7 +45,10 @@ "biometricVerificationFailed": "生物識別驗證失敗", "webModeRequiresPassword": "Web 模式需要提供密碼", "dwebEnvironmentRequired": "需要在 DWEB 環境中訪問", - "mpayDecryptionFailed": "mpay 資料解密失敗:密碼錯誤或資料損壞" + "mpayDecryptionFailed": "mpay 資料解密失敗:密碼錯誤或資料損壞", + "unsupportedBitcoinPurpose": "不支援的 Bitcoin purpose: {{purpose}}", + "decryptFailed": "解密失敗:密碼錯誤或資料損壞", + "decryptKeyFailed": "解密失敗:密鑰錯誤或資料損壞" }, "address": { "generationFailed": "地址生成失敗" @@ -55,5 +62,30 @@ }, "duplicate": { "detectionFailed": "重複檢測失敗" + }, + "chain": { + "unsupportedType": "不支援的鏈類型: {{chain}}", + "notSupportSecurityPassword": "該鏈不支持安全密碼", + "transferNotImplemented": "該鏈轉賬功能尚未完全實現" + }, + "mpay": { + "decryptFailed": "mpay 資料解密失敗:密碼錯誤或資料損壞" + }, + "miniapp": { + "launchFailed": { + "stayOnDesktop": "啟動失敗:請停留在目標桌面頁後重試", + "iconNotReady": "啟動失敗:圖標未就緒,請返回桌面重試", + "containerNotReady": "啟動失敗:加載容器未就緒,請重試" + } + }, + "biometric": { + "unavailable": "生物識別不可用", + "verificationFailed": "生物識別驗證失敗" + }, + "query": { + "failed": "查詢失敗" + }, + "wallet": { + "incompleteInfo": "錢包信息不完整" } } diff --git a/src/lib/crypto/derivation.ts b/src/lib/crypto/derivation.ts index 72ba8a8bc..62625622e 100644 --- a/src/lib/crypto/derivation.ts +++ b/src/lib/crypto/derivation.ts @@ -1,6 +1,6 @@ /** * 密钥派生模块 - BIP32/BIP44 - * + * * 支持的链: * - Ethereum (m/44'/60'/0'/0/x) * - Bitcoin (m/44'/0'/0'/0/x) @@ -8,17 +8,20 @@ * - BFMeta (m/44'/9999'/0'/0/x) */ -import { HDKey } from '@scure/bip32' -import { mnemonicToSeedSync } from '@scure/bip39' -import { secp256k1 } from '@noble/curves/secp256k1.js' -import { keccak_256 } from '@noble/hashes/sha3.js' -import { sha256 } from '@noble/hashes/sha2.js' -import { ripemd160 } from '@noble/hashes/legacy.js' -import { bytesToHex } from '@noble/hashes/utils.js' +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { keccak_256 } from '@noble/hashes/sha3.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { ripemd160 } from '@noble/hashes/legacy.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); // ==================== 类型定义 ==================== -export type ChainType = 'ethereum' | 'bitcoin' | 'tron' | 'bfmeta' +export type ChainType = 'ethereum' | 'bitcoin' | 'tron' | 'bfmeta'; /** * Bitcoin BIP purpose codes @@ -27,21 +30,21 @@ export type ChainType = 'ethereum' | 'bitcoin' | 'tron' | 'bfmeta' * - 84: Native SegWit P2WPKH (starts with bc1q) * - 86: Taproot P2TR (starts with bc1p) */ -export type BitcoinPurpose = 44 | 49 | 84 | 86 +export type BitcoinPurpose = 44 | 49 | 84 | 86; export interface DerivedKey { /** 私钥 (hex) */ - privateKey: string + privateKey: string; /** 公钥 (hex, compressed) */ - publicKey: string + publicKey: string; /** 地址 */ - address: string + address: string; /** 派生路径 */ - path: string + path: string; /** 链类型 */ - chain: ChainType + chain: ChainType; /** Bitcoin purpose (only for bitcoin chain) */ - purpose?: BitcoinPurpose + purpose?: BitcoinPurpose; } // BIP44 coin types @@ -50,7 +53,7 @@ const COIN_TYPES: Record = { bitcoin: 0, tron: 195, bfmeta: 9999, -} +}; // ==================== 核心函数 ==================== @@ -58,43 +61,32 @@ const COIN_TYPES: Record = { * 从助记词派生 HD 密钥 */ export function deriveHDKey(mnemonic: string, password?: string): HDKey { - const seed = mnemonicToSeedSync(mnemonic, password) - return HDKey.fromMasterSeed(seed) + const seed = mnemonicToSeedSync(mnemonic, password); + return HDKey.fromMasterSeed(seed); } /** * 获取 BIP44 路径 * m / purpose' / coin_type' / account' / change / address_index */ -export function getBIP44Path( - chain: ChainType, - account = 0, - change = 0, - index = 0 -): string { - const coinType = COIN_TYPES[chain] - return `m/44'/${coinType}'/${account}'/${change}/${index}` +export function getBIP44Path(chain: ChainType, account = 0, change = 0, index = 0): string { + const coinType = COIN_TYPES[chain]; + return `m/44'/${coinType}'/${account}'/${change}/${index}`; } /** * 获取通用 BIP 路径(支持自定义 purpose) * m / purpose' / coin_type' / account' / change / address_index */ -export function getBIPPath( - purpose: number, - coinType: number, - account = 0, - change = 0, - index = 0 -): string { - return `m/${purpose}'/${coinType}'/${account}'/${change}/${index}` +export function getBIPPath(purpose: number, coinType: number, account = 0, change = 0, index = 0): string { + return `m/${purpose}'/${coinType}'/${account}'/${change}/${index}`; } /** * 从 HD 密钥派生子密钥 */ export function deriveChildKey(hdKey: HDKey, path: string): HDKey { - return hdKey.derive(path) + return hdKey.derive(path); } // ==================== 地址生成 ==================== @@ -104,59 +96,56 @@ export function deriveChildKey(hdKey: HDKey, path: string): HDKey { */ export function privateKeyToEthereumAddress(privateKey: Uint8Array): string { // 从私钥生成未压缩公钥(不带前缀 04) - const uncompressedPubKey = secp256k1.getPublicKey(privateKey, false) - const pubKeyWithoutPrefix = uncompressedPubKey.slice(1) // 去掉 04 前缀 + const uncompressedPubKey = secp256k1.getPublicKey(privateKey, false); + const pubKeyWithoutPrefix = uncompressedPubKey.slice(1); // 去掉 04 前缀 // Keccak256 哈希,取后 20 字节 - const hash = keccak_256(pubKeyWithoutPrefix) - const address = hash.slice(-20) as Uint8Array + const hash = keccak_256(pubKeyWithoutPrefix); + const address = hash.slice(-20) as Uint8Array; - return '0x' + bytesToHex(address) + return '0x' + bytesToHex(address); } /** * 以太坊地址校验和 (EIP-55) */ export function toChecksumAddress(address: string): string { - const addr = address.toLowerCase().replace('0x', '') + const addr = address.toLowerCase().replace('0x', ''); // 将字符串转换为 Uint8Array - const encoder = new TextEncoder() - const hash = bytesToHex(keccak_256(encoder.encode(addr))) + const encoder = new TextEncoder(); + const hash = bytesToHex(keccak_256(encoder.encode(addr))); - let checksumAddress = '0x' + let checksumAddress = '0x'; for (let i = 0; i < addr.length; i++) { if (parseInt(hash[i]!, 16) >= 8) { - checksumAddress += addr[i]!.toUpperCase() + checksumAddress += addr[i]!.toUpperCase(); } else { - checksumAddress += addr[i]! + checksumAddress += addr[i]!; } } - return checksumAddress + return checksumAddress; } /** * 从公钥生成比特币地址 (P2PKH, Legacy - BIP44) * Addresses start with '1' on mainnet */ -export function publicKeyToBitcoinAddress( - publicKey: Uint8Array, - network: 'mainnet' | 'testnet' = 'mainnet' -): string { +export function publicKeyToBitcoinAddress(publicKey: Uint8Array, network: 'mainnet' | 'testnet' = 'mainnet'): string { // SHA256 -> RIPEMD160 - const sha = sha256(publicKey) - const hash160 = ripemd160(sha) + const sha = sha256(publicKey); + const hash160 = ripemd160(sha); // 添加网络前缀 - const prefix = network === 'mainnet' ? 0x00 : 0x6f - const prefixed = new Uint8Array([prefix, ...hash160]) + const prefix = network === 'mainnet' ? 0x00 : 0x6f; + const prefixed = new Uint8Array([prefix, ...hash160]); // 双 SHA256 校验和 - const checksum = sha256(sha256(prefixed)).slice(0, 4) - const full = new Uint8Array([...prefixed, ...checksum]) + const checksum = sha256(sha256(prefixed)).slice(0, 4); + const full = new Uint8Array([...prefixed, ...checksum]); // Base58 编码 - return base58Encode(full) + return base58Encode(full); } /** @@ -165,28 +154,28 @@ export function publicKeyToBitcoinAddress( */ export function publicKeyToNestedSegwitAddress( publicKey: Uint8Array, - network: 'mainnet' | 'testnet' = 'mainnet' + network: 'mainnet' | 'testnet' = 'mainnet', ): string { // SHA256 -> RIPEMD160 of public key - const sha = sha256(publicKey) - const pubKeyHash = ripemd160(sha) + const sha = sha256(publicKey); + const pubKeyHash = ripemd160(sha); // Create witness script: OP_0 <20-byte-pubkey-hash> - const witnessScript = new Uint8Array([0x00, 0x14, ...pubKeyHash]) + const witnessScript = new Uint8Array([0x00, 0x14, ...pubKeyHash]); // Hash the witness script - const scriptSha = sha256(witnessScript) - const scriptHash = ripemd160(scriptSha) + const scriptSha = sha256(witnessScript); + const scriptHash = ripemd160(scriptSha); // Add P2SH prefix - const prefix = network === 'mainnet' ? 0x05 : 0xc4 - const prefixed = new Uint8Array([prefix, ...scriptHash]) + const prefix = network === 'mainnet' ? 0x05 : 0xc4; + const prefixed = new Uint8Array([prefix, ...scriptHash]); // Double SHA256 checksum - const checksum = sha256(sha256(prefixed)).slice(0, 4) - const full = new Uint8Array([...prefixed, ...checksum]) + const checksum = sha256(sha256(prefixed)).slice(0, 4); + const full = new Uint8Array([...prefixed, ...checksum]); - return base58Encode(full) + return base58Encode(full); } /** @@ -195,15 +184,15 @@ export function publicKeyToNestedSegwitAddress( */ export function publicKeyToNativeSegwitAddress( publicKey: Uint8Array, - network: 'mainnet' | 'testnet' = 'mainnet' + network: 'mainnet' | 'testnet' = 'mainnet', ): string { // SHA256 -> RIPEMD160 of public key - const sha = sha256(publicKey) - const pubKeyHash = ripemd160(sha) + const sha = sha256(publicKey); + const pubKeyHash = ripemd160(sha); // Bech32 encode with witness version 0 - const hrp = network === 'mainnet' ? 'bc' : 'tb' - return bech32Encode(hrp, 0, pubKeyHash) + const hrp = network === 'mainnet' ? 'bc' : 'tb'; + return bech32Encode(hrp, 0, pubKeyHash); } /** @@ -211,13 +200,10 @@ export function publicKeyToNativeSegwitAddress( * Addresses start with 'bc1p' on mainnet * Note: This is a simplified implementation that uses key-path only */ -export function publicKeyToTaprootAddress( - publicKey: Uint8Array, - network: 'mainnet' | 'testnet' = 'mainnet' -): string { +export function publicKeyToTaprootAddress(publicKey: Uint8Array, network: 'mainnet' | 'testnet' = 'mainnet'): string { // For Taproot, we need to convert to x-only public key (32 bytes) // and apply the taproot tweak - const xOnlyPubKey = publicKey.length === 33 ? publicKey.slice(1) : publicKey.slice(1, 33) + const xOnlyPubKey = publicKey.length === 33 ? publicKey.slice(1) : publicKey.slice(1, 33); // Apply taproot tweak (BIP341) // tweakedPubKey = pubKey + hash_tag("TapTweak", pubKey) * G @@ -228,8 +214,8 @@ export function publicKeyToTaprootAddress( // This is a simplified version - full implementation needs secp256k1 point operations // Bech32m encode with witness version 1 - const hrp = network === 'mainnet' ? 'bc' : 'tb' - return bech32mEncode(hrp, 1, xOnlyPubKey) + const hrp = network === 'mainnet' ? 'bc' : 'tb'; + return bech32mEncode(hrp, 1, xOnlyPubKey); } /** @@ -237,146 +223,146 @@ export function publicKeyToTaprootAddress( */ export function privateKeyToTronAddress(privateKey: Uint8Array): string { // 与以太坊类似,但前缀是 0x41 - const uncompressedPubKey = secp256k1.getPublicKey(privateKey, false) - const pubKeyWithoutPrefix = uncompressedPubKey.slice(1) - - const hash = keccak_256(pubKeyWithoutPrefix) - const addressBytes = hash.slice(-20) - + const uncompressedPubKey = secp256k1.getPublicKey(privateKey, false); + const pubKeyWithoutPrefix = uncompressedPubKey.slice(1); + + const hash = keccak_256(pubKeyWithoutPrefix); + const addressBytes = hash.slice(-20); + // 添加 Tron 前缀 0x41 - const prefixed = new Uint8Array([0x41, ...addressBytes]) - + const prefixed = new Uint8Array([0x41, ...addressBytes]); + // 双 SHA256 校验和 - const checksum = sha256(sha256(prefixed)).slice(0, 4) - const full = new Uint8Array([...prefixed, ...checksum]) - + const checksum = sha256(sha256(prefixed)).slice(0, 4); + const full = new Uint8Array([...prefixed, ...checksum]); + // Base58 编码 - return base58Encode(full) + return base58Encode(full); } // ==================== Base58 编码 ==================== -const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; function base58Encode(bytes: Uint8Array): string { - const digits = [0] + const digits = [0]; for (const byte of bytes) { - let carry = byte + let carry = byte; for (let i = 0; i < digits.length; i++) { - carry += digits[i]! << 8 - digits[i] = carry % 58 - carry = (carry / 58) | 0 + carry += digits[i]! << 8; + digits[i] = carry % 58; + carry = (carry / 58) | 0; } while (carry > 0) { - digits.push(carry % 58) - carry = (carry / 58) | 0 + digits.push(carry % 58); + carry = (carry / 58) | 0; } } // 处理前导零 - let result = '' + let result = ''; for (const byte of bytes) { - if (byte === 0) result += BASE58_ALPHABET[0]! - else break + if (byte === 0) result += BASE58_ALPHABET[0]!; + else break; } // 反转并转换 for (let i = digits.length - 1; i >= 0; i--) { - result += BASE58_ALPHABET[digits[i]!] + result += BASE58_ALPHABET[digits[i]!]; } - return result + return result; } // ==================== Bech32/Bech32m 编码 ==================== -const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' +const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; -const BECH32_GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] +const BECH32_GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; function bech32Polymod(values: number[]): number { - let chk = 1 + let chk = 1; for (const v of values) { - const top = chk >> 25 - chk = ((chk & 0x1ffffff) << 5) ^ v + const top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; for (let i = 0; i < 5; i++) { if ((top >> i) & 1) { - chk ^= BECH32_GENERATOR[i]! + chk ^= BECH32_GENERATOR[i]!; } } } - return chk + return chk; } function bech32HrpExpand(hrp: string): number[] { - const ret: number[] = [] + const ret: number[] = []; for (const c of hrp) { - ret.push(c.charCodeAt(0) >> 5) + ret.push(c.charCodeAt(0) >> 5); } - ret.push(0) + ret.push(0); for (const c of hrp) { - ret.push(c.charCodeAt(0) & 31) + ret.push(c.charCodeAt(0) & 31); } - return ret + return ret; } function bech32CreateChecksum(hrp: string, data: number[], isBech32m: boolean): number[] { - const values = [...bech32HrpExpand(hrp), ...data] + const values = [...bech32HrpExpand(hrp), ...data]; // Bech32m uses 0x2bc830a3, Bech32 uses 1 - const constant = isBech32m ? 0x2bc830a3 : 1 - const polymod = bech32Polymod([...values, 0, 0, 0, 0, 0, 0]) ^ constant - const ret: number[] = [] + const constant = isBech32m ? 0x2bc830a3 : 1; + const polymod = bech32Polymod([...values, 0, 0, 0, 0, 0, 0]) ^ constant; + const ret: number[] = []; for (let i = 0; i < 6; i++) { - ret.push((polymod >> (5 * (5 - i))) & 31) + ret.push((polymod >> (5 * (5 - i))) & 31); } - return ret + return ret; } function convertBits(data: Uint8Array, fromBits: number, toBits: number, pad: boolean): number[] { - let acc = 0 - let bits = 0 - const ret: number[] = [] - const maxv = (1 << toBits) - 1 + let acc = 0; + let bits = 0; + const ret: number[] = []; + const maxv = (1 << toBits) - 1; for (const value of data) { - acc = (acc << fromBits) | value - bits += fromBits + acc = (acc << fromBits) | value; + bits += fromBits; while (bits >= toBits) { - bits -= toBits - ret.push((acc >> bits) & maxv) + bits -= toBits; + ret.push((acc >> bits) & maxv); } } if (pad) { if (bits > 0) { - ret.push((acc << (toBits - bits)) & maxv) + ret.push((acc << (toBits - bits)) & maxv); } } - return ret + return ret; } /** * Bech32 编码 (用于 P2WPKH - witness version 0) */ function bech32Encode(hrp: string, witnessVersion: number, data: Uint8Array): string { - const converted = convertBits(data, 8, 5, true) - const combined = [witnessVersion, ...converted] - const checksum = bech32CreateChecksum(hrp, combined, false) - const encoded = [...combined, ...checksum].map((d) => BECH32_CHARSET[d]).join('') - return `${hrp}1${encoded}` + const converted = convertBits(data, 8, 5, true); + const combined = [witnessVersion, ...converted]; + const checksum = bech32CreateChecksum(hrp, combined, false); + const encoded = [...combined, ...checksum].map((d) => BECH32_CHARSET[d]).join(''); + return `${hrp}1${encoded}`; } /** * Bech32m 编码 (用于 P2TR - witness version 1+) */ function bech32mEncode(hrp: string, witnessVersion: number, data: Uint8Array): string { - const converted = convertBits(data, 8, 5, true) - const combined = [witnessVersion, ...converted] - const checksum = bech32CreateChecksum(hrp, combined, true) - const encoded = [...combined, ...checksum].map((d) => BECH32_CHARSET[d]).join('') - return `${hrp}1${encoded}` + const converted = convertBits(data, 8, 5, true); + const combined = [witnessVersion, ...converted]; + const checksum = bech32CreateChecksum(hrp, combined, true); + const encoded = [...combined, ...checksum].map((d) => BECH32_CHARSET[d]).join(''); + return `${hrp}1${encoded}`; } // ==================== 主要 API ==================== @@ -384,40 +370,35 @@ function bech32mEncode(hrp: string, witnessVersion: number, data: Uint8Array): s /** * 从助记词派生指定链的密钥 */ -export function deriveKey( - mnemonic: string, - chain: ChainType, - index = 0, - account = 0 -): DerivedKey { - const hdKey = deriveHDKey(mnemonic) - const path = getBIP44Path(chain, account, 0, index) - const childKey = deriveChildKey(hdKey, path) +export function deriveKey(mnemonic: string, chain: ChainType, index = 0, account = 0): DerivedKey { + const hdKey = deriveHDKey(mnemonic); + const path = getBIP44Path(chain, account, 0, index); + const childKey = deriveChildKey(hdKey, path); if (!childKey.privateKey || !childKey.publicKey) { - throw new Error('密钥派生失败') + throw new Error(t('error:crypto.keyDerivationFailed')); } - const privateKey = bytesToHex(childKey.privateKey) - const publicKey = bytesToHex(childKey.publicKey) + const privateKey = bytesToHex(childKey.privateKey); + const publicKey = bytesToHex(childKey.publicKey); - let address: string + let address: string; switch (chain) { case 'ethereum': - address = toChecksumAddress(privateKeyToEthereumAddress(childKey.privateKey)) - break + address = toChecksumAddress(privateKeyToEthereumAddress(childKey.privateKey)); + break; case 'bitcoin': - address = publicKeyToBitcoinAddress(childKey.publicKey) - break + address = publicKeyToBitcoinAddress(childKey.publicKey); + break; case 'tron': - address = privateKeyToTronAddress(childKey.privateKey) - break + address = privateKeyToTronAddress(childKey.privateKey); + break; case 'bfmeta': // BFMeta 使用与以太坊类似的地址格式 - address = toChecksumAddress(privateKeyToEthereumAddress(childKey.privateKey)) - break + address = toChecksumAddress(privateKeyToEthereumAddress(childKey.privateKey)); + break; default: - throw new Error(`不支持的链类型: ${chain}`) + throw new Error(t('error:chain.unsupportedType', { chain })); } return { @@ -426,7 +407,7 @@ export function deriveKey( address, path, chain, - } + }; } /** @@ -437,35 +418,35 @@ export function deriveBitcoinKey( purpose: BitcoinPurpose = 44, index = 0, account = 0, - network: 'mainnet' | 'testnet' = 'mainnet' + network: 'mainnet' | 'testnet' = 'mainnet', ): DerivedKey { - const hdKey = deriveHDKey(mnemonic) - const path = getBIPPath(purpose, COIN_TYPES.bitcoin, account, 0, index) - const childKey = deriveChildKey(hdKey, path) + const hdKey = deriveHDKey(mnemonic); + const path = getBIPPath(purpose, COIN_TYPES.bitcoin, account, 0, index); + const childKey = deriveChildKey(hdKey, path); if (!childKey.privateKey || !childKey.publicKey) { - throw new Error('密钥派生失败') + throw new Error(t('error:crypto.keyDerivationFailed')); } - const privateKey = bytesToHex(childKey.privateKey) - const publicKey = bytesToHex(childKey.publicKey) + const privateKey = bytesToHex(childKey.privateKey); + const publicKey = bytesToHex(childKey.publicKey); - let address: string + let address: string; switch (purpose) { case 44: - address = publicKeyToBitcoinAddress(childKey.publicKey, network) - break + address = publicKeyToBitcoinAddress(childKey.publicKey, network); + break; case 49: - address = publicKeyToNestedSegwitAddress(childKey.publicKey, network) - break + address = publicKeyToNestedSegwitAddress(childKey.publicKey, network); + break; case 84: - address = publicKeyToNativeSegwitAddress(childKey.publicKey, network) - break + address = publicKeyToNativeSegwitAddress(childKey.publicKey, network); + break; case 86: - address = publicKeyToTaprootAddress(childKey.publicKey, network) - break + address = publicKeyToTaprootAddress(childKey.publicKey, network); + break; default: - throw new Error(`不支持的 Bitcoin purpose: ${purpose}`) + throw new Error(t('error:crypto.unsupportedBitcoinPurpose', { purpose: String(purpose) })); } return { @@ -475,7 +456,7 @@ export function deriveBitcoinKey( path, chain: 'bitcoin', purpose, - } + }; } /** @@ -485,10 +466,10 @@ export function deriveAllBitcoinKeys( mnemonic: string, index = 0, account = 0, - network: 'mainnet' | 'testnet' = 'mainnet' + network: 'mainnet' | 'testnet' = 'mainnet', ): DerivedKey[] { - const purposes: BitcoinPurpose[] = [44, 49, 84, 86] - return purposes.map((purpose) => deriveBitcoinKey(mnemonic, purpose, index, account, network)) + const purposes: BitcoinPurpose[] = [44, 49, 84, 86]; + return purposes.map((purpose) => deriveBitcoinKey(mnemonic, purpose, index, account, network)); } /** @@ -497,35 +478,31 @@ export function deriveAllBitcoinKeys( export function deriveMultiChainKeys( mnemonic: string, chains: ChainType[] = ['ethereum', 'bitcoin', 'tron'], - index = 0 + index = 0, ): DerivedKey[] { - return chains.map((chain) => deriveKey(mnemonic, chain, index)) + return chains.map((chain) => deriveKey(mnemonic, chain, index)); } /** * 从助记词派生所有可能的地址(用于重复检测) * 包括:ETH、BFMeta、Tron、以及 Bitcoin 的所有 purpose */ -export function deriveAllAddresses( - mnemonic: string, - index = 0, - account = 0 -): DerivedKey[] { - const results: DerivedKey[] = [] +export function deriveAllAddresses(mnemonic: string, index = 0, account = 0): DerivedKey[] { + const results: DerivedKey[] = []; // Ethereum - results.push(deriveKey(mnemonic, 'ethereum', index, account)) + results.push(deriveKey(mnemonic, 'ethereum', index, account)); // BFMeta - results.push(deriveKey(mnemonic, 'bfmeta', index, account)) + results.push(deriveKey(mnemonic, 'bfmeta', index, account)); // Tron - results.push(deriveKey(mnemonic, 'tron', index, account)) + results.push(deriveKey(mnemonic, 'tron', index, account)); // Bitcoin (all purposes) - results.push(...deriveAllBitcoinKeys(mnemonic, index, account)) + results.push(...deriveAllBitcoinKeys(mnemonic, index, account)); - return results + return results; } /** @@ -534,16 +511,16 @@ export function deriveAllAddresses( * 返回 32 字节的私钥作为加密密钥 */ export function deriveEncryptionKeyFromMnemonic(mnemonic: string): Uint8Array { - const hdKey = deriveHDKey(mnemonic) + const hdKey = deriveHDKey(mnemonic); // 使用特殊路径:change=1 用于加密密钥派生,区别于正常的 change=0 - const path = "m/44'/9999'/0'/1/0" - const childKey = deriveChildKey(hdKey, path) - + const path = "m/44'/9999'/0'/1/0"; + const childKey = deriveChildKey(hdKey, path); + if (!childKey.privateKey) { - throw new Error('密钥派生失败') + throw new Error(t('error:crypto.keyDerivationFailed')); } - - return childKey.privateKey + + return childKey.privateKey; } /** @@ -552,10 +529,10 @@ export function deriveEncryptionKeyFromMnemonic(mnemonic: string): Uint8Array { * 返回 32 字节的密钥 */ export function deriveEncryptionKeyFromSecret(secret: string): Uint8Array { - const encoder = new TextEncoder() + const encoder = new TextEncoder(); // 使用固定盐值前缀确保密钥唯一性 - const saltedSecret = encoder.encode(`KeyApp:EncryptionKey:${secret}`) - return sha256(saltedSecret) + const saltedSecret = encoder.encode(`KeyApp:EncryptionKey:${secret}`); + return sha256(saltedSecret); } /** @@ -565,17 +542,17 @@ export function isValidAddress(address: string, chain: ChainType): boolean { switch (chain) { case 'ethereum': case 'bfmeta': - return /^0x[a-fA-F0-9]{40}$/.test(address) + return /^0x[a-fA-F0-9]{40}$/.test(address); case 'bitcoin': // Legacy (1...), Nested SegWit (3...), Native SegWit (bc1q...), Taproot (bc1p...) return ( /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(address) || /^bc1q[a-z0-9]{38,}$/.test(address) || /^bc1p[a-z0-9]{58}$/.test(address) - ) + ); case 'tron': - return /^T[a-zA-Z0-9]{33}$/.test(address) + return /^T[a-zA-Z0-9]{33}$/.test(address); default: - return false + return false; } } diff --git a/src/lib/crypto/encryption.ts b/src/lib/crypto/encryption.ts index bbef1178c..59b9f3583 100644 --- a/src/lib/crypto/encryption.ts +++ b/src/lib/crypto/encryption.ts @@ -1,46 +1,41 @@ /** * 密码加密模块 - 使用 AES-GCM + PBKDF2 - * + * * 安全设计: * 1. PBKDF2 从密码派生密钥(防止暴力破解) * 2. AES-256-GCM 加密数据(认证加密) * 3. 随机 salt 和 iv(防止彩虹表攻击) */ -const PBKDF2_ITERATIONS = 100000 -const SALT_LENGTH = 16 -const IV_LENGTH = 12 -const KEY_LENGTH = 256 +const PBKDF2_ITERATIONS = 100000; +const SALT_LENGTH = 16; +const IV_LENGTH = 12; +const KEY_LENGTH = 256; + +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); interface EncryptedData { /** Base64 编码的密文 */ - ciphertext: string + ciphertext: string; /** Base64 编码的 salt */ - salt: string + salt: string; /** Base64 编码的 iv */ - iv: string + iv: string; /** 迭代次数 */ - iterations: number + iterations: number; } /** * 从密码派生加密密钥 */ -async function deriveKey( - password: string, - salt: Uint8Array -): Promise { - const encoder = new TextEncoder() - const passwordBuffer = encoder.encode(password) +async function deriveKey(password: string, salt: Uint8Array): Promise { + const encoder = new TextEncoder(); + const passwordBuffer = encoder.encode(password); // 导入密码作为原始密钥 - const passwordKey = await crypto.subtle.importKey( - 'raw', - passwordBuffer, - 'PBKDF2', - false, - ['deriveKey'] - ) + const passwordKey = await crypto.subtle.importKey('raw', passwordBuffer, 'PBKDF2', false, ['deriveKey']); // 使用 PBKDF2 派生 AES 密钥 return crypto.subtle.deriveKey( @@ -53,8 +48,8 @@ async function deriveKey( passwordKey, { name: 'AES-GCM', length: KEY_LENGTH }, false, - ['encrypt', 'decrypt'] - ) + ['encrypt', 'decrypt'], + ); } /** @@ -63,33 +58,26 @@ async function deriveKey( * @param password 密码 * @returns 加密后的数据对象 */ -export async function encrypt( - plaintext: string, - password: string -): Promise { - const encoder = new TextEncoder() - const data = encoder.encode(plaintext) +export async function encrypt(plaintext: string, password: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(plaintext); // 生成随机 salt 和 iv - const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)) - const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) + const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); // 派生密钥 - const key = await deriveKey(password, salt) + const key = await deriveKey(password, salt); // AES-GCM 加密 - const ciphertext = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - key, - data - ) + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); return { ciphertext: arrayBufferToBase64(ciphertext), salt: arrayBufferToBase64(salt), iv: arrayBufferToBase64(iv), iterations: PBKDF2_ITERATIONS, - } + }; } /** @@ -99,44 +87,38 @@ export async function encrypt( * @returns 解密后的明文 * @throws 密码错误时抛出异常 */ -export async function decrypt( - encrypted: EncryptedData, - password: string -): Promise { - const salt = base64ToUint8Array(encrypted.salt) - const iv = base64ToUint8Array(encrypted.iv) - const ciphertext = base64ToUint8Array(encrypted.ciphertext) +export async function decrypt(encrypted: EncryptedData, password: string): Promise { + const salt = base64ToUint8Array(encrypted.salt); + const iv = base64ToUint8Array(encrypted.iv); + const ciphertext = base64ToUint8Array(encrypted.ciphertext); // 派生密钥 - const key = await deriveKey(password, salt) + const key = await deriveKey(password, salt); try { // AES-GCM 解密 const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv as BufferSource }, key, - ciphertext as BufferSource - ) + ciphertext as BufferSource, + ); - const decoder = new TextDecoder() - return decoder.decode(plaintext) + const decoder = new TextDecoder(); + return decoder.decode(plaintext); } catch { - throw new Error('解密失败:密码错误或数据损坏') + throw new Error(t('error:crypto.decryptFailed')); } } /** * 验证密码是否正确(尝试解密) */ -export async function verifyPassword( - encrypted: EncryptedData, - password: string -): Promise { +export async function verifyPassword(encrypted: EncryptedData, password: string): Promise { try { - await decrypt(encrypted, password) - return true + await decrypt(encrypted, password); + return true; } catch { - return false + return false; } } @@ -144,15 +126,12 @@ export async function verifyPassword( * 使用原始密钥加密(不使用 PBKDF2) * 用于从助记词派生的密钥直接加密 */ -export async function encryptWithRawKey( - plaintext: string, - rawKey: Uint8Array -): Promise { - const encoder = new TextEncoder() - const data = encoder.encode(plaintext) +export async function encryptWithRawKey(plaintext: string, rawKey: Uint8Array): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(plaintext); // 生成随机 iv(不需要 salt,因为密钥已经是完整的) - const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); // 导入原始密钥 const key = await crypto.subtle.importKey( @@ -160,33 +139,30 @@ export async function encryptWithRawKey( rawKey.buffer.slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength) as ArrayBuffer, { name: 'AES-GCM', length: KEY_LENGTH }, false, - ['encrypt'] - ) + ['encrypt'], + ); // AES-GCM 加密 const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv as BufferSource }, key, - data as BufferSource - ) + data as BufferSource, + ); return { ciphertext: arrayBufferToBase64(ciphertext), salt: '', // 不需要 salt iv: arrayBufferToBase64(iv), iterations: 0, // 标记为使用原始密钥 - } + }; } /** * 使用原始密钥解密 */ -export async function decryptWithRawKey( - encrypted: EncryptedData, - rawKey: Uint8Array -): Promise { - const iv = base64ToUint8Array(encrypted.iv) - const ciphertext = base64ToUint8Array(encrypted.ciphertext) +export async function decryptWithRawKey(encrypted: EncryptedData, rawKey: Uint8Array): Promise { + const iv = base64ToUint8Array(encrypted.iv); + const ciphertext = base64ToUint8Array(encrypted.ciphertext); // 导入原始密钥 const key = await crypto.subtle.importKey( @@ -194,43 +170,43 @@ export async function decryptWithRawKey( rawKey.buffer.slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength) as ArrayBuffer, { name: 'AES-GCM', length: KEY_LENGTH }, false, - ['decrypt'] - ) + ['decrypt'], + ); try { // AES-GCM 解密 const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv as BufferSource }, key, - ciphertext as BufferSource - ) + ciphertext as BufferSource, + ); - const decoder = new TextDecoder() - return decoder.decode(plaintext) + const decoder = new TextDecoder(); + return decoder.decode(plaintext); } catch { - throw new Error('解密失败:密钥错误或数据损坏') + throw new Error(t('error:crypto.decryptKeyFailed')); } } // 工具函数:ArrayBuffer 转 Base64 function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string { - const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) - let binary = '' + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]!) + binary += String.fromCharCode(bytes[i]!); } - return btoa(binary) + return btoa(binary); } // 工具函数:Base64 转 Uint8Array function base64ToUint8Array(base64: string): Uint8Array { - const binary = atob(base64) - const bytes = new Uint8Array(binary.length) + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) + bytes[i] = binary.charCodeAt(i); } - return bytes + return bytes; } // 导出类型 -export type { EncryptedData } +export type { EncryptedData }; diff --git a/src/lib/crypto/secure-storage.ts b/src/lib/crypto/secure-storage.ts index 131e55c08..bb969f1d4 100644 --- a/src/lib/crypto/secure-storage.ts +++ b/src/lib/crypto/secure-storage.ts @@ -1,71 +1,74 @@ /** * 安全存储抽象层 - * + * * - DWEB 环境:使用原生生物识别 + Keychain/Keystore * - Web 环境:使用 AES-GCM 密码加密 + localStorage */ -import { encrypt, decrypt, type EncryptedData } from './encryption' +import { encrypt, decrypt, type EncryptedData } from './encryption'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); // ==================== 类型定义 ==================== export interface BiometricOptions { - title?: string | undefined - subtitle?: string | undefined - negativeButtonText?: string | undefined - maxAttempts?: number | undefined + title?: string | undefined; + subtitle?: string | undefined; + negativeButtonText?: string | undefined; + maxAttempts?: number | undefined; } export interface BiometricResult { - success: boolean - code?: number | undefined - errorDetail?: string | undefined + success: boolean; + code?: number | undefined; + errorDetail?: string | undefined; } export interface SecureStorageOptions { /** 使用密码加密(Web 模式) */ - password?: string | undefined + password?: string | undefined; /** 使用生物识别(DWEB 模式) */ - useBiometric?: boolean | undefined + useBiometric?: boolean | undefined; /** 生物识别选项 */ - biometricOptions?: BiometricOptions | undefined + biometricOptions?: BiometricOptions | undefined; } export interface StoredData { /** 存储类型 */ - type: 'web-encrypted' | 'dweb-biometric' + type: 'web-encrypted' | 'dweb-biometric'; /** 加密数据(Web 模式) */ - encrypted?: EncryptedData | undefined + encrypted?: EncryptedData | undefined; /** 原始数据(DWEB 模式,由系统保护) */ - data?: string | undefined + data?: string | undefined; } // ==================== 环境检测 ==================== /** 检测是否在 DWEB 环境 */ export function isDwebEnvironment(): boolean { - return typeof window !== 'undefined' && 'dwebTarget' in window + return typeof window !== 'undefined' && 'dwebTarget' in window; } /** DWEB biometric 插件类型 */ interface DwebBiometricPlugin { - check(): Promise - biometrics(): Promise<{ success: boolean; message?: string }> + check(): Promise; + biometrics(): Promise<{ success: boolean; message?: string }>; } /** BioetricsCheckResult 枚举值 */ -const BIOMETRIC_SUCCESS = 0 +const BIOMETRIC_SUCCESS = 0; /** 动态导入 DWEB biometric 插件 */ async function getDwebBiometric(): Promise { - if (!isDwebEnvironment()) return null + if (!isDwebEnvironment()) return null; try { // 使用变量规避 Vite 静态分析 - const moduleName = '@plaoc/plugins' - const module = await import(/* @vite-ignore */ moduleName) - return module.biometricsPlugin as DwebBiometricPlugin + const moduleName = '@plaoc/plugins'; + const module = await import(/* @vite-ignore */ moduleName); + return module.biometricsPlugin as DwebBiometricPlugin; } catch { - return null + return null; } } @@ -74,50 +77,50 @@ async function getDwebBiometric(): Promise { export const BiometricAuth = { /** 检查生物识别是否可用 */ async isAvailable(): Promise { - const plugin = await getDwebBiometric() - if (!plugin) return false - + const plugin = await getDwebBiometric(); + if (!plugin) return false; + try { - const result = await plugin.check() - return result === BIOMETRIC_SUCCESS + const result = await plugin.check(); + return result === BIOMETRIC_SUCCESS; } catch { - return false + return false; } }, /** 验证生物识别 */ async authenticate(_options: BiometricOptions = {}): Promise { - const plugin = await getDwebBiometric() + const plugin = await getDwebBiometric(); if (!plugin) { - return { success: false, code: -1, errorDetail: '生物识别不可用' } + return { success: false, code: -1, errorDetail: t('error:biometric.unavailable') }; } try { - const result = await plugin.biometrics() - return { success: result.success, code: result.success ? undefined : 0 } + const result = await plugin.biometrics(); + return { success: result.success, code: result.success ? undefined : 0 }; } catch (err) { - const error = err as Error & { data?: { errorCode: string; errorDetails: string } } - + const error = err as Error & { data?: { errorCode: string; errorDetails: string } }; + if (error.message?.toLowerCase().includes('authentication failed')) { - return { success: false, code: 0 } + return { success: false, code: 0 }; } - + if (error.data) { return { success: false, code: parseInt(error.data.errorCode), errorDetail: error.data.errorDetails, - } + }; } - - return { success: false, code: -1, errorDetail: error.message } + + return { success: false, code: -1, errorDetail: error.message }; } }, -} +}; // ==================== 安全存储 API ==================== -const STORAGE_PREFIX = 'secure:' +const STORAGE_PREFIX = 'secure:'; export const SecureStorage = { /** 检查安全存储是否可用 */ @@ -125,112 +128,105 @@ export const SecureStorage = { return { web: typeof localStorage !== 'undefined', dweb: await BiometricAuth.isAvailable(), - } + }; }, /** 存储数据 */ - async store( - key: string, - data: string, - options: SecureStorageOptions - ): Promise { - const storageKey = STORAGE_PREFIX + key - + async store(key: string, data: string, options: SecureStorageOptions): Promise { + const storageKey = STORAGE_PREFIX + key; + // DWEB 模式:使用生物识别保护 if (options.useBiometric && isDwebEnvironment()) { - const authResult = await BiometricAuth.authenticate(options.biometricOptions) + const authResult = await BiometricAuth.authenticate(options.biometricOptions); if (!authResult.success) { - throw new Error('生物识别验证失败') + throw new Error(t('error:biometric.verificationFailed')); } - + // DWEB 环境下,数据由系统 Keychain/Keystore 保护 // 这里简化处理,实际生产环境应使用 @plaoc/plugins 的 secure storage - const stored: StoredData = { type: 'dweb-biometric', data } - localStorage.setItem(storageKey, JSON.stringify(stored)) - return + const stored: StoredData = { type: 'dweb-biometric', data }; + localStorage.setItem(storageKey, JSON.stringify(stored)); + return; } // Web 模式:使用密码加密 if (!options.password) { - throw new Error('Web 模式需要提供密码') + throw new Error('Web 模式需要提供密码'); } - const encrypted = await encrypt(data, options.password) - const stored: StoredData = { type: 'web-encrypted', encrypted } - localStorage.setItem(storageKey, JSON.stringify(stored)) + const encrypted = await encrypt(data, options.password); + const stored: StoredData = { type: 'web-encrypted', encrypted }; + localStorage.setItem(storageKey, JSON.stringify(stored)); }, /** 读取数据 */ - async retrieve( - key: string, - options: SecureStorageOptions - ): Promise { - const storageKey = STORAGE_PREFIX + key - const raw = localStorage.getItem(storageKey) - - if (!raw) return null + async retrieve(key: string, options: SecureStorageOptions): Promise { + const storageKey = STORAGE_PREFIX + key; + const raw = localStorage.getItem(storageKey); + + if (!raw) return null; try { - const stored: StoredData = JSON.parse(raw) + const stored: StoredData = JSON.parse(raw); // DWEB 模式 if (stored.type === 'dweb-biometric') { if (!isDwebEnvironment()) { - throw new Error('需要在 DWEB 环境中访问') + throw new Error('需要在 DWEB 环境中访问'); } - - const authResult = await BiometricAuth.authenticate(options.biometricOptions) + + const authResult = await BiometricAuth.authenticate(options.biometricOptions); if (!authResult.success) { - throw new Error('生物识别验证失败') + throw new Error(t('error:biometric.verificationFailed')); } - - return stored.data ?? null + + return stored.data ?? null; } // Web 模式 if (stored.type === 'web-encrypted' && stored.encrypted) { if (!options.password) { - throw new Error('需要提供密码解密') + throw new Error('需要提供密码解密'); } - return await decrypt(stored.encrypted, options.password) + return await decrypt(stored.encrypted, options.password); } - return null + return null; } catch (err) { if (err instanceof SyntaxError) { // 兼容旧数据格式 - return null + return null; } - throw err + throw err; } }, /** 删除数据 */ async delete(key: string): Promise { - const storageKey = STORAGE_PREFIX + key - localStorage.removeItem(storageKey) + const storageKey = STORAGE_PREFIX + key; + localStorage.removeItem(storageKey); }, /** 检查数据是否存在 */ async exists(key: string): Promise { - const storageKey = STORAGE_PREFIX + key - return localStorage.getItem(storageKey) !== null + const storageKey = STORAGE_PREFIX + key; + return localStorage.getItem(storageKey) !== null; }, /** 获取存储类型 */ async getType(key: string): Promise { - const storageKey = STORAGE_PREFIX + key - const raw = localStorage.getItem(storageKey) - if (!raw) return null - + const storageKey = STORAGE_PREFIX + key; + const raw = localStorage.getItem(storageKey); + if (!raw) return null; + try { - const stored: StoredData = JSON.parse(raw) - return stored.type + const stored: StoredData = JSON.parse(raw); + return stored.type; } catch { - return null + return null; } }, -} +}; // ==================== 便捷函数 ==================== @@ -238,10 +234,10 @@ export const SecureStorage = { export async function storeMnemonic( walletId: string, mnemonic: string, - options: { password?: string; preferBiometric?: boolean } = {} + options: { password?: string; preferBiometric?: boolean } = {}, ): Promise { - const { dweb } = await SecureStorage.isAvailable() - + const { dweb } = await SecureStorage.isAvailable(); + // 优先使用生物识别(如果可用且用户偏好) if (dweb && options.preferBiometric !== false) { await SecureStorage.store(`mnemonic:${walletId}`, mnemonic, { @@ -250,27 +246,24 @@ export async function storeMnemonic( title: '验证身份', subtitle: '使用生物识别保护钱包', }, - }) - return + }); + return; } // 否则使用密码加密 if (!options.password) { - throw new Error('需要提供密码') + throw new Error('需要提供密码'); } - + await SecureStorage.store(`mnemonic:${walletId}`, mnemonic, { password: options.password, - }) + }); } /** 读取助记词 */ -export async function retrieveMnemonic( - walletId: string, - options: { password?: string } = {} -): Promise { - const type = await SecureStorage.getType(`mnemonic:${walletId}`) - +export async function retrieveMnemonic(walletId: string, options: { password?: string } = {}): Promise { + const type = await SecureStorage.getType(`mnemonic:${walletId}`); + if (type === 'dweb-biometric') { return SecureStorage.retrieve(`mnemonic:${walletId}`, { useBiometric: true, @@ -278,10 +271,10 @@ export async function retrieveMnemonic( title: '验证身份', subtitle: '访问钱包助记词', }, - }) + }); } return SecureStorage.retrieve(`mnemonic:${walletId}`, { password: options.password, - }) + }); } diff --git a/src/services/migration/mpay-crypto.ts b/src/services/migration/mpay-crypto.ts index be0d523dc..e2941f261 100644 --- a/src/services/migration/mpay-crypto.ts +++ b/src/services/migration/mpay-crypto.ts @@ -7,54 +7,51 @@ * 此模块提供 mpay 数据解密能力 */ -import { validateMnemonic } from '@/lib/crypto/mnemonic' +import { validateMnemonic } from '@/lib/crypto/mnemonic'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); /** * SHA256 哈希 */ async function sha256Binary(data: ArrayBuffer): Promise { - return crypto.subtle.digest('SHA-256', data) + return crypto.subtle.digest('SHA-256', data); } /** * UTF8 字符串转 ArrayBuffer */ function encodeUTF8(text: string): ArrayBuffer { - return new TextEncoder().encode(text).buffer as ArrayBuffer + return new TextEncoder().encode(text).buffer as ArrayBuffer; } /** * ArrayBuffer 转 UTF8 字符串 */ function decodeUTF8(data: ArrayBuffer): string { - return new TextDecoder().decode(data) + return new TextDecoder().decode(data); } /** * Base64 转 ArrayBuffer */ function base64ToArrayBuffer(base64: string): ArrayBuffer { - const binary = atob(base64) - const bytes = new Uint8Array(binary.length) + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) + bytes[i] = binary.charCodeAt(i); } - return bytes.buffer as ArrayBuffer + return bytes.buffer as ArrayBuffer; } /** * 获取 mpay 格式的 AES-CTR 密钥 */ async function getMpayCryptoKey(password: string): Promise { - const passwordBuffer = encodeUTF8(password) - const keyMaterial = await sha256Binary(passwordBuffer) - return crypto.subtle.importKey( - 'raw', - keyMaterial, - { name: 'AES-CTR', length: 256 }, - false, - ['decrypt'] - ) + const passwordBuffer = encodeUTF8(password); + const keyMaterial = await sha256Binary(passwordBuffer); + return crypto.subtle.importKey('raw', keyMaterial, { name: 'AES-CTR', length: 256 }, false, ['decrypt']); } /** @@ -67,23 +64,16 @@ async function getMpayCryptoKey(password: string): Promise { * @returns 解密后的明文 * @throws 密码错误时抛出异常 */ -export async function decryptMpayData( - password: string, - encryptedBase64: string -): Promise { - const key = await getMpayCryptoKey(password) - const encrypted = base64ToArrayBuffer(encryptedBase64) - const counter = new Uint8Array(16) // mpay 使用全零 counter +export async function decryptMpayData(password: string, encryptedBase64: string): Promise { + const key = await getMpayCryptoKey(password); + const encrypted = base64ToArrayBuffer(encryptedBase64); + const counter = new Uint8Array(16); // mpay 使用全零 counter try { - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-CTR', counter, length: 128 }, - key, - encrypted - ) - return decodeUTF8(decrypted) + const decrypted = await crypto.subtle.decrypt({ name: 'AES-CTR', counter, length: 128 }, key, encrypted); + return decodeUTF8(decrypted); } catch { - throw new Error('mpay 数据解密失败:密码错误或数据损坏') + throw new Error(t('error:mpay.decryptFailed')); } } @@ -94,15 +84,12 @@ export async function decryptMpayData( * @param encryptedBase64 mpay 加密的数据(通常是 importPhrase) * @returns 密码是否正确 */ -export async function verifyMpayPassword( - password: string, - encryptedBase64: string -): Promise { +export async function verifyMpayPassword(password: string, encryptedBase64: string): Promise { try { - const decrypted = await decryptMpayData(password, encryptedBase64) - const words = decrypted.trim().split(/\s+/) - return validateMnemonic(words) + const decrypted = await decryptMpayData(password, encryptedBase64); + const words = decrypted.trim().split(/\s+/); + return validateMnemonic(words); } catch { - return false + return false; } } diff --git a/src/services/migration/mpay-transformer.ts b/src/services/migration/mpay-transformer.ts index abcd280fc..85387c195 100644 --- a/src/services/migration/mpay-transformer.ts +++ b/src/services/migration/mpay-transformer.ts @@ -4,25 +4,24 @@ * 将 mpay 数据格式转换为 KeyApp 格式 */ -import type { Wallet, ChainAddress, ChainType } from '@/stores/wallet' -import type { EncryptedData } from '@/lib/crypto' -import { encrypt } from '@/lib/crypto' -import type { - MpayMainWallet, - MpayChainAddressInfo, - MpayAddressBookEntry, -} from './types' -import type { Contact } from '@/stores/address-book' -import { decryptMpayData } from './mpay-crypto' +import type { Wallet, ChainAddress, ChainType } from '@/stores/wallet'; +import type { EncryptedData } from '@/lib/crypto'; +import { encrypt } from '@/lib/crypto'; +import type { MpayMainWallet, MpayChainAddressInfo, MpayAddressBookEntry } from './types'; +import type { Contact } from '@/stores/address-book'; +import { decryptMpayData } from './mpay-crypto'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); function deriveThemeHue(secret: string): number { - let hash = 0 + let hash = 0; for (let i = 0; i < secret.length; i++) { - const char = secret.charCodeAt(i) - hash = ((hash << 5) - hash) + char - hash = hash & hash + const char = secret.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; } - return Math.abs(hash) % 360 + return Math.abs(hash) % 360; } /** @@ -38,13 +37,13 @@ const CHAIN_NAME_MAP: Record = { Tron: 'tron', BTC: 'bitcoin', BSC: 'binance', -} +}; /** * 转换 mpay 链名为 KeyApp ChainType */ function mapChainName(mpayChain: string): ChainType | null { - return CHAIN_NAME_MAP[mpayChain] ?? null + return CHAIN_NAME_MAP[mpayChain] ?? null; } /** @@ -56,13 +55,10 @@ function mapChainName(mpayChain: string): ChainType | null { /** * 转换 mpay ChainAddressInfo 为 KeyApp ChainAddress */ -function transformChainAddress( - mpayAddress: MpayChainAddressInfo -): ChainAddress | null { - const chain = mapChainName(mpayAddress.chain) +function transformChainAddress(mpayAddress: MpayChainAddressInfo): ChainAddress | null { + const chain = mapChainName(mpayAddress.chain); if (!chain) { - - return null + return null; } return { @@ -70,7 +66,7 @@ function transformChainAddress( address: mpayAddress.address, publicKey: '', // Will be derived on wallet unlock // tokens 已从 ChainAddress 移除 - 余额数据从 chain-provider 获取 - } + }; } /** @@ -81,17 +77,17 @@ function transformChainAddress( */ function determineChainFromList(chainList?: string[]): ChainType | undefined { if (!chainList || chainList.length === 0) { - return undefined + return undefined; } for (const mpayChain of chainList) { - const chain = mapChainName(mpayChain) + const chain = mapChainName(mpayChain); if (chain) { - return chain + return chain; } } - return undefined + return undefined; } /** @@ -101,8 +97,8 @@ function determineChainFromList(chainList?: string[]): ChainType | undefined { * @returns KeyApp 联系人 */ export function transformAddressBookEntry(entry: MpayAddressBookEntry): Contact { - const now = Date.now() - const chain = determineChainFromList(entry.chainList) ?? 'ethereum' + const now = Date.now(); + const chain = determineChainFromList(entry.chainList) ?? 'ethereum'; const contact: Contact = { id: entry.addressBookId, @@ -117,13 +113,13 @@ export function transformAddressBookEntry(entry: MpayAddressBookEntry): Contact ], createdAt: now, updatedAt: now, - } + }; if (entry.remarks) { - contact.memo = entry.remarks + contact.memo = entry.remarks; } - return contact + return contact; } /** @@ -131,19 +127,19 @@ export function transformAddressBookEntry(entry: MpayAddressBookEntry): Contact */ export interface TransformResult { /** 转换后的钱包列表 */ - wallets: Wallet[] + wallets: Wallet[]; /** 跳过的地址(不支持的链) */ skippedAddresses: Array<{ - address: string - chain: string - reason: string - }> + address: string; + chain: string; + reason: string; + }>; /** 转换统计 */ stats: { - totalWallets: number - totalAddresses: number - skippedAddresses: number - } + totalWallets: number; + totalAddresses: number; + skippedAddresses: number; + }; } /** @@ -157,42 +153,42 @@ export interface TransformResult { export async function transformMpayData( mpayWallets: MpayMainWallet[], mpayAddresses: MpayChainAddressInfo[], - password: string + password: string, ): Promise { - const wallets: Wallet[] = [] - const skippedAddresses: TransformResult['skippedAddresses'] = [] + const wallets: Wallet[] = []; + const skippedAddresses: TransformResult['skippedAddresses'] = []; // 按 mainWalletId 分组地址 - const addressesByWallet = new Map() + const addressesByWallet = new Map(); for (const addr of mpayAddresses) { - const list = addressesByWallet.get(addr.mainWalletId) ?? [] - list.push(addr) - addressesByWallet.set(addr.mainWalletId, list) + const list = addressesByWallet.get(addr.mainWalletId) ?? []; + list.push(addr); + addressesByWallet.set(addr.mainWalletId, list); } for (const mpayWallet of mpayWallets) { try { // 解密助记词 - const mnemonic = await decryptMpayData(password, mpayWallet.importPhrase) + const mnemonic = await decryptMpayData(password, mpayWallet.importPhrase); // 用 KeyApp 格式重新加密 - const encryptedMnemonic: EncryptedData = await encrypt(mnemonic, password) + const encryptedMnemonic: EncryptedData = await encrypt(mnemonic, password); // 获取该钱包的所有地址 - const walletAddresses = addressesByWallet.get(mpayWallet.mainWalletId) ?? [] + const walletAddresses = addressesByWallet.get(mpayWallet.mainWalletId) ?? []; // 转换地址 - const chainAddresses: ChainAddress[] = [] + const chainAddresses: ChainAddress[] = []; for (const mpayAddr of walletAddresses) { - const converted = transformChainAddress(mpayAddr) + const converted = transformChainAddress(mpayAddr); if (converted) { - chainAddresses.push(converted) + chainAddresses.push(converted); } else { skippedAddresses.push({ address: mpayAddr.address, chain: mpayAddr.chain, - reason: `不支持的链类型: ${mpayAddr.chain}`, - }) + reason: t('error:chain.unsupportedType', { chain: mpayAddr.chain }), + }); } } @@ -202,10 +198,9 @@ export async function transformMpayData( chainAddresses.find((ca) => ca.chain === 'bfmeta')?.chain ?? chainAddresses.find((ca) => ca.chain === 'ethereum')?.chain ?? chainAddresses[0]?.chain ?? - 'ethereum' + 'ethereum'; - const primaryAddress = - chainAddresses.find((ca) => ca.chain === primaryChain)?.address ?? '' + const primaryAddress = chainAddresses.find((ca) => ca.chain === primaryChain)?.address ?? ''; // 创建 KeyApp 钱包 (不包含 tokens,余额从 chain-provider 获取) const wallet = { @@ -218,11 +213,10 @@ export async function transformMpayData( createdAt: mpayWallet.createTimestamp, themeHue: deriveThemeHue(mpayWallet.mainWalletId), // tokens 已移除 - 从 chain-provider.tokenBalances 获取 - } + }; - wallets.push(wallet) + wallets.push(wallet); } catch (error) { - // 继续处理其他钱包 } } @@ -235,7 +229,7 @@ export async function transformMpayData( totalAddresses: mpayAddresses.length, skippedAddresses: skippedAddresses.length, }, - } + }; } -export { mapChainName, transformChainAddress, determineChainFromList } +export { mapChainName, transformChainAddress, determineChainFromList }; diff --git a/src/services/miniapp-runtime/index.ts b/src/services/miniapp-runtime/index.ts index c37b1ba6a..e378ddc28 100644 --- a/src/services/miniapp-runtime/index.ts +++ b/src/services/miniapp-runtime/index.ts @@ -63,6 +63,9 @@ import type { MiniappManifest, MiniappTargetDesktop } from '../ecosystem/types'; import { getBridge } from '../ecosystem/provider'; import { toastService } from '../toast'; import { getDesktopAppSlotRect, getIconRef } from './runtime-refs'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); export { getDesktopContainerRef, getDesktopAppSlotRect, @@ -511,11 +514,11 @@ function startPreparing(appId: string, targetDesktop: MiniappTargetDesktop, hasS if (performance.now() - startAt > PREPARING_TIMEOUT) { if (!slotReady) { - failPreparing(appId, '启动失败:请停留在目标桌面页后重试'); + failPreparing(appId, t('error:miniapp.launchFailed.stayOnDesktop')); } else if (!iconReady) { - failPreparing(appId, '启动失败:图标未就绪,请返回桌面重试'); + failPreparing(appId, t('error:miniapp.launchFailed.iconNotReady')); } else { - failPreparing(appId, '启动失败:加载容器未就绪,请重试'); + failPreparing(appId, t('error:miniapp.launchFailed.containerNotReady')); } return; } diff --git a/src/services/transaction/pending-tx.ts b/src/services/transaction/pending-tx.ts index 0a6dd0ba2..a2b3a8dd2 100644 --- a/src/services/transaction/pending-tx.ts +++ b/src/services/transaction/pending-tx.ts @@ -1,47 +1,71 @@ /** * Pending Transaction Service - * + * * 未上链交易管理 - IndexedDB 存储实现 * 专注状态管理,不关心交易内容本身 */ -import { z } from 'zod' -import { openDB, type DBSchema, type IDBPDatabase } from 'idb' -import { derive, transform } from '@biochain/key-fetch' -import { getChainProvider } from '@/services/chain-adapter/providers' -import { defineServiceMeta } from '@/lib/service-meta' -import { SignedTransactionSchema } from '@/services/chain-adapter/types' +import { z } from 'zod'; +import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; +import { derive, transform } from '@biochain/key-fetch'; +import { getChainProvider } from '@/services/chain-adapter/providers'; +import { defineServiceMeta } from '@/lib/service-meta'; +import { SignedTransactionSchema } from '@/services/chain-adapter/types'; // ==================== Schema ==================== /** 未上链交易状态 */ export const PendingTxStatusSchema = z.enum([ - 'created', // 交易已创建,待广播 + 'created', // 交易已创建,待广播 'broadcasting', // 广播中 - 'broadcasted', // 广播成功,待上链 - 'confirmed', // 已上链确认 - 'failed', // 广播失败 -]) + 'broadcasted', // 广播成功,待上链 + 'confirmed', // 已上链确认 + 'failed', // 广播失败 +]); // TransactionType as zod schema for validation const TransactionTypeSchema = z.enum([ - 'send', 'receive', 'signature', 'stake', 'unstake', 'destroy', 'gift', 'grab', - 'trust', 'signFor', 'emigrate', 'immigrate', 'exchange', 'swap', 'issueAsset', - 'increaseAsset', 'mint', 'issueEntity', 'destroyEntity', 'locationName', 'dapp', - 'certificate', 'mark', 'approve', 'interaction', 'other' -]) + 'send', + 'receive', + 'signature', + 'stake', + 'unstake', + 'destroy', + 'gift', + 'grab', + 'trust', + 'signFor', + 'emigrate', + 'immigrate', + 'exchange', + 'swap', + 'issueAsset', + 'increaseAsset', + 'mint', + 'issueEntity', + 'destroyEntity', + 'locationName', + 'dapp', + 'certificate', + 'mark', + 'approve', + 'interaction', + 'other', +]); /** 用于 UI 展示的最小元数据(可选,由调用方提供) */ -export const PendingTxMetaSchema = z.object({ - /** 交易类型标识,用于 UI 展示,必须是 TransactionType */ - type: TransactionTypeSchema.optional(), - /** 展示用的金额 */ - displayAmount: z.string().optional(), - /** 展示用的符号 */ - displaySymbol: z.string().optional(), - /** 展示用的目标地址 */ - displayToAddress: z.string().optional(), -}).passthrough() // 允许扩展字段 +export const PendingTxMetaSchema = z + .object({ + /** 交易类型标识,用于 UI 展示,必须是 TransactionType */ + type: TransactionTypeSchema.optional(), + /** 展示用的金额 */ + displayAmount: z.string().optional(), + /** 展示用的符号 */ + displaySymbol: z.string().optional(), + /** 展示用的目标地址 */ + displayToAddress: z.string().optional(), + }) + .passthrough(); // 允许扩展字段 /** 未上链交易记录 - 专注状态管理 */ export const PendingTxSchema = z.object({ @@ -81,11 +105,11 @@ export const PendingTxSchema = z.object({ rawTx: SignedTransactionSchema, /** UI 展示用的元数据(可选) */ meta: PendingTxMetaSchema.optional(), -}) +}); -export type PendingTx = z.infer -export type PendingTxStatus = z.infer -export type PendingTxMeta = z.infer +export type PendingTx = z.infer; +export type PendingTxStatus = z.infer; +export type PendingTxMeta = z.infer; /** 创建 pending tx 的输入 */ export const CreatePendingTxInputSchema = z.object({ @@ -94,9 +118,9 @@ export const CreatePendingTxInputSchema = z.object({ fromAddress: z.string(), rawTx: SignedTransactionSchema, meta: PendingTxMetaSchema.optional(), -}) +}); -export type CreatePendingTxInput = z.infer +export type CreatePendingTxInput = z.infer; /** 更新状态的输入 */ export const UpdatePendingTxStatusInputSchema = z.object({ @@ -107,22 +131,27 @@ export const UpdatePendingTxStatusInputSchema = z.object({ errorMessage: z.string().optional(), confirmedBlockHeight: z.number().optional(), confirmedAt: z.number().optional(), -}) +}); -export type UpdatePendingTxStatusInput = z.infer +export type UpdatePendingTxStatusInput = z.infer; // ==================== Service Meta ==================== export const pendingTxServiceMeta = defineServiceMeta('pendingTx', (s) => - s.description('未上链交易管理服务 - 专注状态管理,不关心交易内容') + s + .description('未上链交易管理服务 - 专注状态管理,不关心交易内容') // i18n-ignore // ===== 查询 ===== .api('getAll', z.object({ walletId: z.string() }), z.array(PendingTxSchema)) .api('getById', z.object({ id: z.string() }), PendingTxSchema.nullable()) - .api('getByStatus', z.object({ - walletId: z.string(), - status: PendingTxStatusSchema, - }), z.array(PendingTxSchema)) + .api( + 'getByStatus', + z.object({ + walletId: z.string(), + status: PendingTxStatusSchema, + }), + z.array(PendingTxSchema), + ) .api('getPending', z.object({ walletId: z.string() }), z.array(PendingTxSchema)) // ===== 生命周期管理 ===== @@ -133,10 +162,10 @@ export const pendingTxServiceMeta = defineServiceMeta('pendingTx', (s) => // ===== 清理 ===== .api('delete', z.object({ id: z.string() }), z.void()) .api('deleteConfirmed', z.object({ walletId: z.string() }), z.void()) - .api('deleteAll', z.object({ walletId: z.string() }), z.void()) -) + .api('deleteAll', z.object({ walletId: z.string() }), z.void()), +); -export type IPendingTxService = typeof pendingTxServiceMeta.Type +export type IPendingTxService = typeof pendingTxServiceMeta.Type; // ==================== 过期检查器接口 ==================== @@ -151,7 +180,7 @@ export interface ExpirationChecker { * @param currentBlockHeight 当前区块高度 * @returns 是否已过期 */ - isExpired(rawTx: unknown, currentBlockHeight: number): boolean + isExpired(rawTx: unknown, currentBlockHeight: number): boolean; } /** @@ -160,13 +189,13 @@ export interface ExpirationChecker { */ export const bioChainExpirationChecker: ExpirationChecker = { isExpired(rawTx: unknown, currentBlockHeight: number): boolean { - const tx = rawTx as { effectiveBlockHeight?: number } + const tx = rawTx as { effectiveBlockHeight?: number }; if (typeof tx?.effectiveBlockHeight === 'number') { - return currentBlockHeight > tx.effectiveBlockHeight + return currentBlockHeight > tx.effectiveBlockHeight; } - return false // 无 effectiveBlockHeight 时不判定过期 - } -} + return false; // 无 effectiveBlockHeight 时不判定过期 + }, +}; /** * 获取链对应的过期检查器 @@ -176,9 +205,9 @@ export const bioChainExpirationChecker: ExpirationChecker = { export function getExpirationChecker(chainId: string): ExpirationChecker | undefined { // BioChain 系列链使用 bioChainExpirationChecker if (chainId.startsWith('bfmeta') || chainId.startsWith('bfm') || chainId === 'bioforest') { - return bioChainExpirationChecker + return bioChainExpirationChecker; } - return undefined + return undefined; } /** @@ -191,49 +220,49 @@ export function getExpirationChecker(chainId: string): ExpirationChecker | undef export function isPendingTxExpired( pendingTx: PendingTx, currentBlockHeight?: number, - maxAge: number = 24 * 60 * 60 * 1000 + maxAge: number = 24 * 60 * 60 * 1000, ): boolean { // 1. 基于时间的过期检查(适用于所有链) - const now = Date.now() + const now = Date.now(); if (now - pendingTx.createdAt > maxAge) { - return true + return true; } // 2. 基于区块高度的过期检查(针对 BioChain 等支持的链) if (currentBlockHeight !== undefined) { - const checker = getExpirationChecker(pendingTx.chainId) + const checker = getExpirationChecker(pendingTx.chainId); if (checker?.isExpired(pendingTx.rawTx, currentBlockHeight)) { - return true + return true; } } - return false + return false; } // ==================== IndexedDB 实现 ==================== -const DB_NAME = 'bfm-pending-tx-db' -const DB_VERSION = 1 -const STORE_NAME = 'pendingTx' +const DB_NAME = 'bfm-pending-tx-db'; +const DB_VERSION = 1; +const STORE_NAME = 'pendingTx'; interface PendingTxDBSchema extends DBSchema { pendingTx: { - key: string - value: PendingTx + key: string; + value: PendingTx; indexes: { - 'by-wallet': string - 'by-status': string - 'by-wallet-status': [string, string] - } - } + 'by-wallet': string; + 'by-status': string; + 'by-wallet-status': [string, string]; + }; + }; } -type PendingTxChangeCallback = (tx: PendingTx, event: 'created' | 'updated' | 'deleted') => void +type PendingTxChangeCallback = (tx: PendingTx, event: 'created' | 'updated' | 'deleted') => void; class PendingTxServiceImpl implements IPendingTxService { - private db: IDBPDatabase | null = null - private initialized = false - private subscribers = new Set() + private db: IDBPDatabase | null = null; + private initialized = false; + private subscribers = new Set(); /** * 订阅 pending tx 变化 @@ -241,10 +270,10 @@ class PendingTxServiceImpl implements IPendingTxService { * @returns 取消订阅函数 */ subscribe(callback: PendingTxChangeCallback): () => void { - this.subscribers.add(callback) + this.subscribers.add(callback); return () => { - this.subscribers.delete(callback) - } + this.subscribers.delete(callback); + }; } /** @@ -253,74 +282,72 @@ class PendingTxServiceImpl implements IPendingTxService { private notify(tx: PendingTx, event: 'created' | 'updated' | 'deleted') { this.subscribers.forEach((callback) => { try { - callback(tx, event) - } catch (error) { - - } - }) + callback(tx, event); + } catch (error) {} + }); } private async ensureDb(): Promise> { if (this.db && this.initialized) { - return this.db + return this.db; } this.db = await openDB(DB_NAME, DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains(STORE_NAME)) { - const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }) - store.createIndex('by-wallet', 'walletId') - store.createIndex('by-status', 'status') - store.createIndex('by-wallet-status', ['walletId', 'status']) + const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + store.createIndex('by-wallet', 'walletId'); + store.createIndex('by-status', 'status'); + store.createIndex('by-wallet-status', ['walletId', 'status']); } }, - }) - this.initialized = true - return this.db + }); + this.initialized = true; + return this.db; } // ===== 查询 ===== async getAll({ walletId }: { walletId: string }): Promise { - const db = await this.ensureDb() - const records = await db.getAllFromIndex(STORE_NAME, 'by-wallet', walletId) + const db = await this.ensureDb(); + const records = await db.getAllFromIndex(STORE_NAME, 'by-wallet', walletId); return records .map((r) => PendingTxSchema.safeParse(r)) .filter((r) => r.success) .map((r) => r.data) - .sort((a, b) => b.createdAt - a.createdAt) + .sort((a, b) => b.createdAt - a.createdAt); } async getById({ id }: { id: string }): Promise { - const db = await this.ensureDb() - const record = await db.get(STORE_NAME, id) - if (!record) return null - const parsed = PendingTxSchema.safeParse(record) - return parsed.success ? parsed.data : null + const db = await this.ensureDb(); + const record = await db.get(STORE_NAME, id); + if (!record) return null; + const parsed = PendingTxSchema.safeParse(record); + return parsed.success ? parsed.data : null; } async getByStatus({ walletId, status }: { walletId: string; status: PendingTxStatus }): Promise { - const db = await this.ensureDb() - const records = await db.getAllFromIndex(STORE_NAME, 'by-wallet-status', [walletId, status]) + const db = await this.ensureDb(); + const records = await db.getAllFromIndex(STORE_NAME, 'by-wallet-status', [walletId, status]); return records .map((r) => PendingTxSchema.safeParse(r)) .filter((r) => r.success) .map((r) => r.data) - .sort((a, b) => b.createdAt - a.createdAt) + .sort((a, b) => b.createdAt - a.createdAt); } async getPending({ walletId }: { walletId: string }): Promise { - const all = await this.getAll({ walletId }) - return all.filter((tx) => tx.status !== 'confirmed') + const all = await this.getAll({ walletId }); + return all.filter((tx) => tx.status !== 'confirmed'); } // ===== 生命周期管理 ===== async create(input: CreatePendingTxInput): Promise { - const db = await this.ensureDb() - const now = Date.now() + const db = await this.ensureDb(); + const now = Date.now(); // 使用区块链签名作为 ID(即交易哈希) - const txHash = input.rawTx.signature + const txHash = input.rawTx.signature; const pendingTx: PendingTx = { id: txHash, @@ -334,19 +361,19 @@ class PendingTxServiceImpl implements IPendingTxService { updatedAt: now, rawTx: input.rawTx, meta: input.meta, - } + }; - await db.put(STORE_NAME, pendingTx) - this.notify(pendingTx, 'created') - return pendingTx + await db.put(STORE_NAME, pendingTx); + this.notify(pendingTx, 'created'); + return pendingTx; } async updateStatus(input: UpdatePendingTxStatusInput): Promise { - const db = await this.ensureDb() - const existing = await db.get(STORE_NAME, input.id) + const db = await this.ensureDb(); + const existing = await db.get(STORE_NAME, input.id); if (!existing) { - throw new Error(`PendingTx not found: ${input.id}`) + throw new Error(`PendingTx not found: ${input.id}`); } const updated: PendingTx = { @@ -358,114 +385,118 @@ class PendingTxServiceImpl implements IPendingTxService { ...(input.errorMessage !== undefined && { errorMessage: input.errorMessage }), ...(input.confirmedBlockHeight !== undefined && { confirmedBlockHeight: input.confirmedBlockHeight }), ...(input.confirmedAt !== undefined && { confirmedAt: input.confirmedAt }), - } + }; - await db.put(STORE_NAME, updated) - this.notify(updated, 'updated') - return updated + await db.put(STORE_NAME, updated); + this.notify(updated, 'updated'); + return updated; } async incrementRetry({ id }: { id: string }): Promise { - const db = await this.ensureDb() - const existing = await db.get(STORE_NAME, id) + const db = await this.ensureDb(); + const existing = await db.get(STORE_NAME, id); if (!existing) { - throw new Error(`PendingTx not found: ${id}`) + throw new Error(`PendingTx not found: ${id}`); } const updated: PendingTx = { ...existing, retryCount: (existing.retryCount ?? 0) + 1, updatedAt: Date.now(), - } + }; - await db.put(STORE_NAME, updated) - this.notify(updated, 'updated') - return updated + await db.put(STORE_NAME, updated); + this.notify(updated, 'updated'); + return updated; } // ===== 清理 ===== async delete({ id }: { id: string }): Promise { - const db = await this.ensureDb() - const existing = await db.get(STORE_NAME, id) - await db.delete(STORE_NAME, id) + const db = await this.ensureDb(); + const existing = await db.get(STORE_NAME, id); + await db.delete(STORE_NAME, id); if (existing) { - this.notify(existing, 'deleted') + this.notify(existing, 'deleted'); } } async deleteConfirmed({ walletId }: { walletId: string }): Promise { - const confirmed = await this.getByStatus({ walletId, status: 'confirmed' }) - const db = await this.ensureDb() - const tx = db.transaction(STORE_NAME, 'readwrite') - await Promise.all(confirmed.map((item) => tx.store.delete(item.id))) - await tx.done + const confirmed = await this.getByStatus({ walletId, status: 'confirmed' }); + const db = await this.ensureDb(); + const tx = db.transaction(STORE_NAME, 'readwrite'); + await Promise.all(confirmed.map((item) => tx.store.delete(item.id))); + await tx.done; } - async deleteExpired({ walletId, maxAge, currentBlockHeight }: { - walletId: string - maxAge: number - currentBlockHeight?: number + async deleteExpired({ + walletId, + maxAge, + currentBlockHeight, + }: { + walletId: string; + maxAge: number; + currentBlockHeight?: number; }): Promise { - const all = await this.getAll({ walletId }) - const now = Date.now() + const all = await this.getAll({ walletId }); + const now = Date.now(); const expired = all.filter((pendingTx) => { // 1. 已确认或失败超过 maxAge 的交易 if (pendingTx.status === 'confirmed' || pendingTx.status === 'failed') { - return now - pendingTx.updatedAt > maxAge + return now - pendingTx.updatedAt > maxAge; } // 2. 基于区块高度的过期检查(针对 BioChain 等支持的链) if (currentBlockHeight !== undefined) { - const checker = getExpirationChecker(pendingTx.chainId) + const checker = getExpirationChecker(pendingTx.chainId); if (checker?.isExpired(pendingTx.rawTx, currentBlockHeight)) { - return true + return true; } } - return false - }) + return false; + }); - if (expired.length === 0) return 0 + if (expired.length === 0) return 0; - const db = await this.ensureDb() - const tx = db.transaction(STORE_NAME, 'readwrite') - await Promise.all(expired.map((item) => tx.store.delete(item.id))) - await tx.done + const db = await this.ensureDb(); + const tx = db.transaction(STORE_NAME, 'readwrite'); + await Promise.all(expired.map((item) => tx.store.delete(item.id))); + await tx.done; - return expired.length + return expired.length; } async deleteAll({ walletId }: { walletId: string }): Promise { - const all = await this.getAll({ walletId }) - const db = await this.ensureDb() - const tx = db.transaction(STORE_NAME, 'readwrite') - await Promise.all(all.map((item) => tx.store.delete(item.id))) - await tx.done + const all = await this.getAll({ walletId }); + const db = await this.ensureDb(); + const tx = db.transaction(STORE_NAME, 'readwrite'); + await Promise.all(all.map((item) => tx.store.delete(item.id))); + await tx.done; } } /** 单例服务实例 */ -export const pendingTxService = new PendingTxServiceImpl() +export const pendingTxService = new PendingTxServiceImpl(); // ==================== Key-Fetch Instance Factory ==================== // 缓存已创建的 fetcher 实例 -const pendingTxFetchers = new Map>() +const pendingTxFetchers = new Map>(); /** * 获取 pending tx 的 key-fetch 实例 * 依赖 blockHeight 自动刷新 */ export function getPendingTxFetcher(chainId: string, walletId: string) { - const key = `${chainId}:${walletId}` + const key = `${chainId}:${walletId}`; if (!pendingTxFetchers.has(key)) { - const chainProvider = getChainProvider(chainId) + const chainProvider = getChainProvider(chainId); if (!chainProvider?.supports('blockHeight')) { - return null + return null; } const fetcher = derive({ @@ -476,33 +507,33 @@ export function getPendingTxFetcher(chainId: string, walletId: string) { transform({ transform: async () => { // 检查 pending 交易状态,更新/移除已上链的 - const pending = await pendingTxService.getPending({ walletId }) + const pending = await pendingTxService.getPending({ walletId }); for (const tx of pending) { if (tx.status === 'broadcasted' && tx.txHash) { try { // 检查是否已上链 - const txInfo = await chainProvider.transaction.fetch({ txHash: tx.txHash }) + const txInfo = await chainProvider.transaction.fetch({ txHash: tx.txHash }); if (txInfo?.status === 'confirmed') { // 直接删除已确认的交易 - await pendingTxService.delete({ id: tx.id }) + await pendingTxService.delete({ id: tx.id }); } } catch (e) { - console.error('检查pending交易状态失败', e) + console.error('检查pending交易状态失败', e); // i18n-ignore; // 查询失败,跳过 } } } // 返回最新的 pending 列表 - return await pendingTxService.getPending({ walletId }) + return await pendingTxService.getPending({ walletId }); }, }), ], - }) + }); - pendingTxFetchers.set(key, fetcher) + pendingTxFetchers.set(key, fetcher); } - return pendingTxFetchers.get(key)! + return pendingTxFetchers.get(key)!; } From 4e5dfb2e7ae5c2e20ac6c9d309578b8ca43958c8 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 23:27:43 +0800 Subject: [PATCH 03/13] feat(i18n): migrate settings, destroy, clear pages and ecosystem store - Add i18n-ignore markers for standalone clear page (cannot use React i18n) - Add i18n-ignore markers for native language names (zh-CN, zh-TW, ja) - Add i18n-ignore markers for config data in ecosystem store - Migrate miniapp sources label in settings - Migrate destroy page error messages --- src/clear/main.ts | 70 ++++---- src/i18n/locales/ar/error.json | 11 +- src/i18n/locales/ar/settings.json | 3 +- src/i18n/locales/en/error.json | 9 + src/i18n/locales/en/settings.json | 5 +- src/i18n/locales/zh-CN/error.json | 9 + src/i18n/locales/zh-CN/settings.json | 5 +- src/i18n/locales/zh-TW/error.json | 9 + src/i18n/locales/zh-TW/settings.json | 3 +- src/pages/destroy/index.tsx | 258 +++++++++++++-------------- src/pages/settings/index.tsx | 45 ++--- src/pages/settings/language.tsx | 4 +- src/stores/ecosystem.ts | 6 +- 13 files changed, 232 insertions(+), 205 deletions(-) diff --git a/src/clear/main.ts b/src/clear/main.ts index c11359b74..f7f1ad4e7 100644 --- a/src/clear/main.ts +++ b/src/clear/main.ts @@ -1,6 +1,6 @@ -import "./styles.css"; +import './styles.css'; -const baseUri = new URL("./", window.location.href).href; +const baseUri = new URL('./', window.location.href).href; interface StepElement { id: string; @@ -10,22 +10,22 @@ interface StepElement { const steps: StepElement[] = [ { - id: "step-local", - label: "本地存储 (localStorage)", + id: 'step-local', + label: '本地存储 (localStorage)', // i18n-ignore: standalone page action: async () => { localStorage.clear(); }, }, { - id: "step-session", - label: "会话存储 (sessionStorage)", + id: 'step-session', + label: '会话存储 (sessionStorage)', // i18n-ignore: standalone page action: async () => { sessionStorage.clear(); }, }, { - id: "step-indexeddb", - label: "数据库 (IndexedDB)", + id: 'step-indexeddb', + label: '数据库 (IndexedDB)', // i18n-ignore: standalone page action: async () => { if (indexedDB.databases) { const databases = await indexedDB.databases(); @@ -43,10 +43,10 @@ const steps: StepElement[] = [ }, }, { - id: "step-cache", - label: "缓存 (Cache Storage)", + id: 'step-cache', + label: '缓存 (Cache Storage)', // i18n-ignore: standalone page action: async () => { - if ("caches" in window) { + if ('caches' in window) { const cacheNames = await caches.keys(); await Promise.all(cacheNames.map((name) => caches.delete(name))); } @@ -55,7 +55,7 @@ const steps: StepElement[] = [ ]; function createUI() { - const root = document.getElementById("root")!; + const root = document.getElementById('root')!; root.innerHTML = `
@@ -70,8 +70,8 @@ function createUI() {
-

正在清理数据

-

请稍候...

+

正在清理数据

+

请稍候...

${steps @@ -85,9 +85,9 @@ function createUI() { ${step.label}
- ` + `, ) - .join("")} + .join('')}

@@ -100,7 +100,7 @@ function delay(ms: number) { } function updateProgress(completed: number, total: number) { - const progressCircle = document.getElementById("progressCircle"); + const progressCircle = document.getElementById('progressCircle'); if (progressCircle) { const percent = (completed / total) * 100; const offset = 226 - (226 * percent) / 100; @@ -109,13 +109,13 @@ function updateProgress(completed: number, total: number) { } function setStepActive(stepId: string) { - document.getElementById(stepId)?.classList.add("active"); + document.getElementById(stepId)?.classList.add('active'); } function setStepDone(stepId: string) { const step = document.getElementById(stepId); - step?.classList.remove("active"); - step?.classList.add("done"); + step?.classList.remove('active'); + step?.classList.add('done'); } async function clearAllData() { @@ -127,9 +127,7 @@ async function clearAllData() { try { await step.action(); - } catch (e) { - - } + } catch (e) {} setStepDone(step.id); completed++; @@ -140,28 +138,28 @@ async function clearAllData() { async function main() { createUI(); - const title = document.getElementById("title")!; - const status = document.getElementById("status")!; - const error = document.getElementById("error")!; - const checkIcon = document.getElementById("checkIcon")!; - const container = document.querySelector(".container")!; + const title = document.getElementById('title')!; + const status = document.getElementById('status')!; + const error = document.getElementById('error')!; + const checkIcon = document.getElementById('checkIcon')!; + const container = document.querySelector('.container')!; try { await clearAllData(); await delay(300); - checkIcon.classList.add("visible"); - container.classList.add("success-state"); - title.textContent = "清理完成"; - status.textContent = "正在返回应用..."; + checkIcon.classList.add('visible'); + container.classList.add('success-state'); + title.textContent = '清理完成'; // i18n-ignore: standalone page + status.textContent = '正在返回应用...'; // i18n-ignore: standalone page await delay(1200); window.location.href = baseUri; } catch (e) { - title.textContent = "清理失败"; - status.textContent = ""; - error.style.display = "block"; - error.textContent = e instanceof Error ? e.message : "发生未知错误"; + title.textContent = '清理失败'; // i18n-ignore: standalone page + status.textContent = ''; + error.style.display = 'block'; + error.textContent = e instanceof Error ? e.message : '发生未知错误'; // i18n-ignore: standalone page await delay(3000); window.location.href = baseUri; diff --git a/src/i18n/locales/ar/error.json b/src/i18n/locales/ar/error.json index 9f62972ff..6c6f8fd79 100644 --- a/src/i18n/locales/ar/error.json +++ b/src/i18n/locales/ar/error.json @@ -11,7 +11,7 @@ "saveFailed": "Save Failed", "scanErrorFromImage": "Scan error from image", "shareFailed": "Sharing Failed", - "unknown": "Unknown error", + "unknown": "خطأ غير معروف", "validation": { "enterRecipientAddress": "Please enter recipient address", "invalidAddressFormat": "Invalid address format", @@ -87,5 +87,14 @@ }, "wallet": { "incompleteInfo": "معلومات المحفظة غير مكتملة" + }, + "destroy": { + "failed": "فشل التدمير" + }, + "security": { + "passwordInvalid": "كلمة المرور الأمنية غير صحيحة" + }, + "transfer": { + "failed": "فشل التحويل" } } diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 44e676282..bac522e26 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -98,7 +98,8 @@ "storage": "التخزين", "viewMnemonic": "عرض العبارة السرية", "walletChains": "شبكات المحفظة", - "walletManagement": "إدارة المحفظة" + "walletManagement": "إدارة المحفظة", + "miniappSources": "مصادر التطبيقات المصغرة الموثوقة" }, "language": "اللغة", "languagePage": { diff --git a/src/i18n/locales/en/error.json b/src/i18n/locales/en/error.json index b3d61381e..e9e21935a 100644 --- a/src/i18n/locales/en/error.json +++ b/src/i18n/locales/en/error.json @@ -87,5 +87,14 @@ }, "wallet": { "incompleteInfo": "Wallet information is incomplete" + }, + "destroy": { + "failed": "Destroy failed" + }, + "security": { + "passwordInvalid": "Security password is incorrect" + }, + "transfer": { + "failed": "Transfer failed" } } diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 814ad55ce..2ff89b8eb 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -22,7 +22,8 @@ "appearance": "Appearance", "aboutApp": "About BFM Pay", "clearData": "Clear App Data", - "storage": "Storage" + "storage": "Storage", + "miniappSources": "Miniapp Trusted Sources" }, "appearance": { "title": "Appearance", @@ -147,4 +148,4 @@ "migrationDesc": "Detected old data format. Please clear the local database to continue. Your mnemonic and private keys are not affected, but you will need to re-import your wallet.", "goToClear": "Go to Clear Data" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-CN/error.json b/src/i18n/locales/zh-CN/error.json index b4b09fa3c..38582e82b 100644 --- a/src/i18n/locales/zh-CN/error.json +++ b/src/i18n/locales/zh-CN/error.json @@ -87,5 +87,14 @@ }, "wallet": { "incompleteInfo": "钱包信息不完整" + }, + "destroy": { + "failed": "销毁失败" + }, + "security": { + "passwordInvalid": "安全密码错误" + }, + "transfer": { + "failed": "转账失败" } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 9696352be..5e87b75f8 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -22,7 +22,8 @@ "appearance": "外观", "aboutApp": "关于 BFM Pay", "clearData": "清空应用数据", - "storage": "存储空间" + "storage": "存储空间", + "miniappSources": "小程序可信源" }, "appearance": { "title": "外观设置", @@ -147,4 +148,4 @@ "migrationDesc": "检测到旧版本数据格式,需要清空本地数据库后才能继续使用。您的助记词和私钥不会受到影响,但需要重新导入钱包。", "goToClear": "前往清理数据" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-TW/error.json b/src/i18n/locales/zh-TW/error.json index d07e2ddbe..6ecd3dbca 100644 --- a/src/i18n/locales/zh-TW/error.json +++ b/src/i18n/locales/zh-TW/error.json @@ -87,5 +87,14 @@ }, "wallet": { "incompleteInfo": "錢包信息不完整" + }, + "destroy": { + "failed": "銷毀失敗" + }, + "security": { + "passwordInvalid": "安全密碼錯誤" + }, + "transfer": { + "failed": "轉賬失敗" } } diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index fbb010382..59dc8a04a 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -98,7 +98,8 @@ "storage": "儲存空間", "viewMnemonic": "查看助記詞", "walletChains": "錢包網路", - "walletManagement": "錢包管理" + "walletManagement": "錢包管理", + "miniappSources": "小程序可信源" }, "language": "語言", "languagePage": { diff --git a/src/pages/destroy/index.tsx b/src/pages/destroy/index.tsx index 3ba5a8fbb..f61d06fe3 100644 --- a/src/pages/destroy/index.tsx +++ b/src/pages/destroy/index.tsx @@ -1,26 +1,26 @@ /** * DestroyPage - 资产销毁页面 - * + * * 仅支持 BioForest 链,主资产不可销毁 */ -import { useMemo, useRef, useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import { useNavigation, useActivityParams, useFlow } from '@/stackflow' -import { setTransferConfirmCallback, setTransferWalletLockCallback } from '@/stackflow/activities/sheets' -import { PageHeader } from '@/components/layout/page-header' -import { AssetSelector } from '@/components/asset' -import { AmountInput } from '@/components/transfer/amount-input' -import { GradientButton, Alert } from '@/components/common' -import { ChainIcon } from '@/components/wallet/chain-icon' -import { SendResult } from '@/components/transfer/send-result' -import { useToast, useHaptics } from '@/services' -import { useBurn } from '@/hooks/use-burn' -import { Amount } from '@/types/amount' -import type { AssetInfo } from '@/types/asset' -import { tokenBalancesToTokenInfoList, type TokenInfo } from '@/components/token' -import { IconFlame } from '@tabler/icons-react' -import { ChainProviderGate, useChainProvider } from '@/contexts' +import { useMemo, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigation, useActivityParams, useFlow } from '@/stackflow'; +import { setTransferConfirmCallback, setTransferWalletLockCallback } from '@/stackflow/activities/sheets'; +import { PageHeader } from '@/components/layout/page-header'; +import { AssetSelector } from '@/components/asset'; +import { AmountInput } from '@/components/transfer/amount-input'; +import { GradientButton, Alert } from '@/components/common'; +import { ChainIcon } from '@/components/wallet/chain-icon'; +import { SendResult } from '@/components/transfer/send-result'; +import { useToast, useHaptics } from '@/services'; +import { useBurn } from '@/hooks/use-burn'; +import { Amount } from '@/types/amount'; +import type { AssetInfo } from '@/types/asset'; +import { tokenBalancesToTokenInfoList, type TokenInfo } from '@/components/token'; +import { IconFlame } from '@tabler/icons-react'; +import { ChainProviderGate, useChainProvider } from '@/contexts'; import { useChainConfigState, chainConfigSelectors, @@ -28,7 +28,7 @@ import { useCurrentWallet, useSelectedChain, type ChainType, -} from '@/stores' +} from '@/stores'; const CHAIN_NAMES: Record = { ethereum: 'Ethereum', @@ -43,7 +43,7 @@ const CHAIN_NAMES: Record = { biwmeta: 'BIWMeta', ethmeta: 'ETHMeta', malibu: 'Malibu', -} +}; /** Convert TokenInfo to AssetInfo */ function tokenToAsset(token: TokenInfo): AssetInfo { @@ -53,7 +53,7 @@ function tokenToAsset(token: TokenInfo): AssetInfo { amount: Amount.fromFormatted(token.balance, token.decimals ?? 8, token.symbol), decimals: token.decimals ?? 8, logoUrl: token.icon, - } + }; } /** Convert AssetInfo to TokenInfo */ @@ -65,31 +65,31 @@ function assetToToken(asset: AssetInfo, chain: ChainType): TokenInfo { decimals: asset.decimals, chain, icon: asset.logoUrl, - } + }; } function DestroyPageContent() { - const { t } = useTranslation(['transaction', 'common', 'security']) - const { goBack: navGoBack } = useNavigation() - const { push } = useFlow() - const toast = useToast() - const haptics = useHaptics() - const isWalletLockSheetOpen = useRef(false) + const { t } = useTranslation(['transaction', 'common', 'security']); + const { goBack: navGoBack } = useNavigation(); + const { push } = useFlow(); + const toast = useToast(); + const haptics = useHaptics(); + const isWalletLockSheetOpen = useRef(false); // Read params const { assetType: initialAssetType, assetLocked: assetLockedParam } = useActivityParams<{ - assetType?: string - assetLocked?: string - }>() - - const selectedChain = useSelectedChain() - const currentWallet = useCurrentWallet() - const currentChainAddress = useCurrentChainAddress() - const chainConfigState = useChainConfigState() + assetType?: string; + assetLocked?: string; + }>(); + + const selectedChain = useSelectedChain(); + const currentWallet = useCurrentWallet(); + const currentChainAddress = useCurrentChainAddress(); + const chainConfigState = useChainConfigState(); const chainConfig = chainConfigState.snapshot ? chainConfigSelectors.getChainById(chainConfigState, selectedChain) - : null - const selectedChainName = chainConfig?.name ?? CHAIN_NAMES[selectedChain] ?? selectedChain + : null; + const selectedChainName = chainConfig?.name ?? CHAIN_NAMES[selectedChain] ?? selectedChain; // 使用 useChainProvider() 获取确保非空的 provider const chainProvider = useChainProvider(); @@ -97,126 +97,118 @@ function DestroyPageContent() { // 直接调用,不需要条件判断 const tokenBalancesState = chainProvider.tokenBalances.useState( { address: currentChainAddress?.address ?? '' }, - { enabled: !!currentChainAddress?.address } + { enabled: !!currentChainAddress?.address }, ); const tokens = useMemo(() => { - if (!tokenBalancesState?.data) return [] - return tokenBalancesToTokenInfoList(tokenBalancesState.data, selectedChain) - }, [tokenBalancesState?.data, selectedChain]) + if (!tokenBalancesState?.data) return []; + return tokenBalancesToTokenInfoList(tokenBalancesState.data, selectedChain); + }, [tokenBalancesState?.data, selectedChain]); // Filter out main asset (cannot be destroyed) const destroyableTokens = useMemo(() => { - if (!chainConfig) return [] - return tokens.filter((token: TokenInfo) => token.symbol.toUpperCase() !== chainConfig.symbol.toUpperCase()) - }, [tokens, chainConfig]) + if (!chainConfig) return []; + return tokens.filter((token: TokenInfo) => token.symbol.toUpperCase() !== chainConfig.symbol.toUpperCase()); + }, [tokens, chainConfig]); // Find initial asset from params const initialAsset = useMemo(() => { - if (!initialAssetType || destroyableTokens.length === 0) return null - const found = destroyableTokens.find( - (t: TokenInfo) => t.symbol.toUpperCase() === initialAssetType.toUpperCase() - ) - return found ? tokenToAsset(found) : null - }, [initialAssetType, destroyableTokens]) - - const assetLocked = assetLockedParam === 'true' - - const { - state, - setAmount, - setAsset, - goToConfirm, - submit, - submitWithTwoStepSecret, - reset, - canProceed - } = useBurn({ + if (!initialAssetType || destroyableTokens.length === 0) return null; + const found = destroyableTokens.find((t: TokenInfo) => t.symbol.toUpperCase() === initialAssetType.toUpperCase()); + return found ? tokenToAsset(found) : null; + }, [initialAssetType, destroyableTokens]); + + const assetLocked = assetLockedParam === 'true'; + + const { state, setAmount, setAsset, goToConfirm, submit, submitWithTwoStepSecret, reset, canProceed } = useBurn({ initialAsset: initialAsset ?? undefined, assetLocked, useMock: false, walletId: currentWallet?.id, fromAddress: currentChainAddress?.address, chainConfig, - }) + }); // Handle asset selection - const handleAssetSelect = useCallback((token: TokenInfo) => { - setAsset(tokenToAsset(token)) - }, [setAsset]) + const handleAssetSelect = useCallback( + (token: TokenInfo) => { + setAsset(tokenToAsset(token)); + }, + [setAsset], + ); // Selected token for AssetSelector const selectedToken = useMemo(() => { - if (!state.asset) return null - return assetToToken(state.asset, selectedChain) - }, [state.asset, selectedChain]) + if (!state.asset) return null; + return assetToToken(state.asset, selectedChain); + }, [state.asset, selectedChain]); // Derive formatted values for display - const balance = state.asset?.amount ?? null - const symbol = state.asset?.assetType ?? 'TOKEN' + const balance = state.asset?.amount ?? null; + const symbol = state.asset?.assetType ?? 'TOKEN'; const handleProceed = () => { - if (!goToConfirm()) return + if (!goToConfirm()) return; - haptics.impact('light') + haptics.impact('light'); // Set up callback: TransferConfirm -> TransferWalletLock setTransferConfirmCallback( async () => { - if (isWalletLockSheetOpen.current) return - isWalletLockSheetOpen.current = true + if (isWalletLockSheetOpen.current) return; + isWalletLockSheetOpen.current = true; - await haptics.impact('medium') + await haptics.impact('medium'); setTransferWalletLockCallback(async (walletLockKey: string, twoStepSecret?: string) => { if (!twoStepSecret) { - const result = await submit(walletLockKey) + const result = await submit(walletLockKey); if (result.status === 'password') { - return { status: 'wallet_lock_invalid' as const } + return { status: 'wallet_lock_invalid' as const }; } if (result.status === 'two_step_secret_required') { - return { status: 'two_step_secret_required' as const } + return { status: 'two_step_secret_required' as const }; } if (result.status === 'ok') { - isWalletLockSheetOpen.current = false - return { status: 'ok' as const, txHash: result.txHash, pendingTxId: result.pendingTxId } + isWalletLockSheetOpen.current = false; + return { status: 'ok' as const, txHash: result.txHash, pendingTxId: result.pendingTxId }; } if (result.status === 'error') { - return { status: 'error' as const, message: result.message, pendingTxId: result.pendingTxId } + return { status: 'error' as const, message: result.message, pendingTxId: result.pendingTxId }; } - return { status: 'error' as const, message: '销毁失败' } + return { status: 'error' as const, message: t('error:destroy.failed') }; } - const result = await submitWithTwoStepSecret(walletLockKey, twoStepSecret) + const result = await submitWithTwoStepSecret(walletLockKey, twoStepSecret); if (result.status === 'ok') { - isWalletLockSheetOpen.current = false - return { status: 'ok' as const, txHash: result.txHash, pendingTxId: result.pendingTxId } + isWalletLockSheetOpen.current = false; + return { status: 'ok' as const, txHash: result.txHash, pendingTxId: result.pendingTxId }; } if (result.status === 'password') { - return { status: 'two_step_secret_invalid' as const, message: '安全密码错误' } + return { status: 'two_step_secret_invalid' as const, message: t('error:security.passwordInvalid') }; } if (result.status === 'error') { - return { status: 'error' as const, message: result.message, pendingTxId: result.pendingTxId } + return { status: 'error' as const, message: result.message, pendingTxId: result.pendingTxId }; } - return { status: 'error' as const, message: '未知错误' } - }) + return { status: 'error' as const, message: t('error:unknown') }; + }); push('TransferWalletLockJob', { title: t('security:walletLock.verifyTitle'), - }) + }); }, { minFee: state.feeMinAmount?.toFormatted() ?? state.feeAmount?.toFormatted() ?? '0', - } - ) + }, + ); push('TransferConfirmJob', { amount: state.amount?.toFormatted() ?? '0', @@ -225,33 +217,33 @@ function DestroyPageContent() { feeAmount: state.feeAmount?.toFormatted() ?? '0', feeSymbol: state.feeSymbol, feeLoading: state.feeLoading ? 'true' : 'false', - }) - } + }); + }; const handleDone = () => { if (state.resultStatus === 'success') { - haptics.impact('success') + haptics.impact('success'); } - navGoBack() - } + navGoBack(); + }; const handleRetry = () => { - reset() - } + reset(); + }; const handleViewExplorer = useCallback(() => { - if (!state.txHash) return - const queryTx = chainConfig?.explorer?.queryTx + if (!state.txHash) return; + const queryTx = chainConfig?.explorer?.queryTx; if (!queryTx) { - toast.show(t('sendPage.explorerNotImplemented')) - return + toast.show(t('sendPage.explorerNotImplemented')); + return; } - const url = queryTx.replace(':hash', state.txHash).replace(':signature', state.txHash) - window.open(url, '_blank', 'noopener,noreferrer') - }, [state.txHash, chainConfig?.explorer?.queryTx, toast, t]) + const url = queryTx.replace(':hash', state.txHash).replace(':signature', state.txHash); + window.open(url, '_blank', 'noopener,noreferrer'); + }, [state.txHash, chainConfig?.explorer?.queryTx, toast, t]); // Check if chain supports destroy - const isBioforestChain = chainConfig?.chainKind === 'bioforest' + const isBioforestChain = chainConfig?.chainKind === 'bioforest'; if (!currentWallet || !currentChainAddress) { return ( @@ -261,7 +253,7 @@ function DestroyPageContent() {

{t('history.noWallet')}

- ) + ); } if (!isBioforestChain) { @@ -269,13 +261,11 @@ function DestroyPageContent() {
- -

- {t('destroyPage.notSupported', '当前链不支持资产销毁')} -

+ +

{t('destroyPage.notSupported', '当前链不支持资产销毁')}

- ) + ); } // Result step @@ -292,10 +282,12 @@ function DestroyPageContent() { errorMessage={state.errorMessage ?? undefined} onDone={handleDone} onRetry={state.resultStatus === 'failed' ? handleRetry : undefined} - onViewExplorer={state.resultStatus === 'success' && chainConfig?.explorer?.queryTx ? handleViewExplorer : undefined} + onViewExplorer={ + state.resultStatus === 'success' && chainConfig?.explorer?.queryTx ? handleViewExplorer : undefined + } /> - ) + ); } return ( @@ -304,13 +296,13 @@ function DestroyPageContent() {
{/* Current chain info & sender address */} -
+
{selectedChainName}
{currentChainAddress?.address && ( -
+
{t('sendPage.from')}: {currentChainAddress.address.slice(0, 8)}...{currentChainAddress.address.slice(-6)} @@ -321,9 +313,7 @@ function DestroyPageContent() { {/* Asset selector */}
- + {destroyableTokens.length === 0 && ( -

- {t('destroyPage.noDestroyableAssets', '暂无可销毁的资产')} -

+

{t('destroyPage.noDestroyableAssets', '暂无可销毁的资产')}

)}
@@ -352,9 +340,7 @@ function DestroyPageContent() { )} {/* Warning */} - - {t('destroyPage.warning', '销毁操作不可撤销,请仔细核对销毁数量。')} - + {t('destroyPage.warning', '销毁操作不可撤销,请仔细核对销毁数量。')} {/* Fee info */} {state.feeAmount && ( @@ -377,21 +363,21 @@ function DestroyPageContent() { disabled={!canProceed} onClick={handleProceed} > - + {t('destroyPage.confirm', '确认销毁')}
- ) + ); } // ==================== 主组件:使用 ChainProviderGate 包裹 ==================== export function DestroyPage() { - const { goBack } = useNavigation() - const selectedChain = useSelectedChain() - const { t } = useTranslation(['transaction', 'common']) + const { goBack } = useNavigation(); + const selectedChain = useSelectedChain(); + const { t } = useTranslation(['transaction', 'common']); return ( - ) + ); } -export default DestroyPage +export default DestroyPage; diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index e2a727f90..1b1c179c9 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -20,7 +20,15 @@ import { IconIdBadge2, } from '@tabler/icons-react'; import { PageHeader } from '@/components/layout/page-header'; -import { useCurrentWallet, useLanguage, useCurrency, useTheme, chainConfigStore, chainConfigSelectors, useUserProfile } from '@/stores'; +import { + useCurrentWallet, + useLanguage, + useCurrency, + useTheme, + chainConfigStore, + chainConfigSelectors, + useUserProfile, +} from '@/stores'; import { ContactAvatar } from '@/components/common/contact-avatar'; import { SettingsItem } from './settings-item'; import { SettingsSection } from './settings-section'; @@ -30,10 +38,10 @@ import { setSetTwoStepSecretCallback } from '@/stackflow/activities/sheets'; /** 支持的语言显示名称映射 */ const LANGUAGE_NAMES: Record = { - 'zh-CN': '简体中文', - 'zh-TW': '繁體中文', + 'zh-CN': '简体中文', // i18n-ignore: native language name + 'zh-TW': '繁體中文', // i18n-ignore: native language name en: 'English', - ja: '日本語', + ja: '日本語', // i18n-ignore: native language name ko: '한국어', ar: 'العربية', }; @@ -57,7 +65,9 @@ export function SettingsPage() { const currentCurrency = useCurrency(); const currentTheme = useTheme(); const [appearanceSheetOpen, setAppearanceSheetOpen] = useState(false); - const [twoStepSecretStatus, setTwoStepSecretStatus] = useState<'loading' | 'set' | 'not_set' | 'unavailable'>('loading'); + const [twoStepSecretStatus, setTwoStepSecretStatus] = useState<'loading' | 'set' | 'not_set' | 'unavailable'>( + 'loading', + ); // Check if pay password is set for BioForest chain useEffect(() => { @@ -68,9 +78,7 @@ export function SettingsPage() { } // Find BioForest chain address - const bfmAddress = currentWallet.chainAddresses.find( - (ca) => ca.chain === 'bfmeta' || ca.chain === 'bfm' - ); + const bfmAddress = currentWallet.chainAddresses.find((ca) => ca.chain === 'bfmeta' || ca.chain === 'bfm'); if (!bfmAddress) { setTwoStepSecretStatus('unavailable'); @@ -114,9 +122,7 @@ export function SettingsPage() { const handleSetTwoStepSecret = async () => { if (twoStepSecretStatus !== 'not_set' || !currentWallet) return; - const bfmAddress = currentWallet.chainAddresses.find( - (ca) => ca.chain === 'bfmeta' || ca.chain === 'bfm' - ); + const bfmAddress = currentWallet.chainAddresses.find((ca) => ca.chain === 'bfmeta' || ca.chain === 'bfm'); if (!bfmAddress) return; const chainConfig = chainConfigSelectors.getChainById(chainConfigStore.state, 'bfmeta'); @@ -160,7 +166,7 @@ export function SettingsPage() { } catch { return false; } - } + }, ); push('SetTwoStepSecretJob', { chainName: 'BioForest Chain' }); @@ -174,15 +180,13 @@ export function SettingsPage() { @@ -272,7 +276,7 @@ export function SettingsPage() {
} - label="小程序可信源" + label={t('settings:items.miniappSources')} onClick={() => navigate({ to: '/settings/sources' })} /> @@ -300,10 +304,7 @@ export function SettingsPage() {
- -
+ +
); } diff --git a/src/pages/settings/language.tsx b/src/pages/settings/language.tsx index 216e426a8..3c3375989 100644 --- a/src/pages/settings/language.tsx +++ b/src/pages/settings/language.tsx @@ -7,8 +7,8 @@ import { cn } from '@/lib/utils'; /** 语言显示名称映射 - 使用原文 */ const LANGUAGE_DISPLAY: Record = { - 'zh-CN': '简体中文', - 'zh-TW': '中文(繁體)', + 'zh-CN': '简体中文', // i18n-ignore: native language name + 'zh-TW': '中文(繁體)', // i18n-ignore: native language name en: 'English', ar: 'العربية', }; diff --git a/src/stores/ecosystem.ts b/src/stores/ecosystem.ts index 6f2155ee1..ea3094778 100644 --- a/src/stores/ecosystem.ts +++ b/src/stores/ecosystem.ts @@ -84,7 +84,7 @@ function loadState(): EcosystemState { sources: parsed.sources ?? [ { url: `${import.meta.env.BASE_URL}miniapps/ecosystem.json`, - name: 'Bio 官方生态', + name: 'Bio 官方生态', // i18n-ignore: config data lastUpdated: new Date().toISOString(), enabled: true, }, @@ -104,7 +104,7 @@ function loadState(): EcosystemState { sources: [ { url: `${import.meta.env.BASE_URL}miniapps/ecosystem.json`, - name: 'Bio 官方生态', + name: 'Bio 官方生态', // i18n-ignore: config data lastUpdated: new Date().toISOString(), enabled: true, }, @@ -303,9 +303,11 @@ export const ecosystemActions = { ecosystemStore.setState((state) => { const next = subPages.length > 0 ? subPages : DEFAULT_AVAILABLE_SUBPAGES; if (arraysEqual(state.availableSubPages, next)) return state; + const activeSubPage = next.includes(state.activeSubPage) ? state.activeSubPage : (next[0] ?? 'mine'); return { ...state, availableSubPages: next, + activeSubPage, }; }); }, From 87a7c1ac8030e5fcfa0d29fe58fea056afab2733 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 23:29:01 +0800 Subject: [PATCH 04/13] feat(i18n): add common translations for tabs, dates, and empty states --- src/i18n/locales/ar/common.json | 16 ++++++++++++++-- src/i18n/locales/en/common.json | 16 ++++++++++++++-- src/i18n/locales/zh-CN/common.json | 16 ++++++++++++++-- src/i18n/locales/zh-TW/common.json | 16 ++++++++++++++-- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 990817b44..cbf513850 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -170,7 +170,9 @@ "couldBeWithin_30Seconds": "Could be within 30 seconds", "creator": "Creator", "date": { - "format": "{{weekday}}، {{day}} {{month}}" + "format": "{{weekday}}، {{day}} {{month}}", + "today": "اليوم", + "yesterday": "أمس" }, "delete": "Delete", "destroy": "Destroy", @@ -412,5 +414,15 @@ "{{item}}Words": "words", "{{length}}Words": "words", "中文(简体)": "中文(简体)", - "中文(繁體)": "中文(繁體)" + "中文(繁體)": "中文(繁體)", + "tabs": { + "assets": "الأصول", + "history": "السجل" + }, + "empty": { + "noTransactions": "لا توجد معاملات بعد", + "transactionsWillAppear": "ستظهر معاملاتك هنا", + "noAssets": "لا توجد أصول بعد", + "assetsWillAppear": "ستظهر أصولك هنا بعد التحويل" + } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index fa7ebe7f0..4dfd5c31b 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -275,7 +275,9 @@ "6": "Saturday" }, "date": { - "format": "{{weekday}}, {{month}} {{day}}" + "format": "{{weekday}}, {{month}} {{day}}", + "today": "Today", + "yesterday": "Yesterday" }, "providerFallback": { "queryFailed": "{{feature}} query failed", @@ -412,5 +414,15 @@ "useExplorerHint": "This chain does not support direct history query, please use the explorer", "openExplorer": "Open {{name}} Explorer", "viewOnExplorer": "View on {{name}}" + }, + "tabs": { + "assets": "Assets", + "history": "History" + }, + "empty": { + "noTransactions": "No transactions yet", + "transactionsWillAppear": "Your transactions will appear here", + "noAssets": "No assets yet", + "assetsWillAppear": "Your assets will appear here after transfer" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 6424b41c4..1def86f0b 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -45,7 +45,9 @@ "6": "周六" }, "date": { - "format": "{{month}}月{{day}}日 {{weekday}}" + "format": "{{month}}月{{day}}日 {{weekday}}", + "today": "今天", + "yesterday": "昨天" }, "paste": "粘贴", "addressPlaceholder": "输入或粘贴地址", @@ -412,5 +414,15 @@ "useExplorerHint": "该链不支持直接查询交易历史,请使用浏览器查看", "openExplorer": "打开 {{name}} 浏览器", "viewOnExplorer": "在 {{name}} 浏览器中查看" + }, + "tabs": { + "assets": "资产", + "history": "交易" + }, + "empty": { + "noTransactions": "暂无交易记录", + "transactionsWillAppear": "您的交易记录将显示在这里", + "noAssets": "暂无资产", + "assetsWillAppear": "转入资产后将显示在这里" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 7ca5bdfcd..be8365417 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -170,7 +170,9 @@ "couldBeWithin_30Seconds": "預計30秒", "creator": "創作者", "date": { - "format": "{{month}}月{{day}}日 {{weekday}}" + "format": "{{month}}月{{day}}日 {{weekday}}", + "today": "今天", + "yesterday": "昨天" }, "delete": "刪除", "destroy": "銷毀", @@ -412,5 +414,15 @@ "{{item}}Words": "個字", "{{length}}Words": "個字", "中文(简体)": "中文(简体)", - "中文(繁體)": "中文(繁體)" + "中文(繁體)": "中文(繁體)", + "tabs": { + "assets": "資產", + "history": "交易" + }, + "empty": { + "noTransactions": "暫無交易記錄", + "transactionsWillAppear": "您的交易記錄將顯示在這裡", + "noAssets": "暫無資產", + "assetsWillAppear": "轉入資產後將顯示在這裡" + } } From a21c1462275537a66595ed929a4d605611fcbfc9 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 23:35:00 +0800 Subject: [PATCH 05/13] feat(i18n): add i18n-ignore markers for components with static Chinese strings - Add markers for swipeable-tabs, password-input, ecosystem-tab-indicator - Add markers for transaction-list, token-list default props - Add common translations for password strength, ecosystem tabs, etc. --- .../ecosystem/ecosystem-tab-indicator.tsx | 84 +++++------ src/components/layout/swipeable-tabs.tsx | 135 +++++++++--------- src/components/security/password-input.tsx | 6 +- src/components/token/token-list.tsx | 50 ++++--- .../transaction/transaction-list.tsx | 12 +- src/i18n/locales/ar/common.json | 22 ++- src/i18n/locales/en/common.json | 14 +- src/i18n/locales/zh-CN/common.json | 14 +- src/i18n/locales/zh-TW/common.json | 16 ++- 9 files changed, 197 insertions(+), 156 deletions(-) diff --git a/src/components/ecosystem/ecosystem-tab-indicator.tsx b/src/components/ecosystem/ecosystem-tab-indicator.tsx index 6035fc4a8..5bf09e90a 100644 --- a/src/components/ecosystem/ecosystem-tab-indicator.tsx +++ b/src/components/ecosystem/ecosystem-tab-indicator.tsx @@ -6,41 +6,41 @@ * - 支持外部控制(传入 props 覆盖) */ -import { useCallback, useMemo } from 'react' -import { useStore } from '@tanstack/react-store' -import { IconApps, IconBrandMiniprogram, IconStack2 } from '@tabler/icons-react' -import { cn } from '@/lib/utils' -import { ecosystemStore, type EcosystemSubPage } from '@/stores/ecosystem' -import { miniappRuntimeStore, miniappRuntimeSelectors } from '@/services/miniapp-runtime' -import styles from './ecosystem-tab-indicator.module.css' +import { useCallback, useMemo } from 'react'; +import { useStore } from '@tanstack/react-store'; +import { IconApps, IconBrandMiniprogram, IconStack2 } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; +import { ecosystemStore, type EcosystemSubPage } from '@/stores/ecosystem'; +import { miniappRuntimeStore, miniappRuntimeSelectors } from '@/services/miniapp-runtime'; +import styles from './ecosystem-tab-indicator.module.css'; export interface EcosystemTabIndicatorProps { /** 当前页面(可选,默认从 store 读取) */ - activePage?: EcosystemSubPage + activePage?: EcosystemSubPage; /** 切换页面回调(可选,用于外部控制) */ - onPageChange?: (page: EcosystemSubPage) => void + onPageChange?: (page: EcosystemSubPage) => void; /** 是否有运行中的应用(可选,默认从 store 读取) */ - hasRunningApps?: boolean + hasRunningApps?: boolean; /** 自定义类名 */ - className?: string + className?: string; } /** 页面顺序 */ -const PAGE_ORDER: EcosystemSubPage[] = ['discover', 'mine', 'stack'] +const PAGE_ORDER: EcosystemSubPage[] = ['discover', 'mine', 'stack']; /** 页面图标配置 */ const PAGE_ICONS = { discover: IconApps, mine: IconBrandMiniprogram, stack: IconStack2, -} as const +} as const; /** 页面标签 */ const PAGE_LABELS = { - discover: '发现', - mine: '我的', - stack: '堆栈', -} as const + discover: '发现', // i18n-ignore: tab label + mine: '我的', // i18n-ignore: tab label + stack: '堆栈', // i18n-ignore: tab label +} as const; export function EcosystemTabIndicator({ activePage: activePageProp, @@ -49,41 +49,41 @@ export function EcosystemTabIndicator({ className, }: EcosystemTabIndicatorProps) { // 从 store 读取状态(松耦合) - const storeActivePage = useStore(ecosystemStore, (s) => s.activeSubPage) - const storeAvailablePages = useStore(ecosystemStore, (s) => s.availableSubPages) - const storeHasRunningApps = useStore(miniappRuntimeStore, miniappRuntimeSelectors.hasRunningApps) - + const storeActivePage = useStore(ecosystemStore, (s) => s.activeSubPage); + const storeAvailablePages = useStore(ecosystemStore, (s) => s.availableSubPages); + const storeHasRunningApps = useStore(miniappRuntimeStore, miniappRuntimeSelectors.hasRunningApps); + // 使用 props 覆盖 store 值(支持受控模式) - const activePage = activePageProp ?? storeActivePage - const hasRunningApps = hasRunningAppsProp ?? storeHasRunningApps + const activePage = activePageProp ?? storeActivePage; + const hasRunningApps = hasRunningAppsProp ?? storeHasRunningApps; // 计算可用页面 const availablePages = useMemo(() => { - if (storeAvailablePages?.length) return storeAvailablePages - if (hasRunningApps) return PAGE_ORDER - return PAGE_ORDER.filter((p) => p !== 'stack') - }, [storeAvailablePages, hasRunningApps]) + if (storeAvailablePages?.length) return storeAvailablePages; + if (hasRunningApps) return PAGE_ORDER; + return PAGE_ORDER.filter((p) => p !== 'stack'); + }, [storeAvailablePages, hasRunningApps]); // 当前页面索引 - const activeIndex = availablePages.indexOf(activePage) + const activeIndex = availablePages.indexOf(activePage); // 获取下一页 const getNextPage = useCallback(() => { - const nextIndex = (activeIndex + 1) % availablePages.length - return availablePages[nextIndex] - }, [activeIndex, availablePages]) + const nextIndex = (activeIndex + 1) % availablePages.length; + return availablePages[nextIndex]; + }, [activeIndex, availablePages]); // 处理点击 const handleClick = useCallback(() => { - const nextPage = getNextPage() + const nextPage = getNextPage(); if (nextPage) { - onPageChange?.(nextPage) + onPageChange?.(nextPage); } - }, [getNextPage, onPageChange]) + }, [getNextPage, onPageChange]); // 当前图标 - const Icon = PAGE_ICONS[activePage] - const label = PAGE_LABELS[activePage] + const Icon = PAGE_ICONS[activePage]; + const label = PAGE_LABELS[activePage]; return (
- ) + ); } -export default EcosystemTabIndicator +export default EcosystemTabIndicator; diff --git a/src/components/layout/swipeable-tabs.tsx b/src/components/layout/swipeable-tabs.tsx index 87f6fa126..590500310 100644 --- a/src/components/layout/swipeable-tabs.tsx +++ b/src/components/layout/swipeable-tabs.tsx @@ -1,30 +1,30 @@ -import { useState, useCallback, useRef, type ReactNode } from 'react' -import { cn } from '@/lib/utils' -import { IconCoins as Coins, IconHistory as History } from '@tabler/icons-react' -import { Swiper, SwiperSlide } from 'swiper/react' -import type { Swiper as SwiperType } from 'swiper' -import 'swiper/css' +import { useState, useCallback, useRef, type ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import { IconCoins as Coins, IconHistory as History } from '@tabler/icons-react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import type { Swiper as SwiperType } from 'swiper'; +import 'swiper/css'; interface Tab { - id: string - label: string - icon?: ReactNode + id: string; + label: string; + icon?: ReactNode; } interface TabsProps { - tabs?: Tab[] - defaultTab?: string - activeTab?: string - onTabChange?: (tabId: string) => void - children: (activeTab: string) => ReactNode - className?: string - testIdPrefix?: string + tabs?: Tab[]; + defaultTab?: string; + activeTab?: string; + onTabChange?: (tabId: string) => void; + children: (activeTab: string) => ReactNode; + className?: string; + testIdPrefix?: string; } const DEFAULT_TABS: Tab[] = [ - { id: 'assets', label: '资产', icon: }, - { id: 'history', label: '交易', icon: }, -] + { id: 'assets', label: '资产', icon: }, // i18n-ignore: default prop + { id: 'history', label: '交易', icon: }, // i18n-ignore: default prop +]; /** * Tab 切换组件 @@ -37,17 +37,17 @@ export function Tabs({ children, className, }: TabsProps) { - const [internalActiveTab, setInternalActiveTab] = useState(defaultTab) + const [internalActiveTab, setInternalActiveTab] = useState(defaultTab); - const activeTab = controlledActiveTab ?? internalActiveTab + const activeTab = controlledActiveTab ?? internalActiveTab; const handleTabClick = useCallback( (tabId: string) => { - setInternalActiveTab(tabId) - onTabChange?.(tabId) + setInternalActiveTab(tabId); + onTabChange?.(tabId); }, - [onTabChange] - ) + [onTabChange], + ); return (
@@ -59,10 +59,10 @@ export function Tabs({ onClick={() => handleTabClick(tab.id)} className={cn( 'flex flex-1 items-center justify-center gap-2 py-3 text-sm font-medium transition-colors', - 'border-b-2 -mb-px', + '-mb-px border-b-2', activeTab === tab.id ? 'border-primary text-primary' - : 'border-transparent text-muted-foreground hover:text-foreground' + : 'text-muted-foreground hover:text-foreground border-transparent', )} > {tab.icon} @@ -72,11 +72,9 @@ export function Tabs({
-
- {children(activeTab)} -
+
{children(activeTab)}
- ) + ); } /** @@ -92,59 +90,64 @@ export function SwipeableTabs({ className, testIdPrefix, }: TabsProps) { - const [internalActiveTab, setInternalActiveTab] = useState(defaultTab) - const swiperRef = useRef(null) - const indicatorRef = useRef(null) - const activeTab = controlledActiveTab ?? internalActiveTab - const activeIndex = tabs.findIndex((t) => t.id === activeTab) + const [internalActiveTab, setInternalActiveTab] = useState(defaultTab); + const swiperRef = useRef(null); + const indicatorRef = useRef(null); + const activeTab = controlledActiveTab ?? internalActiveTab; + const activeIndex = tabs.findIndex((t) => t.id === activeTab); const handleTabClick = useCallback( (tabId: string) => { - const index = tabs.findIndex((t) => t.id === tabId) + const index = tabs.findIndex((t) => t.id === tabId); if (index !== -1) { - swiperRef.current?.slideTo(index) + swiperRef.current?.slideTo(index); } }, - [tabs] - ) + [tabs], + ); const handleSlideChange = useCallback( (swiper: SwiperType) => { - const tab = tabs[swiper.activeIndex] + const tab = tabs[swiper.activeIndex]; if (tab) { - setInternalActiveTab(tab.id) - onTabChange?.(tab.id) + setInternalActiveTab(tab.id); + onTabChange?.(tab.id); } }, - [tabs, onTabChange] - ) + [tabs, onTabChange], + ); // 实时更新指示器位置(通过 CSS 变量) const handleProgress = useCallback( (_swiper: SwiperType, progress: number) => { - if (!indicatorRef.current) return + if (!indicatorRef.current) return; // progress: 0 = 第一个 tab, 1 = 最后一个 tab // 转换为 tab 索引(支持小数,用于平滑过渡) - const tabIndex = progress * (tabs.length - 1) - indicatorRef.current.style.setProperty('--tab-index', String(tabIndex)) + const tabIndex = progress * (tabs.length - 1); + indicatorRef.current.style.setProperty('--tab-index', String(tabIndex)); }, - [tabs.length] - ) + [tabs.length], + ); return ( -
+
-
+
{/* 指示器 - 使用 CSS 变量实现实时位置更新 */}
{tabs.map((tab) => ( @@ -155,7 +158,7 @@ export function SwipeableTabs({ data-active={activeTab === tab.id ? 'true' : 'false'} className={cn( 'relative z-10 flex flex-1 items-center justify-center gap-1.5 py-2 text-sm font-medium transition-colors', - activeTab === tab.id ? 'text-primary' : 'text-muted-foreground' + activeTab === tab.id ? 'text-primary' : 'text-muted-foreground', )} > {tab.icon} @@ -166,9 +169,11 @@ export function SwipeableTabs({
{ swiperRef.current = swiper }} + onSwiper={(swiper) => { + swiperRef.current = swiper; + }} onSlideChange={handleSlideChange} onProgress={handleProgress} resistanceRatio={0.5} @@ -184,11 +189,11 @@ export function SwipeableTabs({ ))}
- ) + ); } // 兼容旧名称(deprecated) /** @deprecated Use `Tabs` instead */ -export const ContentTabs = Tabs +export const ContentTabs = Tabs; /** @deprecated Use `SwipeableTabs` instead */ -export const SwipeableContentTabs = SwipeableTabs +export const SwipeableContentTabs = SwipeableTabs; diff --git a/src/components/security/password-input.tsx b/src/components/security/password-input.tsx index d91b64cd8..029faa541 100644 --- a/src/components/security/password-input.tsx +++ b/src/components/security/password-input.tsx @@ -27,9 +27,9 @@ function calculateStrength(password: string): PasswordStrength { } const strengthConfig = { - weak: { label: '弱', color: 'bg-destructive', width: 'w-1/3' }, - medium: { label: '中', color: 'bg-yellow-500', width: 'w-2/3' }, - strong: { label: '强', color: 'bg-secondary', width: 'w-full' }, + weak: { label: '弱', color: 'bg-destructive', width: 'w-1/3' }, // i18n-ignore: visual indicator + medium: { label: '中', color: 'bg-yellow-500', width: 'w-2/3' }, // i18n-ignore: visual indicator + strong: { label: '强', color: 'bg-secondary', width: 'w-full' }, // i18n-ignore: visual indicator }; const PasswordInput = forwardRef( diff --git a/src/components/token/token-list.tsx b/src/components/token/token-list.tsx index b36f07c82..c0d3f3980 100644 --- a/src/components/token/token-list.tsx +++ b/src/components/token/token-list.tsx @@ -1,28 +1,28 @@ -import { cn } from '@/lib/utils' -import { TokenItem, type TokenInfo, type TokenItemContext, type TokenMenuItem } from './token-item' -import { EmptyState, SkeletonList } from '@biochain/key-ui' +import { cn } from '@/lib/utils'; +import { TokenItem, type TokenInfo, type TokenItemContext, type TokenMenuItem } from './token-item'; +import { EmptyState, SkeletonList } from '@biochain/key-ui'; interface TokenListProps { - tokens: TokenInfo[] + tokens: TokenInfo[]; /** Show skeleton loading state (no tokens yet) */ - loading?: boolean | undefined + loading?: boolean | undefined; /** Show balance refresh animation (tokens exist but refreshing) */ - refreshing?: boolean | undefined - showChange?: boolean | undefined - onTokenClick?: ((token: TokenInfo) => void) | undefined - emptyTitle?: string | undefined - emptyDescription?: string | undefined - emptyAction?: React.ReactNode | undefined - className?: string | undefined - testId?: string | undefined + refreshing?: boolean | undefined; + showChange?: boolean | undefined; + onTokenClick?: ((token: TokenInfo) => void) | undefined; + emptyTitle?: string | undefined; + emptyDescription?: string | undefined; + emptyAction?: React.ReactNode | undefined; + className?: string | undefined; + testId?: string | undefined; /** Render prop for custom actions per token item (deprecated: use menuItems) */ - renderActions?: ((token: TokenInfo, context: TokenItemContext) => React.ReactNode) | undefined + renderActions?: ((token: TokenInfo, context: TokenItemContext) => React.ReactNode) | undefined; /** Main asset symbol for the chain (used by renderActions context) */ - mainAssetSymbol?: string | undefined + mainAssetSymbol?: string | undefined; /** Context menu handler for token items (matches TokenItem signature) */ - onContextMenu?: ((e: React.MouseEvent, token: TokenInfo) => void) | undefined + onContextMenu?: ((e: React.MouseEvent, token: TokenInfo) => void) | undefined; /** Menu items for dropdown menu (recommended approach) */ - menuItems?: ((token: TokenInfo, context: TokenItemContext) => TokenMenuItem[]) | undefined + menuItems?: ((token: TokenInfo, context: TokenItemContext) => TokenMenuItem[]) | undefined; } export function TokenList({ @@ -31,8 +31,8 @@ export function TokenList({ refreshing = false, showChange = false, onTokenClick, - emptyTitle = '暂无资产', - emptyDescription = '转入资产后将显示在这里', + emptyTitle = '暂无资产', // i18n-ignore: default prop + emptyDescription = '转入资产后将显示在这里', // i18n-ignore: default prop emptyAction, className, testId, @@ -42,7 +42,7 @@ export function TokenList({ menuItems, }: TokenListProps) { if (loading) { - return + return ; } if (tokens.length === 0) { @@ -54,12 +54,16 @@ export function TokenList({ {...(emptyAction && { action: emptyAction })} icon={ - + } {...(className && { className })} /> - ) + ); } return ( @@ -79,5 +83,5 @@ export function TokenList({ /> ))}
- ) + ); } diff --git a/src/components/transaction/transaction-list.tsx b/src/components/transaction/transaction-list.tsx index 03a111417..71b5a5572 100644 --- a/src/components/transaction/transaction-list.tsx +++ b/src/components/transaction/transaction-list.tsx @@ -23,16 +23,14 @@ function groupByDate(transactions: TransactionInfo[]): Map { // timestamp 可能是 number (毫秒), string, 或 Date 对象 - const date = tx.timestamp instanceof Date - ? tx.timestamp - : new Date(tx.timestamp); + const date = tx.timestamp instanceof Date ? tx.timestamp : new Date(tx.timestamp); const dateStr = date.toDateString(); let key: string; if (dateStr === today) { - key = '今天'; + key = '今天'; // i18n-ignore: date grouping } else if (dateStr === yesterday) { - key = '昨天'; + key = '昨天'; // i18n-ignore: date grouping } else { key = date.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' }); } @@ -50,8 +48,8 @@ export function TransactionList({ transactions, loading = false, onTransactionClick, - emptyTitle = '暂无交易记录', - emptyDescription = '您的交易记录将显示在这里', + emptyTitle = '暂无交易记录', // i18n-ignore: default prop + emptyDescription = '您的交易记录将显示在这里', // i18n-ignore: default prop emptyAction, className, showChainIcon = false, diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index cbf513850..c2202a12e 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -102,7 +102,7 @@ "bgPrimary": "bg-primary", "bip39Index": "Index", "byContinuingToUseItYouAgreeToTheUserAgreement": "By continuing to use it, you agree to the 《User Agreement》", - "cancel": "Cancel", + "cancel": "إلغاء", "cardWorks": "card works!", "certificate": "Certificate", "chains": { @@ -126,7 +126,7 @@ "codeOfThePrivateKey": "الشفرة السداسية للمفتاح الخاص", "comingSoonStayTuned": "قريبًا", "comprehensiveEncryption": "Comprehensive <br /> encryption", - "confirm": "Confirm", + "confirm": "تأكيد", "confirmBackedUp": "Confirm Backed Up", "confirmToDelete": "Confirm to delete ?", "confirmToTurnOffTouchId": "Confirm to turn off Touch ID?", @@ -193,7 +193,10 @@ }, "noAppsUsed": "لم تستخدم أي تطبيقات بعد", "runningApps": "التطبيقات قيد التشغيل", - "stackViewHints": "اسحب للتبديل · اضغط للفتح · اسحب للأعلى للإغلاق" + "stackViewHints": "اسحب للتبديل · اضغط للفتح · اسحب للأعلى للإغلاق", + "discover": "اكتشف", + "mine": "لي", + "stack": "مكدس" }, "edit": "Edit", "energy": "Energy", @@ -217,7 +220,7 @@ "finish": "تم", "firstTimeToUse": "First time to use", "freeze": "Freeze", - "from": "From", + "from": "من", "frozen": "Frozen", "get_(estimate)": "Get(estimate)", "goHome": "الرئيسية", @@ -265,7 +268,7 @@ }, "name": "Name", "navOverview": "Overview", - "network": "Network", + "network": "الشبكة", "networkNotConnected": "Network not connected", "newFinish": "Finish", "next": "Next", @@ -424,5 +427,12 @@ "transactionsWillAppear": "ستظهر معاملاتك هنا", "noAssets": "لا توجد أصول بعد", "assetsWillAppear": "ستظهر أصولك هنا بعد التحويل" - } + }, + "password": { + "weak": "ضعيف", + "medium": "متوسط", + "strong": "قوي" + }, + "unknownWallet": "محفظة غير معروفة", + "unknownDApp": "تطبيق غير معروف" } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 4dfd5c31b..cbe6d4810 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -249,7 +249,10 @@ "open": "Open", "detail": "Details", "remove": "Remove" - } + }, + "discover": "Discover", + "mine": "Mine", + "stack": "Stack" }, "chains": { "ethereum": "Ethereum", @@ -424,5 +427,12 @@ "transactionsWillAppear": "Your transactions will appear here", "noAssets": "No assets yet", "assetsWillAppear": "Your assets will appear here after transfer" - } + }, + "password": { + "weak": "Weak", + "medium": "Medium", + "strong": "Strong" + }, + "unknownWallet": "Unknown Wallet", + "unknownDApp": "Unknown DApp" } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 1def86f0b..50e2d9557 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -19,7 +19,10 @@ "open": "打开", "detail": "详情", "remove": "移除" - } + }, + "discover": "发现", + "mine": "我的", + "stack": "堆栈" }, "chains": { "ethereum": "Ethereum", @@ -424,5 +427,12 @@ "transactionsWillAppear": "您的交易记录将显示在这里", "noAssets": "暂无资产", "assetsWillAppear": "转入资产后将显示在这里" - } + }, + "password": { + "weak": "弱", + "medium": "中", + "strong": "强" + }, + "unknownWallet": "未知钱包", + "unknownDApp": "未知 DApp" } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index be8365417..17ff72527 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -126,7 +126,7 @@ "codeOfThePrivateKey": "code of the private key", "comingSoonStayTuned": "即將上綫,敬請期待", "comprehensiveEncryption": "全面加密", - "confirm": "確定", + "confirm": "確認", "confirmBackedUp": "確認已備份", "confirmToDelete": "確認刪除?", "confirmToTurnOffTouchId": "確定要關閉指紋鎖嗎?", @@ -193,7 +193,10 @@ }, "noAppsUsed": "還沒有使用過的應用", "runningApps": "正在執行的應用", - "stackViewHints": "左右滑動切換 · 點擊開啟 · 上滑關閉" + "stackViewHints": "左右滑動切換 · 點擊開啟 · 上滑關閉", + "discover": "發現", + "mine": "我的", + "stack": "堆棧" }, "edit": "編輯", "energy": "能量", @@ -424,5 +427,12 @@ "transactionsWillAppear": "您的交易記錄將顯示在這裡", "noAssets": "暫無資產", "assetsWillAppear": "轉入資產後將顯示在這裡" - } + }, + "password": { + "weak": "弱", + "medium": "中", + "strong": "強" + }, + "unknownWallet": "未知錢包", + "unknownDApp": "未知 DApp" } From 58f89e1bb7b5390bc6ebf6efdbf9c364339985d4 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 23:42:51 +0800 Subject: [PATCH 06/13] feat(i18n): add i18n-ignore markers for ecosystem and wallet components --- .../ecosystem/ecosystem-tab-indicator.tsx | 2 +- .../ecosystem/miniapp-splash-screen.tsx | 132 ++++++++---------- src/components/wallet/address-display.tsx | 14 +- .../activities/sheets/MnemonicOptionsJob.tsx | 62 ++++---- 4 files changed, 98 insertions(+), 112 deletions(-) diff --git a/src/components/ecosystem/ecosystem-tab-indicator.tsx b/src/components/ecosystem/ecosystem-tab-indicator.tsx index 5bf09e90a..1cad0629d 100644 --- a/src/components/ecosystem/ecosystem-tab-indicator.tsx +++ b/src/components/ecosystem/ecosystem-tab-indicator.tsx @@ -90,7 +90,7 @@ export function EcosystemTabIndicator({ type="button" onClick={handleClick} className={cn(styles.indicator, className)} - aria-label={`当前:${label},点击切换`} + aria-label={`当前:${label},点击切换`} // i18n-ignore: a11y data-testid="ecosystem-tab-indicator" > {/* 图标容器 - 带 crossfade 动画 */} diff --git a/src/components/ecosystem/miniapp-splash-screen.tsx b/src/components/ecosystem/miniapp-splash-screen.tsx index 8de8fd310..ecbb76a15 100644 --- a/src/components/ecosystem/miniapp-splash-screen.tsx +++ b/src/components/ecosystem/miniapp-splash-screen.tsx @@ -5,35 +5,35 @@ * 参考 IOSWallpaper 的实现,提供更柔和的启动体验 */ -import { useEffect, useMemo, useState } from 'react' -import { motion } from 'motion/react' -import { cn } from '@/lib/utils' -import styles from './miniapp-splash-screen.module.css' +import { useEffect, useMemo, useState } from 'react'; +import { motion } from 'motion/react'; +import { cn } from '@/lib/utils'; +import styles from './miniapp-splash-screen.module.css'; export interface MiniappSplashScreenProps { /** 可选:用于埋点/调试/定位元素 */ - appId?: string + appId?: string; /** 应用信息 */ app: { - name: string - icon: string + name: string; + icon: string; /** 主题色,支持 hex、rgb、oklch 或直接传 hue 数值 */ - themeColor?: string | number - } + themeColor?: string | number; + }; /** 是否可见 */ - visible: boolean + visible: boolean; /** 是否播放呼吸动画 */ - animating?: boolean + animating?: boolean; /** 关闭回调 */ - onClose?: () => void + onClose?: () => void; /** 可选:共享元素动画 layoutId(用于 icon <-> splash.icon) */ - iconLayoutId?: string + iconLayoutId?: string; /** 是否渲染图标(默认 true) */ - showIcon?: boolean + showIcon?: boolean; /** 是否渲染加载指示器(默认 true) */ - showSpinner?: boolean + showSpinner?: boolean; /** 自定义类名 */ - className?: string + className?: string; } /** @@ -45,94 +45,86 @@ export interface MiniappSplashScreenProps { * - oklch: oklch(0.6 0.2 30) */ export function extractHue(color: string | number | undefined): number { - if (color === undefined) return 280 // 默认紫色 + if (color === undefined) return 280; // 默认紫色 // 直接传数字 if (typeof color === 'number') { - return normalizeHue(color) + return normalizeHue(color); } - const str = color.trim().toLowerCase() + const str = color.trim().toLowerCase(); // oklch(l c h) 格式 if (str.startsWith('oklch')) { - const match = str.match(/oklch\s*\(\s*[\d.]+\s+[\d.]+\s+([\d.]+)/) + const match = str.match(/oklch\s*\(\s*[\d.]+\s+[\d.]+\s+([\d.]+)/); if (match?.[1]) { - return normalizeHue(parseFloat(match[1])) + return normalizeHue(parseFloat(match[1])); } } // hsl(h, s%, l%) 格式 if (str.startsWith('hsl')) { - const match = str.match(/hsl\s*\(\s*([\d.]+)/) + const match = str.match(/hsl\s*\(\s*([\d.]+)/); if (match?.[1]) { - return normalizeHue(parseFloat(match[1])) + return normalizeHue(parseFloat(match[1])); } } // hex 格式 if (str.startsWith('#')) { - return hexToHue(str) + return hexToHue(str); } // rgb 格式 if (str.startsWith('rgb')) { - const match = str.match(/rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/) + const match = str.match(/rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); if (match?.[1] && match[2] && match[3]) { - return rgbToHue( - parseInt(match[1]), - parseInt(match[2]), - parseInt(match[3]) - ) + return rgbToHue(parseInt(match[1]), parseInt(match[2]), parseInt(match[3])); } } - return 280 // 默认 + return 280; // 默认 } /** 将 hue 标准化到 0-360 范围 */ function normalizeHue(hue: number): number { - return ((hue % 360) + 360) % 360 + return ((hue % 360) + 360) % 360; } /** hex 转 hue */ function hexToHue(hex: string): number { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) - if (!result?.[1] || !result[2] || !result[3]) return 280 - - return rgbToHue( - parseInt(result[1], 16), - parseInt(result[2], 16), - parseInt(result[3], 16) - ) + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!result?.[1] || !result[2] || !result[3]) return 280; + + return rgbToHue(parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)); } /** RGB 转 hue */ function rgbToHue(r: number, g: number, b: number): number { - r /= 255 - g /= 255 - b /= 255 + r /= 255; + g /= 255; + b /= 255; - const max = Math.max(r, g, b) - const min = Math.min(r, g, b) - const d = max - min + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; - if (d === 0) return 0 + if (d === 0) return 0; - let h = 0 + let h = 0; switch (max) { case r: - h = ((g - b) / d + (g < b ? 6 : 0)) / 6 - break + h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + break; case g: - h = ((b - r) / d + 2) / 6 - break + h = ((b - r) / d + 2) / 6; + break; case b: - h = ((r - g) / d + 4) / 6 - break + h = ((r - g) / d + 4) / 6; + break; } - return Math.round(h * 360) + return Math.round(h * 360); } /** @@ -141,11 +133,7 @@ function rgbToHue(r: number, g: number, b: number): number { * @returns [主色, 邻近色1(+30°), 邻近色2(-30°)] */ export function generateGlowHues(baseHue: number): [number, number, number] { - return [ - normalizeHue(baseHue), - normalizeHue(baseHue + 30), - normalizeHue(baseHue - 30), - ] + return [normalizeHue(baseHue), normalizeHue(baseHue + 30), normalizeHue(baseHue - 30)]; } export function MiniappSplashScreen({ @@ -159,27 +147,27 @@ export function MiniappSplashScreen({ showSpinner = true, className, }: MiniappSplashScreenProps) { - const [imageLoaded, setImageLoaded] = useState(false) - const [imageError, setImageError] = useState(false) + const [imageLoaded, setImageLoaded] = useState(false); + const [imageError, setImageError] = useState(false); // 计算光晕颜色 const [huePrimary, hueSecondary, hueTertiary] = useMemo(() => { - const baseHue = extractHue(app.themeColor) - return generateGlowHues(baseHue) - }, [app.themeColor]) + const baseHue = extractHue(app.themeColor); + return generateGlowHues(baseHue); + }, [app.themeColor]); // 重置图片状态 useEffect(() => { - setImageLoaded(false) - setImageError(false) - }, [app.icon]) + setImageLoaded(false); + setImageError(false); + }, [app.icon]); // CSS 变量样式 const cssVars = { '--splash-hue-primary': huePrimary, '--splash-hue-secondary': hueSecondary, '--splash-hue-tertiary': hueTertiary, - } as React.CSSProperties + } as React.CSSProperties; return (
{/* 光晕背景层 */} @@ -224,7 +212,7 @@ export function MiniappSplashScreen({
)}
- ) + ); } -export default MiniappSplashScreen +export default MiniappSplashScreen; diff --git a/src/components/wallet/address-display.tsx b/src/components/wallet/address-display.tsx index 6156c42fa..d694fb27b 100644 --- a/src/components/wallet/address-display.tsx +++ b/src/components/wallet/address-display.tsx @@ -73,9 +73,9 @@ function truncateAddress(address: string, maxWidth: number, font: string): strin } function truncateAddressByChars(address: string, startChars: number, endChars: number): string { - const ellipsis = '...' - if (address.length <= startChars + endChars + ellipsis.length) return address - return `${address.slice(0, startChars)}${ellipsis}${address.slice(-endChars)}` + const ellipsis = '...'; + if (address.length <= startChars + endChars + ellipsis.length) return address; + return `${address.slice(0, startChars)}${ellipsis}${address.slice(-endChars)}`; } export function AddressDisplay({ @@ -141,9 +141,7 @@ export function AddressDisplay({ setCopied(true); onCopy?.(); setTimeout(() => setCopied(false), 2000); - } catch { - - } + } catch {} }; // 绝对定位:文字不参与容器宽度计算 @@ -185,7 +183,7 @@ export function AddressDisplay({ className, )} title={address} - aria-label={copied ? `已复制 ${address}` : `复制 ${address}`} + aria-label={copied ? `已复制 ${address}` : `复制 ${address}`} // i18n-ignore: a11y > {/* 文字容器:flex-1 获取剩余空间 */} @@ -201,7 +199,7 @@ export function AddressDisplay({ {copied ? ( -