diff --git "a/docs/white-book/03-\346\236\266\346\236\204\347\257\207/04-\347\212\266\346\200\201\347\256\241\347\220\206/index.md" "b/docs/white-book/03-\346\236\266\346\236\204\347\257\207/04-\347\212\266\346\200\201\347\256\241\347\220\206/index.md" index db9365af..3257e44e 100644 --- "a/docs/white-book/03-\346\236\266\346\236\204\347\257\207/04-\347\212\266\346\200\201\347\256\241\347\220\206/index.md" +++ "b/docs/white-book/03-\346\236\266\346\236\204\347\257\207/04-\347\212\266\346\200\201\347\256\241\347\220\206/index.md" @@ -76,6 +76,34 @@ Store { | toastQueue | Toast[] | 否 | Toast 队列 | | isLoading | boolean | 否 | 全局加载状态 | +### 地址簿状态 (AddressBookStore) + +| 字段 | 类型 | 持久化 | 说明 | +|-----|------|--------|------| +| contacts | Contact[] | 是 | 联系人列表 | +| isInitialized | boolean | 否 | 是否已初始化 | + +**Contact 结构:** + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | string | 唯一 ID | +| name | string | 联系人名称 | +| avatar | string? | 头像(avatar: 协议) | +| addresses | ContactAddress[] | 地址列表(最多 3 个) | +| memo | string? | 私有备注 | +| createdAt | number | 创建时间 | +| updatedAt | number | 更新时间 | + +**ContactAddress 结构:** + +| 字段 | 类型 | 说明 | +|-----|------|------| +| id | string | 唯一 ID | +| address | string | 区块链地址 | +| label | string? | 自定义标签(最多 10 字符) | +| isDefault | boolean? | 是否默认地址 | + --- ## 服务端状态规范 diff --git "a/docs/white-book/05-\347\273\204\344\273\266\347\257\207/02-\351\200\232\347\224\250\347\273\204\344\273\266/Contact.md" "b/docs/white-book/05-\347\273\204\344\273\266\347\257\207/02-\351\200\232\347\224\250\347\273\204\344\273\266/Contact.md" new file mode 100644 index 00000000..e535f287 --- /dev/null +++ "b/docs/white-book/05-\347\273\204\344\273\266\347\257\207/02-\351\200\232\347\224\250\347\273\204\344\273\266/Contact.md" @@ -0,0 +1,189 @@ +# Contact 联系人组件 + +> 联系人头像、名片、选择器等组件 + +--- + +## 功能概述 + +联系人系统包含以下组件: +- **ContactAvatar**: 联系人头像显示 +- **ContactCard**: 联系人名片(用于分享) +- **ContactPickerJob**: 联系人选择器 +- **AddressInput**: 地址输入(支持联系人匹配) + +--- + +## ContactAvatar + +### 属性规范 + +| 属性 | 类型 | 必需 | 默认值 | 说明 | +|-----|------|-----|-------|------| +| src | string | N | - | 头像 URL(avatar: 协议或图片 URL) | +| size | number | N | 40 | 头像尺寸(px) | +| className | string | N | - | 自定义样式类 | + +### 头像协议 + +头像支持两种格式: + +1. **avatar: 协议**: `avatar:XXXXXXXX`(8 字符 base64 编码的 Avataaars 参数) +2. **图片 URL**: 标准图片 URL + +### 头像生成 + +```typescript +// 从地址生成头像 +generateAvatarFromAddress(address: string, seed?: number): string + +// 返回 avatar: 协议 URL +// 示例: "avatar:A2BxY3Dz" +``` + +--- + +## ContactCard + +### 属性规范 + +| 属性 | 类型 | 必需 | 默认值 | 说明 | +|-----|------|-----|-------|------| +| name | string | Y | - | 联系人名称 | +| addresses | ContactAddressInfo[] | Y | - | 地址列表 | +| avatar | string | N | - | 头像 URL | +| address | string | N | - | 主地址(用于生成二维码) | + +### 显示规则 + +- 名片宽度固定 320px +- 头像尺寸 64px +- 仅显示有自定义标签的地址徽章 +- 二维码包含联系人协议数据 + +--- + +## ContactPickerJob + +### 参数 + +| 参数 | 类型 | 必需 | 说明 | +|-----|------|-----|------| +| chainType | ChainType | N | 目标链类型,用于验证地址 | + +### 显示规则 + +- 按联系人分组显示,每组包含: + - 头像 + 名称(组头) + - 该联系人的所有地址(可点击) +- 地址按钮最小高度 44px(移动端推荐触控区域) +- 无效地址(不匹配目标链)显示为禁用状态 + +### 地址兼容性 + +BioForest 链地址互相兼容(共享相同密钥体系): +- bfmeta、ccchain、pmchain、bfchainv2 等 + +Ethereum 和 BSC 地址格式相同,互相兼容。 + +--- + +## AddressInput 联系人匹配 + +### 功能 + +当输入地址匹配已保存的联系人时: +- 左侧显示联系人头像 +- 显示两行:用户名 + 地址 +- 右侧显示清除按钮 + +### 建议下拉 + +聚焦时显示联系人建议: +- 按搜索词过滤(匹配名称或地址) +- 显示头像、名称、地址 +- 无效地址显示为禁用状态 + +--- + +## 数据结构 + +### ContactAddress + +```typescript +interface ContactAddress { + id: string + address: string + label?: string // 自定义标签,最多 10 字符 + isDefault?: boolean +} +``` + +### Contact + +```typescript +interface Contact { + id: string + name: string + avatar?: string // avatar: 协议 URL + addresses: ContactAddress[] // 最多 3 个地址 + memo?: string // 私有备注,不分享 + createdAt: number + updatedAt: number +} +``` + +--- + +## 地址标签显示 + +### 规则 + +- **仅显示自定义标签**:没有设置标签时不显示任何内容 +- **不回退到链类型**:不自动显示检测到的链类型 +- **最大宽度**:4rem(约 10 字符),超出截断 + +### 示例 + +| 场景 | 标签字段 | 显示 | +|-----|---------|------| +| 有自定义标签 | "主账户" | 主账户 | +| 无标签 | undefined | (不显示) | +| 标签过长 | "我的交易所账户" | 我的交易所... | + +--- + +## 存储规范 + +### 存储键 + +``` +bfm_address_book +``` + +### 存储格式 + +```typescript +interface StorageData { + version: 3 // 当前版本 + contacts: Contact[] +} +``` + +### 版本说明 + +- **v3**(当前):移除 chainType,使用自定义 label +- **v2**:多地址支持 +- **v1**:单地址格式 + +**注意**:v3 为破坏性更新,不兼容旧版本数据。 + +--- + +## 本节小结 + +- 头像使用 Avataaars,支持从地址生成 +- 联系人最多 3 个地址 +- 地址标签仅显示自定义内容 +- BioForest 链地址互相兼容 +- 存储版本 3,不迁移旧数据 diff --git "a/docs/white-book/05-\347\273\204\344\273\266\347\257\207/02-\351\200\232\347\224\250\347\273\204\344\273\266/index.md" "b/docs/white-book/05-\347\273\204\344\273\266\347\257\207/02-\351\200\232\347\224\250\347\273\204\344\273\266/index.md" index 8c8769da..690227dc 100644 --- "a/docs/white-book/05-\347\273\204\344\273\266\347\257\207/02-\351\200\232\347\224\250\347\273\204\344\273\266/index.md" +++ "b/docs/white-book/05-\347\273\204\344\273\266\347\257\207/02-\351\200\232\347\224\250\347\273\204\344\273\266/index.md" @@ -17,6 +17,7 @@ | [AddressDisplay](./AddressDisplay) | 地址显示与复制 | P0 | | [AmountDisplay](./AmountDisplay) | 金额格式化显示(支持动画) | P0 | | [AnimatedNumber](./AnimatedNumber) | 数字动画显示 | P0 | +| [Contact](./Contact) | 联系人头像、名片、选择器 | P0 | | [TokenIcon](./TokenIcon) | 代币/链图标 | P0 | | [QRCode](./QRCode) | 二维码生成与显示 | P0 | | [Scanner](./Scanner) | 二维码扫描与解析 | P0 | diff --git a/e2e/contact-address-input.spec.ts b/e2e/contact-address-input.spec.ts new file mode 100644 index 00000000..cc56b951 --- /dev/null +++ b/e2e/contact-address-input.spec.ts @@ -0,0 +1,224 @@ +/** + * E2E 测试 - 联系人与地址输入集成 + * + * 测试场景: + * 1. 注入钱包和联系人数据 + * 2. 验证 AddressBookPage 能看到联系人 + * 3. 验证 AddressInput 能看到联系人建议 + * 4. 验证 ContactPickerJob 能看到联系人 + */ + +import { test, expect, type Page } from '@playwright/test' + +const TEST_WALLET_DATA = { + wallets: [ + { + id: 'test-wallet-1', + name: '测试钱包', + address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + chain: 'ethereum', + chainAddresses: [ + { chain: 'ethereum', address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', tokens: [] }, + ], + encryptedMnemonic: { ciphertext: 'test', iv: 'test', salt: 'test' }, + createdAt: Date.now(), + tokens: [], + }, + ], + currentWalletId: 'test-wallet-1', + selectedChain: 'ethereum', +} + +const TEST_CONTACT = { + name: 'Alice Test', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345', +} + +const TEST_ADDRESS_BOOK_DATA = { + version: 2, + contacts: [ + { + id: 'contact-1', + name: TEST_CONTACT.name, + addresses: [ + { + id: 'addr-1', + address: TEST_CONTACT.address, + chainType: 'ethereum', + isDefault: true, + }, + ], + createdAt: Date.now(), + updatedAt: Date.now(), + }, + ], +} + +async function setupTestData(page: Page) { + await page.addInitScript((data) => { + localStorage.setItem('bfm_wallets', JSON.stringify(data.wallet)) + localStorage.setItem('bfm_address_book', JSON.stringify(data.addressBook)) + }, { wallet: TEST_WALLET_DATA, addressBook: TEST_ADDRESS_BOOK_DATA }) +} + +test.describe('联系人与转账页面集成', () => { + test('Step 1: 通讯录页面能看到预置联系人', async ({ page }) => { + await setupTestData(page) + await page.goto('/#/address-book') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(500) + + await page.screenshot({ + path: 'e2e/screenshots/contact-01-address-book.png', + fullPage: true, + }) + + // 检查联系人是否显示 + const contactName = page.locator(`text=${TEST_CONTACT.name}`) + await expect(contactName).toBeVisible({ timeout: 5000 }) + console.log(`[OK] Contact "${TEST_CONTACT.name}" visible in address book`) + }) + + test('Step 2: 转账页面 AddressInput 聚焦后显示联系人建议', async ({ page }) => { + await setupTestData(page) + await page.goto('/#/send') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(500) + + await page.screenshot({ + path: 'e2e/screenshots/contact-02-send-page.png', + fullPage: true, + }) + + // 点击地址输入框 + const addressInputField = page.locator('input').first() + await addressInputField.click() + await page.waitForTimeout(500) + + await page.screenshot({ + path: 'e2e/screenshots/contact-03-address-input-focused.png', + fullPage: true, + }) + + // 检查是否有建议下拉 + const suggestionDropdown = page.locator('[role="listbox"]') + const dropdownVisible = await suggestionDropdown.isVisible() + console.log(`Suggestion dropdown visible: ${dropdownVisible}`) + + // 检查"暂无联系人"消息 + const noContacts = page.locator('text=暂无联系人') + const noContactsVisible = await noContacts.isVisible() + console.log(`"暂无联系人" message visible: ${noContactsVisible}`) + + if (noContactsVisible) { + await page.screenshot({ + path: 'e2e/screenshots/contact-04-ERROR-no-contacts.png', + fullPage: true, + }) + } + + // 检查联系人是否在建议中 + const suggestionWithContact = page.locator(`[role="option"]:has-text("${TEST_CONTACT.name}")`) + const contactInSuggestions = await suggestionWithContact.isVisible() + console.log(`Contact "${TEST_CONTACT.name}" in suggestions: ${contactInSuggestions}`) + + if (contactInSuggestions) { + await page.screenshot({ + path: 'e2e/screenshots/contact-05-suggestions-with-contact.png', + fullPage: true, + }) + } + + // 断言:不应该显示"暂无联系人" + expect(noContactsVisible).toBe(false) + }) + + test('Step 3: ContactPickerJob 能看到联系人', async ({ page }) => { + await setupTestData(page) + await page.goto('/#/send') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(500) + + // 点击地址输入框 + const addressInputField = page.locator('input').first() + await addressInputField.click() + await page.waitForTimeout(500) + + // 点击"查看全部联系人"按钮 + const viewAllButton = page.locator('button:has-text("查看全部"), button:has-text("View all")') + if (await viewAllButton.isVisible()) { + await viewAllButton.click() + await page.waitForTimeout(500) + + await page.screenshot({ + path: 'e2e/screenshots/contact-06-contact-picker.png', + fullPage: true, + }) + + // 检查 ContactPickerJob 中是否有联系人 + const pickerContact = page.locator(`text=${TEST_CONTACT.name}`) + const pickerContactVisible = await pickerContact.isVisible() + console.log(`Contact "${TEST_CONTACT.name}" in picker: ${pickerContactVisible}`) + + // 检查"暂无联系人" + const noContactsInPicker = page.locator('text=暂无联系人') + const noContactsInPickerVisible = await noContactsInPicker.isVisible() + console.log(`"暂无联系人" in picker visible: ${noContactsInPickerVisible}`) + + if (noContactsInPickerVisible) { + await page.screenshot({ + path: 'e2e/screenshots/contact-07-ERROR-picker-no-contacts.png', + fullPage: true, + }) + } + + expect(noContactsInPickerVisible).toBe(false) + } + }) + + test('调试:检查 localStorage 和 store 状态', async ({ page }) => { + await setupTestData(page) + + // 检查 localStorage 注入是否成功 + await page.goto('/#/') + await page.waitForLoadState('networkidle') + + const storageData = await page.evaluate(() => { + return { + addressBook: localStorage.getItem('bfm_address_book'), + wallets: localStorage.getItem('bfm_wallets'), + } + }) + + console.log('=== localStorage Debug ===') + console.log('Address book:', storageData.addressBook) + console.log('Wallets:', storageData.wallets?.substring(0, 100) + '...') + + // 去通讯录页面 + await page.goto('/#/address-book') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(500) + + await page.screenshot({ + path: 'e2e/screenshots/debug-01-address-book.png', + fullPage: true, + }) + + // 去转账页面 + await page.goto('/#/send') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(500) + + await page.screenshot({ + path: 'e2e/screenshots/debug-02-send-page.png', + fullPage: true, + }) + + // 检查 localStorage 是否还在 + const storageAfterNav = await page.evaluate(() => { + return localStorage.getItem('bfm_address_book') + }) + console.log('=== After navigation ===') + console.log('Address book still exists:', !!storageAfterNav) + }) +}) diff --git a/e2e/screenshots/contact-01-address-book.png b/e2e/screenshots/contact-01-address-book.png new file mode 100644 index 00000000..693d0034 Binary files /dev/null and b/e2e/screenshots/contact-01-address-book.png differ diff --git a/e2e/screenshots/contact-02-send-page.png b/e2e/screenshots/contact-02-send-page.png new file mode 100644 index 00000000..3f845177 Binary files /dev/null and b/e2e/screenshots/contact-02-send-page.png differ diff --git a/e2e/screenshots/contact-03-address-input-focused.png b/e2e/screenshots/contact-03-address-input-focused.png new file mode 100644 index 00000000..1c19c7bd Binary files /dev/null and b/e2e/screenshots/contact-03-address-input-focused.png differ diff --git a/e2e/screenshots/contact-05-suggestions-with-contact.png b/e2e/screenshots/contact-05-suggestions-with-contact.png new file mode 100644 index 00000000..2a87c2f6 Binary files /dev/null and b/e2e/screenshots/contact-05-suggestions-with-contact.png differ diff --git a/e2e/screenshots/contact-06-contact-picker.png b/e2e/screenshots/contact-06-contact-picker.png new file mode 100644 index 00000000..22600a4c Binary files /dev/null and b/e2e/screenshots/contact-06-contact-picker.png differ diff --git a/e2e/screenshots/debug-01-address-book.png b/e2e/screenshots/debug-01-address-book.png new file mode 100644 index 00000000..c5fce369 Binary files /dev/null and b/e2e/screenshots/debug-01-address-book.png differ diff --git a/e2e/screenshots/debug-02-send-page.png b/e2e/screenshots/debug-02-send-page.png new file mode 100644 index 00000000..c5fce369 Binary files /dev/null and b/e2e/screenshots/debug-02-send-page.png differ diff --git a/package.json b/package.json index cbed76de..507f9576 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@fontsource-variable/figtree": "^5.2.10", "@fontsource/dm-mono": "^5.2.7", "@fontsource/dm-serif-display": "^5.2.8", + "@gamepark/avataaars": "^3.0.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@noble/secp256k1": "^3.0.0", @@ -83,6 +84,7 @@ "@tanstack/react-router": "^1.140.0", "@tanstack/react-store": "^0.8.0", "@types/yargs": "^17.0.35", + "@zumer/snapdom": "^2.0.1", "big.js": "^7.0.1", "bip39": "^3.1.0", "buffer": "^6.0.3", @@ -91,6 +93,7 @@ "i18next": "^25.7.1", "idb": "^8.0.3", "jsqr": "^1.4.0", + "lodash": "^4.17.21", "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -117,6 +120,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/big.js": "^6.2.2", + "@types/lodash": "^4.17.21", "@types/node": "^24.10.1", "@types/qrcode": "^1.5.6", "@types/react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be9b83ec..c878aa8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@fontsource/dm-serif-display': specifier: ^5.2.8 version: 5.2.8 + '@gamepark/avataaars': + specifier: ^3.0.0 + version: 3.0.0(react@19.2.3) '@noble/curves': specifier: ^2.0.1 version: 2.0.1 @@ -98,6 +101,9 @@ importers: '@types/yargs': specifier: ^17.0.35 version: 17.0.35 + '@zumer/snapdom': + specifier: ^2.0.1 + version: 2.0.1 big.js: specifier: ^7.0.1 version: 7.0.1 @@ -122,6 +128,9 @@ importers: jsqr: specifier: ^1.4.0 version: 1.4.0 + lodash: + specifier: ^4.17.21 + version: 4.17.21 qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.3) @@ -195,6 +204,9 @@ importers: '@types/big.js': specifier: ^6.2.2 version: 6.2.2 + '@types/lodash': + specifier: ^4.17.21 + version: 4.17.21 '@types/node': specifier: ^24.10.1 version: 24.10.4 @@ -1345,6 +1357,11 @@ packages: '@fontsource/dm-serif-display@5.2.8': resolution: {integrity: sha512-GYSDSlGU6vyhv9a5MwaiVNf9HCuSVpK8hEFRyG4NNDHCDeHiX7YHDAcWsaoLKKcfXLgWG9YkBkk9T3SxM4rAjQ==} + '@gamepark/avataaars@3.0.0': + resolution: {integrity: sha512-MOe/jtEe2UkxQSfZ2TyqfRnihVBpGUeFwbM9aFZYVQoe5Jdw4kRx0bEKHHw2XnXaYJ2UEJOYys4iAdjBTv4tEg==} + peerDependencies: + react: '>=18' + '@hono/node-server@1.19.7': resolution: {integrity: sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==} engines: {node: '>=18.14.1'} @@ -2507,6 +2524,9 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash@4.17.21': + resolution: {integrity: sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==} + '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -2785,6 +2805,9 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@zumer/snapdom@2.0.1': + resolution: {integrity: sha512-78/qbYl2FTv4H6qaXcNfAujfIOSzdvs83NW63VbyC9QA3sqNPfPvhn4xYMO6Gy11hXwJUEhd0z65yKiNzDwy9w==} + abitype@0.7.1: resolution: {integrity: sha512-VBkRHTDZf9Myaek/dO3yMmOzB/y2s3Zo6nVU7yaw1G+TvCHAjwaJzNGN9yo4K5D8bU/VZXKP1EJpRhFr862PlQ==} peerDependencies: @@ -7635,6 +7658,10 @@ snapshots: '@fontsource/dm-serif-display@5.2.8': {} + '@gamepark/avataaars@3.0.0(react@19.2.3)': + dependencies: + react: 19.2.3 + '@hono/node-server@1.19.7(hono@4.11.1)': dependencies: hono: 4.11.1 @@ -8741,6 +8768,8 @@ snapshots: '@types/linkify-it@5.0.0': {} + '@types/lodash@4.17.21': {} + '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -9122,6 +9151,8 @@ snapshots: transitivePeerDependencies: - typescript + '@zumer/snapdom@2.0.1': {} + abitype@0.7.1(typescript@5.9.3)(zod@4.2.1): dependencies: typescript: 5.9.3 diff --git a/src/components/common/contact-avatar.tsx b/src/components/common/contact-avatar.tsx new file mode 100644 index 00000000..3b720349 --- /dev/null +++ b/src/components/common/contact-avatar.tsx @@ -0,0 +1,107 @@ +/** + * ContactAvatar - 联系人头像组件 + * 支持格式: + * - avatar:HASH - Avataaars 编码(8字符 base64) + * - https://... - 外部图片 URL + * - emoji - 单个表情符号 + * 如需从地址生成头像,请使用 generateAvatarFromAddress(address, seed) + */ + +import { useMemo } from 'react' +import Avatar from '@gamepark/avataaars' +import { renderToStaticMarkup } from 'react-dom/server' +import { decodeAvatar } from '@/lib/avatar-codec' +import { IconUser } from '@tabler/icons-react' + +interface ContactAvatarProps { + /** 头像源:avatar:HASH / http(s) URL / emoji */ + src?: string | undefined + size?: number | undefined + className?: string | undefined +} + +/** 解析 avatar URL */ +function parseAvatarSrc(src: string): { type: 'hash' | 'url' | 'emoji'; value: string } { + try { + const url = new URL(src) + if (url.protocol === 'avatar:') { + return { type: 'hash', value: url.pathname } + } + if (url.protocol === 'http:' || url.protocol === 'https:') { + return { type: 'url', value: src } + } + } catch { + // 不是有效 URL,当作 emoji + } + return { type: 'emoji', value: src } +} + +const DROP_SHADOW = 'drop-shadow(0 8px 16px color-mix(in oklch, var(--primary) 30%, transparent))' + +/** 生成 Avataaars SVG data URL */ +function generateAvatarDataUrl(hash: string): string { + const config = decodeAvatar(hash) + const svgMarkup = renderToStaticMarkup( + + ) + return `data:image/svg+xml,${encodeURIComponent(svgMarkup)}` +} + +export function ContactAvatar({ src, size = 64, className }: ContactAvatarProps) { + const parsed = useMemo(() => src ? parseAvatarSrc(src) : null, [src]) + + const imgSrc = useMemo(() => { + if (!parsed) return null + if (parsed.type === 'hash') return generateAvatarDataUrl(parsed.value) + if (parsed.type === 'url') return parsed.value + return null + }, [parsed]) + + // 无头像或 emoji + if (!imgSrc) { + return ( +
+
+ {parsed?.type === 'emoji' ? ( + {parsed.value} + ) : ( + + )} +
+
+ ) + } + + // 图片(hash 或 URL) + return ( +
+ avatar +
+ ) +} diff --git a/src/components/contact/contact-card.tsx b/src/components/contact/contact-card.tsx new file mode 100644 index 00000000..8268a556 --- /dev/null +++ b/src/components/contact/contact-card.tsx @@ -0,0 +1,73 @@ +/** + * ContactCard - 联系人名片卡片 + * 用于分享和展示联系人信息 + */ + +import { QRCodeSVG } from 'qrcode.react'; +import { ContactAvatar } from '@/components/common/contact-avatar'; +import { generateAvatarFromAddress } from '@/lib/avatar-codec'; +import { detectAddressFormat } from '@/lib/address-format'; +import type { ContactAddressInfo } from '@/lib/qr-parser'; + +const CHAIN_COLORS: Record = { + ethereum: '#627EEA', + bitcoin: '#F7931A', + tron: '#FF0013', +}; + +/** 获取地址显示标签和颜色(只显示自定义 label) */ +function getAddressDisplay(addr: ContactAddressInfo): { label: string; color: string } | null { + if (!addr.label) return null; + const detected = detectAddressFormat(addr.address); + const chainType = detected.chainType; + const color = chainType ? CHAIN_COLORS[chainType] || '#6B7280' : '#6B7280'; + return { label: addr.label, color }; +} + +export interface ContactCardProps { + name: string; + avatar?: string | undefined; + address?: string | undefined; + addresses: ContactAddressInfo[]; + qrContent: string; +} + +export function ContactCard({ name, avatar, address, addresses, qrContent }: ContactCardProps) { + const effectiveAddress = address || addresses[0]?.address; + const effectiveAvatar = avatar || (effectiveAddress ? generateAvatarFromAddress(effectiveAddress) : undefined); + return ( +
+
+ +
+

{name}

+
+ {addresses.map((a, i) => { + const display = getAddressDisplay(a); + if (!display) return null; + return ( + + {display.label} + + ); + })} +
+
+
+ +
+
+ +
+
+ +
+

扫码添加联系人

+
+
+ ); +} diff --git a/src/components/transfer/address-input.stories.tsx b/src/components/transfer/address-input.stories.tsx index 404adf87..2378ea4a 100644 --- a/src/components/transfer/address-input.stories.tsx +++ b/src/components/transfer/address-input.stories.tsx @@ -83,6 +83,7 @@ export const TransferForm: Story = { /** * 带联系人建议 - 聚焦即展开,显示所有联系人 + * AddressInput 直接从 addressBookStore 读取数据(单一数据源) */ export const WithContactSuggestions: Story = { decorators: [ @@ -92,21 +93,21 @@ export const WithContactSuggestions: Story = { addressBookActions.addContact({ name: 'Alice', addresses: [ - { id: '1', address: '0x1234567890abcdef1234567890abcdef12345678', chainType: 'ethereum', isDefault: true }, + { id: '1', address: '0x1234567890abcdef1234567890abcdef12345678', label: 'ETH', isDefault: true }, ], memo: '同事', }); addressBookActions.addContact({ name: 'Bob', addresses: [ - { id: '2', address: '0xabcdef1234567890abcdef1234567890abcdef12', chainType: 'ethereum', isDefault: true }, - { id: '3', address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', chainType: 'bfmeta' }, + { id: '2', address: '0xabcdef1234567890abcdef1234567890abcdef12', label: 'ETH', isDefault: true }, + { id: '3', address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', label: 'BFMETA' }, ], }); addressBookActions.addContact({ name: 'Charlie', addresses: [ - { id: '4', address: '0x9876543210fedcba9876543210fedcba98765432', chainType: 'ethereum', isDefault: true }, + { id: '4', address: '0x9876543210fedcba9876543210fedcba98765432', label: 'ETH', isDefault: true }, ], memo: '朋友', }); @@ -137,6 +138,7 @@ export const WithContactSuggestions: Story = { /** * 按链类型过滤 - 只显示指定链的地址 + * AddressInput 直接从 addressBookStore 读取数据(单一数据源) */ export const FilterByChain: Story = { decorators: [ @@ -145,14 +147,14 @@ export const FilterByChain: Story = { addressBookActions.addContact({ name: 'Alice', addresses: [ - { id: '1', address: '0x1234567890abcdef1234567890abcdef12345678', chainType: 'ethereum', isDefault: true }, - { id: '2', address: 'b7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', chainType: 'bfmeta' }, + { id: '1', address: '0x1234567890abcdef1234567890abcdef12345678', label: 'ETH', isDefault: true }, + { id: '2', address: 'b7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', label: 'BFMETA' }, ], }); addressBookActions.addContact({ name: 'Bob', addresses: [ - { id: '3', address: 'c7ADmvZJJ3n3aDxkvwbXxJX1oGgeiCzL11', chainType: 'ccchain', isDefault: true }, + { id: '3', address: 'c7ADmvZJJ3n3aDxkvwbXxJX1oGgeiCzL11', label: 'CCCHAIN', isDefault: true }, ], }); return ; @@ -164,7 +166,7 @@ export const FilterByChain: Story = { return (

- 设置 chainType="bfmeta",只显示 BFMeta 链的地址 + 设置 chainType="bfmeta",只显示 BFMeta 链有效的地址

, 'onChange'> { value?: string | undefined; @@ -45,29 +53,40 @@ const AddressInput = forwardRef( const errorId = useId(); const listboxId = useId(); - // Get contacts from store + // 直接从 addressBookStore 读取数据(单一数据源) const addressBookState = useStore(addressBookStore); + const hasContacts = addressBookState.contacts.length > 0; const currentValue = value || internalValue; const isValid = isValidAddress(currentValue); const hasError = !!(error || (!isValid && currentValue)); - // Get contact suggestions - now supports empty query for "focus to show all" - const suggestions = useMemo((): ContactSuggestion[] => { + // 检测当前输入是否精确匹配某个联系人的地址 + const matchedContact = useMemo(() => { + if (!currentValue) return null; + return addressBookSelectors.getContactByAddress(addressBookState, currentValue); + }, [addressBookState, currentValue]); + + // 获取联系人建议 - 显示所有联系人,用地址合法性验证标记可选地址 + const suggestions = useMemo(() => { if (!showSuggestions) return []; - // Pass empty string to get all contacts when no input - return addressBookSelectors.suggestContacts(addressBookState, currentValue || '', chainType, maxSuggestions); + const allSuggestions = addressBookSelectors.suggestContacts(addressBookState, currentValue || '', maxSuggestions); + // 标记每个建议的地址是否对当前链有效 + return allSuggestions.map((s) => ({ + ...s, + isValidForCurrentChain: chainType ? isValidAddressForChain(s.matchedAddress.address, chainType) : true, + })); }, [addressBookState, currentValue, chainType, showSuggestions, maxSuggestions]); // Show dropdown when focused and has contacts (even without input) useEffect(() => { - if (focused && showSuggestions && (suggestions.length > 0 || addressBookState.contacts.length > 0)) { + if (focused && showSuggestions && (suggestions.length > 0 || hasContacts)) { setShowDropdown(true); setSelectedIndex(-1); } else { setShowDropdown(false); } - }, [focused, suggestions.length, addressBookState.contacts.length, showSuggestions]); + }, [focused, suggestions.length, hasContacts, showSuggestions]); // Handle click outside to close dropdown useEffect(() => { @@ -104,6 +123,11 @@ const AddressInput = forwardRef( setShowDropdown(false); }, [onChange]); + const handleClearContact = useCallback(() => { + setInternalValue(''); + onChange?.(''); + }, [onChange]); + const handleKeyDown = (e: React.KeyboardEvent) => { if (!showDropdown || suggestions.length === 0) return; @@ -117,7 +141,7 @@ const AddressInput = forwardRef( setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1)); break; case 'Enter': - if (selectedIndex >= 0 && suggestions[selectedIndex]) { + if (selectedIndex >= 0 && suggestions[selectedIndex] && suggestions[selectedIndex].isValidForCurrentChain) { e.preventDefault(); handleSelectSuggestion(suggestions[selectedIndex]); } @@ -134,57 +158,84 @@ const AddressInput = forwardRef(
- setFocused(true)} - onBlur={() => setTimeout(() => setFocused(false), 150)} - onKeyDown={handleKeyDown} - className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent font-mono text-sm outline-none" - placeholder={t('addressPlaceholder')} - autoComplete="off" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - aria-invalid={hasError} - aria-describedby={hasError ? errorId : undefined} - aria-expanded={showDropdown} - aria-controls={showDropdown ? listboxId : undefined} - aria-activedescendant={selectedIndex >= 0 ? `suggestion-${selectedIndex}` : undefined} - role="combobox" - {...props} - /> - -
- {onScan && ( + {/* 匹配到联系人时显示头像和信息 */} + {matchedContact ? ( + <> + +
+

{matchedContact.contact.name}

+

+ {matchedContact.matchedAddress.address} +

+
- )} - -
+ + ) : ( + <> + setFocused(true)} + onBlur={() => setTimeout(() => setFocused(false), 150)} + onKeyDown={handleKeyDown} + className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent font-mono text-sm outline-none" + placeholder={t('addressPlaceholder')} + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + aria-invalid={hasError} + aria-describedby={hasError ? errorId : undefined} + aria-expanded={showDropdown} + aria-controls={showDropdown ? listboxId : undefined} + aria-activedescendant={selectedIndex >= 0 ? `suggestion-${selectedIndex}` : undefined} + role="combobox" + {...props} + /> + +
+ {onScan && ( + + )} + +
+ + )}
{/* Contact suggestions dropdown */} @@ -195,28 +246,46 @@ const AddressInput = forwardRef( > {suggestions.length > 0 ? (
    - {suggestions.map((suggestion, index) => ( -
  • handleSelectSuggestion(suggestion)} - > -
    - -
    -
    -

    {suggestion.contact.name}

    -

    {suggestion.matchedAddress.address}

    -
    - {suggestion.matchedAddress.chainType} -
  • - ))} + {suggestions.map((suggestion, index) => { + const isDisabled = !suggestion.isValidForCurrentChain; + return ( +
  • !isDisabled && handleSelectSuggestion(suggestion)} + > + +
    +

    {suggestion.contact.name}

    +

    {suggestion.matchedAddress.address}

    +
    + {getAddressDisplayLabel(suggestion.matchedAddress) && ( + {getAddressDisplayLabel(suggestion.matchedAddress)} + )} +
  • + ); + })}
) : (
diff --git a/src/frontend-main.tsx b/src/frontend-main.tsx index 8de2092b..22b77987 100644 --- a/src/frontend-main.tsx +++ b/src/frontend-main.tsx @@ -9,6 +9,7 @@ import { MigrationProvider } from './contexts/MigrationContext' import { StackflowApp } from './StackflowApp' import { ChainIconProvider, TokenIconProvider } from './components/wallet' import { useChainConfigs } from './stores/chain-config' +import { AppInitializer } from './providers' import './styles/globals.css' // Mock 模式下注册全局中间件 @@ -57,17 +58,19 @@ export function startFrontendMain(rootElement: HTMLElement): void { - - - - - {/* Mock DevTools - 仅在 mock 模式下显示 */} - {MockDevTools && ( - - - - )} - + + + + + + {/* Mock DevTools - 仅在 mock 模式下显示 */} + {MockDevTools && ( + + + + )} + + diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 1b29fd03..50761c6b 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -4,6 +4,7 @@ "appInfo": "معلومات التطبيق", "back": "رجوع", "chainSelector": "اختر شبكة البلوكتشين", + "clear": "مسح", "close": "إغلاق", "closeDialog": "إغلاق النافذة", "copyAddress": "نسخ العنوان", @@ -58,7 +59,8 @@ "memo": "ملاحظة", "memoPlaceholder": "أضف ملاحظة (اختياري)", "shareContact": "مشاركة البطاقة", - "scanToAdd": "امسح لإضافة جهة اتصال" + "scanToAdd": "امسح لإضافة جهة اتصال", + "changeAvatar": "انقر لتغيير الصورة" }, "addressPlaceholder": "أدخل أو الصق العنوان", "advancedEncryptionTechnologyDigitalWealthIsMoreSecure": "Advanced encryption technology <br /> Digital wealth is more secure", @@ -106,10 +108,15 @@ "name": "الاسم", "namePlaceholder": "أدخل اسم جهة الاتصال", "noContacts": "لا توجد جهات اتصال", + "noValidAddress": "لا يوجد عنوان صالح", "save": "حفظ", "selectAddress": "اختر العنوان", "selectContact": "اختر جهة اتصال", - "viewAll": "عرض جميع جهات الاتصال" + "viewAll": "عرض جميع جهات الاتصال", + "addresses": "العناوين", + "addressLabel": "التسمية", + "addressLabelPlaceholder": "مثل: الحساب الرئيسي", + "addAddress": "إضافة عنوان" }, "continue": "Continue", "contractMethods": "Contract methods", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 141fd029..0b67412a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -4,6 +4,7 @@ "appInfo": "App info", "back": "Back", "chainSelector": "Select blockchain network", + "clear": "Clear", "close": "Close", "closeDialog": "Close dialog", "copyAddress": "Copy address", @@ -99,6 +100,11 @@ "name": "Name", "namePlaceholder": "Enter contact name", "noContacts": "No contacts", + "noValidAddress": "No valid address", + "addresses": "Addresses", + "addressLabel": "Label", + "addressLabelPlaceholder": "e.g. Main", + "addAddress": "Add Address", "save": "Save", "selectAddress": "Select address", "selectContact": "Select Contact", @@ -332,7 +338,8 @@ "memo": "Memo", "memoPlaceholder": "Add memo (optional)", "shareContact": "Share Contact", - "scanToAdd": "Scan to add contact" + "scanToAdd": "Scan to add contact", + "changeAvatar": "Click to change avatar" }, "time": { "justNow": "Just now", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index c3e1f311..fdaa1465 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -33,6 +33,7 @@ "invalidAddress": "无效的地址格式", "back": "返回", "close": "关闭", + "clear": "清除", "loading": "加载中", "addContact": "添加联系人", "moreActions": "更多操作", @@ -59,6 +60,11 @@ "save": "保存", "cancel": "取消", "noContacts": "暂无联系人", + "noValidAddress": "无可用地址", + "addresses": "地址", + "addressLabel": "标签", + "addressLabelPlaceholder": "如:主账户", + "addAddress": "添加地址", "viewAll": "查看全部联系人", "selectContact": "选择联系人", "selectAddress": "选择地址" @@ -310,7 +316,8 @@ "memo": "备注", "memoPlaceholder": "添加备注(可选)", "shareContact": "分享名片", - "scanToAdd": "扫码添加联系人" + "scanToAdd": "扫码添加联系人", + "changeAvatar": "点击切换头像" }, "time": { "justNow": "刚刚", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index babf6d30..2ed0ad45 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -4,6 +4,7 @@ "appInfo": "應用程式資訊", "back": "返回", "chainSelector": "選擇區塊鏈網絡", + "clear": "清除", "close": "關閉", "closeDialog": "關閉彈窗", "copyAddress": "複製地址", @@ -58,7 +59,8 @@ "memo": "備註", "memoPlaceholder": "新增備註(可選)", "shareContact": "分享名片", - "scanToAdd": "掃碼新增聯繫人" + "scanToAdd": "掃碼新增聯繫人", + "changeAvatar": "點擊切換頭像" }, "addressPlaceholder": "輸入或貼上地址", "advancedEncryptionTechnologyDigitalWealthIsMoreSecure": "先進的加密技術 <br /> 數字財富更安全", @@ -106,10 +108,15 @@ "name": "名稱", "namePlaceholder": "請輸入聯絡人名稱", "noContacts": "暫無聯絡人", + "noValidAddress": "無可用地址", "save": "儲存", "selectAddress": "選擇地址", "selectContact": "選擇聯絡人", - "viewAll": "查看全部聯絡人" + "viewAll": "查看全部聯絡人", + "addresses": "地址", + "addressLabel": "標籤", + "addressLabelPlaceholder": "如:主帳戶", + "addAddress": "新增地址" }, "continue": "繼續", "contractMethods": "合約方法", diff --git a/src/lib/__tests__/avatar-codec.test.ts b/src/lib/__tests__/avatar-codec.test.ts new file mode 100644 index 00000000..3c6d8903 --- /dev/null +++ b/src/lib/__tests__/avatar-codec.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { + encodeAvatar, + decodeAvatar, + generateRandomAvatar, + generateAvatarFromSeed, + type AvatarConfig, +} from '../avatar-codec' + +describe('avatar-codec', () => { + it('should encode and decode avatar config correctly', () => { + const config: AvatarConfig = { + topType: 'LongHairMiaWallace', + hairColor: 'BrownDark', + accessoriesType: 'Prescription02', + facialHairType: 'Blank', + facialHairColor: 'Black', + clotheType: 'Hoodie', + clotheColor: 'PastelBlue', + graphicType: 'Bat', + eyeType: 'Happy', + eyebrowType: 'Default', + mouthType: 'Smile', + skinColor: 'Light', + } + + const encoded = encodeAvatar(config) + expect(encoded.length).toBe(8) + + const decoded = decodeAvatar(encoded) + expect(decoded).toEqual(config) + }) + + it('should generate 8-character base64 string', () => { + const config = generateRandomAvatar() + const encoded = encodeAvatar(config) + + expect(encoded.length).toBe(8) + expect(/^[A-Za-z0-9+/=]+$/.test(encoded)).toBe(true) + }) + + it('should generate consistent avatar from same seed', () => { + const seed = 'test-user-123' + const avatar1 = generateAvatarFromSeed(seed) + const avatar2 = generateAvatarFromSeed(seed) + + expect(avatar1).toEqual(avatar2) + }) + + it('should generate different avatars from different seeds', () => { + const avatar1 = generateAvatarFromSeed('user-a') + const avatar2 = generateAvatarFromSeed('user-b') + + expect(avatar1).not.toEqual(avatar2) + }) + + it('should handle invalid encoded string gracefully', () => { + const decoded = decodeAvatar('invalid!') + expect(decoded.topType).toBeDefined() + expect(decoded.skinColor).toBeDefined() + }) + + it('should encode all possible option combinations', () => { + for (let i = 0; i < 10; i++) { + const config = generateRandomAvatar() + const encoded = encodeAvatar(config) + const decoded = decodeAvatar(encoded) + + expect(decoded).toEqual(config) + } + }) +}) diff --git a/src/lib/address-format.ts b/src/lib/address-format.ts index 695a27e9..8dc8d28e 100644 --- a/src/lib/address-format.ts +++ b/src/lib/address-format.ts @@ -107,7 +107,22 @@ export function isValidAddressForChain(address: string, chainType: ChainType): b const info = detectAddressFormat(address) if (!info.isValid) return false if (info.chainType === null) return false - return info.chainType === chainType + + // 完全匹配 + if (info.chainType === chainType) return true + + // BioForest 链之间互相兼容(共享相同密钥体系) + if (isBioforestChain(info.chainType) && isBioforestChain(chainType)) { + return true + } + + // Ethereum 和 BSC 地址格式相同 + if ((info.chainType === 'ethereum' || info.chainType === 'binance') && + (chainType === 'ethereum' || chainType === 'binance')) { + return true + } + + return false } /** diff --git a/src/lib/avatar-codec.ts b/src/lib/avatar-codec.ts new file mode 100644 index 00000000..9c7f05f2 --- /dev/null +++ b/src/lib/avatar-codec.ts @@ -0,0 +1,186 @@ +/** + * Avataaars 编码/解码工具 + * 将 Avatar 配置压缩为 8 字符的 base64 字符串 + */ + +export const AVATAR_OPTIONS = { + topType: [ + 'NoHair', 'Eyepatch', 'Hat', 'Hijab', 'Turban', + 'WinterHat1', 'WinterHat2', 'WinterHat3', 'WinterHat4', + 'LongHairBigHair', 'LongHairBob', 'LongHairBun', 'LongHairCurly', + 'LongHairCurvy', 'LongHairDreads', 'LongHairFrida', 'LongHairFro', + 'LongHairFroBand', 'LongHairNotTooLong', 'LongHairShavedSides', + 'LongHairMiaWallace', 'LongHairStraight', 'LongHairStraight2', + 'LongHairStraightStrand', 'ShortHairDreads01', 'ShortHairDreads02', + 'ShortHairFrizzle', 'ShortHairShaggyMullet', 'ShortHairShortCurly', + 'ShortHairShortFlat', 'ShortHairShortRound', 'ShortHairShortWaved', + 'ShortHairSides', 'ShortHairTheCaesar', 'ShortHairTheCaesarSidePart', + ], + hairColor: [ + 'Auburn', 'Black', 'Blonde', 'BlondeGolden', 'Brown', + 'BrownDark', 'PastelPink', 'Platinum', 'Red', 'SilverGray', + ], + accessoriesType: [ + 'Blank', 'Kurt', 'Prescription01', 'Prescription02', + 'Round', 'Sunglasses', 'Wayfarers', + ], + facialHairType: [ + 'Blank', 'BeardMedium', 'BeardLight', 'BeardMajestic', + 'MoustacheFancy', 'MoustacheMagnum', + ], + facialHairColor: [ + 'Auburn', 'Black', 'Blonde', 'BlondeGolden', + 'Brown', 'BrownDark', 'Platinum', 'Red', + ], + clotheType: [ + 'BlazerShirt', 'BlazerSweater', 'CollarSweater', + 'GraphicShirt', 'Hoodie', 'Overall', + 'ShirtCrewNeck', 'ShirtScoopNeck', 'ShirtVNeck', + ], + clotheColor: [ + 'Black', 'Blue01', 'Blue02', 'Blue03', 'Gray01', 'Gray02', + 'Heather', 'PastelBlue', 'PastelGreen', 'PastelOrange', + 'PastelRed', 'PastelYellow', 'Pink', 'Red', 'White', + ], + graphicType: [ + 'Bat', 'Cumbia', 'Deer', 'Diamond', 'Hola', + 'Pizza', 'Resist', 'Selena', 'Bear', 'SkullOutline', 'Skull', + ], + eyeType: [ + 'Close', 'Cry', 'Default', 'Dizzy', 'EyeRoll', + 'Happy', 'Hearts', 'Side', 'Squint', 'Surprised', + 'Wink', 'WinkWacky', + ], + eyebrowType: [ + 'Angry', 'AngryNatural', 'Default', 'DefaultNatural', + 'FlatNatural', 'RaisedExcited', 'RaisedExcitedNatural', + 'SadConcerned', 'SadConcernedNatural', 'UnibrowNatural', + 'UpDown', 'UpDownNatural', + ], + mouthType: [ + 'Concerned', 'Default', 'Disbelief', 'Eating', + 'Grimace', 'Sad', 'ScreamOpen', 'Serious', + 'Smile', 'Tongue', 'Twinkle', 'Vomit', + ], + skinColor: [ + 'Tanned', 'Yellow', 'Pale', 'Light', 'Brown', 'DarkBrown', 'Black', + ], +} as const + +const BIT_WIDTHS = [6, 4, 3, 3, 3, 4, 4, 4, 4, 4, 4, 3] as const +const FIELD_ORDER = [ + 'topType', 'hairColor', 'accessoriesType', 'facialHairType', + 'facialHairColor', 'clotheType', 'clotheColor', 'graphicType', + 'eyeType', 'eyebrowType', 'mouthType', 'skinColor', +] as const + +export type AvatarConfig = { + [K in keyof typeof AVATAR_OPTIONS]: (typeof AVATAR_OPTIONS)[K][number] +} + +export function encodeAvatar(config: AvatarConfig): string { + let bits = 0n + let offset = 0 + + for (let i = 0; i < FIELD_ORDER.length; i++) { + const field = FIELD_ORDER[i]! + const options = AVATAR_OPTIONS[field] as readonly string[] + const value = config[field] + const index = options.indexOf(value) + const idx = index >= 0 ? index : 0 + + bits |= BigInt(idx) << BigInt(offset) + offset += BIT_WIDTHS[i]! + } + + const bytes = new Uint8Array(6) + for (let i = 0; i < 6; i++) { + bytes[i] = Number((bits >> BigInt(i * 8)) & 0xffn) + } + + return btoa(String.fromCharCode(...bytes)) +} + +export function decodeAvatar(encoded: string): AvatarConfig { + try { + const binary = atob(encoded) + let bits = 0n + + for (let i = 0; i < binary.length && i < 6; i++) { + bits |= BigInt(binary.charCodeAt(i)) << BigInt(i * 8) + } + + const config: Record = {} + let offset = 0 + + for (let i = 0; i < FIELD_ORDER.length; i++) { + const field = FIELD_ORDER[i]! + const width = BIT_WIDTHS[i]! + const mask = (1n << BigInt(width)) - 1n + const index = Number((bits >> BigInt(offset)) & mask) + const options = AVATAR_OPTIONS[field] as readonly string[] + + config[field] = options[index % options.length]! + offset += width + } + + return config as AvatarConfig + } catch { + return generateRandomAvatar() + } +} + +export function generateRandomAvatar(): AvatarConfig { + const random = (arr: readonly T[]): T => arr[Math.floor(Math.random() * arr.length)] as T + + return { + topType: random(AVATAR_OPTIONS.topType), + hairColor: random(AVATAR_OPTIONS.hairColor), + accessoriesType: random(AVATAR_OPTIONS.accessoriesType), + facialHairType: random(AVATAR_OPTIONS.facialHairType), + facialHairColor: random(AVATAR_OPTIONS.facialHairColor), + clotheType: random(AVATAR_OPTIONS.clotheType), + clotheColor: random(AVATAR_OPTIONS.clotheColor), + graphicType: random(AVATAR_OPTIONS.graphicType), + eyeType: random(AVATAR_OPTIONS.eyeType), + eyebrowType: random(AVATAR_OPTIONS.eyebrowType), + mouthType: random(AVATAR_OPTIONS.mouthType), + skinColor: random(AVATAR_OPTIONS.skinColor), + } +} + +export function generateAvatarFromSeed(seed: string): AvatarConfig { + let hash = 0 + for (let i = 0; i < seed.length; i++) { + hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0 + } + + const pick = (arr: readonly T[], offset: number): T => { + const idx = Math.abs((hash + offset * 31) % arr.length) + return arr[idx] as T + } + + return { + topType: pick(AVATAR_OPTIONS.topType, 0), + hairColor: pick(AVATAR_OPTIONS.hairColor, 1), + accessoriesType: pick(AVATAR_OPTIONS.accessoriesType, 2), + facialHairType: pick(AVATAR_OPTIONS.facialHairType, 3), + facialHairColor: pick(AVATAR_OPTIONS.facialHairColor, 4), + clotheType: pick(AVATAR_OPTIONS.clotheType, 5), + clotheColor: pick(AVATAR_OPTIONS.clotheColor, 6), + graphicType: pick(AVATAR_OPTIONS.graphicType, 7), + eyeType: pick(AVATAR_OPTIONS.eyeType, 8), + eyebrowType: pick(AVATAR_OPTIONS.eyebrowType, 9), + mouthType: pick(AVATAR_OPTIONS.mouthType, 10), + skinColor: pick(AVATAR_OPTIONS.skinColor, 11), + } +} + +/** 从地址生成头像 URL(用于联系人默认头像) */ +export function generateAvatarFromAddress(address: string, seed: number = 0): string { + const combinedSeed = seed === 0 ? address.toLowerCase() : `${address.toLowerCase()}:${seed}` + const config = generateAvatarFromSeed(combinedSeed) + return `avatar:${encodeAvatar(config)}` +} + + diff --git a/src/lib/qr-parser.test.ts b/src/lib/qr-parser.test.ts index a30963e7..d108ee95 100644 --- a/src/lib/qr-parser.test.ts +++ b/src/lib/qr-parser.test.ts @@ -222,7 +222,8 @@ describe('qr-parser', () => { const contact = result as ParsedContact expect(contact.name).toBe('张三') expect(contact.addresses).toHaveLength(1) - expect(contact.addresses[0]?.chainType).toBe('ethereum') + // 旧格式的 chainType 会被用作 label + expect(contact.addresses[0]?.label).toBe('ethereum') expect(contact.addresses[0]?.address).toBe('0x742d35Cc6634C0532925a3b844Bc9e7595f12345') }) @@ -231,9 +232,9 @@ describe('qr-parser', () => { type: 'contact', name: '李四', addresses: [ - { chainType: 'ethereum', address: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345' }, - { chainType: 'bitcoin', address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' }, - { chainType: 'tron', address: 'TJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW' }, + { label: 'ETH', address: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345' }, + { label: 'BTC', address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' }, + { label: 'TRX', address: 'TJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW' }, ], }) const result = parseQRContent(content) @@ -246,7 +247,7 @@ describe('qr-parser', () => { const content = JSON.stringify({ type: 'contact', name: '王五', - addresses: [{ chainType: 'ethereum', address: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345' }], + addresses: [{ label: 'ETH', address: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345' }], memo: '好友', avatar: '👨‍💼', }) @@ -291,7 +292,7 @@ describe('qr-parser', () => { const contact = result as ParsedContact expect(contact.name).toBe('张三') expect(contact.addresses).toHaveLength(1) - expect(contact.addresses[0]?.chainType).toBe('ethereum') + expect(contact.addresses[0]?.label).toBe('ETH') }) it('parses contact:// URI with multiple addresses', () => { @@ -327,7 +328,7 @@ describe('qr-parser', () => { it('generates valid JSON for single address', () => { const content = generateContactQRContent({ name: '张三', - addresses: [{ chainType: 'ethereum', address: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345' }], + addresses: [{ label: 'ETH', address: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345' }], }) const parsed = JSON.parse(content) expect(parsed.type).toBe('contact') @@ -339,10 +340,9 @@ describe('qr-parser', () => { const original = { name: '李四', addresses: [ - { chainType: 'ethereum' as const, address: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345' }, - { chainType: 'bitcoin' as const, address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' }, + { label: 'ETH' as const, address: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345' }, + { label: 'BTC' as const, address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' }, ], - memo: '同事', avatar: '👩‍💻', } const content = generateContactQRContent(original) @@ -351,14 +351,15 @@ describe('qr-parser', () => { const contact = result as ParsedContact expect(contact.name).toBe(original.name) expect(contact.addresses).toHaveLength(2) - expect(contact.memo).toBe(original.memo) expect(contact.avatar).toBe(original.avatar) + // memo 不应该被包含在分享内容中 + expect(contact.memo).toBeUndefined() }) it('handles special characters in name', () => { const content = generateContactQRContent({ name: '张三 (老板)', - addresses: [{ chainType: 'ethereum', address: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345' }], + addresses: [{ label: 'ETH', address: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345' }], }) const result = parseQRContent(content) expect(result.type).toBe('contact') diff --git a/src/lib/qr-parser.ts b/src/lib/qr-parser.ts index 906e9504..696d6b5c 100644 --- a/src/lib/qr-parser.ts +++ b/src/lib/qr-parser.ts @@ -40,10 +40,10 @@ export interface ParsedDeepLink { raw: string } -/** 联系人地址 */ +/** 联系人地址(QR 协议格式) */ export interface ContactAddressInfo { - chainType: 'ethereum' | 'bitcoin' | 'tron' address: string + /** 地址标签(用于显示) */ label?: string | undefined } @@ -206,9 +206,9 @@ function parseContactURI(content: string): ParsedContact | null { type: 'contact', name: data.name, addresses: data.addresses.map((a: { chainType?: string; chain?: string; address: string; label?: string }) => ({ - chainType: a.chainType || a.chain, address: a.address, - label: a.label, + // 优先用 label,否则用旧格式的 chainType 作为 label + label: a.label || a.chainType || a.chain, })), memo: data.memo, avatar: data.avatar, @@ -233,20 +233,20 @@ function parseContactURI(content: string): ParsedContact | null { const params = new URLSearchParams(query) const addresses: ContactAddressInfo[] = [] - // 解析各链地址 + // 解析各链地址(用链类型作为默认 label) const ethAddr = params.get('eth') if (ethAddr && ETH_ADDRESS_REGEX.test(ethAddr)) { - addresses.push({ chainType: 'ethereum', address: ethAddr, label: params.get('eth_label') ?? undefined }) + addresses.push({ address: ethAddr, label: params.get('eth_label') || 'ETH' }) } const btcAddr = params.get('btc') if (btcAddr && BTC_ADDRESS_REGEX.test(btcAddr)) { - addresses.push({ chainType: 'bitcoin', address: btcAddr, label: params.get('btc_label') ?? undefined }) + addresses.push({ address: btcAddr, label: params.get('btc_label') || 'BTC' }) } const trxAddr = params.get('trx') if (trxAddr && TRON_ADDRESS_REGEX.test(trxAddr)) { - addresses.push({ chainType: 'tron', address: trxAddr, label: params.get('trx_label') ?? undefined }) + addresses.push({ address: trxAddr, label: params.get('trx_label') || 'TRX' }) } if (addresses.length === 0) return null @@ -262,23 +262,20 @@ function parseContactURI(content: string): ParsedContact | null { /** * 生成联系人二维码内容 + * 注意:不包含 memo(备注是私人信息,不分享) */ export function generateContactQRContent(contact: { name: string addresses: ContactAddressInfo[] - memo?: string | undefined avatar?: string | undefined }): string { - // 使用 JSON 格式,更灵活 return JSON.stringify({ type: 'contact', name: contact.name, addresses: contact.addresses.map(a => ({ - chainType: a.chainType, address: a.address, label: a.label, })), - memo: contact.memo, avatar: contact.avatar, }) } diff --git a/src/pages/address-book/index.stories.tsx b/src/pages/address-book/index.stories.tsx index 52564696..2bec5ebe 100644 --- a/src/pages/address-book/index.stories.tsx +++ b/src/pages/address-book/index.stories.tsx @@ -16,8 +16,8 @@ export default meta type Story = StoryObj // Helper to create addresses -function createAddresses(address: string, chainType = 'ethereum') { - return [{ id: crypto.randomUUID(), address, chainType: chainType as 'ethereum' }] +function createAddresses(address: string, label = 'ETH') { + return [{ id: crypto.randomUUID(), address, label }] } const resetStores = () => { @@ -99,9 +99,9 @@ export const MultipleAddresses: Story = { addressBookActions.addContact({ name: 'Multi-Chain User', addresses: [ - { id: '1', address: '0x1234567890abcdef1234567890abcdef12345678', chainType: 'ethereum' }, - { id: '2', address: 'b7ADmvZJJ3n3aDxkvwbXxJX1oGgeiCzL11', chainType: 'bfmeta' }, - { id: '3', address: 'TJYs1234567890abcdef1234567890abc', chainType: 'tron' }, + { id: '1', address: '0x1234567890abcdef1234567890abcdef12345678', label: 'ETH' }, + { id: '2', address: 'b7ADmvZJJ3n3aDxkvwbXxJX1oGgeiCzL11', label: 'BFMETA' }, + { id: '3', address: 'TJYs1234567890abcdef1234567890abc', label: 'TRX' }, ], memo: '多链用户', }) diff --git a/src/pages/address-book/index.test.tsx b/src/pages/address-book/index.test.tsx index 10e16081..943249c2 100644 --- a/src/pages/address-book/index.test.tsx +++ b/src/pages/address-book/index.test.tsx @@ -6,8 +6,8 @@ import { addressBookActions, walletStore } from '@/stores' const mockPush = vi.fn() // Helper to create addresses -function createAddresses(address: string, chainType = 'ethereum') { - return [{ id: crypto.randomUUID(), address, chainType: chainType as 'ethereum' }] +function createAddresses(address: string, label = 'ETH') { + return [{ id: crypto.randomUUID(), address, label }] } // Mock dependencies @@ -148,8 +148,8 @@ describe('AddressBookPage', () => { addressBookActions.addContact({ name: 'Multi', addresses: [ - { id: '1', address: '0x1111', chainType: 'ethereum' }, - { id: '2', address: 'b7ADmv...', chainType: 'bfmeta' }, + { id: '1', address: '0x1111', label: 'ETH' }, + { id: '2', address: 'b7ADmv...', label: 'BFMETA' }, ], }) diff --git a/src/pages/address-book/index.tsx b/src/pages/address-book/index.tsx index c18d6a56..cee79d0d 100644 --- a/src/pages/address-book/index.tsx +++ b/src/pages/address-book/index.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; +import { useState, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigation, useFlow } from '@/stackflow'; import { setWalletLockConfirmCallback } from '@/stackflow/activities/sheets'; @@ -9,6 +9,8 @@ import { IconUser as User, IconDotsVertical as MoreVertical, } from '@tabler/icons-react'; +import { ContactAvatar } from '@/components/common/contact-avatar'; +import { generateAvatarFromAddress } from '@/lib/avatar-codec'; import { PageHeader } from '@/components/layout/page-header'; import { addressBookStore, @@ -29,13 +31,6 @@ export function AddressBookPage() { const contacts = addressBookState.contacts; const currentWallet = useStore(walletStore, walletSelectors.getCurrentWallet); - // Initialize address book from storage - useEffect(() => { - if (!addressBookState.isInitialized) { - addressBookActions.initialize(); - } - }, [addressBookState.isInitialized]); - const [searchQuery, setSearchQuery] = useState(''); const deletingContactRef = useRef(null); @@ -70,7 +65,6 @@ export function AddressBookPage() { name: contact.name, addresses: JSON.stringify( contact.addresses.map((a) => ({ - chainType: a.chainType, address: a.address, label: a.label, })), @@ -203,9 +197,10 @@ function ContactListItem({ contact, onEdit, onDelete, onShare }: ContactListItem
{/* 头像 */} -
- {contact.name.slice(0, 1).toUpperCase()} -
+ {/* 信息 */}
diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 84c3f78b..b8059f63 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigation, useFlow } from '@/stackflow'; import { TokenList } from '@/components/token/token-list'; @@ -29,7 +29,6 @@ import { useCurrentChainTokens, useHasWallet, useWalletInitialized, - walletActions, type ChainType, } from '@/stores'; @@ -68,12 +67,6 @@ export function HomePage() { const [copied, setCopied] = useState(false); - useEffect(() => { - if (!isInitialized) { - walletActions.initialize(); - } - }, [isInitialized]); - const handleCopyAddress = async () => { if (chainAddress?.address) { await clipboard.write({ text: chainAddress.address }); diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index 9cd9113d..22370dfd 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next'; import { useNavigation, useActivityParams, useFlow } from '@/stackflow'; 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'; -import { AddressInput } from '@/components/transfer/address-input'; +import { AddressInput } from '@/components/transfer'; import { AmountInput } from '@/components/transfer/amount-input'; import { GradientButton } from '@/components/common/gradient-button'; import { Alert } from '@/components/common/alert'; @@ -108,6 +109,16 @@ export function SendPage() { return () => window.removeEventListener('contact-picker-select', handleContactSelect as EventListener); }, [setToAddress]); + // 转账成功后,追踪最近使用的联系人(单一数据源:只存 ID) + useEffect(() => { + if (state.resultStatus === 'success' && state.toAddress) { + const matched = addressBookSelectors.getContactByAddress(addressBookStore.state, state.toAddress); + if (matched) { + preferencesActions.trackRecentContact(matched.contact.id); + } + } + }, [state.resultStatus, state.toAddress]); + const handleContactPicker = useCallback(() => { push('ContactPickerJob', { chainType: selectedChain }); }, [push, selectedChain]); @@ -268,7 +279,7 @@ export function SendPage() { )}
- {/* Address input */} + {/* Address input - 直接从 addressBookStore 读取(单一数据源) */} { + // 统一初始化所有需要持久化的 store + if (!addressBookState.isInitialized) { + addressBookActions.initialize() + } + setIsReady(true) + }, []) // 只在挂载时执行一次 + + // 可选:在 store 未初始化完成时显示 loading + // 但这里因为是同步初始化,所以直接渲染 + if (!isReady) { + return null + } + + return <>{children} +} diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 00000000..3a4c3c2e --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1 @@ +export { AppInitializer } from './AppInitializer' diff --git a/src/services/migration/mpay-transformer.test.ts b/src/services/migration/mpay-transformer.test.ts index c39c1456..9d9ca268 100644 --- a/src/services/migration/mpay-transformer.test.ts +++ b/src/services/migration/mpay-transformer.test.ts @@ -252,7 +252,7 @@ describe('mpay-transformer', () => { expect(contact.name).toBe('Alice') expect(contact.addresses).toHaveLength(1) expect(contact.addresses[0]?.address).toBe('bfm123456') - expect(contact.addresses[0]?.chainType).toBe('bfmeta') + expect(contact.addresses[0]?.label).toBe('BFMETA') expect(contact.memo).toBe('My friend') expect(contact.createdAt).toBeTypeOf('number') expect(contact.updatedAt).toBeTypeOf('number') @@ -270,7 +270,7 @@ describe('mpay-transformer', () => { expect(contact.id).toBe('addr-book-2') expect(contact.name).toBe('Bob') expect(contact.addresses[0]?.address).toBe('0xabc123') - expect(contact.addresses[0]?.chainType).toBe('ethereum') // Default + expect(contact.addresses[0]?.label).toBe('ETHEREUM') // Default expect(contact.memo).toBeUndefined() }) @@ -284,7 +284,7 @@ describe('mpay-transformer', () => { const contact = transformAddressBookEntry(entry) - expect(contact.addresses[0]?.chainType).toBe('ethereum') // Default when no chain detected + expect(contact.addresses[0]?.label).toBe('ETHEREUM') // Default when no chain detected }) it('should select first mappable chain from multi-chain entry', () => { @@ -298,7 +298,7 @@ describe('mpay-transformer', () => { const contact = transformAddressBookEntry(entry) // First mappable chain should be selected - expect(contact.addresses[0]?.chainType).toBe('ethereum') + expect(contact.addresses[0]?.label).toBe('ETHEREUM') }) it('should skip unmappable chains and use first mappable', () => { @@ -311,7 +311,7 @@ describe('mpay-transformer', () => { const contact = transformAddressBookEntry(entry) - expect(contact.addresses[0]?.chainType).toBe('bfmeta') + expect(contact.addresses[0]?.label).toBe('BFMETA') }) it('should use ethereum as default when all chains are unmappable', () => { @@ -324,7 +324,7 @@ describe('mpay-transformer', () => { const contact = transformAddressBookEntry(entry) - expect(contact.addresses[0]?.chainType).toBe('ethereum') // Default + expect(contact.addresses[0]?.label).toBe('ETHEREUM') // Default }) it('should handle entry without remarks', () => { diff --git a/src/services/migration/mpay-transformer.ts b/src/services/migration/mpay-transformer.ts index 1120e72e..8e9d86ae 100644 --- a/src/services/migration/mpay-transformer.ts +++ b/src/services/migration/mpay-transformer.ts @@ -119,7 +119,7 @@ export function transformAddressBookEntry(entry: MpayAddressBookEntry): Contact { id: crypto.randomUUID(), address: entry.address, - chainType: chain, + label: chain.toUpperCase(), isDefault: true, }, ], diff --git a/src/stackflow/activities/sheets/ContactAddConfirmJob.tsx b/src/stackflow/activities/sheets/ContactAddConfirmJob.tsx index cd8f7087..5757aad0 100644 --- a/src/stackflow/activities/sheets/ContactAddConfirmJob.tsx +++ b/src/stackflow/activities/sheets/ContactAddConfirmJob.tsx @@ -11,12 +11,15 @@ import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { - IconUser as User, IconWallet as Wallet, IconCheck as Check, + IconRefresh as Refresh, } from '@tabler/icons-react' +import { ContactAvatar } from '@/components/common/contact-avatar' +import { generateAvatarFromAddress } from '@/lib/avatar-codec' import { cn } from '@/lib/utils' -import { addressBookActions, type ChainType } from '@/stores' +import { detectAddressFormat } from '@/lib/address-format' +import { addressBookActions } from '@/stores' import { useFlow } from '../../stackflow' import { ActivityParamsProvider, useActivityParams } from '../../hooks' @@ -33,17 +36,10 @@ export type ContactAddConfirmJobParams = { } interface AddressInfo { - chainType: ChainType address: string label?: string } -const CHAIN_NAMES: Record = { - ethereum: 'Ethereum', - bitcoin: 'Bitcoin', - tron: 'Tron', -} - function ContactAddConfirmJobContent() { const { t } = useTranslation('common') const { pop } = useFlow() @@ -51,6 +47,7 @@ function ContactAddConfirmJobContent() { const [name, setName] = useState(params.name || '') const [memo, setMemo] = useState(params.memo || '') + const [avatarSeed, setAvatarSeed] = useState(0) const [isSaving, setIsSaving] = useState(false) const [saved, setSaved] = useState(false) @@ -63,23 +60,31 @@ function ContactAddConfirmJobContent() { } })() + // 切换头像 + const handleChangeAvatar = useCallback(() => { + setAvatarSeed(prev => prev + 1) + }, []) + const handleSave = useCallback(async () => { if (!name.trim() || addresses.length === 0) return setIsSaving(true) try { + // 生成最终头像(avatar:HASH 格式) + const finalAvatar = params.avatar || + (addresses[0]?.address ? generateAvatarFromAddress(addresses[0].address, avatarSeed) : undefined) + // 添加联系人 addressBookActions.addContact({ name: name.trim(), addresses: addresses.map((a, i) => ({ id: `addr-${i}`, address: a.address, - chainType: a.chainType, label: a.label, isDefault: i === 0, })), memo: memo.trim() || undefined, - avatar: params.avatar, + avatar: finalAvatar, }) setSaved(true) @@ -89,7 +94,7 @@ function ContactAddConfirmJobContent() { } finally { setIsSaving(false) } - }, [name, memo, addresses, params.avatar, pop]) + }, [name, memo, addresses, params.avatar, avatarSeed, pop]) const handleCancel = useCallback(() => { pop() @@ -114,13 +119,22 @@ function ContactAddConfirmJobContent() {
{/* Avatar & Name */}
-
- {params.avatar ? ( - {params.avatar} - ) : ( - +
+ setName(e.target.value)} @@ -135,23 +149,28 @@ function ContactAddConfirmJobContent() { {t('addressBook.addresses')}
- {addresses.map((addr, i) => ( -
- -
-
- {CHAIN_NAMES[addr.chainType] || addr.chainType} - {addr.label && ` · ${addr.label}`} -
-
- {addr.address} + {addresses.map((addr, i) => { + const detected = detectAddressFormat(addr.address) + const displayLabel = addr.label || detected.chainType?.toUpperCase() || '' + return ( +
+ +
+ {displayLabel && ( +
+ {displayLabel} +
+ )} +
+ {addr.address} +
-
- ))} + ) + })}
diff --git a/src/stackflow/activities/sheets/ContactEditJob.tsx b/src/stackflow/activities/sheets/ContactEditJob.tsx index 6a309e0e..3c08af37 100644 --- a/src/stackflow/activities/sheets/ContactEditJob.tsx +++ b/src/stackflow/activities/sheets/ContactEditJob.tsx @@ -4,92 +4,134 @@ import { BottomSheet } from "@/components/layout/bottom-sheet"; import { useTranslation } from "react-i18next"; import { useStore } from "@tanstack/react-store"; import { cn } from "@/lib/utils"; -import { addressBookStore, addressBookActions, addressBookSelectors, type ChainType } from "@/stores"; -import { detectAddressFormat } from "@/lib/address-format"; -import { IconUser as User, IconWallet as Wallet, IconFileText as FileText } from "@tabler/icons-react"; +import { addressBookStore, addressBookActions } from "@/stores"; +import { IconFileText as FileText, IconRefresh as Refresh, IconPlus, IconTrash } from "@tabler/icons-react"; +import { ContactAvatar } from "@/components/common/contact-avatar"; +import { generateAvatarFromAddress } from "@/lib/avatar-codec"; import { useFlow } from "../../stackflow"; import { ActivityParamsProvider, useActivityParams } from "../../hooks"; type ContactEditJobParams = { contactId?: string; - defaultChain?: string; }; +interface AddressEntry { + id: string; + label: string; + address: string; +} + +const MAX_ADDRESSES = 3; +const MAX_LABEL_LENGTH = 10; + function ContactEditJobContent() { const { t } = useTranslation("common"); const { pop } = useFlow(); - const { contactId, defaultChain } = useActivityParams(); + const { contactId } = useActivityParams(); const contacts = useStore(addressBookStore, (s) => s.contacts); const contact = contactId ? contacts.find((c) => c.id === contactId) : null; const [name, setName] = useState(""); - const [address, setAddress] = useState(""); + const [addresses, setAddresses] = useState([{ id: crypto.randomUUID(), label: "", address: "" }]); const [memo, setMemo] = useState(""); + const [avatarSeed, setAvatarSeed] = useState(() => Math.floor(Math.random() * 10000)); + const [avatarChanged, setAvatarChanged] = useState(false); const isEditing = !!contact; - // Get default address for editing - const defaultAddress = contact - ? addressBookSelectors.getDefaultAddress(contact, defaultChain as ChainType | undefined) - : undefined; - useEffect(() => { if (contact) { setName(contact.name); - setAddress(defaultAddress?.address ?? ""); + setAddresses( + contact.addresses.length > 0 + ? contact.addresses.map((a) => ({ id: a.id, label: a.label || "", address: a.address })) + : [{ id: crypto.randomUUID(), label: "", address: "" }] + ); setMemo(contact.memo ?? ""); + setAvatarChanged(false); } - }, [contact, defaultAddress]); + }, [contact]); + + // 切换头像(使用随机 seed) + const handleChangeAvatar = useCallback(() => { + setAvatarSeed(Math.floor(Math.random() * 10000)); + setAvatarChanged(true); + }, []); + + // 第一个有效地址用于生成头像 + const firstValidAddress = addresses.find((a) => a.address.trim())?.address.trim(); + // 如果用户修改过头像或者是新建联系人,使用生成的头像;否则使用已保存的头像 + const currentAvatar = (!isEditing || avatarChanged) + ? (firstValidAddress ? generateAvatarFromAddress(firstValidAddress, avatarSeed) : undefined) + : (contact?.avatar || (firstValidAddress ? generateAvatarFromAddress(firstValidAddress, avatarSeed) : undefined)); + + // 添加新地址 + const handleAddAddress = useCallback(() => { + if (addresses.length >= MAX_ADDRESSES) return; + setAddresses((prev) => [...prev, { id: crypto.randomUUID(), label: "", address: "" }]); + }, [addresses.length]); + + // 删除地址 + const handleRemoveAddress = useCallback((id: string) => { + setAddresses((prev) => { + if (prev.length <= 1) return prev; + return prev.filter((a) => a.id !== id); + }); + }, []); + + // 更新地址 + const handleUpdateAddress = useCallback((id: string, field: "label" | "address", value: string) => { + setAddresses((prev) => + prev.map((a) => (a.id === id ? { ...a, [field]: value } : a)) + ); + }, []); const handleSave = useCallback(() => { const trimmedName = name.trim(); - const trimmedAddress = address.trim(); const trimmedMemo = memo.trim(); - if (!trimmedName || !trimmedAddress) return; + // 过滤有效地址 + const validAddresses = addresses + .filter((a) => a.address.trim()) + .map((a, i) => ({ + id: a.id, + address: a.address.trim(), + label: a.label.trim() || undefined, + isDefault: i === 0, + })); + + if (!trimmedName || validAddresses.length === 0) return; - // Detect chain type from address format - const detectedFormat = detectAddressFormat(trimmedAddress); - const chainType = (defaultChain as ChainType) ?? detectedFormat.chainType ?? "ethereum"; + // 生成最终头像(如果用户修改过或是新建,使用当前生成的头像) + const finalAvatar = (!isEditing || avatarChanged) + ? (validAddresses[0] ? generateAvatarFromAddress(validAddresses[0].address, avatarSeed) : undefined) + : contact?.avatar; if (isEditing && contact) { - // Update contact name and memo + // 更新联系人 addressBookActions.updateContact(contact.id, { name: trimmedName, - ...(trimmedMemo ? { memo: trimmedMemo } : {}), + avatar: finalAvatar, + addresses: validAddresses, + memo: trimmedMemo || undefined, }); - - // Update address if changed - if (defaultAddress && defaultAddress.address !== trimmedAddress) { - // Remove old address and add new one - addressBookActions.removeAddressFromContact(contact.id, defaultAddress.id); - addressBookActions.addAddressToContact(contact.id, { - address: trimmedAddress, - chainType, - isDefault: true, - }); - } } else { - // Add new contact with single address + // 添加新联系人 addressBookActions.addContact({ name: trimmedName, - addresses: [ - { - id: crypto.randomUUID(), - address: trimmedAddress, - chainType, - isDefault: true, - }, - ], - ...(trimmedMemo ? { memo: trimmedMemo } : {}), + addresses: validAddresses, + avatar: finalAvatar, + memo: trimmedMemo || undefined, }); } pop(); - }, [name, address, memo, isEditing, contact, defaultAddress, defaultChain, pop]); + }, [name, addresses, memo, avatarSeed, isEditing, contact, pop]); - const canSave = name.trim().length > 0 && address.trim().length > 0; + const hasValidAddress = addresses.some((a) => a.address.trim()); + const canSave = name.trim().length > 0 && hasValidAddress; + const canAddMore = addresses.length < MAX_ADDRESSES; return ( @@ -107,85 +149,143 @@ function ContactEditJobContent() {
{/* Content */} -
- {/* Name input */} -
- - setName(e.target.value)} - placeholder={t("contact.namePlaceholder")} - maxLength={20} - className={cn( - "w-full rounded-xl border border-border bg-background px-4 py-3", - "focus:outline-none focus:ring-2 focus:ring-primary" - )} - /> -
+
+
+ {/* Avatar & Name */} +
+ + setName(e.target.value)} + placeholder={t("contact.namePlaceholder")} + maxLength={20} + className={cn( + "flex-1 rounded-xl border border-border bg-background px-4 py-3", + "focus:outline-none focus:ring-2 focus:ring-primary" + )} + /> +
- {/* Address input */} -
- - setAddress(e.target.value)} - placeholder={t("contact.addressPlaceholder")} - className={cn( - "w-full rounded-xl border border-border bg-background px-4 py-3 font-mono text-sm", - "focus:outline-none focus:ring-2 focus:ring-primary" - )} - /> -
+ {/* Addresses */} +
+
+ {t("contact.addresses")} + {addresses.length}/{MAX_ADDRESSES} +
- {/* Memo input */} -
- -