diff --git a/e2e/bioforest-full-flow.spec.ts b/e2e/bioforest-full-flow.spec.ts index 05ce11b3a..4cf50f639 100644 --- a/e2e/bioforest-full-flow.spec.ts +++ b/e2e/bioforest-full-flow.spec.ts @@ -268,8 +268,8 @@ async function performTransfer( await continueBtn.click() await page.waitForTimeout(500) - // 点击确认转账 (TransferPreviewJob) - const confirmBtn = page.locator(`[data-testid="confirm-preview-button"], button:has-text("${UI_TEXT.confirm.source}")`).first() + // 点击确认转账 + const confirmBtn = page.locator(`[data-testid="confirm-transfer-button"], button:has-text("${UI_TEXT.confirm.source}")`).first() await expect(confirmBtn).toBeVisible({ timeout: 5000 }) await confirmBtn.click() await page.waitForTimeout(500) diff --git a/e2e/bioforest-real-transfer.spec.ts b/e2e/bioforest-real-transfer.spec.ts index 1a9b4c9e3..9752e413f 100644 --- a/e2e/bioforest-real-transfer.spec.ts +++ b/e2e/bioforest-real-transfer.spec.ts @@ -178,7 +178,7 @@ async function doTransfer(page: Page, toAddress: string, amount: string, needPay await expect(continueBtn).toBeEnabled({ timeout: 15000 }) await continueBtn.click() - await page.locator('[data-testid="confirm-preview-button"]').click() + await page.locator('[data-testid="confirm-transfer-button"]').click() // 验证钱包锁 const patternInput = page.locator('[data-testid="wallet-pattern-input"]') diff --git a/e2e/bioforest-transfer.spec.ts b/e2e/bioforest-transfer.spec.ts index 8a4793e4a..b79f2961c 100644 --- a/e2e/bioforest-transfer.spec.ts +++ b/e2e/bioforest-transfer.spec.ts @@ -201,9 +201,9 @@ describeOrSkip('BioForest 转账测试', () => { await continueBtn.click() console.log(' ✅ 继续到确认页') - // 7. 确认转账(TransferPreviewJob) + // 7. 确认转账 console.log('7. 确认转账...') - const confirmBtn = page.locator('[data-testid="confirm-preview-button"]') + const confirmBtn = page.locator('[data-testid="confirm-transfer-button"]') await expect(confirmBtn).toBeVisible({ timeout: 5000 }) await confirmBtn.click() console.log(' ✅ 点击确认') diff --git a/e2e/send-transaction.mock.spec.ts b/e2e/send-transaction.mock.spec.ts index 6ec52f51f..c56109e18 100644 --- a/e2e/send-transaction.mock.spec.ts +++ b/e2e/send-transaction.mock.spec.ts @@ -344,14 +344,14 @@ test.describe('发送交易 - Job 弹窗流程', () => { await page.waitForTimeout(500) // 检查确认弹窗内容 - const confirmBtn = page.locator('[data-testid="confirm-preview-button"]') - const cancelBtn = page.locator('[data-testid="cancel-preview-button"]') + const confirmBtn = page.locator('[data-testid="confirm-transfer-button"]') + const cancelBtn = page.locator('[data-testid="cancel-transfer-button"]') // 至少一个按钮应该可见(确认或取消) const hasConfirmUI = await confirmBtn.isVisible() || await cancelBtn.isVisible() if (hasConfirmUI) { - console.log('TransferPreviewJob opened successfully') + console.log('TransferConfirmJob opened successfully') // 截图 await expect(page).toHaveScreenshot('send-confirm-job.png') @@ -365,7 +365,7 @@ test.describe('发送交易 - Job 弹窗流程', () => { await expect(page.locator('[data-testid="send-continue-button"]')).toBeVisible() } } else { - console.log('TransferPreviewJob may not have opened - check mock configuration') + console.log('TransferConfirmJob may not have opened - check mock configuration') } } else { console.log('Continue button not enabled - mock service may not be configured correctly') @@ -390,7 +390,7 @@ test.describe('发送交易 - Job 弹窗流程', () => { await continueBtn.click() await page.waitForTimeout(500) - const confirmBtn = page.locator('[data-testid="confirm-preview-button"]') + const confirmBtn = page.locator('[data-testid="confirm-transfer-button"]') if (await confirmBtn.isVisible()) { await confirmBtn.click() diff --git a/src/components/wallet/wallet-address-portfolio-from-provider.tsx b/src/components/wallet/wallet-address-portfolio-from-provider.tsx deleted file mode 100644 index 9a38fd0a4..000000000 --- a/src/components/wallet/wallet-address-portfolio-from-provider.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useMemo } from 'react' -import { WalletAddressPortfolioView, type WalletAddressPortfolioViewProps } from './wallet-address-portfolio-view' -import type { ChainType } from '@/stores' -import { ChainProviderGate, useChainProvider } from '@/contexts' -import type { TokenInfo } from '@/components/token/token-item' -import { toTransactionInfoList } from '@/components/transaction/adapters' - -export interface WalletAddressPortfolioFromProviderProps { - chainId: ChainType - address: string - chainName?: string - onTokenClick?: WalletAddressPortfolioViewProps['onTokenClick'] - onTransactionClick?: WalletAddressPortfolioViewProps['onTransactionClick'] - className?: string - testId?: string -} - -/** - * 从 Provider 获取地址资产组合(内部实现) - * - * 使用 ChainProvider 响应式 API 获取数据,复用 WalletAddressPortfolioView 展示。 - */ -function WalletAddressPortfolioFromProviderInner({ - chainId, - address, - chainName, - onTokenClick, - onTransactionClick, - className, - testId, -}: WalletAddressPortfolioFromProviderProps) { - const chainProvider = useChainProvider() - - // 使用新的响应式 API - const { data: tokens = [], isLoading: tokensLoading } = chainProvider.allBalances.useState( - { address }, - { enabled: !!address } - ) - - const { data: txResult, isLoading: transactionsLoading } = chainProvider.transactionHistory.useState( - { address, limit: 50 }, - { enabled: !!address } - ) - - // 转换为 TokenInfo 格式 - const tokenInfoList: TokenInfo[] = useMemo(() => { - return tokens.map((token) => ({ - symbol: token.symbol, - name: token.name, - chain: chainId, - balance: token.amount.toFormatted(), - decimals: token.decimals, - fiatValue: undefined, - change24h: 0, - icon: token.icon, - })) - }, [tokens, chainId]) - - // 转换交易历史格式 - const transactions = useMemo(() => { - if (!txResult) return [] - return toTransactionInfoList(txResult, chainId) - }, [txResult, chainId]) - - return ( - - ) -} - -/** - * 从 Provider 获取地址资产组合 - * - * 使用 ChainProviderGate 确保 ChainProvider 可用,再使用响应式 API 获取数据。 - * 适用于 Stories 测试和任意地址查询场景。 - */ -export function WalletAddressPortfolioFromProvider(props: WalletAddressPortfolioFromProviderProps) { - return ( - -
-

Chain not supported: {props.chainId}

-
- - } - > - -
- ) -} diff --git a/src/components/wallet/wallet-address-portfolio.stories.tsx b/src/components/wallet/wallet-address-portfolio.stories.tsx index 6f04f298a..792767e79 100644 --- a/src/components/wallet/wallet-address-portfolio.stories.tsx +++ b/src/components/wallet/wallet-address-portfolio.stories.tsx @@ -1,19 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import type { ReactRenderer } from '@storybook/react'; -import type { DecoratorFunction } from 'storybook/internal/types'; -import { useEffect, useState, useMemo, useCallback } from 'react'; import { expect, waitFor, within } from '@storybook/test'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { WalletAddressPortfolioView } from './wallet-address-portfolio-view'; -import { WalletAddressPortfolioFromProvider } from './wallet-address-portfolio-from-provider'; -import { TokenIconProvider } from './token-icon'; -import { chainConfigActions, chainConfigStore, useChainConfigState, useChainConfigs } from '@/stores/chain-config'; -import { clearProviderCache } from '@/services/chain-adapter'; -import { resolveAssetUrl } from '@/lib/asset-url'; import { Amount } from '@/types/amount'; import type { TokenInfo } from '@/components/token/token-item'; import type { TransactionInfo, TransactionType } from '@/components/transaction/transaction-item'; -import type { ChainConfig } from '@/services/chain-config'; + +// ==================== Mock 数据 ==================== const mockTokens: TokenInfo[] = [ { symbol: 'BFT', name: 'BFT', balance: '1234.56789012', decimals: 8, chain: 'bfmeta' }, @@ -44,436 +36,56 @@ const mockTransactions: TransactionInfo[] = [ }, ]; -// 基于 Bitcoin mempool.space 真实数据格式创建的 mock 交易 -// 参考: https://mempool.space/api/address/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa/txs -const mockBitcoinTransactions: TransactionInfo[] = [ - { - id: '78a44e6e62bf7638065bf58327c8486217dbf84bba617def8f8a2816a23e14c9', - type: 'receive' as TransactionType, - status: 'pending', - amount: Amount.fromRaw('546', 8, 'BTC'), - symbol: 'BTC', - address: 'bc1pl4qpz24u7zf6zn7lckdckglns02xghrh4jw3qeh2w37x3m6k657qv6pqw7', - timestamp: new Date(Date.now() - 600000), // 10 min ago - chain: 'bitcoin', - }, - { - id: 'd274d00350d384f443cb1e42defdd7e12f350aee37813d305b6bb9468270de19', - type: 'receive' as TransactionType, - status: 'confirmed', - amount: Amount.fromRaw('546', 8, 'BTC'), - symbol: 'BTC', - address: 'bc1py4h77ccc0yalhrv2w8h5l5htw2t2up7nhcmg5t89ndgkjhpxek3qz3dsgc', - timestamp: new Date(1767688327000), - chain: 'bitcoin', - }, - { - id: 'c11dfe6e1033eb354c6bf7b3428f9290d635cefbe16876b86a8ce84be8c5637d', - type: 'receive' as TransactionType, - status: 'confirmed', - amount: Amount.fromRaw('546', 8, 'BTC'), - symbol: 'BTC', - address: 'bc1p75kfwfe6se8uztt67nat7fev50ydly9lmcrdj6gtalz6zmsjachqtryrtc', - timestamp: new Date(1767682433000), - chain: 'bitcoin', - }, - { - id: 'd8773c23582a0bf0fe7f640fd1053c14c5ebf3782fed994bd9cd2aed7d19dedd', - type: 'receive' as TransactionType, - status: 'confirmed', - amount: Amount.fromRaw('12168', 8, 'BTC'), - symbol: 'BTC', - address: 'bc1ppcagzftu7w6pl7saqvs4nvtpwpr2g7ja96tdylt3lz775yjkkcsqlu7exa', - timestamp: new Date(1767681823000), - chain: 'bitcoin', - }, -]; - -// 基于 TronGrid API 真实数据格式创建的 mock 交易 -// 参考: https://api.trongrid.io/v1/accounts/TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9/transactions -const mockTronTransactions: TransactionInfo[] = [ - { - id: 'caacd034fd600a9a66cb9c841f39b81101e97e23068c8d73152f8741654139e1', - type: 'send' as TransactionType, - status: 'confirmed', - amount: Amount.fromRaw('149000000', 6, 'TRX'), // 149 TRX - symbol: 'TRX', - address: 'TF17BgPaZYbz8oxbjhriubPDsA7ArKoLX3', - timestamp: new Date(1767691140000), - chain: 'tron', - }, - { - id: '2b8d0c9e7f3a1b5e4d6c8a9f0e1d2c3b4a5f6e7d8c9b0a1f2e3d4c5b6a7f8e9d', - type: 'receive' as TransactionType, - status: 'confirmed', - amount: Amount.fromRaw('500000000', 6, 'TRX'), // 500 TRX - symbol: 'TRX', - address: 'TAnahWWRPm6jhiYR6gMCE3eLDqL8LfCnVE', - timestamp: new Date(1767680000000), - chain: 'tron', - }, - { - id: '3c9e1d0f8a2b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d', - type: 'send' as TransactionType, - status: 'confirmed', - amount: Amount.fromRaw('1000000000', 6, 'TRX'), // 1000 TRX - symbol: 'TRX', - address: 'TRkJg1B9WgM8uXzthJJhBhX7K1GBqH9dXb', - timestamp: new Date(1767670000000), - chain: 'tron', - }, -]; - -// 基于 Etherscan API 真实数据格式创建的 mock 交易 (Vitalik's wallet) const mockEthereumTransactions: TransactionInfo[] = [ { id: '0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890', type: 'send' as TransactionType, status: 'confirmed', - amount: Amount.fromRaw('1000000000000000000', 18, 'ETH'), // 1 ETH + amount: Amount.fromRaw('1000000000000000000', 18, 'ETH'), symbol: 'ETH', address: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD38', - timestamp: new Date(Date.now() - 7200000), // 2 hours ago + timestamp: new Date(Date.now() - 7200000), chain: 'ethereum', }, { id: '0x2b3c4d5e6f78901abcdef2345678901abcdef2345678901abcdef2345678901', type: 'receive' as TransactionType, status: 'confirmed', - amount: Amount.fromRaw('5000000000000000000', 18, 'ETH'), // 5 ETH + amount: Amount.fromRaw('5000000000000000000', 18, 'ETH'), symbol: 'ETH', address: '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', - timestamp: new Date(Date.now() - 86400000), // 1 day ago + timestamp: new Date(Date.now() - 86400000), chain: 'ethereum', }, +]; + +const mockTronTransactions: TransactionInfo[] = [ { - id: '0x3c4d5e6f789012abcdef3456789012abcdef3456789012abcdef3456789012', + id: 'caacd034fd600a9a66cb9c841f39b81101e97e23068c8d73152f8741654139e1', type: 'send' as TransactionType, status: 'confirmed', - amount: Amount.fromRaw('100000000', 6, 'USDC'), // 100 USDC - symbol: 'USDC', - address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - timestamp: new Date(Date.now() - 172800000), // 2 days ago - chain: 'ethereum', + amount: Amount.fromRaw('149000000', 6, 'TRX'), + symbol: 'TRX', + address: 'TF17BgPaZYbz8oxbjhriubPDsA7ArKoLX3', + timestamp: new Date(Date.now() - 3600000), + chain: 'tron', }, ]; -// BSC 交易 mock const mockBinanceTransactions: TransactionInfo[] = [ { id: '0xbsc1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab', type: 'receive' as TransactionType, status: 'confirmed', - amount: Amount.fromRaw('10000000000000000000', 18, 'BNB'), // 10 BNB + amount: Amount.fromRaw('10000000000000000000', 18, 'BNB'), symbol: 'BNB', address: '0x8894E0a0c962CB723c1976a4421c95949bE2D4E3', timestamp: new Date(Date.now() - 3600000), chain: 'binance', }, - { - id: '0xbsc2345678901abcdef2345678901abcdef2345678901abcdef2345678901bc', - type: 'send' as TransactionType, - status: 'confirmed', - amount: Amount.fromRaw('5000000000000000000', 18, 'BNB'), // 5 BNB - symbol: 'BNB', - address: '0x28C6c06298d514Db089934071355E5743bf21d60', - timestamp: new Date(Date.now() - 14400000), // 4 hours ago - chain: 'binance', - }, ]; -function ChainConfigProvider({ children }: { children: React.ReactNode }) { - const state = useChainConfigState(); - const [initStarted, setInitStarted] = useState(false); - - useEffect(() => { - if (!initStarted && !state.snapshot && !state.isLoading) { - setInitStarted(true); - clearProviderCache(); - chainConfigActions.initialize(); - } - }, [initStarted, state.snapshot, state.isLoading]); - - if (state.error) { - return ( -
-
-

