From 050d921714538df74d9b8518134e778eaae6bfed Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 23 Dec 2025 12:59:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(transfer):=20AddressInput=20=E8=81=9A?= =?UTF-8?q?=E7=84=A6=E8=87=AA=E5=8A=A8=E5=B1=95=E5=BC=80=E8=81=94=E7=B3=BB?= =?UTF-8?q?=E4=BA=BA=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 suggestContacts 支持空查询,返回所有联系人 - AddressInput 聚焦时自动展开建议面板 - 添加查看全部联系人按钮,跳转到 ContactPickerJob - 创建 ContactPickerJob 联系人选择器 - 支持按链类型过滤联系人 - 更新白皮书和 i18n Closes #93 --- .../index.md" | 78 +++++-- .../transfer/address-input.stories.tsx | 51 ++++- src/components/transfer/address-input.tsx | 94 +++++--- src/i18n/locales/ar/common.json | 6 +- src/i18n/locales/en/common.json | 6 +- src/i18n/locales/zh-CN/common.json | 6 +- src/i18n/locales/zh-TW/common.json | 6 +- src/pages/send/index.tsx | 18 +- .../activities/sheets/ContactPickerJob.tsx | 208 ++++++++++++++++++ src/stackflow/activities/sheets/index.ts | 1 + src/stackflow/stackflow.ts | 4 +- src/stores/address-book.test.ts | 63 ++++++ src/stores/address-book.ts | 63 ++++-- 13 files changed, 526 insertions(+), 78 deletions(-) create mode 100644 src/stackflow/activities/sheets/ContactPickerJob.tsx diff --git "a/docs/white-book/04-\346\234\215\345\212\241\347\257\207/09-\350\201\224\347\263\273\344\272\272\346\234\215\345\212\241/index.md" "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/09-\350\201\224\347\263\273\344\272\272\346\234\215\345\212\241/index.md" index 20919b0d1..4d685630f 100644 --- "a/docs/white-book/04-\346\234\215\345\212\241\347\257\207/09-\350\201\224\347\263\273\344\272\272\346\234\215\345\212\241/index.md" +++ "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/09-\350\201\224\347\263\273\344\272\272\346\234\215\345\212\241/index.md" @@ -120,28 +120,70 @@ interface ContactSuggestion { ### AddressInput 组件增强 ``` -┌─────────────────────────────────────────┐ -│ [图标] 输入地址或选择联系人 [扫描] │ -├─────────────────────────────────────────┤ -│ 建议联系人: │ -│ ┌─────────────────────────────────────┐ │ -│ │ 👤 Alice │ │ -│ │ b7ADmv... (BFMeta) │ │ -│ └─────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────┐ │ -│ │ 👤 Bob │ │ -│ │ 0x742d... (Ethereum) │ │ -│ └─────────────────────────────────────┘ │ -└─────────────────────────────────────────┘ +┌──────────────────────────────────────────────┐ +│ [输入地址...] [扫描] [粘贴] │ +├──────────────────────────────────────────────┤ +│ 👤 Alice │ +│ b7ADmv... (BFMeta) │ +├──────────────────────────────────────────────┤ +│ 👤 Bob │ +│ 0x742d... (Ethereum) │ +├──────────────────────────────────────────────┤ +│ [👥 查看全部联系人...] │ +└──────────────────────────────────────────────┘ ``` ### 交互流程 -1. 用户开始输入地址 -2. 实时搜索匹配的联系人(按名称或地址前缀) -3. 显示建议列表(最多5个) -4. 用户选择联系人后,自动填入对应地址 -5. 根据当前选择的链,优先显示该链的地址 +1. **聚焦即展开**:用户聚焦输入框时,自动展开建议面板 +2. **默认显示**:即使无输入,显示最近使用 + 所有联系人地址 +3. **链类型过滤**:根据当前选择的链类型过滤显示的地址 +4. **实时搜索**:输入时实时过滤匹配的联系人(按名称或地址前缀) +5. **选择填入**:用户选择联系人后,自动填入对应地址 +6. **联系人入口**:面板底部显示"查看全部联系人"按钮,跳转到 ContactPickerActivity + +### 建议排序规则 + +1. 精确匹配(地址完全相同)→ 最高优先级 +2. 前缀匹配(地址以输入开头) +3. 名称匹配(联系人名称包含输入) +4. 最近使用(按 `updatedAt` 降序) +5. 同链优先(当前链的地址排在前面) + +--- + +## ContactPickerActivity + +联系人选择器页面,用于完整浏览和选择联系人。 + +### 功能 + +- 显示所有联系人列表 +- 支持搜索过滤 +- 按链类型过滤(可选) +- 选择联系人地址后返回到调用页面 + +### 入口 + +- AddressInput 建议面板底部的"查看全部联系人"按钮 +- 可通过 `push('ContactPickerActivity', { chainType, onSelect })` 调用 + +### UI 结构 + +``` +┌──────────────────────────────────────────────┐ +│ ← 选择联系人 [链过滤 ▼] │ +├──────────────────────────────────────────────┤ +│ [🔍 搜索联系人...] │ +├──────────────────────────────────────────────┤ +│ 👤 Alice │ +│ b7ADmv... (BFMeta) [默认] │ +│ 0x742d... (Ethereum) │ +├──────────────────────────────────────────────┤ +│ 👤 Bob │ +│ TJYs... (TRON) │ +└──────────────────────────────────────────────┘ +``` --- diff --git a/src/components/transfer/address-input.stories.tsx b/src/components/transfer/address-input.stories.tsx index d2b1ca045..404adf87a 100644 --- a/src/components/transfer/address-input.stories.tsx +++ b/src/components/transfer/address-input.stories.tsx @@ -82,7 +82,7 @@ export const TransferForm: Story = { }; /** - * 带联系人建议 - 输入联系人名称或地址前缀会显示建议 + * 带联系人建议 - 聚焦即展开,显示所有联系人 */ export const WithContactSuggestions: Story = { decorators: [ @@ -118,12 +118,15 @@ export const WithContactSuggestions: Story = { return (
-

