Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 60 additions & 18 deletions docs/white-book/04-服务篇/09-联系人服务/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) │
└──────────────────────────────────────────────┘
```

---

Expand Down
51 changes: 49 additions & 2 deletions src/components/transfer/address-input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const TransferForm: Story = {
};

/**
* 带联系人建议 - 输入联系人名称或地址前缀会显示建议
* 带联系人建议 - 聚焦即展开,显示所有联系人
*/
export const WithContactSuggestions: Story = {
decorators: [
Expand Down Expand Up @@ -118,16 +118,63 @@ export const WithContactSuggestions: Story = {

return (
<div className="space-y-6 p-4">
<p className="text-muted-foreground text-sm">试着输入 "Al"、"Bob" 或 "0x12" 来查看建议</p>
<p className="text-muted-foreground text-sm">
聚焦输入框即可看到所有联系人建议,输入可过滤
</p>
<AddressInput
label="收款地址"
value={address}
onChange={setAddress}
onScan={() => alert('Open QR scanner')}
onContactPicker={() => alert('Open contact picker')}
showSuggestions
/>
<p className="text-muted-foreground text-sm">当前值: {address || '(空)'}</p>
</div>
);
},
};

/**
* 按链类型过滤 - 只显示指定链的地址
*/
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 <Story />;
},
],
render: () => {
const [address, setAddress] = useState('');

return (
<div className="space-y-6 p-4">
<p className="text-muted-foreground text-sm">
设置 chainType="bfmeta",只显示 BFMeta 链的地址
</p>
<AddressInput
label="BFMeta 地址"
value={address}
onChange={setAddress}
chainType="bfmeta"
onContactPicker={() => alert('Open contact picker for BFMeta')}
showSuggestions
/>
</div>
);
},
};
94 changes: 60 additions & 34 deletions src/components/transfer/address-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -11,12 +11,16 @@ interface AddressInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
onChange?: ((value: string) => 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 {
Expand All @@ -31,7 +35,7 @@ function isValidAddress(address: string): boolean {
}

const AddressInput = forwardRef<HTMLInputElement, AddressInputProps>(
({ 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);
Expand All @@ -48,21 +52,22 @@ const AddressInput = forwardRef<HTMLInputElement, AddressInputProps>(
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(() => {
Expand Down Expand Up @@ -182,35 +187,56 @@ const AddressInput = forwardRef<HTMLInputElement, AddressInputProps>(
</div>

{/* Contact suggestions dropdown */}
{showDropdown && suggestions.length > 0 && (
<ul
{showDropdown && (
<div
id={listboxId}
role="listbox"
className="bg-popover border-border absolute z-50 mt-1 w-full overflow-hidden rounded-xl border shadow-lg"
>
{suggestions.map((suggestion, index) => (
<li
key={`${suggestion.contact.id}-${suggestion.matchedAddress.id}`}
id={`suggestion-${index}`}
role="option"
aria-selected={index === selectedIndex}
className={cn(
'flex cursor-pointer items-center gap-3 px-3 py-2 transition-colors',
index === selectedIndex ? 'bg-accent' : 'hover:bg-muted/50',
)}
onClick={() => handleSelectSuggestion(suggestion)}
{suggestions.length > 0 ? (
<ul role="listbox">
{suggestions.map((suggestion, index) => (
<li
key={`${suggestion.contact.id}-${suggestion.matchedAddress.id}`}
id={`suggestion-${index}`}
role="option"
aria-selected={index === selectedIndex}
className={cn(
'flex cursor-pointer items-center gap-3 px-3 py-2 transition-colors',
index === selectedIndex ? 'bg-accent' : 'hover:bg-muted/50',
)}
onClick={() => handleSelectSuggestion(suggestion)}
>
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-full">
<IconUser className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{suggestion.contact.name}</p>
<p className="text-muted-foreground truncate font-mono text-xs">{suggestion.matchedAddress.address}</p>
</div>
<span className="text-muted-foreground shrink-0 text-xs uppercase">{suggestion.matchedAddress.chainType}</span>
</li>
))}
</ul>
) : (
<div className="text-muted-foreground px-3 py-4 text-center text-sm">
{t('contact.noContacts')}
</div>
)}
{/* View all contacts button */}
{onContactPicker && (
<button
type="button"
onClick={() => {
setShowDropdown(false);
onContactPicker();
}}
className="text-primary hover:bg-muted/50 border-border flex w-full items-center justify-center gap-2 border-t px-3 py-2.5 text-sm font-medium transition-colors"
>
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-full">
<IconUser className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{suggestion.contact.name}</p>
<p className="text-muted-foreground truncate font-mono text-xs">{suggestion.matchedAddress.address}</p>
</div>
<span className="text-muted-foreground shrink-0 text-xs uppercase">{suggestion.matchedAddress.chainType}</span>
</li>
))}
</ul>
<IconUsers className="size-4" />
{t('contact.viewAll')}
</button>
)}
</div>
)}
</div>

Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/ar/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@
"memoPlaceholder": "أضف ملاحظة",
"add": "إضافة",
"save": "حفظ",
"cancel": "إلغاء"
"cancel": "إلغاء",
"noContacts": "لا توجد جهات اتصال",
"viewAll": "عرض جميع جهات الاتصال",
"selectContact": "اختر جهة اتصال",
"selectAddress": "اختر العنوان"
},
"acceptExchange": "Accept exchange",
"account": "[MISSING:ar] 用户",
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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] 用户",
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@
"memoPlaceholder": "添加备注信息",
"add": "添加",
"save": "保存",
"cancel": "取消"
"cancel": "取消",
"noContacts": "暂无联系人",
"viewAll": "查看全部联系人",
"selectContact": "选择联系人",
"selectAddress": "选择地址"
},
"acceptExchange": "接收交换",
"account": "用户",
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/zh-TW/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@
"memoPlaceholder": "新增備註",
"add": "新增",
"save": "儲存",
"cancel": "取消"
"cancel": "取消",
"noContacts": "暫無聯絡人",
"viewAll": "查看全部聯絡人",
"selectContact": "選擇聯絡人",
"selectAddress": "選擇地址"
},
"acceptExchange": "接受交換",
"account": "賬戶",
Expand Down
Loading
Loading