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)
},
/** 按链类型过滤联系人 */