试着输入 "Al"、"Bob" 或 "0x12" 来查看建议

+

+ 聚焦输入框即可看到所有联系人建议,输入可过滤 +

alert('Open QR scanner')} + onContactPicker={() => alert('Open contact picker')} showSuggestions />

当前值: {address || '(空)'}

@@ -131,3 +134,47 @@ export const WithContactSuggestions: Story = { ); }, }; + +/** + * 按链类型过滤 - 只显示指定链的地址 + */ +export const FilterByChain: Story = { + decorators: [ + (Story) => { + addressBookActions.clearAll(); + addressBookActions.addContact({ + name: 'Alice', + addresses: [ + { id: '1', address: '0x1234567890abcdef1234567890abcdef12345678', chainType: 'ethereum', isDefault: true }, + { id: '2', address: 'b7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', chainType: 'bfmeta' }, + ], + }); + addressBookActions.addContact({ + name: 'Bob', + addresses: [ + { id: '3', address: 'c7ADmvZJJ3n3aDxkvwbXxJX1oGgeiCzL11', chainType: 'ccchain', isDefault: true }, + ], + }); + return ; + }, + ], + render: () => { + const [address, setAddress] = useState(''); + + return ( +
+

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

+ alert('Open contact picker for BFMeta')} + showSuggestions + /> +
+ ); + }, +}; diff --git a/src/components/transfer/address-input.tsx b/src/components/transfer/address-input.tsx index d7b74e8fc..5928bd795 100644 --- a/src/components/transfer/address-input.tsx +++ b/src/components/transfer/address-input.tsx @@ -2,7 +2,7 @@ import { useState, forwardRef, useId, useMemo, useCallback, useRef, useEffect } import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; import { useStore } from '@tanstack/react-store'; -import { IconLineScan as ScanLine, IconClipboardCopy as ClipboardPaste, IconUser } from '@tabler/icons-react'; +import { IconLineScan as ScanLine, IconClipboardCopy as ClipboardPaste, IconUser, IconUsers } from '@tabler/icons-react'; import { clipboardService } from '@/services/clipboard'; import { addressBookStore, addressBookSelectors, type ChainType, type ContactSuggestion } from '@/stores'; @@ -11,12 +11,16 @@ interface AddressInputProps extends Omit void) | undefined; onScan?: (() => void) | undefined; onPaste?: (() => void) | undefined; + /** Callback to open contact picker */ + onContactPicker?: (() => void) | undefined; error?: string | undefined; label?: string | undefined; /** Chain type to filter contact addresses */ chainType?: ChainType | undefined; /** Enable contact suggestions dropdown */ showSuggestions?: boolean | undefined; + /** Maximum suggestions to show (default: 5) */ + maxSuggestions?: number | undefined; } function isValidAddress(address: string): boolean { @@ -31,7 +35,7 @@ function isValidAddress(address: string): boolean { } const AddressInput = forwardRef( - ({ value = '', onChange, onScan, onPaste, error, label, className, chainType, showSuggestions = true, ...props }, ref) => { + ({ value = '', onChange, onScan, onPaste, onContactPicker, error, label, className, chainType, showSuggestions = true, maxSuggestions = 5, ...props }, ref) => { const [focused, setFocused] = useState(false); const [internalValue, setInternalValue] = useState(value); const [showDropdown, setShowDropdown] = useState(false); @@ -48,21 +52,22 @@ const AddressInput = forwardRef( const isValid = isValidAddress(currentValue); const hasError = !!(error || (!isValid && currentValue)); - // Get contact suggestions based on input + // Get contact suggestions - now supports empty query for "focus to show all" const suggestions = useMemo((): ContactSuggestion[] => { - if (!showSuggestions || !currentValue || currentValue.length < 1) return []; - return addressBookSelectors.suggestContacts(addressBookState, currentValue, chainType).slice(0, 5); - }, [addressBookState, currentValue, chainType, showSuggestions]); + if (!showSuggestions) return []; + // Pass empty string to get all contacts when no input + return addressBookSelectors.suggestContacts(addressBookState, currentValue || '', chainType, maxSuggestions); + }, [addressBookState, currentValue, chainType, showSuggestions, maxSuggestions]); - // Show dropdown when there are suggestions and input is focused + // Show dropdown when focused and has contacts (even without input) useEffect(() => { - if (focused && suggestions.length > 0 && currentValue.length > 0) { + if (focused && showSuggestions && (suggestions.length > 0 || addressBookState.contacts.length > 0)) { setShowDropdown(true); setSelectedIndex(-1); } else { setShowDropdown(false); } - }, [focused, suggestions.length, currentValue]); + }, [focused, suggestions.length, addressBookState.contacts.length, showSuggestions]); // Handle click outside to close dropdown useEffect(() => { @@ -182,35 +187,56 @@ const AddressInput = forwardRef(
{/* Contact suggestions dropdown */} - {showDropdown && suggestions.length > 0 && ( - + + {t('contact.viewAll')} + + )} + )} diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 2cd874cae..d4d4708e6 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -55,7 +55,11 @@ "memoPlaceholder": "أضف ملاحظة", "add": "إضافة", "save": "حفظ", - "cancel": "إلغاء" + "cancel": "إلغاء", + "noContacts": "لا توجد جهات اتصال", + "viewAll": "عرض جميع جهات الاتصال", + "selectContact": "اختر جهة اتصال", + "selectAddress": "اختر العنوان" }, "acceptExchange": "Accept exchange", "account": "[MISSING:ar] 用户", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 48cb9b630..eb1bc1b98 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -55,7 +55,11 @@ "memoPlaceholder": "Add memo", "add": "Add", "save": "Save", - "cancel": "Cancel" + "cancel": "Cancel", + "noContacts": "No contacts", + "viewAll": "View all contacts", + "selectContact": "Select Contact", + "selectAddress": "Select address" }, "acceptExchange": "Accept exchange", "account": "[MISSING:en] 用户", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index b6402d328..e4f00fc75 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -54,7 +54,11 @@ "memoPlaceholder": "添加备注信息", "add": "添加", "save": "保存", - "cancel": "取消" + "cancel": "取消", + "noContacts": "暂无联系人", + "viewAll": "查看全部联系人", + "selectContact": "选择联系人", + "selectAddress": "选择地址" }, "acceptExchange": "接收交换", "account": "用户", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 4128bde36..b1f2640ed 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -54,7 +54,11 @@ "memoPlaceholder": "新增備註", "add": "新增", "save": "儲存", - "cancel": "取消" + "cancel": "取消", + "noContacts": "暫無聯絡人", + "viewAll": "查看全部聯絡人", + "selectContact": "選擇聯絡人", + "selectAddress": "選擇地址" }, "acceptExchange": "接受交換", "account": "賬戶", diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index 871dbb5f0..361cac8fb 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigation, useActivityParams, useFlow } from '@/stackflow'; import { setPasswordConfirmCallback, setPayPasswordConfirmCallback, setTransferConfirmCallback } from '@/stackflow/activities/sheets'; +import type { Contact, ContactAddress } from '@/stores'; import { PageHeader } from '@/components/layout/page-header'; import { AddressInput } from '@/components/transfer/address-input'; import { AmountInput } from '@/components/transfer/amount-input'; @@ -102,6 +103,19 @@ export function SendPage() { } }, [initialAddress, initialAmount, state.toAddress, state.amount, state.asset, setToAddress, setAmount]); + // Listen for contact picker selection + useEffect(() => { + const handleContactSelect = (e: CustomEvent<{ contact: Contact; address: ContactAddress }>) => { + setToAddress(e.detail.address.address); + }; + window.addEventListener('contact-picker-select', handleContactSelect as EventListener); + return () => window.removeEventListener('contact-picker-select', handleContactSelect as EventListener); + }, [setToAddress]); + + const handleContactPicker = useCallback(() => { + push('ContactPickerJob', { chainType: selectedChain }); + }, [push, selectedChain]); + // Derive formatted values for display const balance = state.asset?.amount ?? null; const symbol = state.asset?.assetType ?? 'TOKEN'; @@ -252,6 +266,8 @@ export function SendPage() { onChange={setToAddress} placeholder={t('sendPage.toAddressPlaceholder', { chain: selectedChainName })} onScan={handleScan} + onContactPicker={handleContactPicker} + chainType={selectedChain} error={state.addressError ?? undefined} /> diff --git a/src/stackflow/activities/sheets/ContactPickerJob.tsx b/src/stackflow/activities/sheets/ContactPickerJob.tsx new file mode 100644 index 000000000..0a5008687 --- /dev/null +++ b/src/stackflow/activities/sheets/ContactPickerJob.tsx @@ -0,0 +1,208 @@ +import { useState, useMemo } from "react"; +import type { ActivityComponentType } from "@stackflow/react"; +import { BottomSheet } from "@/components/layout/bottom-sheet"; +import { useTranslation } from "react-i18next"; +import { useStore } from "@tanstack/react-store"; +import { cn } from "@/lib/utils"; +import { IconUser, IconSearch, IconChevronRight } from "@tabler/icons-react"; +import { addressBookStore, addressBookSelectors, type ChainType, type Contact, type ContactAddress } from "@/stores"; +import { useFlow } from "../../stackflow"; +import { ActivityParamsProvider, useActivityParams } from "../../hooks"; + +type ContactPickerJobParams = { + chainType?: string; +}; + +function truncateAddress(address: string, startChars = 8, endChars = 6): string { + if (address.length <= startChars + endChars + 3) return address; + return `${address.slice(0, startChars)}...${address.slice(-endChars)}`; +} + +function ContactPickerJobContent() { + const { t } = useTranslation("common"); + const { pop } = useFlow(); + const { chainType: chainTypeParam } = useActivityParams(); + const chainType = chainTypeParam as ChainType | undefined; + + const [searchQuery, setSearchQuery] = useState(""); + const [expandedContactId, setExpandedContactId] = useState(null); + + const addressBookState = useStore(addressBookStore); + + const filteredContacts = useMemo(() => { + let contacts = addressBookState.contacts; + + // Filter by chain type if specified + if (chainType) { + contacts = addressBookSelectors.getContactsByChain(addressBookState, chainType); + } + + // Filter by search query + if (searchQuery) { + contacts = addressBookSelectors.searchContacts( + { ...addressBookState, contacts }, + searchQuery + ); + } + + // Sort by updated time (most recent first) + return [...contacts].sort((a, b) => b.updatedAt - a.updatedAt); + }, [addressBookState, chainType, searchQuery]); + + const handleSelectAddress = (contact: Contact, address: ContactAddress) => { + // Store selected address in a way that can be retrieved by the caller + // We use a custom event since stackflow doesn't support return values directly + const event = new CustomEvent("contact-picker-select", { + detail: { contact, address }, + }); + window.dispatchEvent(event); + pop(); + }; + + const getRelevantAddresses = (contact: Contact) => { + if (chainType) { + return contact.addresses.filter((a) => a.chainType === chainType); + } + return contact.addresses; + }; + + return ( + +
+ {/* Handle */} +
+
+
+ + {/* Title */} +
+