Chain config error

-

{state.error}

-
-
- ); - } - - if (!state.snapshot) { - return ( -
-
-

Loading chain configuration...

-
-
- ); - } - - return <>{children}; -} - -function TokenIconProviderWrapper({ children }: { children: React.ReactNode }) { - const configs = useChainConfigs(); - - const resolvedConfigs = useMemo(() => { - return configs.map((config) => ({ - ...config, - tokenIconBase: config.tokenIconBase?.map(resolveAssetUrl), - })); - }, [configs]); - - const getTokenIconBases = useCallback( - (chainId: string) => resolvedConfigs.find((c) => c.id === chainId)?.tokenIconBase ?? [], - [resolvedConfigs], - ); - - return ( - - {children} - - ); -} - -const REAL_ADDRESSES: Record = { - ethereum: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', - tron: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9', - bitcoin: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', - binance: '0x8894E0a0c962CB723c1976a4421c95949bE2D4E3', - bfmeta: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j', -}; - -function formatApiLabel(api: ChainConfig['apis'][number]): string { - const apiKeyEnv = api.config && 'apiKeyEnv' in api.config ? (api.config.apiKeyEnv as string | undefined) : undefined; - if (apiKeyEnv) return `${api.type} (${apiKeyEnv})`; - return api.type; -} - -function generateSingleApiConfigs(baseConfig: ChainConfig): ChainConfig[] { - return baseConfig.apis.map((api, index) => ({ - ...baseConfig, - id: `${baseConfig.id}-api-${index}-${api.type}`, - name: `${baseConfig.name} (${api.type})`, - apis: [api], - enabled: true, - source: 'manual' as const, - })); -} - -function DynamicCompareConfigInjector({ chainId }: { chainId: string }) { - const state = useChainConfigState(); - const [injected, setInjected] = useState(false); - - useEffect(() => { - if (!state.snapshot) return; - if (injected) return; - - const baseConfig = state.snapshot.configs.find((c) => c.id === chainId); - if (!baseConfig) return; - - const alreadyInjected = state.snapshot.configs.some((c) => c.id === `${chainId}-api-0-${baseConfig.apis[0]?.type}`); - if (alreadyInjected) { - setInjected(true); - return; - } - - const singleApiConfigs = generateSingleApiConfigs(baseConfig); - - chainConfigStore.setState((prev) => { - if (!prev.snapshot) return prev; - return { - ...prev, - snapshot: { - ...prev.snapshot, - configs: [...prev.snapshot.configs, ...singleApiConfigs], - }, - }; - }); - - clearProviderCache(); - setInjected(true); - }, [state.snapshot, injected, chainId]); - - return null; -} - -function DynamicProviderPanel({ - api, - baseConfig, - index, - address, -}: { - api: ChainConfig['apis'][number]; - baseConfig: ChainConfig; - index: number; - address: string; -}) { - const dynamicChainId = `${baseConfig.id}-api-${index}-${api.type}`; - const testId = `cmp-${baseConfig.id}-${api.type}`; - - return ( -
-
-
-
-

{api.type}

-

{api.endpoint}

-
-

#{index}

-
-

{formatApiLabel(api)}

-
- -
- ); -} - -function DynamicCompareProvidersGrid({ chainId }: { chainId: string }) { - const state = useChainConfigState(); - const baseConfig = state.snapshot?.configs.find((c) => c.id === chainId); - - if (!baseConfig) { - return
Chain config not found: {chainId}
; - } - - const address = REAL_ADDRESSES[chainId] ?? ''; - - return ( -
-
-

{baseConfig.name}

-

- Comparing {baseConfig.apis.length} provider(s) from default-chains.json -

-

{address}

-
-
- {baseConfig.apis.map((api, index) => ( - - ))} -
-
- ); -} - -const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 0, - }, - }, - }); - -const withChainConfig: DecoratorFunction = (Story) => ( - - - -
- -
-
-
-
-); - -const createCompareDecorator = (chainId: string): DecoratorFunction => (Story) => ( - - - - -
- -
-
-
-
-); - -function hasPositiveNumber(text: string): boolean { - const matches = text.match(/\d+(?:\.\d+)?/g); - if (!matches) return false; - for (const value of matches) { - const parsed = Number(value); - if (Number.isFinite(parsed) && parsed > 0) return true; - } - return false; -} - -async function verifyNonEmptyAssetsAndHistory(canvas: ReturnType, testId: string): Promise { - await waitFor( - () => { - const portfolio = canvas.getByTestId(testId); - expect(portfolio).toBeVisible(); - }, - { timeout: 25_000 }, - ); - - await waitFor( - () => { - const tokenList = canvas.queryByTestId(`${testId}-token-list`); - expect(tokenList).not.toBeNull(); - const tokenItems = tokenList?.querySelectorAll('[data-testid^="token-item-"]') ?? []; - expect(tokenItems.length).toBeGreaterThan(0); - const tokenText = tokenItems[0]?.textContent ?? ''; - expect(hasPositiveNumber(tokenText)).toBe(true); - - const tokenEmpty = canvas.queryByTestId(`${testId}-token-list-empty`); - expect(tokenEmpty).toBeNull(); - }, - { timeout: 25_000 }, - ); - - const historyTab = canvas.getByTestId(`${testId}-tabs-tab-history`); - historyTab.click(); - - await waitFor( - () => { - const txList = canvas.queryByTestId(`${testId}-transaction-list`); - expect(txList).not.toBeNull(); - - const txEmpty = canvas.queryByTestId(`${testId}-transaction-list-empty`); - expect(txEmpty).toBeNull(); - }, - { timeout: 25_000 }, - ); -} - -async function expectAnyProviderPanelOk(options: { - canvas: ReturnType; - label: string; - testIds: string[]; -}): Promise { - const errors: string[] = []; - for (const testId of options.testIds) { - try { - await verifyNonEmptyAssetsAndHistory(options.canvas, testId); - return; - } catch (error) { - errors.push(`${testId}: ${error instanceof Error ? error.message : String(error)}`); - } - } - - throw new Error(`No provider panel returned non-empty data for ${options.label}. Details: ${errors.join(' | ')}`); -} +// ==================== Meta ==================== const meta = { title: 'Wallet/WalletAddressPortfolio', @@ -487,6 +99,8 @@ const meta = { export default meta; type Story = StoryObj; +// ==================== 基础状态 Stories ==================== + export const Default: Story = { args: { chainId: 'bfmeta', @@ -535,304 +149,70 @@ export const Empty: Story = { }, }; -export const RealDataBfmeta: Story = { - name: 'Real Data: biochain-bfmeta', - decorators: [withChainConfig], - parameters: { - chromatic: { delay: 5000 }, - docs: { - description: { - story: 'Fetches real token balances and transactions from BFMeta chain using the actual chainProvider API.', - }, - }, - }, - render: () => ( - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Wait for data to load - check component renders without error - await waitFor( - () => { - const portfolio = canvas.getByTestId('bfmeta-portfolio'); - expect(portfolio).toBeVisible(); - - // Check loading finished (either has tokens, empty state, or loading skeleton stopped) - const tokenList = canvas.queryByTestId('bfmeta-portfolio-token-list'); - const tokenEmpty = canvas.queryByTestId('bfmeta-portfolio-token-list-empty'); - const loading = portfolio.querySelector('.animate-pulse'); - - // Should have either: token list, empty state, or still loading - expect(tokenList || tokenEmpty || loading).not.toBeNull(); - - // If token list exists, verify it has items - if (tokenList) { - const tokenItems = tokenList.querySelectorAll('[data-testid^="token-item-"]'); - expect(tokenItems.length).toBeGreaterThan(0); - } - }, - { timeout: 15000 }, - ); - }, -}; - -export const RealDataEthereum: Story = { - name: 'Real Data: eth-eth', - decorators: [withChainConfig], - parameters: { - chromatic: { delay: 5000 }, - docs: { - description: { - story: - 'Fetches real token balances and transactions from Ethereum mainnet using blockscout API. Uses Vitalik address for real ETH transfers.', - }, - }, - }, - render: () => ( - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await waitFor( - () => { - const portfolio = canvas.getByTestId('ethereum-portfolio'); - expect(portfolio).toBeVisible(); - - // Verify token list exists with actual tokens - const tokenList = canvas.queryByTestId('ethereum-portfolio-token-list'); - expect(tokenList).not.toBeNull(); - - const tokenItems = tokenList?.querySelectorAll('[data-testid^="token-item-"]'); - expect(tokenItems?.length).toBeGreaterThan(0); - }, - { timeout: 15000 }, - ); - }, -}; - -export const RealDataBitcoin: Story = { - name: 'Real Data: bitcoin', - decorators: [withChainConfig], - parameters: { - chromatic: { delay: 5000 }, - docs: { - description: { - story: 'Fetches real balance and transactions from Bitcoin mainnet using mempool.space API.', - }, - }, - }, - render: () => ( - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await waitFor( - () => { - const portfolio = canvas.getByTestId('bitcoin-portfolio'); - expect(portfolio).toBeVisible(); - - // Verify token list exists with BTC balance - const tokenList = canvas.queryByTestId('bitcoin-portfolio-token-list'); - expect(tokenList).not.toBeNull(); - - const tokenItems = tokenList?.querySelectorAll('[data-testid^="token-item-"]'); - expect(tokenItems?.length).toBeGreaterThan(0); - }, - { timeout: 15000 }, - ); - }, -}; - -export const RealDataTron: Story = { - name: 'Real Data: tron', - decorators: [withChainConfig], - parameters: { - chromatic: { delay: 5000 }, - docs: { - description: { - story: 'Fetches real balance and transactions from Tron mainnet using TronGrid API.', - }, - }, - }, - render: () => ( - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await waitFor( - () => { - const portfolio = canvas.getByTestId('tron-portfolio'); - expect(portfolio).toBeVisible(); - - // Verify token list exists with TRX balance - const tokenList = canvas.queryByTestId('tron-portfolio-token-list'); - expect(tokenList).not.toBeNull(); - - const tokenItems = tokenList?.querySelectorAll('[data-testid^="token-item-"]'); - expect(tokenItems?.length).toBeGreaterThan(0); - }, - { timeout: 15000 }, - ); - }, -}; - -export const RealDataBinance: Story = { - name: 'Real Data: binance', - decorators: [withChainConfig], - parameters: { - chromatic: { delay: 5000 }, - docs: { - description: { - story: 'Fetches real BNB balance from BSC mainnet using public RPC.', - }, - }, - }, - render: () => ( - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await waitFor( - () => { - const portfolio = canvas.getByTestId('binance-portfolio'); - expect(portfolio).toBeVisible(); - - // Verify token list exists with BNB balance - const tokenList = canvas.queryByTestId('binance-portfolio-token-list'); - expect(tokenList).not.toBeNull(); - - const tokenItems = tokenList?.querySelectorAll('[data-testid^="token-item-"]'); - expect(tokenItems?.length).toBeGreaterThan(0); - }, - { timeout: 15000 }, - ); - }, -}; - -export const CompareProvidersBfmeta: Story = { - name: 'Compare Providers: BFMeta', - decorators: [createCompareDecorator('bfmeta')], - parameters: { - chromatic: { delay: 8000 }, - docs: { - description: { - story: 'Dynamically compares all providers configured in default-chains.json for BFMeta chain.', - }, - }, - }, - render: () => , -}; - -export const CompareProvidersEthereum: Story = { - name: 'Compare Providers: Ethereum', - decorators: [createCompareDecorator('ethereum')], - parameters: { - chromatic: { delay: 8000 }, - docs: { - description: { - story: 'Dynamically compares all providers configured in default-chains.json for Ethereum chain.', - }, - }, - }, - render: () => , -}; +// ==================== 各链正常数据 Stories ==================== -export const CompareProvidersTron: Story = { - name: 'Compare Providers: Tron', - decorators: [createCompareDecorator('tron')], - parameters: { - chromatic: { delay: 8000 }, - docs: { - description: { - story: 'Dynamically compares all providers configured in default-chains.json for Tron chain.', - }, - }, +export const BFMetaNormalData: Story = { + name: 'BFMeta - Normal Data', + args: { + chainId: 'bfmeta', + chainName: 'BFMeta', + tokens: [ + { symbol: 'BFT', name: 'BFT', balance: '1234.56789012', decimals: 8, chain: 'bfmeta' }, + { symbol: 'USDT', name: 'USDT', balance: '500.00', decimals: 8, chain: 'bfmeta' }, + ], + transactions: mockTransactions, + tokensSupported: true, + transactionsSupported: true, }, - render: () => , }; -export const CompareProvidersBitcoin: Story = { - name: 'Compare Providers: Bitcoin', - decorators: [createCompareDecorator('bitcoin')], - parameters: { - chromatic: { delay: 8000 }, - docs: { - description: { - story: 'Dynamically compares all providers configured in default-chains.json for Bitcoin chain.', - }, - }, +export const EthereumNormalData: Story = { + name: 'Ethereum - Normal Data', + args: { + chainId: 'ethereum', + chainName: 'Ethereum', + tokens: [ + { symbol: 'ETH', name: 'Ethereum', balance: '23.683156206881918', decimals: 18, chain: 'ethereum' }, + { symbol: 'USDC', name: 'USD Coin', balance: '1500.00', decimals: 6, chain: 'ethereum' }, + ], + transactions: mockEthereumTransactions, + tokensSupported: true, + transactionsSupported: true, }, - render: () => , }; -export const CompareProvidersBinance: Story = { - name: 'Compare Providers: Binance', - decorators: [createCompareDecorator('binance')], - parameters: { - chromatic: { delay: 8000 }, - docs: { - description: { - story: 'Dynamically compares all providers configured in default-chains.json for BNB Smart Chain.', - }, - }, +export const BinanceNormalData: Story = { + name: 'Binance - Normal Data', + args: { + chainId: 'binance', + chainName: 'BNB Smart Chain', + tokens: [ + { symbol: 'BNB', name: 'BNB', balance: '234.084063038409', decimals: 18, chain: 'binance' }, + { symbol: 'BUSD', name: 'BUSD', balance: '2000.00', decimals: 18, chain: 'binance' }, + ], + transactions: mockBinanceTransactions, + tokensSupported: true, + transactionsSupported: true, }, - render: () => , }; -/** - * ProviderFallbackWarning 截图验证 Stories - * - * 覆盖四种主要链的各种场景: - * 1. 正常数据(supported: true) - * 2. Fallback 警告(supported: false) - */ - -// ==================== BFMeta 链 ==================== -export const BFMetaNormalData: Story = { - name: 'BFMeta - Normal Data', +export const TronNormalData: Story = { + name: 'Tron - Normal Data', args: { - chainId: 'bfmeta', - chainName: 'BFMeta', + chainId: 'tron', + chainName: 'Tron', tokens: [ - { symbol: 'BFT', name: 'BFT', balance: '1234.56789012', decimals: 8, chain: 'bfmeta' }, - { symbol: 'USDT', name: 'USDT', balance: '500.00', decimals: 8, chain: 'bfmeta' }, + { symbol: 'TRX', name: 'Tron', balance: '163377.648279', decimals: 6, chain: 'tron' }, + { symbol: 'USDT', name: 'Tether', balance: '10000.00', decimals: 6, chain: 'tron' }, ], - transactions: mockTransactions, + transactions: mockTronTransactions, tokensSupported: true, transactionsSupported: true, }, }; +// ==================== Fallback 警告 Stories ==================== + export const BFMetaFallbackWarning: Story = { name: 'BFMeta - Fallback Warning', args: { @@ -853,22 +233,6 @@ export const BFMetaFallbackWarning: Story = { }, }; -// ==================== Ethereum 链 ==================== -export const EthereumNormalData: Story = { - name: 'Ethereum - Normal Data (23.68 ETH)', - args: { - chainId: 'ethereum', - chainName: 'Ethereum', - tokens: [ - { symbol: 'ETH', name: 'Ethereum', balance: '23.683156206881918', decimals: 18, chain: 'ethereum' }, - { symbol: 'USDC', name: 'USD Coin', balance: '1500.00', decimals: 6, chain: 'ethereum' }, - ], - transactions: mockEthereumTransactions, - tokensSupported: true, - transactionsSupported: true, - }, -}; - export const EthereumFallbackWarning: Story = { name: 'Ethereum - Fallback Warning', args: { @@ -889,22 +253,6 @@ export const EthereumFallbackWarning: Story = { }, }; -// ==================== BSC/Binance 链 ==================== -export const BinanceNormalData: Story = { - name: 'Binance - Normal Data (234.08 BNB)', - args: { - chainId: 'binance', - chainName: 'BNB Smart Chain', - tokens: [ - { symbol: 'BNB', name: 'BNB', balance: '234.084063038409', decimals: 18, chain: 'binance' }, - { symbol: 'BUSD', name: 'BUSD', balance: '2000.00', decimals: 18, chain: 'binance' }, - ], - transactions: mockBinanceTransactions, - tokensSupported: true, - transactionsSupported: true, - }, -}; - export const BinanceFallbackWarning: Story = { name: 'Binance - Fallback Warning', args: { @@ -925,22 +273,6 @@ export const BinanceFallbackWarning: Story = { }, }; -// ==================== Tron 链 ==================== -export const TronNormalData: Story = { - name: 'Tron - Normal Data (163,377 TRX)', - args: { - chainId: 'tron', - chainName: 'Tron', - tokens: [ - { symbol: 'TRX', name: 'Tron', balance: '163377.648279', decimals: 6, chain: 'tron' }, - { symbol: 'USDT', name: 'Tether', balance: '10000.00', decimals: 6, chain: 'tron' }, - ], - transactions: mockTronTransactions, - tokensSupported: true, - transactionsSupported: true, - }, -}; - export const TronFallbackWarning: Story = { name: 'Tron - Fallback Warning', args: { @@ -961,7 +293,8 @@ export const TronFallbackWarning: Story = { }, }; -// ==================== 混合场景 ==================== +// ==================== 边界情况 Stories ==================== + export const PartialFallback: Story = { name: 'Partial Fallback - Tokens OK, Transactions Failed', args: { @@ -971,8 +304,8 @@ export const PartialFallback: Story = { { symbol: 'ETH', name: 'Ethereum', balance: '5.5', decimals: 18, chain: 'ethereum' }, ], transactions: [], - tokensSupported: true, // 余额查询成功 - transactionsSupported: false, // 交易历史查询失败 + tokensSupported: true, + transactionsSupported: false, transactionsFallbackReason: 'Etherscan API key invalid', }, }; @@ -984,13 +317,12 @@ export const EmptyButSupported: Story = { chainName: 'Ethereum', tokens: [], transactions: [], - tokensSupported: true, // Provider 正常,只是没数据 + tokensSupported: true, transactionsSupported: true, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await waitFor(() => { - // 应该没有警告 expect(canvas.queryAllByTestId('provider-fallback-warning')).toHaveLength(0); }); }, diff --git a/src/i18n/locales/en/transaction.json b/src/i18n/locales/en/transaction.json index 8a476f426..226ddfd02 100644 --- a/src/i18n/locales/en/transaction.json +++ b/src/i18n/locales/en/transaction.json @@ -60,8 +60,7 @@ "twoStepSecretDescription": "This address has a security password set. Please enter your security password to confirm the transfer.", "twoStepSecretPlaceholder": "Enter security password", "twoStepSecretError": "Incorrect security password", - "fee": "Fee", - "previewTitle": "Transaction Preview" + "fee": "Fee" }, "destroyPage": { "title": "Destroy", diff --git a/src/i18n/locales/zh-CN/transaction.json b/src/i18n/locales/zh-CN/transaction.json index cb5017c59..b3c8b5a22 100644 --- a/src/i18n/locales/zh-CN/transaction.json +++ b/src/i18n/locales/zh-CN/transaction.json @@ -60,8 +60,7 @@ "twoStepSecretDescription": "该地址已设置安全密码,请输入安全密码确认转账。", "twoStepSecretPlaceholder": "输入安全密码", "twoStepSecretError": "安全密码错误", - "fee": "手续费", - "previewTitle": "交易预览" + "fee": "手续费" }, "destroyPage": { "title": "销毁", diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index bd2ec942e..af1aee480 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigation, useActivityParams, useFlow } from '@/stackflow'; -import { setTransferPreviewCallback, setTransferWalletLockCallback, setScannerResultCallback } from '@/stackflow/activities/sheets'; +import { setTransferConfirmCallback, setTransferWalletLockCallback, setScannerResultCallback } from '@/stackflow/activities/sheets'; import type { Contact, ContactAddress } from '@/stores'; import { addressBookStore, addressBookSelectors, preferencesActions } from '@/stores'; import { PageHeader } from '@/components/layout/page-header'; @@ -237,8 +237,8 @@ function SendPageContent() { haptics.impact('light'); - // Set up callback: TransferPreview -> TransferWalletLock (合并的钱包锁+二次签名) - setTransferPreviewCallback( + // Set up callback: TransferConfirm -> TransferWalletLock (合并的钱包锁+二次签名) + setTransferConfirmCallback( async () => { if (isWalletLockSheetOpen.current) return; isWalletLockSheetOpen.current = true; @@ -300,17 +300,13 @@ function SendPageContent() { } ); - push('TransferPreviewJob', { + push('TransferConfirmJob', { amount: state.amount?.toFormatted() ?? '0', symbol, - decimals: String(state.asset?.decimals ?? chainConfig?.decimals ?? 8), - fromAddress: currentChainAddress?.address ?? '', toAddress: state.toAddress, feeAmount: state.feeAmount?.toFormatted() ?? '0', feeSymbol: state.feeSymbol, feeLoading: state.feeLoading ? 'true' : 'false', - chainId: selectedChain, - chainName: selectedChainName, }); }; diff --git a/src/stackflow/activities/sheets/TransferPreviewJob.tsx b/src/stackflow/activities/sheets/TransferPreviewJob.tsx deleted file mode 100644 index 25a5e0dd2..000000000 --- a/src/stackflow/activities/sheets/TransferPreviewJob.tsx +++ /dev/null @@ -1,250 +0,0 @@ -/** - * 转账预览确认组件 - * - * 显示完整的交易详情预览,替代简单的 TransferConfirmJob - * 流程: Send页面(填写) → TransferPreviewJob(交易详情预览) → TransferWalletLockJob(签名+广播+状态) - */ -import { useState, useCallback, useRef, useEffect } from 'react'; -import type { ActivityComponentType } from '@stackflow/react'; -import { BottomSheet, SheetContent } from '@/components/layout/bottom-sheet'; -import { useTranslation } from 'react-i18next'; -import { cn } from '@/lib/utils'; -import { FeeDisplay } from '@/components/transaction/fee-display'; -import { AddressDisplay } from '@/components/wallet/address-display'; -import { ChainIcon } from '@/components/wallet/chain-icon'; -import { AmountDisplay } from '@/components/common'; -import type { ChainType } from '@/stores'; -import { - IconArrowUp as ArrowUp, - IconArrowDown as ArrowDown, -} from '@tabler/icons-react'; -import { useFlow } from '../../stackflow'; -import { ActivityParamsProvider, useActivityParams } from '../../hooks'; -import { setFeeEditCallback } from './FeeEditJob'; - -interface TransferPreviewConfig { - onConfirm: () => Promise; - minFee?: string | undefined; - onFeeChange?: ((newFee: string) => void) | undefined; -} - -let pendingConfig: TransferPreviewConfig | null = null; - -export function setTransferPreviewCallback( - onConfirm: () => Promise, - options?: { minFee?: string; onFeeChange?: (newFee: string) => void } -) { - pendingConfig = { - onConfirm, - minFee: options?.minFee, - onFeeChange: options?.onFeeChange, - }; -} - -function clearTransferPreviewCallback() { - pendingConfig = null; -} - -type TransferPreviewJobParams = { - /** 转账金额 */ - amount: string; - /** 资产符号 */ - symbol: string; - /** 资产小数位 */ - decimals?: string; - /** 法币价值 */ - fiatValue?: string; - /** 发送地址 */ - fromAddress: string; - /** 接收地址 */ - toAddress: string; - /** 手续费金额 */ - feeAmount: string; - /** 手续费符号 */ - feeSymbol: string; - /** 手续费法币价值 */ - feeFiatValue?: string; - /** 手续费加载中 */ - feeLoading?: string; - /** 链ID */ - chainId: string; - /** 链名称 */ - chainName?: string; -}; - -function TransferPreviewJobContent() { - const { t } = useTranslation(['transaction', 'common']); - const { pop, push } = useFlow(); - const params = useActivityParams(); - - const [isConfirming, setIsConfirming] = useState(false); - const [customFee, setCustomFee] = useState(null); - - // 捕获配置 - const configRef = useRef(pendingConfig); - const initialized = useRef(false); - - if (!initialized.current && pendingConfig) { - configRef.current = pendingConfig; - clearTransferPreviewCallback(); - initialized.current = true; - } - - useEffect(() => { - return () => { - clearTransferPreviewCallback(); - }; - }, []); - - const feeLoading = params.feeLoading === 'true'; - const displayFee = customFee ?? params.feeAmount; - const canEditFee = !!configRef.current?.onFeeChange; - const decimals = params.decimals ? parseInt(params.decimals, 10) : 8; - const amountNum = parseFloat(params.amount) || 0; - - const handleEditFee = useCallback(() => { - const config = configRef.current; - if (!config?.onFeeChange) return; - - setFeeEditCallback( - { - currentFee: displayFee, - minFee: config.minFee ?? params.feeAmount, - symbol: params.feeSymbol, - }, - (result) => { - setCustomFee(result.fee); - config.onFeeChange?.(result.fee); - } - ); - push('FeeEditJob', {}); - }, [displayFee, params.feeAmount, params.feeSymbol, push]); - - const handleConfirm = useCallback(async () => { - const config = configRef.current; - - if (!config?.onConfirm || isConfirming) return; - - setIsConfirming(true); - try { - pop(); - await config.onConfirm(); - } finally { - setIsConfirming(false); - } - }, [isConfirming, pop]); - - const handleClose = useCallback(() => { - pop(); - }, [pop]); - - return ( - - -
- {/* 金额头部 */} -
-
- -
- -
-

