From 4715dd7ffef37527fe23c9ea243834cb0c660f70 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 22:12:13 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(miniapps/teleport):=20=E5=AF=B9?= =?UTF-8?q?=E6=8E=A5=E4=B8=80=E9=94=AE=E4=BC=A0=E9=80=81=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 API 服务层 (src/api/) - types.ts: 定义传送相关的类型 - client.ts: API 客户端,对接 MetaBox payment 后端 - hooks.ts: React Query hooks 管理 API 状态 - 更新 App.tsx - 集成 TanStack Query 管理数据 - 从后端获取真实的 assetTypeList 配置 - 实现完整的传送流程(签名 -> 发送 -> 轮询状态) - 添加订单状态监控和错误处理 - 更新 i18n 翻译文件 - 添加新的翻译 key(processing, wallet, confirm 等) - 同步更新 zh/zh-CN/zh-TW/en 所有语言 - 更新测试用例 - 添加 QueryClientProvider wrapper - Mock API 调用 - 安装依赖 - @tanstack/react-query - @bnqkl/metabox-core@0.5.2 - @bnqkl/wallet-typings@0.23.8 --- miniapps/teleport/package.json | 3 + miniapps/teleport/src/App.test.tsx | 90 +++- miniapps/teleport/src/App.tsx | 414 +++++++++++++----- miniapps/teleport/src/api/client.ts | 125 ++++++ miniapps/teleport/src/api/hooks.ts | 149 +++++++ miniapps/teleport/src/api/index.ts | 7 + miniapps/teleport/src/api/types.ts | 201 +++++++++ miniapps/teleport/src/i18n/locales/en.json | 60 ++- miniapps/teleport/src/i18n/locales/zh-CN.json | 62 ++- miniapps/teleport/src/i18n/locales/zh-TW.json | 62 ++- miniapps/teleport/src/i18n/locales/zh.json | 62 ++- miniapps/teleport/src/main.tsx | 14 +- pnpm-lock.yaml | 26 +- 13 files changed, 1082 insertions(+), 193 deletions(-) create mode 100644 miniapps/teleport/src/api/client.ts create mode 100644 miniapps/teleport/src/api/hooks.ts create mode 100644 miniapps/teleport/src/api/index.ts create mode 100644 miniapps/teleport/src/api/types.ts diff --git a/miniapps/teleport/package.json b/miniapps/teleport/package.json index 7ea8e513..aa648f3c 100644 --- a/miniapps/teleport/package.json +++ b/miniapps/teleport/package.json @@ -29,10 +29,13 @@ "@base-ui/react": "^1.0.0", "@biochain/bio-sdk": "workspace:*", "@biochain/keyapp-sdk": "workspace:*", + "@bnqkl/metabox-core": "0.5.2", + "@bnqkl/wallet-typings": "0.23.8", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@tanstack/react-query": "^5.90.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.23.26", diff --git a/miniapps/teleport/src/App.test.tsx b/miniapps/teleport/src/App.test.tsx index 54ea97f7..81143d4a 100644 --- a/miniapps/teleport/src/App.test.tsx +++ b/miniapps/teleport/src/App.test.tsx @@ -1,7 +1,34 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' +// Mock the API client +vi.mock('./api/client', () => ({ + getTransmitAssetTypeList: vi.fn().mockResolvedValue({ + transmitSupport: { + ETH: { + ETH: { + enable: true, + isAirdrop: false, + assetType: 'ETH', + recipientAddress: '0x123', + targetChain: 'BFMCHAIN', + targetAsset: 'BFM', + ratio: { numerator: 1, denominator: 1 }, + transmitDate: { + startDate: '2020-01-01', + endDate: '2030-12-31', + }, + }, + }, + }, + }), + transmit: vi.fn(), + getTransmitRecords: vi.fn(), + getTransmitRecordDetail: vi.fn(), +})) + // Mock bio SDK const mockBio = { request: vi.fn(), @@ -10,18 +37,36 @@ const mockBio = { isConnected: vi.fn(() => true), } +// Create wrapper with QueryClient +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + describe('Teleport App', () => { beforeEach(() => { vi.clearAllMocks() ;(window as unknown as { bio: typeof mockBio }).bio = mockBio }) - it('should render initial connect step', () => { - render() + it('should render initial connect step', async () => { + render(, { wrapper: createWrapper() }) expect(screen.getByText('一键传送')).toBeInTheDocument() expect(screen.getByText('跨钱包传送')).toBeInTheDocument() - expect(screen.getByRole('button', { name: '启动传送门' })).toBeInTheDocument() + + // Wait for the button to change from loading to ready + await waitFor(() => { + expect(screen.getByRole('button', { name: '启动传送门' })).toBeInTheDocument() + }) }) it('should show loading state when connecting', async () => { @@ -29,7 +74,12 @@ describe('Teleport App', () => { () => new Promise((resolve) => setTimeout(() => resolve({ address: '0x123', chain: 'bioforest' }), 1000)) ) - render() + render(, { wrapper: createWrapper() }) + + // Wait for the button to be ready + await waitFor(() => { + expect(screen.getByRole('button', { name: '启动传送门' })).toBeInTheDocument() + }) fireEvent.click(screen.getByRole('button', { name: '启动传送门' })) @@ -37,9 +87,14 @@ describe('Teleport App', () => { }) it('should proceed to select-asset after connecting', async () => { - mockBio.request.mockResolvedValue({ address: '0x123', chain: 'bioforest' }) + mockBio.request.mockResolvedValue({ address: '0x123', chain: 'ETH' }) - render() + render(, { wrapper: createWrapper() }) + + // Wait for the button to be ready + await waitFor(() => { + expect(screen.getByRole('button', { name: '启动传送门' })).toBeInTheDocument() + }) fireEvent.click(screen.getByRole('button', { name: '启动传送门' })) @@ -51,7 +106,12 @@ describe('Teleport App', () => { it('should show error when bio SDK not initialized', async () => { ;(window as unknown as { bio: undefined }).bio = undefined - render() + render(, { wrapper: createWrapper() }) + + // Wait for the button to be ready + await waitFor(() => { + expect(screen.getByRole('button', { name: '启动传送门' })).toBeInTheDocument() + }) fireEvent.click(screen.getByRole('button', { name: '启动传送门' })) @@ -63,7 +123,12 @@ describe('Teleport App', () => { it('should show error when connection fails', async () => { mockBio.request.mockRejectedValue(new Error('Network error')) - render() + render(, { wrapper: createWrapper() }) + + // Wait for the button to be ready + await waitFor(() => { + expect(screen.getByRole('button', { name: '启动传送门' })).toBeInTheDocument() + }) fireEvent.click(screen.getByRole('button', { name: '启动传送门' })) @@ -73,9 +138,14 @@ describe('Teleport App', () => { }) it('should call bio_selectAccount on connect', async () => { - mockBio.request.mockResolvedValue({ address: '0x123', chain: 'bioforest' }) + mockBio.request.mockResolvedValue({ address: '0x123', chain: 'ETH' }) + + render(, { wrapper: createWrapper() }) - render() + // Wait for the button to be ready + await waitFor(() => { + expect(screen.getByRole('button', { name: '启动传送门' })).toBeInTheDocument() + }) fireEvent.click(screen.getByRole('button', { name: '启动传送门' })) diff --git a/miniapps/teleport/src/App.tsx b/miniapps/teleport/src/App.tsx index cb439bfb..9af63057 100644 --- a/miniapps/teleport/src/App.tsx +++ b/miniapps/teleport/src/App.tsx @@ -1,5 +1,6 @@ -import { useState, useCallback, useEffect } from 'react' -import type { BioAccount } from '@biochain/bio-sdk' +import { useState, useCallback, useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import type { BioAccount, BioSignedTransaction } from '@biochain/bio-sdk' import { Button } from '@/components/ui/button' import { Card, CardContent, CardTitle, CardDescription } from '@/components/ui/card' import { Input } from '@/components/ui/input' @@ -9,44 +10,76 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { AuroraBackground } from './components/AuroraBackground' import { motion, AnimatePresence } from 'framer-motion' import { cn } from '@/lib/utils' -import { ChevronLeft, Zap, ArrowDown, Check, Coins, Leaf, DollarSign, Wallet, Loader2 } from 'lucide-react' +import { ChevronLeft, Zap, ArrowDown, Check, Coins, Leaf, DollarSign, Wallet, Loader2, AlertCircle, RefreshCw } from 'lucide-react' +import './i18n' +import { + useTransmitAssetTypeList, + useTransmit, + useTransmitRecordDetail, + type DisplayAsset, + type FromTrJson, + type ToTrInfo, + type InternalChainName, + type TransferAssetTransaction, + SWAP_ORDER_STATE_ID, +} from './api' -type Step = 'connect' | 'select-asset' | 'input-amount' | 'select-target' | 'confirm' | 'success' +type Step = 'connect' | 'select-asset' | 'input-amount' | 'select-target' | 'confirm' | 'processing' | 'success' | 'error' -interface Asset { - id: string - symbol: string - name: string - balance: string - chain: string -} - -const MOCK_ASSETS: Asset[] = [ - { id: 'bfm', symbol: 'BFM', name: 'BioForest', balance: '1,234.56', chain: 'bioforest' }, - { id: 'eth', symbol: 'ETH', name: 'Ethereum', balance: '2.5', chain: 'ethereum' }, - { id: 'usdt', symbol: 'USDT', name: 'Tether', balance: '500.00', chain: 'ethereum' }, -] - -const ASSET_COLORS: Record = { - BFM: 'bg-emerald-600', +const CHAIN_COLORS: Record = { ETH: 'bg-indigo-600', - USDT: 'bg-teal-600', + BSC: 'bg-amber-600', + TRON: 'bg-red-600', + BFMCHAIN: 'bg-emerald-600', + ETHMETA: 'bg-purple-600', + PMCHAIN: 'bg-cyan-600', } export default function App() { + const { t } = useTranslation() const [step, setStep] = useState('connect') const [sourceAccount, setSourceAccount] = useState(null) const [targetAccount, setTargetAccount] = useState(null) - const [selectedAsset, setSelectedAsset] = useState(null) + const [selectedAsset, setSelectedAsset] = useState(null) const [amount, setAmount] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [orderId, setOrderId] = useState(null) + + // API Hooks + const { data: assets, isLoading: assetsLoading, error: assetsError } = useTransmitAssetTypeList() + const transmitMutation = useTransmit() + const { data: recordDetail } = useTransmitRecordDetail(orderId || '', { enabled: !!orderId }) + + // 监听订单状态变化 + useEffect(() => { + if (!recordDetail) return + + if (recordDetail.orderState === SWAP_ORDER_STATE_ID.SUCCESS) { + setStep('success') + } else if ( + recordDetail.orderState === SWAP_ORDER_STATE_ID.FROM_TX_ON_CHAIN_FAIL || + recordDetail.orderState === SWAP_ORDER_STATE_ID.TO_TX_ON_CHAIN_FAIL + ) { + setError(recordDetail.orderFailReason || '交易失败') + setStep('error') + } + }, [recordDetail]) // 关闭启动屏 useEffect(() => { window.bio?.request({ method: 'bio_closeSplashScreen' }) }, []) + // 过滤可用资产(根据源账户的链) + const availableAssets = useMemo(() => { + if (!assets || !sourceAccount) return [] + return assets.filter(asset => { + // 匹配链名(大小写不敏感) + return asset.chain.toLowerCase() === sourceAccount.chain.toLowerCase() + }) + }, [assets, sourceAccount]) + const handleConnect = useCallback(async () => { if (!window.bio) { setError('Bio SDK 未初始化') @@ -68,8 +101,22 @@ export default function App() { } }, []) - const handleSelectAsset = (asset: Asset) => { + const handleSelectAsset = async (asset: DisplayAsset) => { setSelectedAsset(asset) + + // 获取该资产的余额 + if (window.bio && sourceAccount) { + try { + const balance = await window.bio.request({ + method: 'bio_getBalance', + params: [{ address: sourceAccount.address, chain: sourceAccount.chain }], + }) + setSelectedAsset({ ...asset, balance }) + } catch { + // 如果获取余额失败,使用默认值 + } + } + setStep('input-amount') } @@ -89,7 +136,10 @@ export default function App() { try { const account = await window.bio.request({ method: 'bio_pickWallet', - params: [{ chain: selectedAsset?.chain, exclude: sourceAccount.address }], + params: [{ + chain: selectedAsset?.targetChain, + exclude: sourceAccount.address, + }], }) setTargetAccount(account) setStep('confirm') @@ -101,27 +151,72 @@ export default function App() { }, [sourceAccount, selectedAsset]) const handleConfirm = useCallback(async () => { - if (!window.bio || !sourceAccount || !selectedAsset) return + if (!window.bio || !sourceAccount || !selectedAsset || !targetAccount) return setLoading(true) setError(null) + try { - await window.bio.request<{ txHash: string }>({ - method: 'bio_sendTransaction', + // 1. 创建未签名交易(转账到 recipientAddress) + const unsignedTx = await window.bio.request({ + method: 'bio_createTransaction', params: [{ from: sourceAccount.address, - to: targetAccount?.address, + to: selectedAsset.recipientAddress, amount: amount, - chain: selectedAsset.chain, - asset: selectedAsset.symbol, + chain: sourceAccount.chain, + asset: selectedAsset.assetType, }], }) - setStep('success') + + // 2. 签名交易 + const signedTx = await window.bio.request({ + method: 'bio_signTransaction', + params: [{ + from: sourceAccount.address, + chain: sourceAccount.chain, + unsignedTx, + }], + }) + + // 3. 构造 fromTrJson(根据链类型) + const fromTrJson: FromTrJson = {} + const chainLower = sourceAccount.chain.toLowerCase() + + if (chainLower === 'eth') { + fromTrJson.eth = { signTransData: signedTx.signature } + } else if (chainLower === 'bsc') { + fromTrJson.bsc = { signTransData: signedTx.signature } + } else { + // 内链交易 + fromTrJson.bcf = { + chainName: sourceAccount.chain as InternalChainName, + trJson: signedTx.data as TransferAssetTransaction, + } + } + + // 4. 构造 toTrInfo + const toTrInfo: ToTrInfo = { + chainName: selectedAsset.targetChain, + address: targetAccount.address, + assetType: selectedAsset.targetAsset, + } + + // 5. 发起传送请求 + setStep('processing') + const result = await transmitMutation.mutateAsync({ + fromTrJson, + toTrInfo, + }) + + setOrderId(result.orderId) + // 状态变化由 useEffect 监听 recordDetail 来处理 } catch (err) { - setError(err instanceof Error ? err.message : '转账失败') + setError(err instanceof Error ? err.message : '传送失败') + setStep('error') } finally { setLoading(false) } - }, [sourceAccount, targetAccount, selectedAsset, amount]) + }, [sourceAccount, targetAccount, selectedAsset, amount, transmitMutation]) const handleReset = useCallback(() => { setStep('connect') @@ -130,6 +225,7 @@ export default function App() { setSelectedAsset(null) setAmount('') setError(null) + setOrderId(null) }, []) const handleBack = () => { @@ -139,36 +235,49 @@ export default function App() { 'select-target': 'input-amount', 'confirm': 'select-target', 'connect': 'connect', + 'processing': 'processing', 'success': 'success', + 'error': 'confirm', } setStep(backMap[step]) + setError(null) } + // 计算预期接收金额 + const expectedReceive = useMemo(() => { + if (!selectedAsset || !amount) return '0' + const { numerator, denominator } = selectedAsset.ratio + const amountNum = parseFloat(amount) + const ratioNum = Number(numerator) / Number(denominator) + return (amountNum * ratioNum).toFixed(8).replace(/\.?0+$/, '') + }, [selectedAsset, amount]) + return (
{/* Header */}
- {!['connect', 'success'].includes(step) ? ( + {!['connect', 'success', 'processing'].includes(step) ? ( ) :
} -

一键传送

+

{t('app.title')}

{/* Content */}
- {error && ( + {error && step !== 'error' && ( - + + {error} @@ -195,19 +304,23 @@ export default function App() {
-

跨钱包传送

-

安全地将资产转移到另一个钱包

+

{t('app.subtitle')}

+

{t('app.description')}

+ + {assetsError && ( +

{t('connect.configError')}

+ )} )} @@ -220,36 +333,44 @@ export default function App() { exit={{ opacity: 0, x: -20 }} className="flex-1 flex flex-col gap-4" > - +
- 选择资产 - {MOCK_ASSETS.map((asset, i) => ( - - handleSelectAsset(asset)} + {t('asset.select')} + {availableAssets.length === 0 ? ( + + + {t('asset.noAssets')} + + + ) : ( + availableAssets.map((asset, i) => ( + - - -
- {asset.symbol} - {asset.name} -
-
-
{asset.balance}
- 可用 -
-
-
-
- ))} + handleSelectAsset(asset)} + > + + +
+ {asset.symbol} + {asset.chain} → {asset.targetChain} +
+
+
{asset.balance || '-'}
+ {t('asset.balance')} +
+
+
+ + )) + )}
)} @@ -263,14 +384,14 @@ export default function App() { exit={{ opacity: 0, x: -20 }} className="flex-1 flex flex-col gap-4" > - + - +
{selectedAsset.symbol} - 可用: {selectedAsset.balance} + {t('asset.balance')}: {selectedAsset.balance || '-'}
- + {selectedAsset.balance && ( + + )}
+ {amount && ( +

+ {t('amount.expected')}: {expectedReceive} {selectedAsset.targetAsset} +

+ )}
)} @@ -309,11 +437,14 @@ export default function App() { > - 即将传送 + {t('target.willTransfer')}
- + {amount} {selectedAsset?.symbol}
+

+ → {expectedReceive} {selectedAsset?.targetAsset} +

@@ -327,8 +458,8 @@ export default function App() {
-

请选择接收资产的

-

目标钱包

+

{t('target.selectOn')} {selectedAsset?.targetChain} {t('target.chainTarget')}

+

{t('wallet.target')}

)} @@ -355,9 +486,9 @@ export default function App() {
- 发送 + {t('confirm.send')}
- + {amount} {selectedAsset?.symbol}
@@ -368,9 +499,15 @@ export default function App() { -
- - +
+ {t('confirm.receive')} +
+ {expectedReceive} {selectedAsset?.targetAsset} +
+
+
+ +
@@ -378,13 +515,23 @@ export default function App() {
- 网络 + {t('confirm.sourceChain')} {selectedAsset?.chain}
- 手续费 - 免费 + {t('confirm.targetChain')} + {selectedAsset?.targetChain} +
+ +
+ {t('confirm.ratio')} + {selectedAsset?.ratio.numerator}:{selectedAsset?.ratio.denominator} +
+ +
+ {t('confirm.fee')} + {t('confirm.free')}
@@ -392,12 +539,42 @@ export default function App() {
)} + {/* Processing */} + {step === 'processing' && ( + +
+
+ + + + + +
+
+

{t('processing.title')}

+

+ {recordDetail?.orderState === SWAP_ORDER_STATE_ID.FROM_TX_WAIT_ON_CHAIN && t('processing.waitingFrom')} + {recordDetail?.orderState === SWAP_ORDER_STATE_ID.TO_TX_WAIT_ON_CHAIN && t('processing.waitingTo')} + {!recordDetail && t('processing.processing')} +

+
+ {orderId && ( +

{t('processing.orderId')}: {orderId}

+ )} + + )} + {/* Success */} {step === 'success' && (
-

传送成功

-

{amount} {selectedAsset?.symbol}

-

已发送至目标钱包

+

{t('success.title')}

+

{expectedReceive} {selectedAsset?.targetAsset}

+

{t('success.sentTo')}

)} + + {/* Error */} + {step === 'error' && ( + + + + + + +
+

{t('error.title')}

+

{error || t('error.unknown')}

+
+
+ + +
+
+ )}
@@ -428,10 +633,11 @@ export default function App() { ) } -function WalletCard({ label, address, name, compact, highlight }: { +function WalletCard({ label, address, name, chain, compact, highlight }: { label: string address?: string name?: string + chain?: string compact?: boolean highlight?: boolean }) { @@ -445,7 +651,7 @@ function WalletCard({ label, address, name, compact, highlight }: {
- {label} + {label} {chain && {chain}}
{name || truncateAddress(address)}
@@ -462,7 +668,7 @@ function WalletCard({ label, address, name, compact, highlight }: {
- {label} + {label} {chain && {chain}} {name || 'Unknown'} {address}
@@ -471,7 +677,7 @@ function WalletCard({ label, address, name, compact, highlight }: { ) } -function AssetAvatar({ symbol, size = 'md' }: { symbol: string; size?: 'sm' | 'md' | 'lg' }) { +function AssetAvatar({ symbol, chain, size = 'md' }: { symbol: string; chain: string; size?: 'sm' | 'md' | 'lg' }) { const icons: Record = { BFM: , ETH: , @@ -480,7 +686,7 @@ function AssetAvatar({ symbol, size = 'md' }: { symbol: string; size?: 'sm' | 'm const sizeClass = size === 'lg' ? 'size-16 [&_svg]:size-8' : size === 'md' ? 'size-10 [&_svg]:size-5' : 'size-6 [&_svg]:size-3' return ( - + {icons[symbol] || } diff --git a/miniapps/teleport/src/api/client.ts b/miniapps/teleport/src/api/client.ts new file mode 100644 index 00000000..075edcfa --- /dev/null +++ b/miniapps/teleport/src/api/client.ts @@ -0,0 +1,125 @@ +/** + * Teleport API Client + * 对接 MetaBox payment 后端 + */ + +import type { + TransmitAssetTypeListResponse, + TransmitRequest, + TransmitResponse, + TransmitRecordsRequest, + TransmitRecordsResponse, + TransmitRecordDetail, + RetryResponse, +} from './types' + +const API_BASE_URL = 'https://api.eth-metaverse.com/payment' + +class ApiError extends Error { + constructor( + message: string, + public status: number, + public data?: unknown, + ) { + super(message) + this.name = 'ApiError' + } +} + +async function request( + endpoint: string, + options: RequestInit = {}, +): Promise { + const url = `${API_BASE_URL}${endpoint}` + + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + + if (!response.ok) { + const data = await response.json().catch(() => null) + throw new ApiError( + data?.message || `HTTP ${response.status}`, + response.status, + data, + ) + } + + return response.json() +} + +/** + * 获取传送配置(支持的链/币种/比例等) + * GET /payment/transmit/assetTypeList + */ +export async function getTransmitAssetTypeList(): Promise { + return request('/transmit/assetTypeList') +} + +/** + * 发起传送 + * POST /payment/transmit + */ +export async function transmit(data: TransmitRequest): Promise { + return request('/transmit', { + method: 'POST', + body: JSON.stringify(data), + }) +} + +/** + * 获取传送记录列表 + * GET /payment/transmit/records + */ +export async function getTransmitRecords( + params: TransmitRecordsRequest, +): Promise { + const searchParams = new URLSearchParams() + searchParams.set('page', String(params.page)) + searchParams.set('pageSize', String(params.pageSize)) + if (params.fromChain) searchParams.set('fromChain', params.fromChain) + if (params.fromAddress) searchParams.set('fromAddress', params.fromAddress) + if (params.fromAsset) searchParams.set('fromAsset', params.fromAsset) + + return request(`/transmit/records?${searchParams}`) +} + +/** + * 获取传送记录详情 + * GET /payment/transmit/recordDetail + */ +export async function getTransmitRecordDetail( + orderId: string, +): Promise { + return request( + `/transmit/recordDetail?orderId=${encodeURIComponent(orderId)}`, + ) +} + +/** + * 重试发送方交易上链 + * POST /payment/transmit/retryFromTxOnChain + */ +export async function retryFromTxOnChain(orderId: string): Promise { + return request('/transmit/retryFromTxOnChain', { + method: 'POST', + body: JSON.stringify({ orderId }), + }) +} + +/** + * 重试接收方交易上链 + * POST /payment/transmit/retryToTxOnChain + */ +export async function retryToTxOnChain(orderId: string): Promise { + return request('/transmit/retryToTxOnChain', { + method: 'POST', + body: JSON.stringify({ orderId }), + }) +} + +export { ApiError } diff --git a/miniapps/teleport/src/api/hooks.ts b/miniapps/teleport/src/api/hooks.ts new file mode 100644 index 00000000..d66045a2 --- /dev/null +++ b/miniapps/teleport/src/api/hooks.ts @@ -0,0 +1,149 @@ +/** + * Teleport API React Hooks + * 使用 TanStack Query 管理 API 状态 + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + getTransmitAssetTypeList, + transmit, + getTransmitRecords, + getTransmitRecordDetail, + retryFromTxOnChain, + retryToTxOnChain, +} from './client' +import type { + TransmitRequest, + TransmitRecordsRequest, + DisplayAsset, + ChainName, +} from './types' + +// Query Keys +export const queryKeys = { + assetTypeList: ['transmit', 'assetTypeList'] as const, + records: (params: TransmitRecordsRequest) => ['transmit', 'records', params] as const, + recordDetail: (orderId: string) => ['transmit', 'recordDetail', orderId] as const, +} + +/** + * 获取传送配置并转换为可展示的资产列表 + */ +export function useTransmitAssetTypeList() { + return useQuery({ + queryKey: queryKeys.assetTypeList, + queryFn: getTransmitAssetTypeList, + staleTime: 5 * 60 * 1000, // 5 minutes + select: (data): DisplayAsset[] => { + const assets: DisplayAsset[] = [] + const support = data.transmitSupport + + for (const [chainKey, chainAssets] of Object.entries(support)) { + if (!chainAssets) continue + + for (const [assetKey, config] of Object.entries(chainAssets)) { + if (!config.enable) continue + + // 检查传送时间是否有效 + const now = new Date() + const startDate = new Date(config.transmitDate.startDate) + const endDate = new Date(config.transmitDate.endDate) + if (now < startDate || now > endDate) continue + + assets.push({ + id: `${chainKey}-${assetKey}`, + chain: chainKey as ChainName, + assetType: config.assetType, + symbol: config.assetType, + name: config.assetType, + balance: '0', // 余额需要从钱包获取 + decimals: 8, // 默认精度,实际需要从链上获取 + recipientAddress: config.recipientAddress, + targetChain: config.targetChain, + targetAsset: config.targetAsset, + ratio: config.ratio, + contractAddress: config.contractAddress, + isAirdrop: config.isAirdrop, + }) + } + } + + return assets + }, + }) +} + +/** + * 发起传送 + */ +export function useTransmit() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (data: TransmitRequest) => transmit(data), + onSuccess: () => { + // 传送成功后刷新记录列表 + queryClient.invalidateQueries({ queryKey: ['transmit', 'records'] }) + }, + }) +} + +/** + * 获取传送记录列表 + */ +export function useTransmitRecords(params: TransmitRecordsRequest) { + return useQuery({ + queryKey: queryKeys.records(params), + queryFn: () => getTransmitRecords(params), + staleTime: 30 * 1000, // 30 seconds + }) +} + +/** + * 获取传送记录详情 + */ +export function useTransmitRecordDetail(orderId: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: queryKeys.recordDetail(orderId), + queryFn: () => getTransmitRecordDetail(orderId), + enabled: options?.enabled ?? !!orderId, + refetchInterval: (query) => { + // 如果订单还在处理中,每 5 秒刷新一次 + const data = query.state.data + if (data && data.orderState < 4) { + return 5000 + } + return false + }, + }) +} + +/** + * 重试发送方交易上链 + */ +export function useRetryFromTxOnChain() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: retryFromTxOnChain, + onSuccess: (_, orderId) => { + queryClient.invalidateQueries({ queryKey: queryKeys.recordDetail(orderId) }) + queryClient.invalidateQueries({ queryKey: ['transmit', 'records'] }) + }, + }) +} + +/** + * 重试接收方交易上链 + */ +export function useRetryToTxOnChain() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: retryToTxOnChain, + onSuccess: (_, orderId) => { + queryClient.invalidateQueries({ queryKey: queryKeys.recordDetail(orderId) }) + queryClient.invalidateQueries({ queryKey: ['transmit', 'records'] }) + }, + }) +} diff --git a/miniapps/teleport/src/api/index.ts b/miniapps/teleport/src/api/index.ts new file mode 100644 index 00000000..2c89e06e --- /dev/null +++ b/miniapps/teleport/src/api/index.ts @@ -0,0 +1,7 @@ +/** + * Teleport API Module + */ + +export * from './types' +export * from './client' +export * from './hooks' diff --git a/miniapps/teleport/src/api/types.ts b/miniapps/teleport/src/api/types.ts new file mode 100644 index 00000000..75a4b376 --- /dev/null +++ b/miniapps/teleport/src/api/types.ts @@ -0,0 +1,201 @@ +/** + * Teleport API Types + * 基于 @bnqkl/metabox-core@0.5.2 类型定义 + */ + +// 链名类型 +export type ExternalChainName = 'ETH' | 'BSC' | 'TRON' +export type InternalChainName = 'BFMCHAIN' | 'ETHMETA' | 'PMCHAIN' | 'CCCHAIN' | 'BTGMETA' | 'BFCHAINV2' +export type ChainName = ExternalChainName | InternalChainName + +// 资产类型 +export type InternalAssetType = string +export type ExternalAssetType = string + +// 分数类型 +export interface Fraction { + numerator: string | number + denominator: string | number +} + +// 传送支持配置 +export interface TransmitSupport { + enable: boolean + isAirdrop: boolean + assetType: string + recipientAddress: string + targetChain: InternalChainName + targetAsset: InternalAssetType + ratio: Fraction + transmitDate: { + startDate: string + endDate: string + } + snapshotHeight?: number + contractAddress?: string +} + +export type TransmitSupportItem = Record + +// 传送配置响应 +export interface TransmitAssetTypeListResponse { + transmitSupport: { + BFCHAIN?: TransmitSupportItem + CCCHAIN?: TransmitSupportItem + BFMCHAIN?: TransmitSupportItem + ETHMETA?: TransmitSupportItem + BTGMETA?: TransmitSupportItem + PMCHAIN?: TransmitSupportItem + ETH?: TransmitSupportItem + } +} + +// 外链发起方交易体 +export interface ExternalFromTrJson { + eth?: { signTransData: string } + bsc?: { signTransData: string } + tron?: unknown + trc20?: unknown +} + +// 内链发起方交易体 +export interface InternalFromTrJson { + bcf?: { + chainName: InternalChainName + trJson: TransferAssetTransaction + } +} + +// 转账交易体 +export interface TransferAssetTransaction { + senderId: string + recipientId: string + amount: string + fee: string + timestamp: number + signature: string + asset: { + transferAsset: { + amount: string + assetType: string + } + } +} + +// 发起方交易体(合并外链和内链) +export type FromTrJson = ExternalFromTrJson & InternalFromTrJson + +// 接收方交易信息 +export interface ToTrInfo { + chainName: InternalChainName + address: string + assetType: InternalAssetType +} + +// 传送请求 +export interface TransmitRequest { + fromTrJson: FromTrJson + toTrInfo?: ToTrInfo +} + +// 传送响应 +export interface TransmitResponse { + orderId: string +} + +// 订单状态 +export enum SWAP_ORDER_STATE_ID { + INIT = 1, + FROM_TX_WAIT_ON_CHAIN = 2, + FROM_TX_ON_CHAIN_FAIL = 201, + TO_TX_WAIT_ON_CHAIN = 3, + TO_TX_ON_CHAIN_FAIL = 301, + SUCCESS = 4, +} + +// 记录状态 +export enum SWAP_RECORD_STATE { + PENDING = 1, + TO_BE_POSTED = 2, + POSTED = 3, + FAIL = 4, +} + +// 交易信息 +export interface RecordTxInfo { + chainName: ChainName + amount: string + asset: string + decimals: number + assetLogoUrl?: string +} + +// 交易详情信息 +export interface RecordDetailTxInfo { + chainName: ChainName + address: string + txId?: string + txHash?: string + contractAddress?: string +} + +// 传送记录 +export interface TransmitRecord { + orderId: string + state: SWAP_RECORD_STATE + orderState: SWAP_ORDER_STATE_ID + fromTxInfo?: RecordTxInfo + toTxInfo?: RecordTxInfo + createdTime: string +} + +// 传送记录详情 +export interface TransmitRecordDetail { + state: SWAP_RECORD_STATE + orderState: SWAP_ORDER_STATE_ID + fromTxInfo?: RecordDetailTxInfo + toTxInfo?: RecordDetailTxInfo + orderFailReason?: string + updatedTime: string + swapRatio: number +} + +// 分页请求 +export interface PageRequest { + page: number + pageSize: number +} + +// 记录列表请求 +export interface TransmitRecordsRequest extends PageRequest { + fromChain?: ChainName + fromAddress?: string + fromAsset?: string +} + +// 记录列表响应 +export interface TransmitRecordsResponse { + page: number + pageSize: number + dataList: TransmitRecord[] +} + +// 重试响应 +export type RetryResponse = boolean + +// UI 用的资产展示类型 +export interface DisplayAsset { + id: string + chain: ChainName + assetType: string + symbol: string + name: string + balance: string + decimals: number + recipientAddress: string + targetChain: InternalChainName + targetAsset: InternalAssetType + ratio: Fraction + contractAddress?: string + isAirdrop: boolean +} diff --git a/miniapps/teleport/src/i18n/locales/en.json b/miniapps/teleport/src/i18n/locales/en.json index 7af26153..b86e9277 100644 --- a/miniapps/teleport/src/i18n/locales/en.json +++ b/miniapps/teleport/src/i18n/locales/en.json @@ -2,44 +2,68 @@ "app": { "title": "Teleport", "subtitle": "Cross-Wallet Transfer", - "description": "Safely transfer assets from one wallet to another" + "description": "Safely transfer assets to another wallet" }, "connect": { - "button": "Select Source Wallet", - "loading": "Connecting..." + "button": "Start Teleport", + "loading": "Connecting...", + "loadingConfig": "Loading configuration...", + "configError": "Failed to load configuration, please refresh" }, "asset": { - "select": "Select asset to transfer", - "balance": "Available" + "select": "Select Asset", + "balance": "Available", + "noAssets": "No assets available for transfer on this chain" + }, + "wallet": { + "source": "Source Wallet", + "target": "Target Wallet", + "sender": "Sender", + "receiver": "Receiver" }, "amount": { "label": "Transfer Amount", "placeholder": "0.00", - "max": "Max", - "next": "Next" + "max": "MAX", + "next": "Next", + "expected": "Expected to receive" }, "target": { "title": "Select Target Wallet", - "description": "Choose the wallet to receive the assets", + "willTransfer": "Will transfer", + "selectOn": "Please select", + "chainTarget": "chain's", "button": "Select Target Wallet", - "loading": "Selecting..." + "loading": "Scanning..." }, "confirm": { - "title": "Confirm Transfer", - "from": "From", - "to": "To", - "amount": "Transfer", - "network": "Network", + "send": "Send", + "receive": "Receive", + "sourceChain": "Source Chain", + "targetChain": "Target Chain", + "ratio": "Conversion Ratio", + "fee": "Fee", + "free": "Free", "button": "Confirm Transfer", "loading": "Processing..." }, + "processing": { + "title": "Transferring", + "waitingFrom": "Waiting for sender transaction...", + "waitingTo": "Waiting for receiver transaction...", + "processing": "Processing...", + "orderId": "Order ID" + }, "success": { - "title": "Transfer Successful!", - "message": "sent", - "txWait": "Transaction confirmation may take a few minutes", - "done": "Done" + "title": "Transfer Successful", + "sentTo": "Sent to target wallet", + "newTransfer": "New Transfer" }, "error": { + "title": "Transfer Failed", + "unknown": "Unknown error", + "restart": "Start Over", + "retry": "Retry", "sdkNotInit": "Bio SDK not initialized", "connectionFailed": "Connection failed", "selectFailed": "Selection failed", diff --git a/miniapps/teleport/src/i18n/locales/zh-CN.json b/miniapps/teleport/src/i18n/locales/zh-CN.json index e12a19a7..bf727045 100644 --- a/miniapps/teleport/src/i18n/locales/zh-CN.json +++ b/miniapps/teleport/src/i18n/locales/zh-CN.json @@ -2,48 +2,72 @@ "app": { "title": "一键传送", "subtitle": "跨钱包传送", - "description": "安全地将资产从一个钱包转移到另一个钱包" + "description": "安全地将资产转移到另一个钱包" }, "connect": { - "button": "选择源钱包", - "loading": "连接中..." + "button": "启动传送门", + "loading": "连接中...", + "loadingConfig": "加载配置中...", + "configError": "加载配置失败,请刷新重试" }, "asset": { - "select": "选择要传送的资产", - "balance": "可用" + "select": "选择资产", + "balance": "可用", + "noAssets": "当前链暂无可传送资产" + }, + "wallet": { + "source": "源钱包", + "target": "目标钱包", + "sender": "发送方", + "receiver": "接收方" }, "amount": { "label": "传送数量", "placeholder": "0.00", - "max": "全部", - "next": "下一步" + "max": "MAX", + "next": "下一步", + "expected": "预计获得" }, "target": { "title": "选择目标钱包", - "description": "选择接收资产的目标钱包", + "willTransfer": "即将传送", + "selectOn": "请选择", + "chainTarget": "链上的", "button": "选择目标钱包", - "loading": "选择中..." + "loading": "扫描中..." }, "confirm": { - "title": "确认传送", - "from": "从", - "to": "到", - "amount": "传送", - "network": "网络", + "send": "发送", + "receive": "接收", + "sourceChain": "源链", + "targetChain": "目标链", + "ratio": "转换比例", + "fee": "手续费", + "free": "免费", "button": "确认传送", "loading": "处理中..." }, + "processing": { + "title": "传送中", + "waitingFrom": "等待发送方交易上链...", + "waitingTo": "等待接收方交易上链...", + "processing": "正在处理...", + "orderId": "订单号" + }, "success": { - "title": "传送成功!", - "message": "已发送", - "txWait": "交易确认可能需要几分钟", - "done": "完成" + "title": "传送成功", + "sentTo": "已发送至目标钱包", + "newTransfer": "发起新传送" }, "error": { + "title": "传送失败", + "unknown": "未知错误", + "restart": "重新开始", + "retry": "重试", "sdkNotInit": "Bio SDK 未初始化", "connectionFailed": "连接失败", "selectFailed": "选择失败", - "transferFailed": "转账失败", + "transferFailed": "传送失败", "invalidAmount": "请输入有效金额" } } diff --git a/miniapps/teleport/src/i18n/locales/zh-TW.json b/miniapps/teleport/src/i18n/locales/zh-TW.json index d7661b8f..c381da0d 100644 --- a/miniapps/teleport/src/i18n/locales/zh-TW.json +++ b/miniapps/teleport/src/i18n/locales/zh-TW.json @@ -2,48 +2,72 @@ "app": { "title": "一鍵傳送", "subtitle": "跨錢包傳送", - "description": "安全地將資產從一個錢包轉移到另一個錢包" + "description": "安全地將資產轉移到另一個錢包" }, "connect": { - "button": "選擇源錢包", - "loading": "連接中..." + "button": "啟動傳送門", + "loading": "連接中...", + "loadingConfig": "載入配置中...", + "configError": "載入配置失敗,請刷新重試" }, "asset": { - "select": "選擇要傳送的資產", - "balance": "可用" + "select": "選擇資產", + "balance": "可用", + "noAssets": "當前鏈暫無可傳送資產" + }, + "wallet": { + "source": "源錢包", + "target": "目標錢包", + "sender": "發送方", + "receiver": "接收方" }, "amount": { "label": "傳送數量", "placeholder": "0.00", - "max": "全部", - "next": "下一步" + "max": "MAX", + "next": "下一步", + "expected": "預計獲得" }, "target": { "title": "選擇目標錢包", - "description": "選擇接收資產的目標錢包", + "willTransfer": "即將傳送", + "selectOn": "請選擇", + "chainTarget": "鏈上的", "button": "選擇目標錢包", - "loading": "選擇中..." + "loading": "掃描中..." }, "confirm": { - "title": "確認傳送", - "from": "從", - "to": "到", - "amount": "傳送", - "network": "網絡", + "send": "發送", + "receive": "接收", + "sourceChain": "源鏈", + "targetChain": "目標鏈", + "ratio": "轉換比例", + "fee": "手續費", + "free": "免費", "button": "確認傳送", "loading": "處理中..." }, + "processing": { + "title": "傳送中", + "waitingFrom": "等待發送方交易上鏈...", + "waitingTo": "等待接收方交易上鏈...", + "processing": "正在處理...", + "orderId": "訂單號" + }, "success": { - "title": "傳送成功!", - "message": "已發送", - "txWait": "交易確認可能需要幾分鐘", - "done": "完成" + "title": "傳送成功", + "sentTo": "已發送至目標錢包", + "newTransfer": "發起新傳送" }, "error": { + "title": "傳送失敗", + "unknown": "未知錯誤", + "restart": "重新開始", + "retry": "重試", "sdkNotInit": "Bio SDK 未初始化", "connectionFailed": "連接失敗", "selectFailed": "選擇失敗", - "transferFailed": "轉賬失敗", + "transferFailed": "傳送失敗", "invalidAmount": "請輸入有效金額" } } diff --git a/miniapps/teleport/src/i18n/locales/zh.json b/miniapps/teleport/src/i18n/locales/zh.json index e12a19a7..bf727045 100644 --- a/miniapps/teleport/src/i18n/locales/zh.json +++ b/miniapps/teleport/src/i18n/locales/zh.json @@ -2,48 +2,72 @@ "app": { "title": "一键传送", "subtitle": "跨钱包传送", - "description": "安全地将资产从一个钱包转移到另一个钱包" + "description": "安全地将资产转移到另一个钱包" }, "connect": { - "button": "选择源钱包", - "loading": "连接中..." + "button": "启动传送门", + "loading": "连接中...", + "loadingConfig": "加载配置中...", + "configError": "加载配置失败,请刷新重试" }, "asset": { - "select": "选择要传送的资产", - "balance": "可用" + "select": "选择资产", + "balance": "可用", + "noAssets": "当前链暂无可传送资产" + }, + "wallet": { + "source": "源钱包", + "target": "目标钱包", + "sender": "发送方", + "receiver": "接收方" }, "amount": { "label": "传送数量", "placeholder": "0.00", - "max": "全部", - "next": "下一步" + "max": "MAX", + "next": "下一步", + "expected": "预计获得" }, "target": { "title": "选择目标钱包", - "description": "选择接收资产的目标钱包", + "willTransfer": "即将传送", + "selectOn": "请选择", + "chainTarget": "链上的", "button": "选择目标钱包", - "loading": "选择中..." + "loading": "扫描中..." }, "confirm": { - "title": "确认传送", - "from": "从", - "to": "到", - "amount": "传送", - "network": "网络", + "send": "发送", + "receive": "接收", + "sourceChain": "源链", + "targetChain": "目标链", + "ratio": "转换比例", + "fee": "手续费", + "free": "免费", "button": "确认传送", "loading": "处理中..." }, + "processing": { + "title": "传送中", + "waitingFrom": "等待发送方交易上链...", + "waitingTo": "等待接收方交易上链...", + "processing": "正在处理...", + "orderId": "订单号" + }, "success": { - "title": "传送成功!", - "message": "已发送", - "txWait": "交易确认可能需要几分钟", - "done": "完成" + "title": "传送成功", + "sentTo": "已发送至目标钱包", + "newTransfer": "发起新传送" }, "error": { + "title": "传送失败", + "unknown": "未知错误", + "restart": "重新开始", + "retry": "重试", "sdkNotInit": "Bio SDK 未初始化", "connectionFailed": "连接失败", "selectFailed": "选择失败", - "transferFailed": "转账失败", + "transferFailed": "传送失败", "invalidAmount": "请输入有效金额" } } diff --git a/miniapps/teleport/src/main.tsx b/miniapps/teleport/src/main.tsx index 9d212c56..22d81b70 100644 --- a/miniapps/teleport/src/main.tsx +++ b/miniapps/teleport/src/main.tsx @@ -2,10 +2,22 @@ import './index.css' import '@biochain/bio-sdk' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + refetchOnWindowFocus: false, + }, + }, +}) + createRoot(document.getElementById('root') as HTMLElement).render( - + + + ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 636b8799..234c6786 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -469,6 +469,12 @@ importers: '@biochain/keyapp-sdk': specifier: workspace:* version: link:../../packages/keyapp-sdk + '@bnqkl/metabox-core': + specifier: 0.5.2 + version: 0.5.2 + '@bnqkl/wallet-typings': + specifier: 0.23.8 + version: 0.23.8 '@radix-ui/react-avatar': specifier: ^1.1.11 version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -481,6 +487,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.7)(react@19.2.3) + '@tanstack/react-query': + specifier: ^5.90.12 + version: 5.90.12(react@19.2.3) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1203,6 +1212,9 @@ packages: '@bnqkl/json-dry-factory@1.0.0': resolution: {integrity: sha512-0bDlvG5M3QfbeilwbXsVGSe1TzrscOwfB+8c0itPmkpQTJYGGhOXULiMQAnm1GxdaMd9hdmmrHYus3AchWkmrg==} + '@bnqkl/metabox-core@0.5.2': + resolution: {integrity: sha512-BOwjQD+OeGMMC1ZfyqNdCWg+YZRlD95/NcFfo5447NOyGj8JneoRwi0G2+R2O8RyLdYixSIvTZgDJ0Yp50SmAA==} + '@bnqkl/server-util@1.3.4': resolution: {integrity: sha512-EG4cvGcawwZaoRRHBXNwO16dz5Z4mV8vuqAdxS7kiDy8gIAYosPICJxf2wL/d8dfNXBSqrroH5peo9EZYzZB9w==} @@ -8296,6 +8308,14 @@ snapshots: dependencies: '@deno/shim-deno': 0.16.1 + '@bnqkl/metabox-core@0.5.2': + dependencies: + '@bnqkl/wallet-typings': 0.23.8 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@bnqkl/server-util@1.3.4(reflect-metadata@0.2.2)': dependencies: '@bfmeta/wallet-typings': 1.6.11 @@ -10280,7 +10300,7 @@ snapshots: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16) '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16) '@vitest/runner': 4.0.16 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) transitivePeerDependencies: - react - react-dom @@ -10721,7 +10741,7 @@ snapshots: '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)) playwright: 1.57.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) transitivePeerDependencies: - bufferutil - msw @@ -10737,7 +10757,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) ws: 8.18.3 transitivePeerDependencies: - bufferutil From 84ff517a62aa2fad20c3dfb9abc28cfcf44231fd Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 22:19:30 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(miniapps/teleport):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=AE=8C=E5=A4=87=E7=9A=84=E6=B5=8B=E8=AF=95=E5=A5=97?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 API 单元测试 - client.test.ts: 测试 API 客户端所有方法 - hooks.test.tsx: 测试 React Query hooks - 添加 Storybook stories - App.stories.tsx: 测试各个步骤的 UI 交互 - 更新 E2E 测试 - 添加 API Mock (route intercept) - 更新 i18n helpers 适配新翻译 - 添加成功页面和错误页面测试 - 测试完整传送流程 测试覆盖: - 31 个单元测试 - 7 个 Storybook 交互测试 - 8 个 E2E 测试 --- miniapps/teleport/e2e/helpers/i18n.ts | 30 +- miniapps/teleport/e2e/ui.spec.ts | 227 +++++++++++++-- miniapps/teleport/src/App.stories.tsx | 337 +++++++++++++++++++++++ miniapps/teleport/src/api/client.test.ts | 191 +++++++++++++ miniapps/teleport/src/api/hooks.test.tsx | 234 ++++++++++++++++ 5 files changed, 985 insertions(+), 34 deletions(-) create mode 100644 miniapps/teleport/src/App.stories.tsx create mode 100644 miniapps/teleport/src/api/client.test.ts create mode 100644 miniapps/teleport/src/api/hooks.test.tsx diff --git a/miniapps/teleport/e2e/helpers/i18n.ts b/miniapps/teleport/e2e/helpers/i18n.ts index 193782c3..e3d99bb8 100644 --- a/miniapps/teleport/e2e/helpers/i18n.ts +++ b/miniapps/teleport/e2e/helpers/i18n.ts @@ -6,27 +6,43 @@ import type { Page, Locator } from '@playwright/test' export const UI_TEXT = { connect: { - button: /选择源钱包|启动传送门|Select Source Wallet/i, - loading: /连接中|Connecting/i, + button: /启动传送门|Start Teleport/i, + loading: /连接中|加载配置中|Connecting|Loading/i, }, asset: { - select: /选择要传送的资产|Select asset/i, + select: /选择资产|Select Asset/i, + noAssets: /当前链暂无可传送资产|No assets available/i, }, amount: { next: /下一步|Next/i, - max: /全部|Max/i, + max: /MAX/i, + expected: /预计获得|Expected to receive/i, }, target: { - title: /选择目标钱包|Select Target Wallet/i, + title: /目标钱包|Target Wallet/i, button: /选择目标钱包|Select Target Wallet/i, + willTransfer: /即将传送|Will transfer/i, }, confirm: { - title: /确认传送|Confirm Transfer/i, + send: /发送|Send/i, + receive: /接收|Receive/i, button: /确认传送|Confirm Transfer/i, + free: /免费|Free/i, + }, + processing: { + title: /传送中|Transferring/i, + waitingFrom: /等待发送方|Waiting for sender/i, + waitingTo: /等待接收方|Waiting for receiver/i, }, success: { title: /传送成功|Transfer Successful/i, - done: /完成|Done/i, + newTransfer: /发起新传送|New Transfer/i, + }, + error: { + title: /传送失败|Transfer Failed/i, + restart: /重新开始|Start Over/i, + retry: /重试|Retry/i, + sdkNotInit: /Bio SDK 未初始化|Bio SDK not initialized/i, }, } as const diff --git a/miniapps/teleport/e2e/ui.spec.ts b/miniapps/teleport/e2e/ui.spec.ts index f7ff1265..cf2f48a7 100644 --- a/miniapps/teleport/e2e/ui.spec.ts +++ b/miniapps/teleport/e2e/ui.spec.ts @@ -1,29 +1,114 @@ import { test, expect } from '@playwright/test' import { UI_TEXT } from './helpers/i18n' +// Mock API response for asset type list +const mockAssetTypeListResponse = JSON.stringify({ + transmitSupport: { + ETH: { + ETH: { + enable: true, + isAirdrop: false, + assetType: 'ETH', + recipientAddress: '0x1234567890abcdef1234567890abcdef12345678', + targetChain: 'BFMCHAIN', + targetAsset: 'BFM', + ratio: { numerator: 1, denominator: 1 }, + transmitDate: { startDate: '2020-01-01', endDate: '2030-12-31' }, + }, + USDT: { + enable: true, + isAirdrop: false, + assetType: 'USDT', + recipientAddress: '0x1234567890abcdef1234567890abcdef12345678', + targetChain: 'BFMCHAIN', + targetAsset: 'USDM', + ratio: { numerator: 1, denominator: 1 }, + transmitDate: { startDate: '2020-01-01', endDate: '2030-12-31' }, + }, + }, + }, +}) + +// Mock Bio SDK with chain support const mockBioSDK = ` window.bio = { - request: async ({ method }) => { + request: async ({ method, params }) => { + if (method === 'bio_closeSplashScreen') return if (method === 'bio_selectAccount') { - return { address: '0x1234567890abcdef1234567890abcdef12345678', name: 'Test Wallet' } + return { address: '0x1234567890abcdef1234567890abcdef12345678', chain: 'ETH', name: 'Test Wallet' } } if (method === 'bio_pickWallet') { - return { address: '0xabcdef1234567890abcdef1234567890abcdef12', name: 'Target Wallet' } + return { address: 'bfm_abcdef1234567890abcdef1234567890abcdef12', chain: 'BFMCHAIN', name: 'Target Wallet' } + } + if (method === 'bio_getBalance') { + return '1000.00' + } + if (method === 'bio_createTransaction') { + return { chainId: 'ETH', data: { raw: '0x...' } } + } + if (method === 'bio_signTransaction') { + return { chainId: 'ETH', data: {}, signature: '0x123abc...' } } return {} - } + }, + on: () => {}, + off: () => {}, + isConnected: () => true, } ` test.describe('Teleport UI', () => { test.beforeEach(async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }) + + // Mock API calls + await page.route('**/payment/transmit/assetTypeList', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: mockAssetTypeListResponse, + }) + }) + + await page.route('**/payment/transmit/records**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ page: 1, pageSize: 10, dataList: [] }), + }) + }) + + await page.route('**/payment/transmit', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ orderId: 'mock-order-123' }), + }) + } + }) + + await page.route('**/payment/transmit/recordDetail**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + state: 3, + orderState: 4, // SUCCESS + swapRatio: 1, + updatedTime: new Date().toISOString(), + }), + }) + }) }) test('01 - connect page', async ({ page }) => { + await page.addInitScript(mockBioSDK) await page.goto('/') await page.waitForLoadState('networkidle') - await expect(page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first()).toBeVisible() + + // Wait for config to load and button to show + await expect(page.getByRole('button', { name: UI_TEXT.connect.button })).toBeVisible({ timeout: 10000 }) await expect(page).toHaveScreenshot('01-connect.png') }) @@ -32,7 +117,13 @@ test.describe('Teleport UI', () => { await page.goto('/') await page.waitForLoadState('networkidle') - await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() + // Wait for and click connect button + const connectBtn = page.getByRole('button', { name: UI_TEXT.connect.button }) + await expect(connectBtn).toBeVisible({ timeout: 10000 }) + await connectBtn.click() + + // Wait for asset selection page + await expect(page.getByText(UI_TEXT.asset.select)).toBeVisible({ timeout: 5000 }) await expect(page.locator('[data-slot="card"]').first()).toBeVisible() await expect(page).toHaveScreenshot('02-select-asset.png') @@ -43,10 +134,16 @@ test.describe('Teleport UI', () => { await page.goto('/') await page.waitForLoadState('networkidle') - await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() - await page.waitForSelector('[data-slot="card"]') + // Connect + const connectBtn = page.getByRole('button', { name: UI_TEXT.connect.button }) + await expect(connectBtn).toBeVisible({ timeout: 10000 }) + await connectBtn.click() - await page.click('[data-slot="card"]:has-text("BFM")') + // Select first asset (ETH) + await expect(page.locator('[data-slot="card"]').first()).toBeVisible({ timeout: 5000 }) + await page.locator('[data-slot="card"]').first().click() + + // Verify amount input is visible await expect(page.locator('input[type="number"]')).toBeVisible() await expect(page).toHaveScreenshot('03-input-amount.png') @@ -57,14 +154,22 @@ test.describe('Teleport UI', () => { await page.goto('/') await page.waitForLoadState('networkidle') - await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() - await page.waitForSelector('[data-slot="card"]') + // Connect + const connectBtn = page.getByRole('button', { name: UI_TEXT.connect.button }) + await expect(connectBtn).toBeVisible({ timeout: 10000 }) + await connectBtn.click() - await page.click('[data-slot="card"]:has-text("BFM")') - await page.waitForSelector('input[type="number"]') + // Select asset + await expect(page.locator('[data-slot="card"]').first()).toBeVisible({ timeout: 5000 }) + await page.locator('[data-slot="card"]').first().click() + // Fill amount + await page.waitForSelector('input[type="number"]') await page.fill('input[type="number"]', '500') await expect(page.locator('input[type="number"]')).toHaveValue('500') + + // Verify expected receive is shown + await expect(page.getByText(UI_TEXT.amount.expected)).toBeVisible() await expect(page).toHaveScreenshot('04-amount-filled.png') }) @@ -74,15 +179,23 @@ test.describe('Teleport UI', () => { await page.goto('/') await page.waitForLoadState('networkidle') - await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() - await page.waitForSelector('[data-slot="card"]') + // Connect + const connectBtn = page.getByRole('button', { name: UI_TEXT.connect.button }) + await expect(connectBtn).toBeVisible({ timeout: 10000 }) + await connectBtn.click() - await page.click('[data-slot="card"]:has-text("BFM")') - await page.waitForSelector('input[type="number"]') + // Select asset + await expect(page.locator('[data-slot="card"]').first()).toBeVisible({ timeout: 5000 }) + await page.locator('[data-slot="card"]').first().click() + // Fill amount and click next + await page.waitForSelector('input[type="number"]') await page.fill('input[type="number"]', '500') - await page.locator(`button:has-text("${UI_TEXT.amount.next.source}")`).first().click() - await expect(page.locator(`text=${UI_TEXT.target.title.source}`).first()).toBeVisible() + await page.getByRole('button', { name: UI_TEXT.amount.next }).click() + + // Verify target wallet selection page + await expect(page.getByText(UI_TEXT.target.willTransfer)).toBeVisible({ timeout: 5000 }) + await expect(page.getByRole('button', { name: UI_TEXT.target.button })).toBeVisible() await expect(page).toHaveScreenshot('05-select-target.png') }) @@ -92,19 +205,79 @@ test.describe('Teleport UI', () => { await page.goto('/') await page.waitForLoadState('networkidle') - await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() - await page.waitForSelector('[data-slot="card"]') + // Connect + const connectBtn = page.getByRole('button', { name: UI_TEXT.connect.button }) + await expect(connectBtn).toBeVisible({ timeout: 10000 }) + await connectBtn.click() - await page.click('[data-slot="card"]:has-text("BFM")') - await page.waitForSelector('input[type="number"]') + // Select asset + await expect(page.locator('[data-slot="card"]').first()).toBeVisible({ timeout: 5000 }) + await page.locator('[data-slot="card"]').first().click() + // Fill amount and proceed + await page.waitForSelector('input[type="number"]') await page.fill('input[type="number"]', '500') - await page.locator(`button:has-text("${UI_TEXT.amount.next.source}")`).first().click() - await page.waitForSelector(`text=${UI_TEXT.target.title.source}`) + await page.getByRole('button', { name: UI_TEXT.amount.next }).click() - await page.locator(`button:has-text("${UI_TEXT.target.button.source}")`).first().click() - await expect(page.locator(`text=${UI_TEXT.confirm.title.source}`).first()).toBeVisible() + // Select target wallet + await expect(page.getByRole('button', { name: UI_TEXT.target.button })).toBeVisible({ timeout: 5000 }) + await page.getByRole('button', { name: UI_TEXT.target.button }).click() + + // Verify confirm page + await expect(page.getByText(UI_TEXT.confirm.send)).toBeVisible({ timeout: 5000 }) + await expect(page.getByText(UI_TEXT.confirm.receive)).toBeVisible() + await expect(page.getByRole('button', { name: UI_TEXT.confirm.button })).toBeVisible() await expect(page).toHaveScreenshot('06-confirm.png') }) + + test('07 - success page', async ({ page }) => { + await page.addInitScript(mockBioSDK) + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Connect + const connectBtn = page.getByRole('button', { name: UI_TEXT.connect.button }) + await expect(connectBtn).toBeVisible({ timeout: 10000 }) + await connectBtn.click() + + // Select asset + await expect(page.locator('[data-slot="card"]').first()).toBeVisible({ timeout: 5000 }) + await page.locator('[data-slot="card"]').first().click() + + // Fill amount and proceed + await page.waitForSelector('input[type="number"]') + await page.fill('input[type="number"]', '500') + await page.getByRole('button', { name: UI_TEXT.amount.next }).click() + + // Select target wallet + await expect(page.getByRole('button', { name: UI_TEXT.target.button })).toBeVisible({ timeout: 5000 }) + await page.getByRole('button', { name: UI_TEXT.target.button }).click() + + // Confirm transfer + await expect(page.getByRole('button', { name: UI_TEXT.confirm.button })).toBeVisible({ timeout: 5000 }) + await page.getByRole('button', { name: UI_TEXT.confirm.button }).click() + + // Verify success page + await expect(page.getByText(UI_TEXT.success.title)).toBeVisible({ timeout: 10000 }) + await expect(page.getByRole('button', { name: UI_TEXT.success.newTransfer })).toBeVisible() + + await expect(page).toHaveScreenshot('07-success.png') + }) + + test('08 - error when SDK not initialized', async ({ page }) => { + // Don't add bio SDK mock + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Wait for connect button + const connectBtn = page.getByRole('button', { name: UI_TEXT.connect.button }) + await expect(connectBtn).toBeVisible({ timeout: 10000 }) + await connectBtn.click() + + // Verify error message + await expect(page.getByText(UI_TEXT.error.sdkNotInit)).toBeVisible({ timeout: 5000 }) + + await expect(page).toHaveScreenshot('08-error-no-sdk.png') + }) }) diff --git a/miniapps/teleport/src/App.stories.tsx b/miniapps/teleport/src/App.stories.tsx new file mode 100644 index 00000000..387d793f --- /dev/null +++ b/miniapps/teleport/src/App.stories.tsx @@ -0,0 +1,337 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { within, userEvent, expect, waitFor } from '@storybook/test' +import App from './App' +import './i18n' + +// Mock API responses +const mockAssetTypeList = { + transmitSupport: { + ETH: { + ETH: { + enable: true, + isAirdrop: false, + assetType: 'ETH', + recipientAddress: '0x1234567890abcdef1234567890abcdef12345678', + targetChain: 'BFMCHAIN', + targetAsset: 'BFM', + ratio: { numerator: 1, denominator: 1 }, + transmitDate: { + startDate: '2020-01-01', + endDate: '2030-12-31', + }, + }, + USDT: { + enable: true, + isAirdrop: false, + assetType: 'USDT', + recipientAddress: '0x1234567890abcdef1234567890abcdef12345678', + targetChain: 'BFMCHAIN', + targetAsset: 'USDM', + ratio: { numerator: 1, denominator: 1 }, + transmitDate: { + startDate: '2020-01-01', + endDate: '2030-12-31', + }, + contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + }, + BFMCHAIN: { + BFM: { + enable: true, + isAirdrop: false, + assetType: 'BFM', + recipientAddress: 'bfm123456789', + targetChain: 'ETHMETA', + targetAsset: 'ETHM', + ratio: { numerator: 10, denominator: 1 }, + transmitDate: { + startDate: '2020-01-01', + endDate: '2030-12-31', + }, + }, + }, + }, +} + +// Setup mock fetch +const setupMockFetch = () => { + const originalFetch = window.fetch + window.fetch = async (url: RequestInfo | URL) => { + const urlStr = url.toString() + if (urlStr.includes('/transmit/assetTypeList')) { + return new Response(JSON.stringify(mockAssetTypeList), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (urlStr.includes('/transmit/records')) { + return new Response(JSON.stringify({ page: 1, pageSize: 10, dataList: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (urlStr.includes('/transmit') && !urlStr.includes('records')) { + return new Response(JSON.stringify({ orderId: 'mock-order-123' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return originalFetch(url) + } +} + +// Setup mock Bio SDK +const setupMockBio = (overrides?: Partial) => { + window.bio = { + request: async ({ method, params }) => { + if (method === 'bio_closeSplashScreen') return + if (method === 'bio_selectAccount') { + return { + address: '0x1234567890abcdef1234567890abcdef12345678', + chain: 'ETH', + name: 'My Wallet', + } + } + if (method === 'bio_pickWallet') { + return { + address: 'bfm_abcdef1234567890abcdef1234567890abcdef12', + chain: 'BFMCHAIN', + name: 'Target Wallet', + } + } + if (method === 'bio_getBalance') { + return '1000.00' + } + if (method === 'bio_createTransaction') { + return { chainId: 'ETH', data: { raw: '0x...' } } + } + if (method === 'bio_signTransaction') { + return { chainId: 'ETH', data: {}, signature: '0x123abc...' } + } + return {} + }, + on: () => {}, + off: () => {}, + isConnected: () => true, + ...overrides, + } as typeof window.bio +} + +// Decorator with QueryClient +const withQueryClient = (Story: React.ComponentType) => { + setupMockFetch() + setupMockBio() + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity }, + }, + }) + + return ( + +
+ +
+
+ ) +} + +const meta = { + title: 'Pages/Teleport', + component: App, + decorators: [withQueryClient], + parameters: { + layout: 'fullscreen', + viewport: { + defaultViewport: 'mobile1', + }, + backgrounds: { + default: 'dark', + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const ConnectStep: Story = { + name: '01 - 连接钱包', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for loading to finish + await waitFor(async () => { + const button = await canvas.findByRole('button', { name: /启动传送门/i }) + await expect(button).toBeInTheDocument() + }, { timeout: 5000 }) + }, +} + +export const SelectAssetStep: Story = { + name: '02 - 选择资产', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for loading + await waitFor(async () => { + const button = await canvas.findByRole('button', { name: /启动传送门/i }) + await expect(button).toBeInTheDocument() + }, { timeout: 5000 }) + + // Click connect + const connectButton = await canvas.findByRole('button', { name: /启动传送门/i }) + await userEvent.click(connectButton) + + // Verify asset list is shown + await waitFor(async () => { + await expect(canvas.getByText('选择资产')).toBeInTheDocument() + }, { timeout: 3000 }) + }, +} + +export const InputAmountStep: Story = { + name: '03 - 输入金额', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for loading + await waitFor(async () => { + const button = await canvas.findByRole('button', { name: /启动传送门/i }) + await expect(button).toBeInTheDocument() + }, { timeout: 5000 }) + + // Click connect + const connectButton = await canvas.findByRole('button', { name: /启动传送门/i }) + await userEvent.click(connectButton) + + // Wait for asset list + await waitFor(async () => { + await expect(canvas.getByText('选择资产')).toBeInTheDocument() + }, { timeout: 3000 }) + + // Click on ETH asset + const cards = canvas.getAllByRole('article') + if (cards.length > 0) { + await userEvent.click(cards[0]) + } + + // Verify amount input is shown + await waitFor(async () => { + const input = canvas.getByRole('spinbutton') + await expect(input).toBeInTheDocument() + }, { timeout: 3000 }) + }, +} + +export const SelectTargetStep: Story = { + name: '04 - 选择目标钱包', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for loading + await waitFor(async () => { + const button = await canvas.findByRole('button', { name: /启动传送门/i }) + await expect(button).toBeInTheDocument() + }, { timeout: 5000 }) + + // Click connect + await userEvent.click(await canvas.findByRole('button', { name: /启动传送门/i })) + + // Wait for and click asset + await waitFor(() => expect(canvas.getByText('选择资产')).toBeInTheDocument(), { timeout: 3000 }) + const cards = canvas.getAllByRole('article') + if (cards.length > 0) await userEvent.click(cards[0]) + + // Enter amount + await waitFor(() => expect(canvas.getByRole('spinbutton')).toBeInTheDocument(), { timeout: 3000 }) + await userEvent.type(canvas.getByRole('spinbutton'), '100') + + // Click next + await userEvent.click(canvas.getByRole('button', { name: /下一步/i })) + + // Verify target selection is shown + await waitFor(async () => { + await expect(canvas.getByText(/选择目标钱包/i)).toBeInTheDocument() + }, { timeout: 3000 }) + }, +} + +export const ConfirmStep: Story = { + name: '05 - 确认传送', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for loading + await waitFor(async () => { + const button = await canvas.findByRole('button', { name: /启动传送门/i }) + await expect(button).toBeInTheDocument() + }, { timeout: 5000 }) + + // Click connect + await userEvent.click(await canvas.findByRole('button', { name: /启动传送门/i })) + + // Select asset + await waitFor(() => expect(canvas.getByText('选择资产')).toBeInTheDocument(), { timeout: 3000 }) + const cards = canvas.getAllByRole('article') + if (cards.length > 0) await userEvent.click(cards[0]) + + // Enter amount + await waitFor(() => expect(canvas.getByRole('spinbutton')).toBeInTheDocument(), { timeout: 3000 }) + await userEvent.type(canvas.getByRole('spinbutton'), '100') + await userEvent.click(canvas.getByRole('button', { name: /下一步/i })) + + // Select target + await waitFor(() => expect(canvas.getByText(/选择目标钱包/i)).toBeInTheDocument(), { timeout: 3000 }) + await userEvent.click(canvas.getByRole('button', { name: /选择目标钱包/i })) + + // Verify confirm page + await waitFor(async () => { + await expect(canvas.getByText(/确认传送/i)).toBeInTheDocument() + }, { timeout: 3000 }) + }, +} + +export const NoSdkError: Story = { + name: '错误 - SDK 未初始化', + decorators: [ + (Story) => { + setupMockFetch() + // Don't setup bio SDK + window.bio = undefined as unknown as typeof window.bio + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity }, + }, + }) + + return ( + +
+ +
+
+ ) + }, + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for loading + await waitFor(async () => { + const button = await canvas.findByRole('button', { name: /启动传送门/i }) + await expect(button).toBeInTheDocument() + }, { timeout: 5000 }) + + // Click connect + await userEvent.click(await canvas.findByRole('button', { name: /启动传送门/i })) + + // Verify error message + await waitFor(async () => { + await expect(canvas.getByText(/Bio SDK 未初始化/i)).toBeInTheDocument() + }, { timeout: 3000 }) + }, +} diff --git a/miniapps/teleport/src/api/client.test.ts b/miniapps/teleport/src/api/client.test.ts new file mode 100644 index 00000000..5f7dd32c --- /dev/null +++ b/miniapps/teleport/src/api/client.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + getTransmitAssetTypeList, + transmit, + getTransmitRecords, + getTransmitRecordDetail, + retryFromTxOnChain, + retryToTxOnChain, + ApiError, +} from './client' + +const mockFetch = vi.fn() +global.fetch = mockFetch + +describe('Teleport API Client', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('getTransmitAssetTypeList', () => { + it('should fetch asset type list', async () => { + const mockResponse = { + transmitSupport: { + ETH: { + ETH: { + enable: true, + assetType: 'ETH', + recipientAddress: '0x123', + targetChain: 'BFMCHAIN', + targetAsset: 'BFM', + ratio: { numerator: 1, denominator: 1 }, + }, + }, + }, + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) + + const result = await getTransmitAssetTypeList() + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.eth-metaverse.com/payment/transmit/assetTypeList', + expect.objectContaining({ + headers: { 'Content-Type': 'application/json' }, + }) + ) + }) + + it('should throw ApiError on HTTP error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.resolve({ message: 'Internal Server Error' }), + }) + + await expect(getTransmitAssetTypeList()).rejects.toThrow(ApiError) + }) + }) + + describe('transmit', () => { + it('should send transmit request', async () => { + const mockRequest = { + fromTrJson: { eth: { signTransData: '0x123' } }, + toTrInfo: { + chainName: 'BFMCHAIN' as const, + address: '0xabc', + assetType: 'BFM', + }, + } + + const mockResponse = { orderId: 'order-123' } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) + + const result = await transmit(mockRequest) + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.eth-metaverse.com/payment/transmit', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(mockRequest), + }) + ) + }) + }) + + describe('getTransmitRecords', () => { + it('should fetch records with pagination', async () => { + const mockResponse = { + page: 1, + pageSize: 10, + dataList: [{ orderId: '1', state: 1, orderState: 4 }], + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) + + const result = await getTransmitRecords({ page: 1, pageSize: 10 }) + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/transmit/records?'), + expect.any(Object) + ) + }) + + it('should include filter params', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ page: 1, pageSize: 10, dataList: [] }), + }) + + await getTransmitRecords({ + page: 1, + pageSize: 10, + fromChain: 'ETH', + fromAddress: '0x123', + }) + + const url = mockFetch.mock.calls[0][0] + expect(url).toContain('fromChain=ETH') + expect(url).toContain('fromAddress=0x123') + }) + }) + + describe('getTransmitRecordDetail', () => { + it('should fetch record detail', async () => { + const mockResponse = { + state: 3, + orderState: 4, + swapRatio: 1, + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) + + const result = await getTransmitRecordDetail('order-123') + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('orderId=order-123'), + expect.any(Object) + ) + }) + }) + + describe('retryFromTxOnChain', () => { + it('should retry from tx', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(true), + }) + + const result = await retryFromTxOnChain('order-123') + expect(result).toBe(true) + }) + }) + + describe('retryToTxOnChain', () => { + it('should retry to tx', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(true), + }) + + const result = await retryToTxOnChain('order-123') + expect(result).toBe(true) + }) + }) + + describe('ApiError', () => { + it('should contain status and data', () => { + const error = new ApiError('Test error', 400, { detail: 'Bad request' }) + expect(error.message).toBe('Test error') + expect(error.status).toBe(400) + expect(error.data).toEqual({ detail: 'Bad request' }) + }) + }) +}) diff --git a/miniapps/teleport/src/api/hooks.test.tsx b/miniapps/teleport/src/api/hooks.test.tsx new file mode 100644 index 00000000..13c80c67 --- /dev/null +++ b/miniapps/teleport/src/api/hooks.test.tsx @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { + useTransmitAssetTypeList, + useTransmit, + useTransmitRecords, + useTransmitRecordDetail, + queryKeys, +} from './hooks' +import * as client from './client' + +vi.mock('./client') + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +describe('Teleport API Hooks', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('useTransmitAssetTypeList', () => { + it('should fetch and transform asset list', async () => { + const mockData = { + transmitSupport: { + ETH: { + ETH: { + enable: true, + isAirdrop: false, + assetType: 'ETH', + recipientAddress: '0x123', + targetChain: 'BFMCHAIN' as const, + targetAsset: 'BFM', + ratio: { numerator: 1, denominator: 1 }, + transmitDate: { + startDate: '2020-01-01', + endDate: '2030-12-31', + }, + }, + }, + }, + } + + vi.mocked(client.getTransmitAssetTypeList).mockResolvedValue(mockData) + + const { result } = renderHook(() => useTransmitAssetTypeList(), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toHaveLength(1) + expect(result.current.data?.[0]).toMatchObject({ + chain: 'ETH', + symbol: 'ETH', + targetChain: 'BFMCHAIN', + }) + }) + + it('should filter disabled assets', async () => { + const mockData = { + transmitSupport: { + ETH: { + ETH: { + enable: false, + isAirdrop: false, + assetType: 'ETH', + recipientAddress: '0x123', + targetChain: 'BFMCHAIN' as const, + targetAsset: 'BFM', + ratio: { numerator: 1, denominator: 1 }, + transmitDate: { + startDate: '2020-01-01', + endDate: '2030-12-31', + }, + }, + }, + }, + } + + vi.mocked(client.getTransmitAssetTypeList).mockResolvedValue(mockData) + + const { result } = renderHook(() => useTransmitAssetTypeList(), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toHaveLength(0) + }) + + it('should filter expired assets', async () => { + const mockData = { + transmitSupport: { + ETH: { + ETH: { + enable: true, + isAirdrop: false, + assetType: 'ETH', + recipientAddress: '0x123', + targetChain: 'BFMCHAIN' as const, + targetAsset: 'BFM', + ratio: { numerator: 1, denominator: 1 }, + transmitDate: { + startDate: '2020-01-01', + endDate: '2020-12-31', // Expired + }, + }, + }, + }, + } + + vi.mocked(client.getTransmitAssetTypeList).mockResolvedValue(mockData) + + const { result } = renderHook(() => useTransmitAssetTypeList(), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toHaveLength(0) + }) + }) + + describe('useTransmit', () => { + it('should submit transmit request', async () => { + const mockResponse = { orderId: 'order-123' } + vi.mocked(client.transmit).mockResolvedValue(mockResponse) + + const { result } = renderHook(() => useTransmit(), { + wrapper: createWrapper(), + }) + + result.current.mutate({ + fromTrJson: { eth: { signTransData: '0x123' } }, + toTrInfo: { + chainName: 'BFMCHAIN', + address: '0xabc', + assetType: 'BFM', + }, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toEqual(mockResponse) + }) + }) + + describe('useTransmitRecords', () => { + it('should fetch records', async () => { + const mockData = { + page: 1, + pageSize: 10, + dataList: [ + { + orderId: '1', + state: 3, + orderState: 4, + createdTime: '2024-01-01', + }, + ], + } + + vi.mocked(client.getTransmitRecords).mockResolvedValue(mockData) + + const { result } = renderHook( + () => useTransmitRecords({ page: 1, pageSize: 10 }), + { wrapper: createWrapper() } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data?.dataList).toHaveLength(1) + }) + }) + + describe('useTransmitRecordDetail', () => { + it('should fetch record detail', async () => { + const mockData = { + state: 3, + orderState: 4, + swapRatio: 1, + updatedTime: '2024-01-01', + } + + vi.mocked(client.getTransmitRecordDetail).mockResolvedValue(mockData) + + const { result } = renderHook( + () => useTransmitRecordDetail('order-123'), + { wrapper: createWrapper() } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toEqual(mockData) + }) + + it('should not fetch when orderId is empty', () => { + const { result } = renderHook( + () => useTransmitRecordDetail('', { enabled: false }), + { wrapper: createWrapper() } + ) + + expect(result.current.isFetching).toBe(false) + }) + }) + + describe('queryKeys', () => { + it('should generate correct query keys', () => { + expect(queryKeys.assetTypeList).toEqual(['transmit', 'assetTypeList']) + expect(queryKeys.records({ page: 1, pageSize: 10 })).toEqual([ + 'transmit', + 'records', + { page: 1, pageSize: 10 }, + ]) + expect(queryKeys.recordDetail('order-123')).toEqual([ + 'transmit', + 'recordDetail', + 'order-123', + ]) + }) + }) +}) From a5458fd25512dca9b29f582e618f1b530bc4d92f Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 22:35:47 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(miniapps/teleport):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20i18n=20lint=20=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniapps/teleport/src/App.stories.tsx | 2 +- miniapps/teleport/src/App.tsx | 15 ++++++++------- .../src/components/GlowButton.stories.tsx | 1 + miniapps/teleport/src/i18n/locales/en.json | 8 +++++++- miniapps/teleport/src/i18n/locales/zh-CN.json | 8 +++++++- miniapps/teleport/src/i18n/locales/zh-TW.json | 8 +++++++- miniapps/teleport/src/i18n/locales/zh.json | 8 +++++++- 7 files changed, 38 insertions(+), 12 deletions(-) diff --git a/miniapps/teleport/src/App.stories.tsx b/miniapps/teleport/src/App.stories.tsx index 387d793f..8ba79c9e 100644 --- a/miniapps/teleport/src/App.stories.tsx +++ b/miniapps/teleport/src/App.stories.tsx @@ -84,7 +84,7 @@ const setupMockFetch = () => { // Setup mock Bio SDK const setupMockBio = (overrides?: Partial) => { window.bio = { - request: async ({ method, params }) => { + request: async ({ method, params: _params }) => { if (method === 'bio_closeSplashScreen') return if (method === 'bio_selectAccount') { return { diff --git a/miniapps/teleport/src/App.tsx b/miniapps/teleport/src/App.tsx index 9af63057..ce9eb123 100644 --- a/miniapps/teleport/src/App.tsx +++ b/miniapps/teleport/src/App.tsx @@ -360,7 +360,7 @@ export default function App() {
{asset.symbol} - {asset.chain} → {asset.targetChain} + {t('asset.transfer', { from: asset.chain, to: asset.targetChain })}
{asset.balance || '-'}
@@ -391,7 +391,7 @@ export default function App() {
{selectedAsset.symbol} - {t('asset.balance')}: {selectedAsset.balance || '-'} + {t('common.labelValue', { label: t('asset.balance'), value: selectedAsset.balance || '-' })}
{amount && (

- {t('amount.expected')}: {expectedReceive} {selectedAsset.targetAsset} + {t('common.labelValue', { label: t('amount.expected'), value: '' })}{expectedReceive} {selectedAsset.targetAsset}

)} @@ -443,7 +443,7 @@ export default function App() { {amount} {selectedAsset?.symbol}

- → {expectedReceive} {selectedAsset?.targetAsset} + {t('common.arrow')} {expectedReceive} {selectedAsset?.targetAsset}

@@ -526,7 +526,7 @@ export default function App() {
{t('confirm.ratio')} - {selectedAsset?.ratio.numerator}:{selectedAsset?.ratio.denominator} + {`${selectedAsset?.ratio.numerator}:${selectedAsset?.ratio.denominator}`}
@@ -570,7 +570,7 @@ export default function App() {

{orderId && ( -

{t('processing.orderId')}: {orderId}

+

{t('common.labelValue', { label: t('processing.orderId'), value: orderId })}

)} )} @@ -641,6 +641,7 @@ function WalletCard({ label, address, name, chain, compact, highlight }: { compact?: boolean highlight?: boolean }) { + const { t } = useTranslation() if (compact) { return ( @@ -669,7 +670,7 @@ function WalletCard({ label, address, name, chain, compact, highlight }: {
{label} {chain && {chain}} - {name || 'Unknown'} + {name || t('common.unknown')} {address}
diff --git a/miniapps/teleport/src/components/GlowButton.stories.tsx b/miniapps/teleport/src/components/GlowButton.stories.tsx index f87a85b3..95ad0bf3 100644 --- a/miniapps/teleport/src/components/GlowButton.stories.tsx +++ b/miniapps/teleport/src/components/GlowButton.stories.tsx @@ -1,3 +1,4 @@ +/* oxlint-disable i18next/no-literal-string */ import type { Meta, StoryObj } from '@storybook/react-vite' import { GlowButton } from './GlowButton' import { Send } from 'lucide-react' diff --git a/miniapps/teleport/src/i18n/locales/en.json b/miniapps/teleport/src/i18n/locales/en.json index b86e9277..e5accf9f 100644 --- a/miniapps/teleport/src/i18n/locales/en.json +++ b/miniapps/teleport/src/i18n/locales/en.json @@ -4,6 +4,11 @@ "subtitle": "Cross-Wallet Transfer", "description": "Safely transfer assets to another wallet" }, + "common": { + "unknown": "Unknown", + "labelValue": "{{label}}: {{value}}", + "arrow": "→" + }, "connect": { "button": "Start Teleport", "loading": "Connecting...", @@ -13,7 +18,8 @@ "asset": { "select": "Select Asset", "balance": "Available", - "noAssets": "No assets available for transfer on this chain" + "noAssets": "No assets available for transfer on this chain", + "transfer": "{{from}} → {{to}}" }, "wallet": { "source": "Source Wallet", diff --git a/miniapps/teleport/src/i18n/locales/zh-CN.json b/miniapps/teleport/src/i18n/locales/zh-CN.json index bf727045..ae9934b9 100644 --- a/miniapps/teleport/src/i18n/locales/zh-CN.json +++ b/miniapps/teleport/src/i18n/locales/zh-CN.json @@ -4,6 +4,11 @@ "subtitle": "跨钱包传送", "description": "安全地将资产转移到另一个钱包" }, + "common": { + "unknown": "未知", + "labelValue": "{{label}}: {{value}}", + "arrow": "→" + }, "connect": { "button": "启动传送门", "loading": "连接中...", @@ -13,7 +18,8 @@ "asset": { "select": "选择资产", "balance": "可用", - "noAssets": "当前链暂无可传送资产" + "noAssets": "当前链暂无可传送资产", + "transfer": "{{from}} → {{to}}" }, "wallet": { "source": "源钱包", diff --git a/miniapps/teleport/src/i18n/locales/zh-TW.json b/miniapps/teleport/src/i18n/locales/zh-TW.json index c381da0d..b7ecfb4c 100644 --- a/miniapps/teleport/src/i18n/locales/zh-TW.json +++ b/miniapps/teleport/src/i18n/locales/zh-TW.json @@ -4,6 +4,11 @@ "subtitle": "跨錢包傳送", "description": "安全地將資產轉移到另一個錢包" }, + "common": { + "unknown": "未知", + "labelValue": "{{label}}: {{value}}", + "arrow": "→" + }, "connect": { "button": "啟動傳送門", "loading": "連接中...", @@ -13,7 +18,8 @@ "asset": { "select": "選擇資產", "balance": "可用", - "noAssets": "當前鏈暫無可傳送資產" + "noAssets": "當前鏈暫無可傳送資產", + "transfer": "{{from}} → {{to}}" }, "wallet": { "source": "源錢包", diff --git a/miniapps/teleport/src/i18n/locales/zh.json b/miniapps/teleport/src/i18n/locales/zh.json index bf727045..ae9934b9 100644 --- a/miniapps/teleport/src/i18n/locales/zh.json +++ b/miniapps/teleport/src/i18n/locales/zh.json @@ -4,6 +4,11 @@ "subtitle": "跨钱包传送", "description": "安全地将资产转移到另一个钱包" }, + "common": { + "unknown": "未知", + "labelValue": "{{label}}: {{value}}", + "arrow": "→" + }, "connect": { "button": "启动传送门", "loading": "连接中...", @@ -13,7 +18,8 @@ "asset": { "select": "选择资产", "balance": "可用", - "noAssets": "当前链暂无可传送资产" + "noAssets": "当前链暂无可传送资产", + "transfer": "{{from}} → {{to}}" }, "wallet": { "source": "源钱包", From dcff8feb5df786d278ab722b57068f5364444ee2 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 22:40:31 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(miniapps/teleport):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Storybook=20=E6=B5=8B=E8=AF=95=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniapps/teleport/src/App.stories.tsx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/miniapps/teleport/src/App.stories.tsx b/miniapps/teleport/src/App.stories.tsx index 8ba79c9e..33da845a 100644 --- a/miniapps/teleport/src/App.stories.tsx +++ b/miniapps/teleport/src/App.stories.tsx @@ -212,11 +212,11 @@ export const InputAmountStep: Story = { await expect(canvas.getByText('选择资产')).toBeInTheDocument() }, { timeout: 3000 }) - // Click on ETH asset - const cards = canvas.getAllByRole('article') - if (cards.length > 0) { - await userEvent.click(cards[0]) - } + // Click on ETH asset by finding the card-title with ETH text + const ethAssets = canvas.getAllByText('ETH') + const ethCardTitle = ethAssets.find(el => el.getAttribute('data-slot') === 'card-title') + const ethCard = ethCardTitle?.closest('[data-slot="card"]') + if (ethCard) await userEvent.click(ethCard) // Verify amount input is shown await waitFor(async () => { @@ -242,8 +242,10 @@ export const SelectTargetStep: Story = { // Wait for and click asset await waitFor(() => expect(canvas.getByText('选择资产')).toBeInTheDocument(), { timeout: 3000 }) - const cards = canvas.getAllByRole('article') - if (cards.length > 0) await userEvent.click(cards[0]) + const ethAssets = canvas.getAllByText('ETH') + const ethCardTitle = ethAssets.find(el => el.getAttribute('data-slot') === 'card-title') + const ethCard = ethCardTitle?.closest('[data-slot="card"]') + if (ethCard) await userEvent.click(ethCard) // Enter amount await waitFor(() => expect(canvas.getByRole('spinbutton')).toBeInTheDocument(), { timeout: 3000 }) @@ -275,8 +277,10 @@ export const ConfirmStep: Story = { // Select asset await waitFor(() => expect(canvas.getByText('选择资产')).toBeInTheDocument(), { timeout: 3000 }) - const cards = canvas.getAllByRole('article') - if (cards.length > 0) await userEvent.click(cards[0]) + const ethAssets = canvas.getAllByText('ETH') + const ethCardTitle = ethAssets.find(el => el.getAttribute('data-slot') === 'card-title') + const ethCard = ethCardTitle?.closest('[data-slot="card"]') + if (ethCard) await userEvent.click(ethCard) // Enter amount await waitFor(() => expect(canvas.getByRole('spinbutton')).toBeInTheDocument(), { timeout: 3000 })