+ {t("contact.selectContact")} +

+
+ + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={t("addressBook.searchPlaceholder")} + className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none" + /> +
+
+ + {/* Contact List */} +
+ {filteredContacts.length === 0 ? ( +
+ {searchQuery ? t("addressBook.noResults") : t("contact.noContacts")} +
+ ) : ( +
+ {filteredContacts.map((contact) => { + const addresses = getRelevantAddresses(contact); + const hasMultipleAddresses = addresses.length > 1; + const isExpanded = expandedContactId === contact.id; + + // If only one address or expanded, show all addresses + if (!hasMultipleAddresses) { + const address = addresses[0]; + if (!address) return null; + return ( + + ); + } + + // Multiple addresses - show expandable + return ( +
+ + {isExpanded && ( +
+ {addresses.map((address) => ( + + ))} +
+ )} +
+ ); + })} +
+ )} +
+ + {/* Safe area */} +
+
+ + ); +} + +export const ContactPickerJob: ActivityComponentType = ({ params }) => { + return ( + + + + ); +}; diff --git a/src/stackflow/activities/sheets/index.ts b/src/stackflow/activities/sheets/index.ts index 914737ea5..19c907f13 100644 --- a/src/stackflow/activities/sheets/index.ts +++ b/src/stackflow/activities/sheets/index.ts @@ -6,6 +6,7 @@ export { PayPasswordConfirmJob, setPayPasswordConfirmCallback } from "./PayPassw export { SetPayPasswordJob, setSetPayPasswordCallback } from "./SetPayPasswordJob"; export { MnemonicOptionsJob, setMnemonicOptionsCallback } from "./MnemonicOptionsJob"; export { ContactEditJob } from "./ContactEditJob"; +export { ContactPickerJob } from "./ContactPickerJob"; export { WalletAddJob } from "./WalletAddJob"; export { SecurityWarningJob, setSecurityWarningConfirmCallback } from "./SecurityWarningJob"; export { TransferConfirmJob, setTransferConfirmCallback } from "./TransferConfirmJob"; diff --git a/src/stackflow/stackflow.ts b/src/stackflow/stackflow.ts index a1efec296..d3b87b5bc 100644 --- a/src/stackflow/stackflow.ts +++ b/src/stackflow/stackflow.ts @@ -28,7 +28,7 @@ import { AddressBookActivity } from "./activities/AddressBookActivity"; import { NotificationsActivity } from "./activities/NotificationsActivity"; import { StakingActivity } from "./activities/StakingActivity"; import { WelcomeActivity } from "./activities/WelcomeActivity"; -import { ChainSelectorJob, WalletRenameJob, WalletDeleteJob, PasswordConfirmJob, PayPasswordConfirmJob, SetPayPasswordJob, MnemonicOptionsJob, ContactEditJob, WalletAddJob, SecurityWarningJob, TransferConfirmJob } from "./activities/sheets"; +import { ChainSelectorJob, WalletRenameJob, WalletDeleteJob, PasswordConfirmJob, PayPasswordConfirmJob, SetPayPasswordJob, MnemonicOptionsJob, ContactEditJob, ContactPickerJob, WalletAddJob, SecurityWarningJob, TransferConfirmJob } from "./activities/sheets"; export const { Stack, useFlow, useStepFlow, activities } = stackflow({ transitionDuration: 350, @@ -72,6 +72,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({ SetPayPasswordJob: "/job/set-pay-password", MnemonicOptionsJob: "/job/mnemonic-options", ContactEditJob: "/job/contact-edit", + ContactPickerJob: "/job/contact-picker", WalletAddJob: "/job/wallet-add", SecurityWarningJob: "/job/security-warning", TransferConfirmJob: "/job/transfer-confirm", @@ -114,6 +115,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({ SetPayPasswordJob, MnemonicOptionsJob, ContactEditJob, + ContactPickerJob, WalletAddJob, SecurityWarningJob, TransferConfirmJob, diff --git a/src/stores/address-book.test.ts b/src/stores/address-book.test.ts index 9f9140946..f97be1124 100644 --- a/src/stores/address-book.test.ts +++ b/src/stores/address-book.test.ts @@ -316,6 +316,69 @@ describe('AddressBookStore', () => { expect(suggestions[0]?.matchType).toBe('prefix') }) + it('suggestContacts returns all contacts when query is empty', () => { + addressBookActions.addContact({ + name: 'Alice', + addresses: [{ id: '1', address: '0x1111', chainType: 'ethereum' }], + }) + addressBookActions.addContact({ + name: 'Bob', + addresses: [{ id: '2', address: '0x2222', chainType: 'ethereum' }], + }) + + const state = addressBookStore.state + + // Empty query should return all contacts + const suggestions = addressBookSelectors.suggestContacts(state, '') + expect(suggestions).toHaveLength(2) + }) + + it('suggestContacts filters by chainType when provided', () => { + addressBookActions.addContact({ + name: 'Alice', + addresses: [ + { id: '1', address: '0x1111', chainType: 'ethereum' }, + { id: '2', address: 'b7ADmv...', chainType: 'bfmeta' }, + ], + }) + addressBookActions.addContact({ + name: 'Bob', + addresses: [{ id: '3', address: 'c7ADmv...', chainType: 'ccchain' }], + }) + + const state = addressBookStore.state + + // Filter by ethereum + const ethSuggestions = addressBookSelectors.suggestContacts(state, '', 'ethereum') + expect(ethSuggestions).toHaveLength(1) + expect(ethSuggestions[0]?.contact.name).toBe('Alice') + + // Filter by bfmeta + const bfmetaSuggestions = addressBookSelectors.suggestContacts(state, '', 'bfmeta') + expect(bfmetaSuggestions).toHaveLength(1) + expect(bfmetaSuggestions[0]?.matchedAddress.chainType).toBe('bfmeta') + }) + + it('suggestContacts respects limit parameter', () => { + // Add 10 contacts + for (let i = 0; i < 10; i++) { + addressBookActions.addContact({ + name: `Contact ${i}`, + addresses: [{ id: `${i}`, address: `0x${i}000`, chainType: 'ethereum' }], + }) + } + + const state = addressBookStore.state + + // Default limit is 5 + const defaultLimit = addressBookSelectors.suggestContacts(state, '') + expect(defaultLimit).toHaveLength(5) + + // Custom limit + const customLimit = addressBookSelectors.suggestContacts(state, '', undefined, 3) + expect(customLimit).toHaveLength(3) + }) + it('getContactsByChain filters by chain type', () => { addressBookActions.addContact({ name: 'Alice', diff --git a/src/stores/address-book.ts b/src/stores/address-book.ts index 3f2da5b5a..8ce56ab61 100644 --- a/src/stores/address-book.ts +++ b/src/stores/address-book.ts @@ -346,27 +346,55 @@ export const addressBookSelectors = { ) }, - /** 获取联系人建议(用于地址输入) */ + /** + * 获取联系人建议(用于地址输入) + * @param state - 地址簿状态 + * @param partialAddress - 部分地址(可为空,空时返回所有联系人) + * @param chainType - 链类型过滤(可选) + * @param limit - 返回数量限制(默认 5) + */ suggestContacts: ( state: AddressBookState, partialAddress: string, - chainType?: ChainType + chainType?: ChainType, + limit: number = 5 ): ContactSuggestion[] => { - if (!partialAddress || partialAddress.length < 2) return [] - - const lowerPartial = partialAddress.toLowerCase() const suggestions: ContactSuggestion[] = [] + const hasQuery = partialAddress && partialAddress.length > 0 + const lowerPartial = hasQuery ? partialAddress.toLowerCase() : '' - for (const contact of state.contacts) { - // 优先匹配指定链类型的地址 + // 按最近更新时间排序联系人 + const sortedContacts = [...state.contacts].sort( + (a, b) => b.updatedAt - a.updatedAt + ) + + for (const contact of sortedContacts) { + // 过滤指定链类型的地址 const relevantAddresses = chainType ? contact.addresses.filter((a) => a.chainType === chainType) : contact.addresses + if (relevantAddresses.length === 0) continue + + if (!hasQuery) { + // 无查询:返回默认地址 + const defaultAddr = + relevantAddresses.find((a) => a.isDefault) ?? relevantAddresses[0] + if (defaultAddr) { + suggestions.push({ + contact, + matchedAddress: defaultAddr, + matchType: 'name', + score: 40, // 基础分数,靠 updatedAt 排序 + }) + } + continue + } + + // 有查询:匹配地址和名称 for (const addr of relevantAddresses) { const lowerAddr = addr.address.toLowerCase() - // 精确匹配 if (lowerAddr === lowerPartial) { suggestions.push({ contact, @@ -374,18 +402,14 @@ export const addressBookSelectors = { matchType: 'exact', score: 100, }) - } - // 前缀匹配 - else if (lowerAddr.startsWith(lowerPartial)) { + } else if (lowerAddr.startsWith(lowerPartial)) { suggestions.push({ contact, matchedAddress: addr, matchType: 'prefix', score: 80, }) - } - // 包含匹配 - else if (lowerAddr.includes(lowerPartial)) { + } else if (lowerAddr.includes(lowerPartial)) { suggestions.push({ contact, matchedAddress: addr, @@ -410,7 +434,7 @@ export const addressBookSelectors = { } } - // 去重(同一联系人只保留最高分的) + // 去重(同一联系人+地址只保留最高分的) const seen = new Map() for (const s of suggestions) { const key = `${s.contact.id}:${s.matchedAddress.id}` @@ -420,10 +444,13 @@ export const addressBookSelectors = { } } - // 排序返回 + // 排序:先按分数,再按最近更新时间 return Array.from(seen.values()) - .sort((a, b) => b.score - a.score) - .slice(0, 5) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score + return b.contact.updatedAt - a.contact.updatedAt + }) + .slice(0, limit) }, /** 按链类型过滤联系人 */