{t('type.send')}

- - {params.fiatValue && ( -

≈ ${params.fiatValue}

- )} -
-
- - {/* 交易详情 */} -
-

{t('detail.info')}

- - {/* 发送地址 */} -
- {t('detail.fromAddress')} - -
- -
- - {/* 接收地址 */} -
- {t('detail.toAddress')} - -
- -
- - {/* 网络 */} -
- {t('detail.network')} -
- - {params.chainName ?? params.chainId} -
-
- -
- - {/* 手续费 */} -
- {t('detail.fee')} - -
-
- - {/* 操作按钮 */} -
- - -
-
- - - ); -} - -export const TransferPreviewJob: ActivityComponentType = ({ params }) => { - return ( - - - - ); -}; diff --git a/src/stackflow/activities/sheets/TransferWalletLockJob.tsx b/src/stackflow/activities/sheets/TransferWalletLockJob.tsx index 4b5100b75..b313eaf17 100644 --- a/src/stackflow/activities/sheets/TransferWalletLockJob.tsx +++ b/src/stackflow/activities/sheets/TransferWalletLockJob.tsx @@ -55,7 +55,7 @@ type Step = 'wallet_lock' | 'two_step_secret'; function TransferWalletLockJobContent() { const { t } = useTranslation(["security", "transaction", "common"]); - const { pop, push, replace } = useFlow(); + const { pop, push } = useFlow(); const { title } = useActivityParams(); const clipboard = useClipboard(); const toast = useToast(); @@ -110,12 +110,7 @@ function TransferWalletLockJobContent() { return unsubscribe; }, [txHash, selectedChain, currentWallet?.address]); - // Navigate back to home (replace entire stack) - const goToHome = useCallback(() => { - replace('MainTabsActivity', {}, { animate: true }); - }, [replace]); - - // 上链成功后 5 秒倒计时自动关闭,返回首页 + // 上链成功后 5 秒倒计时自动关闭 useEffect(() => { if (txStatus !== 'confirmed') { setCountdown(null); @@ -127,7 +122,7 @@ function TransferWalletLockJobContent() { setCountdown((prev) => { if (prev === null || prev <= 1) { clearInterval(timer); - goToHome(); + pop(); return null; } return prev - 1; @@ -135,7 +130,7 @@ function TransferWalletLockJobContent() { }, 1000); return () => clearInterval(timer); - }, [txStatus, goToHome]); + }, [txStatus, pop]); // Get chain config for explorer URL const chainConfig = useMemo(() => { diff --git a/src/stackflow/activities/sheets/index.ts b/src/stackflow/activities/sheets/index.ts index 4466a8ce9..b7040f658 100644 --- a/src/stackflow/activities/sheets/index.ts +++ b/src/stackflow/activities/sheets/index.ts @@ -11,7 +11,6 @@ export { WalletAddJob } from "./WalletAddJob"; export { WalletListJob } from "./WalletListJob"; export { SecurityWarningJob, setSecurityWarningConfirmCallback } from "./SecurityWarningJob"; export { TransferConfirmJob, setTransferConfirmCallback } from "./TransferConfirmJob"; -export { TransferPreviewJob, setTransferPreviewCallback } from "./TransferPreviewJob"; export { TransferWalletLockJob, setTransferWalletLockCallback } from "./TransferWalletLockJob"; export { FeeEditJob, setFeeEditCallback, type FeeEditConfig, type FeeEditResult } from "./FeeEditJob"; export { ScannerJob, setScannerResultCallback, scanValidators, getValidatorForChain, type ScannerJobParams, type ScannerResultEvent, type ScanValidator } from "./ScannerJob"; diff --git a/src/stackflow/stackflow.ts b/src/stackflow/stackflow.ts index f81b1af91..9f41f8cd8 100644 --- a/src/stackflow/stackflow.ts +++ b/src/stackflow/stackflow.ts @@ -48,7 +48,6 @@ import { WalletListJob, SecurityWarningJob, TransferConfirmJob, - TransferPreviewJob, TransferWalletLockJob, FeeEditJob, ScannerJob, @@ -116,7 +115,6 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({ WalletListJob: '/job/wallet-list', SecurityWarningJob: '/job/security-warning', TransferConfirmJob: '/job/transfer-confirm', - TransferPreviewJob: '/job/transfer-preview', TransferWalletLockJob: '/job/transfer-wallet-lock', FeeEditJob: '/job/fee-edit', ScannerJob: '/job/scanner', @@ -183,7 +181,6 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({ WalletListJob, SecurityWarningJob, TransferConfirmJob, - TransferPreviewJob, TransferWalletLockJob, FeeEditJob, ScannerJob,