diff --git a/.factory/mcp.json b/.factory/mcp.json
new file mode 100644
index 00000000..e69de29b
diff --git a/CHAT.md b/CHAT.md
index 9b132e7c..6335178c 100644
--- a/CHAT.md
+++ b/CHAT.md
@@ -654,3 +654,89 @@ queryBlock:`https://tracker.bfmeta.org/#/info/block-details/:height`
---
使用 yargs 统一重构 `pnpm agent`和子命令
+
+---
+
+我需要你强化提示词:
+
+1. 建议把提示词改成英文.
+2. 目前的流程里面没有关于 openspec 的命令, 我要的效果是:
+ 1. worktree 提供工作目录
+ 2. white-book 提供项目记忆(长期记忆)
+ 3. openspec 提供短期记忆(工作计划)
+ 4. github-project 提供记忆的综合管理, 并行能力(多任务协同进行、项目管理)
+3. 但是目前既然没有openspec ,而且 github-project 也提供了一定的spec管理能力, 我的想法是,把openspec的规范和github-project做深度的融合.
+ 1. 首先你要知道 openspec 提供了三种核心工具: spec(新增一个提案)、apply(开始提案工作)、archive(提案归档)
+ 2. 我要你把 openspec 的工作逻辑理解清楚,请你参考 https://github.com/Fission-AI/OpenSpec/ 源代码
+ 3. 我们最终的效果,应该是用 github-issues + github-project-task 来承载 OpenSpec 的文件(比如spec文件、tasks文件、design文件等等),
+ 请你深入了解后,给出一份计划
+
+---
+
+我要的效果是这样的工作流:
+
+1. 用户(我或者另一个AI、或者是社区的某个普通人或者贡献者)先提出一个问题或者需求
+2. AI 和我讨论之后,一遍调查代码和白皮书,一边按需询问用户,最终明确了要需求
+3. 然后AI做好计划+撰写白皮书,和用户确认后开始工作(并不确认就回到步骤2)
+ - 用户主要看的是白皮书的变更内容, 因为工作计划涉及到一些细节的东西, 用户可能对此并不熟悉.
+ - 当然像我这类核心贡献者还是会看具体的工作计划.
+4. AI开始编程, 这个期间可能遇到一些意料之外的问题, 需要回去找用户进一步发起讨论, 根据结果进一步对计划做出补充和调整
+5. AI完成任务, 提交PR, 如果检查通过等待用户进行验收审核. 如果检查不通过就继续
+6. 用户验收通过, AI讲PR合并到main分支. 结束这轮工作
+
+我的疑问是, github-project/issues 如何渗入这个流程中, 为这个流程提供“状态”存储, 从而实现一个无状态AI可以在任意一个步骤去恢复, 并延续工作
+
+---
+
+开始一个新任务: 实现完整的二维码扫描功能.
+要求:
+
+1. 高性能高可用的实现
+ - 充分利用多线程, 或者WebGPU 等新特性
+2. mock需要支持将单张图片,或者视频,或者多张图片作为输入流来处理
+ - 本质是将内容绘制到canvas中, 封装一个 Native级别的组件,来让扫描能获取到帧信息进行处理
+3. 需要可靠性测试: 生成二维码, 然后对二维码做一些处理, 配合mock来验证可用性
+
+---
+
+优化名片 的样式,然后使用 snapdom 来实现DOM的截图下载
+https://snapdom.dev/
+注意按需导入, 导入和生成截图需要时间,这个时间内下载按钮处于loading状态
+
+---
+
+我们需要一种开源的头像生成器, 来完善我们的联系人头像功能
+
+---
+
+新任务: 阅读白皮书中关于“暗色模式”的内容, 查阅源码,调查文档, 得出一份更加健全的“暗色模式”最佳实践规范, 然后践行它:
+
+1. 使用oxlint插件来开发一些检查工具
+2. 完善白皮书
+3. 修复所有页面的暗色模式最佳实践
+4. 将规范带到 CI 检查,更新相关的工作流提示词
+
+---
+
+合并tabs中的“首页”、“钱包”、“转账”三个页面.
+
+1. “转账”页面主要展示的是一个交易列表页面
+2. “钱包”页面主要展示的是钱包列表页面
+
+把这两个页面合并到“首页”中,并统一称为“钱包”:
+
+做两层Tab:
+
+1. 钱包卡片 是一个tab,能滑动切换不同的钱包
+ 1. 这里钱包的样式要能模拟“卡片”的风格,并且有炫酷的效果, 有立体精致的质感, 最好能跟随重力传感器运动和运动, 运动还能有不同角度的反光
+ 2. 能展开钱包列表, 整理钱包的顺序
+ 3. 每个钱包还能自定义主题色, 直接影响我们的primary颜色
+ - 这需要对globals.css做重构优化, 将和primary颜色有关的颜色(比如chart、sidebar-primary 等改成 color-mix)
+ 4. 这里还能有设置入口,能对单个钱包进行设置:也就是我们的`/wallet/*`页面
+2. 下方是当前钱包的信息, 两个tab `[资产] [交易]`
+
+---
+
+1. 卡片参考这个[DEMO代码](https://codepen.io/jh3y/pen/EaVNNxa) 我要这里的“防伪效果”. 就是使用每个链的 logo 转化成无色后去做图形平铺, 显示炫光来实现防伪的效果
+2. 重力感应要轻微的, touch也可以影响卡片, 二者可以叠加
+3. 建议引入 swiper, 达到最好的效果
diff --git a/clear.html b/clear.html
new file mode 100644
index 00000000..b44a28ed
--- /dev/null
+++ b/clear.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ 清理数据 - BFM Pay
+
+
+
+
+
+
diff --git "a/docs/white-book/02-\350\256\276\350\256\241\347\257\207/02-\350\247\206\350\247\211\350\256\276\350\256\241/theme-colors.md" "b/docs/white-book/02-\350\256\276\350\256\241\347\257\207/02-\350\247\206\350\247\211\350\256\276\350\256\241/theme-colors.md"
index 5ba799ee..f9239d3a 100644
--- "a/docs/white-book/02-\350\256\276\350\256\241\347\257\207/02-\350\247\206\350\247\211\350\256\276\350\256\241/theme-colors.md"
+++ "b/docs/white-book/02-\350\256\276\350\256\241\347\257\207/02-\350\247\206\350\247\211\350\256\276\350\256\241/theme-colors.md"
@@ -234,7 +234,112 @@ shadcn/ui 使用**配色对**设计,每个颜色变量都有对应的 `xxx-for
---
+## 钱包个性化主题色
+
+### 概述
+
+每个钱包拥有独立的主题色(色相值 0-360),用于:
+- 钱包卡片渐变背景
+- 确认/保存按钮动态颜色
+- 视觉区分多个钱包
+
+### CSS 自定义属性
+
+```css
+@property --primary-hue {
+ syntax: "";
+ inherits: true;
+ initial-value: 323;
+}
+
+@property --primary-lightness {
+ syntax: "";
+ inherits: true;
+ initial-value: 0.59;
+}
+
+@property --primary-saturation {
+ syntax: "";
+ inherits: true;
+ initial-value: 0.26;
+}
+```
+
+### 动态主题色应用
+
+```tsx
+// 按钮跟随钱包主题色
+
+```
+
+### 色相派生算法
+
+从钱包地址稳定派生初始色相,确保跨设备一致:
+
+```typescript
+function deriveThemeHue(address: string): number {
+ let hash = 0;
+ for (let i = 0; i < address.length; i++) {
+ hash = (hash << 5) - hash + address.charCodeAt(i);
+ hash = hash & hash;
+ }
+ return ((hash % 360) + 360) % 360;
+}
+```
+
+### 预设颜色
+
+提供 12 个预设色相,覆盖色轮主要区域:
+
+```typescript
+const WALLET_THEME_COLORS = [
+ { hue: 0, name: '红色' },
+ { hue: 30, name: '橙色' },
+ { hue: 60, name: '黄色' },
+ { hue: 90, name: '黄绿' },
+ { hue: 120, name: '绿色' },
+ { hue: 150, name: '青绿' },
+ { hue: 180, name: '青色' },
+ { hue: 210, name: '天蓝' },
+ { hue: 240, name: '蓝色' },
+ { hue: 270, name: '紫色' },
+ { hue: 300, name: '品红' },
+ { hue: 330, name: '玫红' },
+];
+```
+
+### 趋避算法
+
+为避免多钱包颜色相近,选择器会:
+1. 显示已用色相标记
+2. 降低相近颜色(距离 < 30°)的视觉权重
+
+```typescript
+function calculateAvoidanceWeight(hue: number, existingHues: number[]): number {
+ const minDistance = 30;
+ let minDist = 180;
+
+ for (const existing of existingHues) {
+ const dist = Math.min(Math.abs(hue - existing), 360 - Math.abs(hue - existing));
+ if (dist < minDist) minDist = dist;
+ }
+
+ return minDist < minDistance ? minDist / minDistance : 1;
+}
+```
+
+---
+
## 相关链接
- [shadcn/ui 主题文档](https://ui.shadcn.com/docs/theming)
- [Tailwind CSS 颜色](https://tailwindcss.com/docs/customizing-colors)
+- [WalletCard 组件](../../05-组件篇/03-钱包组件/WalletCard.md)
+- [WalletConfig 组件](../../05-组件篇/03-钱包组件/WalletConfig.md)
diff --git "a/docs/white-book/05-\347\273\204\344\273\266\347\257\207/03-\351\222\261\345\214\205\347\273\204\344\273\266/WalletCard.md" "b/docs/white-book/05-\347\273\204\344\273\266\347\257\207/03-\351\222\261\345\214\205\347\273\204\344\273\266/WalletCard.md"
index cefbbbbc..d8053a60 100644
--- "a/docs/white-book/05-\347\273\204\344\273\266\347\257\207/03-\351\222\261\345\214\205\347\273\204\344\273\266/WalletCard.md"
+++ "b/docs/white-book/05-\347\273\204\344\273\266\347\257\207/03-\351\222\261\345\214\205\347\273\204\344\273\266/WalletCard.md"
@@ -1,12 +1,16 @@
# WalletCard 钱包卡片
-> 展示钱包概览和快捷操作
+> 3D 拟物化钱包卡片,支持触控倾斜、动态光影和个性化主题色
---
## 功能描述
-展示钱包的基本信息、资产总览和常用操作入口。
+展示钱包的基本信息,采用 3D 变换实现真实卡片质感:
+- 触控/悬停时的倾斜响应
+- 动态光泽和阴影效果
+- 个性化主题色渐变背景
+- 链图标防伪水印
---
@@ -15,43 +19,40 @@
| 属性 | 类型 | 必需 | 默认值 | 说明 |
|-----|------|-----|-------|------|
| wallet | Wallet | Y | - | 钱包数据 |
-| balance | Balance | N | - | 余额数据 |
-| fiatValue | FiatValue | N | - | 法币估值 |
-| onTransfer | () => void | N | - | 转账回调 |
-| onReceive | () => void | N | - | 收款回调 |
-| onMore | () => void | N | - | 更多操作回调 |
-| loading | boolean | N | false | 加载状态 |
+| chain | ChainType | Y | - | 当前选中的链 |
+| chainName | string | Y | - | 链名称显示 |
+| address | string | N | - | 当前链地址 |
+| chainIconUrl | string | N | - | 链图标 URL(防伪水印) |
+| watermarkLogoSize | number | N | 40 | 水印平铺尺寸(含间距) |
+| watermarkLogoActualSize | number | N | 24 | 水印实际尺寸 |
+| themeHue | number | N | 323 | 主题色相(0-360) |
+| onCopyAddress | () => void | N | - | 复制地址回调 |
+| onOpenChainSelector | () => void | N | - | 打开链选择器 |
+| onOpenSettings | () => void | N | - | 打开设置回调 |
+| className | string | N | - | 自定义样式类 |
### Wallet 数据结构
-```
-Wallet {
+```typescript
+interface Wallet {
id: string
name: string
address: string
- chainId: string
- avatar?: string
- type: 'mnemonic' | 'privateKey' | 'watch'
-}
-```
-
-### Balance 数据结构
-
-```
-Balance {
- amount: string
- symbol: string
- decimals: number
+ chain: ChainType
+ chainAddresses: ChainAddress[]
+ createdAt: number
+ themeHue: number // 个性化主题色相
+ tokens: Token[]
}
```
-### FiatValue 数据结构
+### ChainAddress 数据结构
-```
-FiatValue {
- value: number
- currency: string // USD, CNY, etc.
- change24h?: number // 24h 涨跌幅
+```typescript
+interface ChainAddress {
+ chain: ChainType
+ address: string
+ tokens: Token[]
}
```
@@ -61,48 +62,131 @@ FiatValue {
```
┌─────────────────────────────────────────┐
-│ ┌────┐ │
-│ │头像│ 钱包名称 [更多 ⋮] │
-│ └────┘ 0x1234...5678 [复制] │
-├─────────────────────────────────────────┤
-│ │
-│ ¥ 12,345.67 │ ← 法币估值
-│ +2.5% 今日 │ ← 涨跌幅(可选)
+│ [链选择 ▼] [设置 ⚙] │ ← 顶部工具栏
│ │
-│ 1.2345 BFM │ ← 原生代币余额
+│ 钱包名称 │ ← 居中标题
│ │
-├─────────────────────────────────────────┤
-│ │
-│ [转账] [铸造] [收款] │ ← 快捷操作
+│ 0x1234...5678 [复制] │ ← 底部地址栏
│ │
+│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ← 链图标水印层
└─────────────────────────────────────────┘
+ ↑ ↑
+ └── 渐变背景 (themeHue) ───────┘
```
---
-## 状态规范
+## 3D 效果规范
+
+### 透视容器
-### 加载态
+```css
+.wallet-card-container {
+ perspective: 1000px;
+}
+```
+
+### 倾斜变换
+使用 CSS 自定义属性实现平滑动画:
+
+```css
+.wallet-card {
+ --tilt-x: 0; /* 垂直倾斜角度 */
+ --tilt-y: 0; /* 水平倾斜角度 */
+ --tilt-intensity: 0; /* 交互强度 0-1 */
+
+ transform: rotateX(calc(var(--tilt-x) * 1deg))
+ rotateY(calc(var(--tilt-y) * 1deg));
+ transform-style: preserve-3d;
+ transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
```
-┌─────────────────────────────────────────┐
-│ ████ ████████████ │
-│ ████████████ │
-├─────────────────────────────────────────┤
-│ ████████████ │
-│ ████████ │
-├─────────────────────────────────────────┤
-│ ████ ████ ████ │
-└─────────────────────────────────────────┘
+
+### 光泽层
+
+```typescript
+// 高光跟随触点移动
+const glareStyle = {
+ background: `radial-gradient(
+ circle at ${pointerX}% ${pointerY}%,
+ rgba(255,255,255,0.4) 0%,
+ rgba(255,255,255,0.1) 30%,
+ transparent 60%
+ )`,
+ opacity: tiltIntensity * 0.8,
+};
+```
+
+### 阴影效果
+
+```css
+.wallet-card {
+ box-shadow:
+ inset 0 1px 1px rgba(255,255,255,0.3), /* 顶部高光 */
+ inset 0 -1px 1px rgba(0,0,0,0.2), /* 底部阴影 */
+ 0 20px 40px -15px rgba(0,0,0,0.4); /* 投影 */
+}
+```
+
+---
+
+## 主题色系统
+
+### 色相派生
+
+钱包主题色从地址稳定派生,确保跨设备一致:
+
+```typescript
+function deriveThemeHue(address: string): number {
+ let hash = 0;
+ for (let i = 0; i < address.length; i++) {
+ hash = (hash << 5) - hash + address.charCodeAt(i);
+ hash = hash & hash;
+ }
+ return ((hash % 360) + 360) % 360;
+}
+```
+
+### 渐变背景
+
+使用 OKLCH 色彩空间生成和谐渐变:
+
+```typescript
+const gradient = `linear-gradient(
+ 135deg,
+ oklch(0.5 0.2 ${themeHue}) 0%,
+ oklch(0.4 0.22 ${themeHue + 20}) 50%,
+ oklch(0.3 0.18 ${themeHue + 40}) 100%
+)`;
```
-### 无余额态
+---
+
+## 防伪水印
+
+### 实现原理
-正常显示,余额为 "0 BFM"
+1. 加载链图标并转换为单色遮罩
+2. 使用 `mask-image` 平铺显示
+3. 透明度低于主内容,作为背景装饰
-### 错误态
+```typescript
+const watermarkStyle = {
+ maskImage: monoMaskUrl,
+ maskSize: `${logoSize}px ${logoSize}px`,
+ maskRepeat: 'repeat',
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
+};
+```
-显示重试按钮替代余额
+### Hook: useChainIconUrls
+
+```typescript
+// 获取所有链的图标 URL 映射
+const chainIconUrls = useChainIconUrls();
+// { ethereum: '/icons/eth.svg', tron: '/icons/tron.svg', ... }
+```
---
@@ -111,20 +195,21 @@ FiatValue {
### 必须 (MUST)
1. 显示钱包名称
-2. 显示缩略地址
+2. 显示缩略地址(使用 AddressDisplay 组件)
3. 地址可复制
+4. 应用 themeHue 主题色
### 建议 (SHOULD)
-1. 显示原生代币余额
-2. 提供快捷操作按钮
-3. 支持下拉刷新余额
+1. 支持触控倾斜交互
+2. 显示动态光泽效果
+3. 显示链图标水印
+4. 提供链切换入口
### 可选 (MAY)
-1. 显示法币估值
-2. 显示 24h 涨跌幅
-3. 显示钱包头像
+1. 自定义水印尺寸
+2. 禁用 3D 效果(性能优化)
---
@@ -134,21 +219,26 @@ FiatValue {
| 部分 | 规格 |
|-----|------|
-| 卡片内边距 | 16px |
-| 头像尺寸 | 40px |
-| 法币金额字号 | 28px |
-| 代币余额字号 | 16px |
-| 操作按钮间距 | 24px |
+| 卡片比例 | 1.6:1 (宽:高) |
+| 卡片圆角 | 16px (rounded-2xl) |
+| 卡片内边距 | 20px |
+| 钱包名字号 | 24px / font-bold |
+| 地址字号 | 14px / font-mono |
+| 水印尺寸 | 24px (实际) / 40px (含间距) |
-### 颜色规格
+### 倾斜参数
-| 状态 | 涨跌颜色 |
-|-----|---------|
-| 上涨 | --color-success |
-| 下跌 | --color-destructive |
-| 持平 | --color-muted-foreground |
+| 参数 | 值 |
+|-----|------|
+| 最大倾斜角度 | ±15° |
+| 透视距离 | 1000px |
+| 过渡曲线 | cubic-bezier(0.34, 1.56, 0.64, 1) |
+| 过渡时长 | 400ms |
+
+---
-### 背景
+## 相关组件
-- **MAY** 使用渐变背景突出品牌感
-- 建议使用 primary 色系的浅色渐变
+- [WalletConfig](./WalletConfig.md) - 钱包配置(编辑名称/主题色)
+- [AddressDisplay](../02-通用组件/AddressDisplay.md) - 地址显示与复制
+- [ChainIcon](./ChainIcon.md) - 链图标组件
diff --git "a/docs/white-book/05-\347\273\204\344\273\266\347\257\207/03-\351\222\261\345\214\205\347\273\204\344\273\266/WalletConfig.md" "b/docs/white-book/05-\347\273\204\344\273\266\347\257\207/03-\351\222\261\345\214\205\347\273\204\344\273\266/WalletConfig.md"
new file mode 100644
index 00000000..89da2805
--- /dev/null
+++ "b/docs/white-book/05-\347\273\204\344\273\266\347\257\207/03-\351\222\261\345\214\205\347\273\204\344\273\266/WalletConfig.md"
@@ -0,0 +1,270 @@
+# WalletConfig 钱包配置
+
+> 统一的钱包名称和主题色配置组件,支持三种使用模式
+
+---
+
+## 功能描述
+
+提供钱包个性化配置界面,包括:
+- 钱包名称编辑
+- 主题色选择(色相滑块 + 预设颜色)
+- 实时卡片预览
+- 钱包管理操作(导出助记词、删除钱包)
+
+---
+
+## 使用模式
+
+### 模式一览
+
+| 模式 | 场景 | 操作按钮 | 完成行为 |
+|------|------|----------|----------|
+| `edit-only` | 创建/恢复最后一步 | 确认 | 调用 `onEditOnlyComplete` |
+| `default` | 钱包详情页 | 编辑/助记词/删除 | 切换到 `edit` |
+| `edit` | 从 default 切换 | 保存/取消 | 切回 `default` |
+
+### 模式流转
+
+```
+创建钱包流程:
+ 密码 → 助记词 → 链选择 → [edit-only] → 完成
+ ↓
+ onEditOnlyComplete()
+
+钱包详情页:
+ [default] ⟷ [edit]
+ ↓ ↓
+ 点击编辑 保存/取消
+```
+
+---
+
+## 属性规范
+
+| 属性 | 类型 | 必需 | 默认值 | 说明 |
+|-----|------|-----|-------|------|
+| mode | `'edit-only' \| 'default' \| 'edit'` | Y | - | 使用模式 |
+| walletId | string | Y | - | 钱包 ID |
+| onEditOnlyComplete | () => void | N | - | edit-only 模式完成回调 |
+| className | string | N | - | 自定义样式类 |
+
+---
+
+## 布局规范
+
+### edit-only / edit 模式
+
+```
+┌─────────────────────────────────────────┐
+│ │
+│ ┌─────────────────────────────┐ │
+│ │ │ │
+│ │ WalletCard 预览 │ │ ← 实时预览
+│ │ │ │
+│ └─────────────────────────────┘ │
+│ │
+├─────────────────────────────────────────┤
+│ 钱包名称 │
+│ ┌─────────────────────────────────┐ │
+│ │ 输入框 │ │
+│ └─────────────────────────────────┘ │
+├─────────────────────────────────────────┤
+│ 自定义颜色 270.5° │
+│ ┌─────────────────────────────────┐ │
+│ │ ░░░░░░░●░░░░░░░░░░░░░░░░░░░░░ │ │ ← 彩虹色条
+│ └─────────────────────────────────┘ │
+│ │
+│ ○ ○ ○ ● ○ ○ ○ ○ ○ ○ ○ ○ │ ← 预设颜色
+│ │
+├─────────────────────────────────────────┤
+│ │
+│ ┌────────────┐ ┌────────────────┐ │
+│ │ 取消 │ │ 保存 │ │ ← edit 模式
+│ └────────────┘ └────────────────┘ │
+│ │
+│ ┌─────────────────────────────────┐ │
+│ │ 确认 │ │ ← edit-only 模式
+│ └─────────────────────────────────┘ │
+└─────────────────────────────────────────┘
+```
+
+### default 模式
+
+```
+┌─────────────────────────────────────────┐
+│ │
+│ ┌─────────────────────────────┐ │
+│ │ WalletCard 预览 │ │
+│ └─────────────────────────────┘ │
+│ │
+├─────────────────────────────────────────┤
+│ ┌─────────────────────────────────┐ │
+│ │ ✏️ 编辑名称和主题 │ │
+│ └─────────────────────────────────┘ │
+│ ┌─────────────────────────────────┐ │
+│ │ 🔑 查看助记词 │ │
+│ └─────────────────────────────────┘ │
+│ ┌─────────────────────────────────┐ │
+│ │ 🗑️ 删除钱包 │ │ ← 危险操作
+│ └─────────────────────────────────┘ │
+└─────────────────────────────────────────┘
+```
+
+---
+
+## 主题色选择
+
+### 色相滑块
+
+```typescript
+// 彩虹渐变背景
+background: linear-gradient(
+ to right,
+ oklch(0.6 0.25 0), // 红
+ oklch(0.6 0.25 60), // 黄
+ oklch(0.6 0.25 120), // 绿
+ oklch(0.6 0.25 180), // 青
+ oklch(0.6 0.25 240), // 蓝
+ oklch(0.6 0.25 300), // 紫
+ oklch(0.6 0.25 360) // 红
+);
+```
+
+### 已用色相标记
+
+显示其他钱包已使用的色相位置,帮助用户选择区分度高的颜色:
+
+```typescript
+// 在滑块上显示已用色相
+{existingHues.map((hue) => (
+
+))}
+```
+
+### 预设颜色权重
+
+为避免颜色冲突,预设颜色根据与已用色相的距离显示不同透明度:
+
+```typescript
+function calculateAvoidanceWeight(hue: number, existingHues: number[]): number {
+ const minDistance = 30; // 最小安全距离
+ let minDist = 180;
+
+ for (const existing of existingHues) {
+ const dist = hueDistance(hue, existing);
+ if (dist < minDist) minDist = dist;
+ }
+
+ return minDist < minDistance ? minDist / minDistance : 1;
+}
+```
+
+---
+
+## 按钮主题色
+
+确认/保存按钮跟随当前选择的主题色,提供视觉反馈:
+
+```tsx
+
+```
+
+---
+
+## 行为规范
+
+### 必须 (MUST)
+
+1. 实时预览名称和颜色变化
+2. 名称不能为空
+3. 保存前验证名称有效性
+4. edit-only 模式必须调用 `onEditOnlyComplete`
+
+### 建议 (SHOULD)
+
+1. 显示已用色相标记
+2. 降低相近颜色的视觉权重
+3. 按钮颜色跟随主题色
+
+### 可选 (MAY)
+
+1. 名称长度限制(默认 20 字符)
+2. 名称格式验证
+
+---
+
+## 使用示例
+
+### 创建钱包最后一步
+
+```tsx
+// pages/wallet/create.tsx
+function ThemeStep({ walletId, onComplete }: Props) {
+ return (
+
+ );
+}
+```
+
+### 钱包详情页
+
+```tsx
+// activities/WalletConfigActivity.tsx
+function WalletConfigActivity() {
+ const { walletId } = useActivityParams();
+
+ return (
+
+
+
+ );
+}
+```
+
+---
+
+## 相关组件
+
+- [WalletCard](./WalletCard.md) - 钱包卡片预览
+- [ProgressSteps](../01-基础组件/ProgressSteps.md) - 进度指示器
+
+---
+
+## 相关 Hook
+
+### useWalletTheme
+
+```typescript
+import { WALLET_THEME_COLORS } from '@/hooks/useWalletTheme';
+
+// 预设颜色列表
+WALLET_THEME_COLORS = [
+ { hue: 0, color: 'oklch(...)', name: '红色' },
+ { hue: 30, color: 'oklch(...)', name: '橙色' },
+ // ...
+];
+```
+
+### deriveThemeHue
+
+```typescript
+import { deriveThemeHue } from '@/hooks/useWalletTheme';
+
+// 从地址派生初始主题色
+const initialHue = deriveThemeHue(wallet.chainAddresses[0].address);
+```
diff --git a/e2e/wallet-create.spec.ts b/e2e/wallet-create.spec.ts
index 903c7ef2..78527d27 100644
--- a/e2e/wallet-create.spec.ts
+++ b/e2e/wallet-create.spec.ts
@@ -107,6 +107,11 @@ test.describe('钱包创建流程 - 截图测试', () => {
await page.click('[data-testid="verify-next-button"]')
await page.waitForSelector('[data-testid="chain-selector-step"]')
await expect(page).toHaveScreenshot('07-chain-selector-step.png')
+
+ // 8. 进入主题设置步骤
+ await page.click('[data-testid="chain-selector-complete-button"]')
+ await page.waitForSelector('[data-testid="theme-step"]')
+ await expect(page).toHaveScreenshot('08-theme-step.png')
})
test('图案确认 - 错误状态', async ({ page }) => {
@@ -171,13 +176,19 @@ test.describe('钱包创建流程 - 功能测试', () => {
await expect(verifyNextBtn).toBeEnabled()
await verifyNextBtn.click()
- // 5. 选择链并完成创建
+ // 5. 选择链并进入主题设置
await page.waitForSelector('[data-testid="chain-selector-step"]')
- const completeBtn = page.locator('[data-testid="chain-selector-complete-button"]')
- await expect(completeBtn).toBeEnabled()
- await completeBtn.click()
+ const chainNextBtn = page.locator('[data-testid="chain-selector-complete-button"]')
+ await expect(chainNextBtn).toBeEnabled()
+ await chainNextBtn.click()
+
+ // 6. 主题设置步骤 - 直接完成(使用默认主题)
+ await page.waitForSelector('[data-testid="theme-step"]')
+ const themeCompleteBtn = page.locator('[data-testid="theme-complete-button"]')
+ await expect(themeCompleteBtn).toBeEnabled()
+ await themeCompleteBtn.click()
- // 6. 验证跳转到首页且钱包已创建
+ // 7. 验证跳转到首页且钱包已创建
await page.waitForURL(/.*#\/$/)
// 等待钱包名称显示,确认首页加载完成
await expect(page.locator('[data-testid="wallet-name"]:visible').first()).toBeVisible({ timeout: 10000 })
@@ -247,6 +258,11 @@ test.describe('钱包创建流程 - 功能测试', () => {
await page.locator('[data-testid="chain-selector-chain-tron"]').click()
await page.click('[data-testid="chain-selector-complete-button"]')
+
+ // 主题设置步骤 - 直接完成(使用默认主题)
+ await page.waitForSelector('[data-testid="theme-step"]')
+ await page.click('[data-testid="theme-complete-button"]')
+
await page.waitForURL(/.*#\/$/)
const wallets = await getWalletDataFromIndexedDB(page)
diff --git a/e2e/wallet-home-3d.spec.ts b/e2e/wallet-home-3d.spec.ts
new file mode 100644
index 00000000..a4aa2f8a
--- /dev/null
+++ b/e2e/wallet-home-3d.spec.ts
@@ -0,0 +1,373 @@
+import { test, expect, type Page } from '@playwright/test'
+
+/**
+ * 钱包首页 3D 卡片 E2E 测试
+ *
+ * 测试三页合一后的首页功能:
+ * - 3D 钱包卡片展示和交互
+ * - 钱包轮播切换
+ * - 资产/交易 Tab 切换
+ * - 快捷操作按钮
+ * - 钱包列表展开
+ */
+
+test.describe('Wallet Home 3D', () => {
+ test.beforeEach(async ({ page }) => {
+ // 假设已有钱包,直接访问首页
+ await page.goto('/')
+ })
+
+ test.describe('Wallet Card Display', () => {
+ test('should display wallet card with 3D perspective', async ({ page }) => {
+ const card = page.locator('.wallet-card-container')
+ await expect(card).toBeVisible()
+
+ // 检查 3D 透视容器
+ const perspectiveContainer = page.locator('.perspective-\\[1000px\\]')
+ await expect(perspectiveContainer).toBeVisible()
+ })
+
+ test('should display wallet name on card', async ({ page }) => {
+ const walletName = page.locator('.wallet-card h2')
+ await expect(walletName).toBeVisible()
+ })
+
+ test('should display chain selector button', async ({ page }) => {
+ const chainButton = page.locator('.wallet-card button').filter({ hasText: /Ethereum|Tron|Bitcoin/i })
+ await expect(chainButton.first()).toBeVisible()
+ })
+
+ test('should display truncated address', async ({ page }) => {
+ const address = page.locator('.wallet-card .font-mono')
+ await expect(address).toBeVisible()
+ const text = await address.textContent()
+ expect(text).toMatch(/^0x[\da-f]{4,6}\.{3}[\da-f]{4}$/i)
+ })
+ })
+
+ test.describe('Wallet Card Interactions', () => {
+ test('should copy address on click', async ({ page }) => {
+ // 找到复制按钮(最后一个按钮在底部行)
+ const copyButton = page.locator('.wallet-card button').last()
+ await copyButton.click()
+
+ // 应该显示成功状态(绿色勾)
+ const checkIcon = page.locator('.wallet-card .text-green-300')
+ await expect(checkIcon).toBeVisible({ timeout: 1000 })
+ })
+
+ test('should open chain selector on chain button click', async ({ page }) => {
+ const chainButton = page.locator('.wallet-card button').filter({ hasText: /Ethereum|Tron|Bitcoin/i }).first()
+ await chainButton.click()
+
+ // 应该打开链选择器
+ // 具体验证取决于 ChainSelectorJob 的实现
+ await page.waitForTimeout(300)
+ })
+
+ test('should respond to mouse hover with 3D effect', async ({ page }) => {
+ const card = page.locator('.wallet-card')
+ const box = await card.boundingBox()
+ if (!box) throw new Error('Card not visible')
+
+ // 移动到卡片右上角
+ await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.2)
+
+ // 验证卡片处于激活状态
+ await expect(card).toHaveAttribute('data-active', 'true')
+ })
+ })
+
+ test.describe('Wallet Carousel', () => {
+ test('should display wallet count button when multiple wallets', async ({ page }) => {
+ const walletCountButton = page.locator('button').filter({ hasText: /\d+ 个钱包/ })
+ // 只有多个钱包时才显示
+ const isVisible = await walletCountButton.isVisible().catch(() => false)
+ if (isVisible) {
+ await expect(walletCountButton).toBeVisible()
+ }
+ })
+
+ test('should swipe to next wallet', async ({ page }) => {
+ const swiper = page.locator('.wallet-swiper')
+ if (!(await swiper.isVisible())) return
+
+ const box = await swiper.boundingBox()
+ if (!box) return
+
+ // 获取当前钱包名称
+ const walletName = page.locator('.wallet-card h2')
+ const initialName = await walletName.textContent()
+
+ // 模拟左滑
+ await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(box.x + box.width * 0.2, box.y + box.height / 2, { steps: 10 })
+ await page.mouse.up()
+
+ await page.waitForTimeout(500)
+
+ // 检查是否切换了钱包(如果有多个钱包)
+ const newName = await walletName.textContent()
+ // 如果只有一个钱包,名称相同;否则应该不同
+ })
+ })
+
+ test.describe('Quick Actions', () => {
+ test('should display send button', async ({ page }) => {
+ const sendButton = page.locator('button').filter({ hasText: /发送|转账|Send/i })
+ await expect(sendButton.first()).toBeVisible()
+ })
+
+ test('should display receive button', async ({ page }) => {
+ const receiveButton = page.locator('button').filter({ hasText: /收款|Receive/i })
+ await expect(receiveButton.first()).toBeVisible()
+ })
+
+ test('should display scan button', async ({ page }) => {
+ const scanButton = page.locator('button').filter({ hasText: /扫码|Scan/i })
+ await expect(scanButton.first()).toBeVisible()
+ })
+
+ test('should navigate to send page', async ({ page }) => {
+ const sendButton = page.locator('button').filter({ hasText: /发送|转账|Send/i }).first()
+ await sendButton.click()
+
+ await expect(page).toHaveURL(/\/send/)
+ })
+
+ test('should navigate to receive page', async ({ page }) => {
+ const receiveButton = page.locator('button').filter({ hasText: /收款|Receive/i }).first()
+ await receiveButton.click()
+
+ await expect(page).toHaveURL(/\/receive/)
+ })
+ })
+
+ test.describe('Content Tabs', () => {
+ test('should display assets and history tabs', async ({ page }) => {
+ const assetsTab = page.locator('button').filter({ hasText: /资产|Assets/i })
+ const historyTab = page.locator('button').filter({ hasText: /交易|History/i })
+
+ await expect(assetsTab.first()).toBeVisible()
+ await expect(historyTab.first()).toBeVisible()
+ })
+
+ test('should show assets content by default', async ({ page }) => {
+ // 资产 tab 应该默认激活
+ const assetsTab = page.locator('button').filter({ hasText: /资产|Assets/i }).first()
+ await expect(assetsTab).toHaveClass(/text-primary|border-primary/)
+ })
+
+ test('should switch to history tab', async ({ page }) => {
+ const historyTab = page.locator('button').filter({ hasText: /交易|History/i }).first()
+ await historyTab.click()
+
+ await expect(historyTab).toHaveClass(/text-primary|border-primary|text-foreground/)
+ })
+
+ test('should display token list in assets tab', async ({ page }) => {
+ // 确保在资产 tab
+ const assetsTab = page.locator('button').filter({ hasText: /资产|Assets/i }).first()
+ await assetsTab.click()
+
+ // 应该显示代币列表或空状态
+ const tokenList = page.locator('[class*="token"]')
+ const emptyState = page.locator('[class*="empty"]')
+
+ const hasTokens = await tokenList.first().isVisible().catch(() => false)
+ const isEmpty = await emptyState.first().isVisible().catch(() => false)
+
+ expect(hasTokens || isEmpty).toBeTruthy()
+ })
+
+ test('should display transaction list in history tab', async ({ page }) => {
+ const historyTab = page.locator('button').filter({ hasText: /交易|History/i }).first()
+ await historyTab.click()
+
+ await page.waitForTimeout(300)
+
+ // 应该显示交易列表或空状态
+ const txList = page.locator('[class*="transaction"]')
+ const emptyState = page.locator('[class*="empty"]')
+
+ const hasTx = await txList.first().isVisible().catch(() => false)
+ const isEmpty = await emptyState.first().isVisible().catch(() => false)
+
+ expect(hasTx || isEmpty).toBeTruthy()
+ })
+ })
+
+ test.describe('Wallet List Sheet', () => {
+ test('should open wallet list on button click', async ({ page }) => {
+ const walletCountButton = page.locator('button').filter({ hasText: /\d+ 个钱包/ })
+ if (!(await walletCountButton.isVisible())) return
+
+ await walletCountButton.click()
+
+ // 应该显示钱包列表 sheet
+ const sheet = page.locator('[class*="animate-slide-in-bottom"]')
+ await expect(sheet).toBeVisible({ timeout: 1000 })
+ })
+
+ test('should display wallet list title', async ({ page }) => {
+ const walletCountButton = page.locator('button').filter({ hasText: /\d+ 个钱包/ })
+ if (!(await walletCountButton.isVisible())) return
+
+ await walletCountButton.click()
+
+ const title = page.getByRole('heading', { name: /我的钱包|My Wallets/i })
+ await expect(title).toBeVisible()
+ })
+
+ test('should close wallet list on backdrop click', async ({ page }) => {
+ const walletCountButton = page.locator('button').filter({ hasText: /\d+ 个钱包/ })
+ if (!(await walletCountButton.isVisible())) return
+
+ await walletCountButton.click()
+ await page.waitForTimeout(300)
+
+ // 点击背景关闭
+ const backdrop = page.locator('.bg-black\\/50')
+ await backdrop.click()
+
+ await expect(backdrop).not.toBeVisible({ timeout: 1000 })
+ })
+
+ test('should switch wallet from list', async ({ page }) => {
+ const walletCountButton = page.locator('button').filter({ hasText: /\d+ 个钱包/ })
+ if (!(await walletCountButton.isVisible())) return
+
+ await walletCountButton.click()
+ await page.waitForTimeout(300)
+
+ // 获取第一个非当前钱包
+ const walletItems = page.locator('[class*="divide-y"] > div')
+ const count = await walletItems.count()
+ if (count < 2) return
+
+ // 点击第二个钱包
+ const secondWallet = walletItems.nth(1)
+ await secondWallet.click()
+
+ // Sheet 应该关闭
+ const backdrop = page.locator('.bg-black\\/50')
+ await expect(backdrop).not.toBeVisible({ timeout: 1000 })
+ })
+ })
+
+ test.describe('Tab Bar', () => {
+ test('should display only 2 tabs (wallet and settings)', async ({ page }) => {
+ const tabBar = page.locator('[class*="tab-bar"], nav')
+ if (!(await tabBar.isVisible())) return
+
+ const tabs = tabBar.locator('button, a')
+ const count = await tabs.count()
+
+ // 应该只有 2 个 tab
+ expect(count).toBe(2)
+ })
+
+ test('should navigate to settings', async ({ page }) => {
+ const settingsTab = page.locator('button, a').filter({ hasText: /设置|Settings/i }).first()
+ if (!(await settingsTab.isVisible())) return
+
+ await settingsTab.click()
+
+ await expect(page).toHaveURL(/\/settings/)
+ })
+ })
+
+ test.describe('Visual Regression', () => {
+ test('wallet card should match snapshot', async ({ page }) => {
+ const card = page.locator('.wallet-card-container')
+ if (!(await card.isVisible())) return
+
+ await expect(card).toHaveScreenshot('wallet-card-3d.png', {
+ animations: 'disabled',
+ })
+ })
+
+ test('content tabs should match snapshot', async ({ page }) => {
+ const tabs = page.locator('[class*="content-tabs"], [class*="ContentTabs"]').first()
+ if (!(await tabs.isVisible())) return
+
+ await expect(tabs).toHaveScreenshot('content-tabs.png', {
+ animations: 'disabled',
+ })
+ })
+ })
+})
+
+test.describe('Wallet Home 3D - No Wallet', () => {
+ test.beforeEach(async ({ page }) => {
+ // 清除本地存储,模拟无钱包状态
+ await page.goto('/')
+ await page.evaluate(() => {
+ localStorage.clear()
+ sessionStorage.clear()
+ })
+ await page.reload()
+ })
+
+ test('should display welcome screen when no wallet', async ({ page }) => {
+ const welcomeTitle = page.getByRole('heading').filter({ hasText: /欢迎|Welcome/i })
+ const isVisible = await welcomeTitle.isVisible().catch(() => false)
+
+ if (isVisible) {
+ await expect(welcomeTitle).toBeVisible()
+ }
+ })
+
+ test('should display create wallet button', async ({ page }) => {
+ const createButton = page.locator('button').filter({ hasText: /创建钱包|Create Wallet/i })
+ const isVisible = await createButton.first().isVisible().catch(() => false)
+
+ if (isVisible) {
+ await expect(createButton.first()).toBeVisible()
+ }
+ })
+
+ test('should display import wallet button', async ({ page }) => {
+ const importButton = page.locator('button').filter({ hasText: /导入钱包|Import Wallet/i })
+ const isVisible = await importButton.first().isVisible().catch(() => false)
+
+ if (isVisible) {
+ await expect(importButton.first()).toBeVisible()
+ }
+ })
+})
+
+test.describe('Wallet Home 3D - Accessibility', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/')
+ })
+
+ test('wallet card should have proper aria labels', async ({ page }) => {
+ const card = page.locator('.wallet-card')
+ if (!(await card.isVisible())) return
+
+ // 按钮应该有 aria-label
+ const buttons = card.locator('button')
+ const count = await buttons.count()
+
+ for (let i = 0; i < count; i++) {
+ const button = buttons.nth(i)
+ const hasLabel = await button.getAttribute('aria-label')
+ const hasText = await button.textContent()
+ expect(hasLabel || hasText).toBeTruthy()
+ }
+ })
+
+ test('tabs should be keyboard navigable', async ({ page }) => {
+ const assetsTab = page.locator('button').filter({ hasText: /资产|Assets/i }).first()
+ const historyTab = page.locator('button').filter({ hasText: /交易|History/i }).first()
+
+ if (!(await assetsTab.isVisible())) return
+
+ await assetsTab.focus()
+ await page.keyboard.press('Tab')
+
+ await expect(historyTab).toBeFocused()
+ })
+})
diff --git a/package.json b/package.json
index 507f9576..296e7ddd 100644
--- a/package.json
+++ b/package.json
@@ -100,6 +100,7 @@
"react-error-boundary": "^6.0.0",
"react-i18next": "^16.4.0",
"react-inspector": "^9.0.0",
+ "swiper": "^12.0.3",
"tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0",
"yargs": "^18.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c878aa8a..cc02498d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -149,6 +149,9 @@ importers:
react-inspector:
specifier: ^9.0.0
version: 9.0.0(react@19.2.3)
+ swiper:
+ specifier: ^12.0.3
+ version: 12.0.3
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -5481,6 +5484,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ swiper@12.0.3:
+ resolution: {integrity: sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==}
+ engines: {node: '>= 4.7.0'}
+
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -11952,6 +11959,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ swiper@12.0.3: {}
+
symbol-tree@3.2.4: {}
tabbable@6.3.0: {}
diff --git a/src/clear/main.ts b/src/clear/main.ts
new file mode 100644
index 00000000..07a334c4
--- /dev/null
+++ b/src/clear/main.ts
@@ -0,0 +1,171 @@
+import "./styles.css";
+
+const baseUri = new URL("./", window.location.href).href;
+
+interface StepElement {
+ id: string;
+ label: string;
+ action: () => Promise;
+}
+
+const steps: StepElement[] = [
+ {
+ id: "step-local",
+ label: "本地存储 (localStorage)",
+ action: async () => {
+ localStorage.clear();
+ },
+ },
+ {
+ id: "step-session",
+ label: "会话存储 (sessionStorage)",
+ action: async () => {
+ sessionStorage.clear();
+ },
+ },
+ {
+ id: "step-indexeddb",
+ label: "数据库 (IndexedDB)",
+ action: async () => {
+ if (indexedDB.databases) {
+ const databases = await indexedDB.databases();
+ for (const db of databases) {
+ if (db.name) {
+ await new Promise((resolve) => {
+ const request = indexedDB.deleteDatabase(db.name!);
+ request.onsuccess = () => resolve();
+ request.onerror = () => resolve();
+ request.onblocked = () => resolve();
+ });
+ }
+ }
+ }
+ },
+ },
+ {
+ id: "step-cache",
+ label: "缓存 (Cache Storage)",
+ action: async () => {
+ if ("caches" in window) {
+ const cacheNames = await caches.keys();
+ await Promise.all(cacheNames.map((name) => caches.delete(name)));
+ }
+ },
+ },
+];
+
+function createUI() {
+ const root = document.getElementById("root")!;
+ root.innerHTML = `
+
+
+
+
正在清理数据
+
请稍候...
+
+
+ ${steps
+ .map(
+ (step) => `
+
+
+
+
+
${step.label}
+
+ `
+ )
+ .join("")}
+
+
+
+
+ `;
+}
+
+function delay(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function updateProgress(completed: number, total: number) {
+ const progressCircle = document.getElementById("progressCircle");
+ if (progressCircle) {
+ const percent = (completed / total) * 100;
+ const offset = 226 - (226 * percent) / 100;
+ progressCircle.style.strokeDashoffset = String(offset);
+ }
+}
+
+function setStepActive(stepId: string) {
+ document.getElementById(stepId)?.classList.add("active");
+}
+
+function setStepDone(stepId: string) {
+ const step = document.getElementById(stepId);
+ step?.classList.remove("active");
+ step?.classList.add("done");
+}
+
+async function clearAllData() {
+ let completed = 0;
+
+ for (const step of steps) {
+ setStepActive(step.id);
+ await delay(300);
+
+ try {
+ await step.action();
+ } catch (e) {
+ console.error(`${step.label}:`, e);
+ }
+
+ setStepDone(step.id);
+ completed++;
+ updateProgress(completed, steps.length);
+ }
+}
+
+async function main() {
+ createUI();
+
+ const title = document.getElementById("title")!;
+ const status = document.getElementById("status")!;
+ const error = document.getElementById("error")!;
+ const checkIcon = document.getElementById("checkIcon")!;
+ const container = document.querySelector(".container")!;
+
+ try {
+ await clearAllData();
+
+ await delay(300);
+ checkIcon.classList.add("visible");
+ container.classList.add("success-state");
+ title.textContent = "清理完成";
+ status.textContent = "正在返回应用...";
+
+ await delay(1200);
+ window.location.href = baseUri;
+ } catch (e) {
+ title.textContent = "清理失败";
+ status.textContent = "";
+ error.style.display = "block";
+ error.textContent = e instanceof Error ? e.message : "发生未知错误";
+
+ await delay(3000);
+ window.location.href = baseUri;
+ }
+}
+
+main();
diff --git a/src/clear/styles.css b/src/clear/styles.css
new file mode 100644
index 00000000..ad47ef7c
--- /dev/null
+++ b/src/clear/styles.css
@@ -0,0 +1,210 @@
+:root {
+ --primary-hue: 323;
+ --background: oklch(0.141 0.005 285.823);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.71 0.01 286);
+ --primary: oklch(0.67 0.26 var(--primary-hue));
+ --destructive: oklch(0.577 0.245 27.325);
+ --success: oklch(0.65 0.2 145);
+ --radius: 0.625rem;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family:
+ "Figtree",
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ background: var(--background);
+ min-height: 100vh;
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: var(--foreground);
+ padding: 1.5rem;
+ overflow: hidden;
+}
+
+.container {
+ text-align: center;
+ max-width: 320px;
+ width: 100%;
+}
+
+/* Progress ring */
+.progress-ring {
+ width: 80px;
+ height: 80px;
+ margin: 0 auto 2rem;
+ position: relative;
+}
+
+.progress-ring > svg {
+ transform: rotate(-90deg);
+}
+
+.progress-ring circle {
+ fill: none;
+ stroke-width: 4;
+}
+
+.progress-ring .bg {
+ stroke: var(--muted);
+}
+
+.progress-ring .progress {
+ stroke: var(--primary);
+ stroke-linecap: round;
+ stroke-dasharray: 226;
+ stroke-dashoffset: 226;
+ transition: stroke-dashoffset 0.5s ease-out;
+}
+
+.progress-ring .check {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transform: scale(0.5);
+ transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+
+.progress-ring .check.visible {
+ opacity: 1;
+ transform: scale(1);
+}
+
+.progress-ring .check svg {
+ width: 36px;
+ height: 36px;
+ color: var(--success);
+}
+
+/* Text content */
+h1 {
+ font-size: 1.375rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ letter-spacing: -0.02em;
+}
+
+.status {
+ font-size: 0.875rem;
+ color: var(--muted-foreground);
+ margin-bottom: 2rem;
+ min-height: 1.5rem;
+}
+
+/* Steps list */
+.steps {
+ background: var(--card);
+ border-radius: var(--radius);
+ padding: 1rem;
+ text-align: left;
+}
+
+.step {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 0;
+ font-size: 0.875rem;
+ color: var(--muted-foreground);
+ opacity: 0.5;
+ transition: all 0.3s ease;
+}
+
+.step.active {
+ opacity: 1;
+ color: var(--foreground);
+}
+
+.step.done {
+ opacity: 1;
+}
+
+.step.done .step-icon {
+ background: var(--success);
+}
+
+.step.done .step-icon svg {
+ opacity: 1;
+}
+
+.step-icon {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--muted);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: background 0.3s ease;
+}
+
+.step-icon svg {
+ width: 12px;
+ height: 12px;
+ color: var(--background);
+ opacity: 0;
+ transition: opacity 0.3s ease;
+}
+
+.step.active .step-icon {
+ background: var(--primary);
+ animation: stepPulse 1s ease-in-out infinite;
+}
+
+@keyframes stepPulse {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 oklch(0.67 0.26 var(--primary-hue) / 0.4);
+ }
+ 50% {
+ box-shadow: 0 0 0 6px oklch(0.67 0.26 var(--primary-hue) / 0);
+ }
+}
+
+/* Success state */
+.success-state h1 {
+ color: var(--success);
+}
+
+/* Error state */
+.error-message {
+ color: var(--destructive);
+ font-size: 0.875rem;
+ margin-top: 1rem;
+ display: none;
+}
+
+/* Fade animations */
+.fade-in {
+ animation: fadeIn 0.5s ease-out;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/src/components/common/step-indicator.test.tsx b/src/components/common/step-indicator.test.tsx
index c1fbab5e..03edffff 100644
--- a/src/components/common/step-indicator.test.tsx
+++ b/src/components/common/step-indicator.test.tsx
@@ -44,7 +44,10 @@ describe('ProgressSteps', () => {
expect(bars[0]).toHaveClass('bg-primary')
expect(bars[1]).toHaveClass('bg-primary')
expect(bars[2]).toHaveClass('bg-muted')
- expect(bars[3]).toHaveClass('bg-muted')
+ // 最后一步使用彩虹渐变 (inline style),检查 style 属性包含 gradient
+ const lastBar = bars[3]!
+ const lastBarStyle = lastBar.getAttribute('style')
+ expect(lastBarStyle).toContain('linear-gradient')
})
it('applies custom className', () => {
diff --git a/src/components/common/step-indicator.tsx b/src/components/common/step-indicator.tsx
index d8e4bff9..e3febe47 100644
--- a/src/components/common/step-indicator.tsx
+++ b/src/components/common/step-indicator.tsx
@@ -59,12 +59,33 @@ interface ProgressStepsProps {
export function ProgressSteps({ total, current, className }: ProgressStepsProps) {
return (
- {Array.from({ length: total }).map((_, index) => (
-
- ))}
+ {Array.from({ length: total }).map((_, index) => {
+ const isLastStep = index === total - 1;
+ const isActive = index < current;
+
+ return (
+
+ );
+ })}
);
}
diff --git a/src/components/home/content-tabs.stories.tsx b/src/components/home/content-tabs.stories.tsx
new file mode 100644
index 00000000..e02ad448
--- /dev/null
+++ b/src/components/home/content-tabs.stories.tsx
@@ -0,0 +1,215 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import { ContentTabs, SwipeableContentTabs } from './content-tabs'
+import { fn } from '@storybook/test'
+
+const AssetsContent = () => (
+
+ {['USDT', 'ETH', 'BTC', 'BNB'].map((token) => (
+
+
+
+ {token[0]}
+
+
+
{token}
+
+ {(Math.random() * 1000).toFixed(2)}
+
+
+
+
+
${(Math.random() * 10000).toFixed(2)}
+
0.5 ? 'text-green-500' : 'text-red-500'}`}>
+ {Math.random() > 0.5 ? '+' : '-'}{(Math.random() * 5).toFixed(2)}%
+
+
+
+ ))}
+
+)
+
+const HistoryContent = () => (
+
+ {['转账', '收款', '兑换', '质押'].map((type, i) => (
+
+
+
+
+ {type === '转账' ? '↑' : type === '收款' ? '↓' : type === '兑换' ? '⇄' : '🔒'}
+
+
+
+
{type}
+
2024-01-{10 + i}
+
+
+
+
+ {type === '收款' ? '+' : '-'}{(Math.random() * 100).toFixed(2)} USDT
+
+
已完成
+
+
+ ))}
+
+)
+
+const meta: Meta = {
+ title: 'Home/ContentTabs',
+ component: ContentTabs,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ onTabChange: fn(),
+ },
+ render: (args) => (
+
+ {(tab) => (tab === 'assets' ? : )}
+
+ ),
+}
+
+export const HistoryActive: Story = {
+ args: {
+ defaultTab: 'history',
+ onTabChange: fn(),
+ },
+ render: (args) => (
+
+ {(tab) => (tab === 'assets' ? : )}
+
+ ),
+}
+
+export const CustomTabs: Story = {
+ args: {
+ tabs: [
+ { id: 'all', label: '全部' },
+ { id: 'sent', label: '转出' },
+ { id: 'received', label: '转入' },
+ { id: 'pending', label: '待处理' },
+ ],
+ defaultTab: 'all',
+ onTabChange: fn(),
+ },
+ render: (args) => (
+
+ {(tab) => (
+
+ 当前选中: {tab}
+
+ )}
+
+ ),
+}
+
+export const SwipeableDefault: Story = {
+ args: {
+ onTabChange: fn(),
+ },
+ render: (args) => (
+
+ {(tab) => (tab === 'assets' ? : )}
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: '可滑动版本的 Tab 切换,带有滑动指示器动画。',
+ },
+ },
+ },
+}
+
+export const SwipeableHistoryActive: Story = {
+ args: {
+ defaultTab: 'history',
+ onTabChange: fn(),
+ },
+ render: (args) => (
+
+ {(tab) => (tab === 'assets' ? : )}
+
+ ),
+}
+
+export const SwipeableCustomTabs: Story = {
+ args: {
+ tabs: [
+ { id: 'tokens', label: '代币' },
+ { id: 'nfts', label: 'NFT' },
+ { id: 'defi', label: 'DeFi' },
+ ],
+ defaultTab: 'tokens',
+ onTabChange: fn(),
+ },
+ render: (args) => (
+
+ {(tab) => (
+
+
+ {tab === 'tokens' ? '🪙' : tab === 'nfts' ? '🖼️' : '📊'}
+
+
{tab.toUpperCase()} 内容
+
+ )}
+
+ ),
+}
+
+export const Controlled: Story = {
+ args: {
+ activeTab: 'history',
+ onTabChange: fn(),
+ },
+ render: (args) => (
+
+ {(tab) => (tab === 'assets' ? : )}
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: '受控模式,activeTab 由外部状态管理。',
+ },
+ },
+ },
+}
+
+export const DarkMode: Story = {
+ args: {
+ onTabChange: fn(),
+ },
+ render: (args) => (
+
+ {(tab) => (tab === 'assets' ? : )}
+
+ ),
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ parameters: {
+ backgrounds: {
+ default: 'dark',
+ },
+ },
+}
diff --git a/src/components/home/content-tabs.test.tsx b/src/components/home/content-tabs.test.tsx
new file mode 100644
index 00000000..4efa3957
--- /dev/null
+++ b/src/components/home/content-tabs.test.tsx
@@ -0,0 +1,193 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { ContentTabs, SwipeableContentTabs } from './content-tabs'
+
+describe('ContentTabs', () => {
+ it('renders default tabs', () => {
+ render({(tab) => Content: {tab}
})
+
+ expect(screen.getByRole('button', { name: /资产/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /交易/i })).toBeInTheDocument()
+ })
+
+ it('renders content for default tab', () => {
+ render({(tab) => Content: {tab}
})
+
+ expect(screen.getByText('Content: assets')).toBeInTheDocument()
+ })
+
+ it('switches tab on click', async () => {
+ render({(tab) => Content: {tab}
})
+
+ const historyTab = screen.getByRole('button', { name: /交易/i })
+ await userEvent.click(historyTab)
+
+ expect(screen.getByText('Content: history')).toBeInTheDocument()
+ })
+
+ it('calls onTabChange when tab changes', async () => {
+ const handleTabChange = vi.fn()
+ render(
+ {(tab) => Content: {tab}
}
+ )
+
+ const historyTab = screen.getByRole('button', { name: /交易/i })
+ await userEvent.click(historyTab)
+
+ expect(handleTabChange).toHaveBeenCalledWith('history')
+ })
+
+ it('respects controlled activeTab', () => {
+ render(
+ {(tab) => Content: {tab}
}
+ )
+
+ expect(screen.getByText('Content: history')).toBeInTheDocument()
+ })
+
+ it('respects defaultTab', () => {
+ render(
+ {(tab) => Content: {tab}
}
+ )
+
+ expect(screen.getByText('Content: history')).toBeInTheDocument()
+ })
+
+ it('renders custom tabs', () => {
+ const customTabs = [
+ { id: 'tab1', label: 'Tab 1' },
+ { id: 'tab2', label: 'Tab 2' },
+ { id: 'tab3', label: 'Tab 3' },
+ ]
+
+ render(
+
+ {(tab) => Content: {tab}
}
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Tab 1' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Tab 2' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Tab 3' })).toBeInTheDocument()
+ })
+
+ it('renders tab icons', () => {
+ render({(tab) => Content: {tab}
})
+
+ // Default tabs have icons (Coins and History)
+ const buttons = screen.getAllByRole('button')
+ buttons.forEach((button) => {
+ expect(button.querySelector('svg')).toBeInTheDocument()
+ })
+ })
+
+ it('applies active styles to selected tab', async () => {
+ render({(tab) => Content: {tab}
})
+
+ const assetsTab = screen.getByRole('button', { name: /资产/i })
+ const historyTab = screen.getByRole('button', { name: /交易/i })
+
+ // Assets tab should be active by default
+ expect(assetsTab).toHaveClass('border-primary')
+ expect(historyTab).not.toHaveClass('border-primary')
+
+ await userEvent.click(historyTab)
+
+ expect(historyTab).toHaveClass('border-primary')
+ expect(assetsTab).not.toHaveClass('border-primary')
+ })
+
+ it('applies custom className', () => {
+ const { container } = render(
+ {(tab) => Content: {tab}
}
+ )
+
+ expect(container.querySelector('.custom-tabs')).toBeInTheDocument()
+ })
+})
+
+describe('SwipeableContentTabs', () => {
+ it('renders default tabs', () => {
+ render({(tab) => Content: {tab}
})
+
+ expect(screen.getByRole('button', { name: /资产/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /交易/i })).toBeInTheDocument()
+ })
+
+ it('renders content for all tabs (for swipe)', () => {
+ render({(tab) => Content: {tab}
})
+
+ // Both contents should be in DOM for swipe animation
+ expect(screen.getByText('Content: assets')).toBeInTheDocument()
+ expect(screen.getByText('Content: history')).toBeInTheDocument()
+ })
+
+ it('switches tab on click', async () => {
+ const handleTabChange = vi.fn()
+ render(
+
+ {(tab) => Content: {tab}
}
+
+ )
+
+ const historyTab = screen.getByRole('button', { name: /交易/i })
+ await userEvent.click(historyTab)
+
+ expect(handleTabChange).toHaveBeenCalledWith('history')
+ })
+
+ it('has sliding indicator', () => {
+ const { container } = render(
+ {(tab) => Content: {tab}
}
+ )
+
+ // Should have a sliding indicator div
+ const indicator = container.querySelector('[class*="transition-transform"]')
+ expect(indicator).toBeInTheDocument()
+ })
+
+ it('applies transform for active tab', async () => {
+ const { container } = render(
+ {(tab) => Content: {tab}
}
+ )
+
+ const historyTab = screen.getByRole('button', { name: /交易/i })
+ await userEvent.click(historyTab)
+
+ // Content container should have transform applied
+ const contentContainer = container.querySelector('[class*="transition-transform"]')
+ expect(contentContainer).toBeInTheDocument()
+ })
+
+ it('respects controlled activeTab', () => {
+ render(
+
+ {(tab) => Content: {tab}
}
+
+ )
+
+ // History tab should be visually active (uses text-primary for active)
+ const historyTab = screen.getByRole('button', { name: /交易/i })
+ expect(historyTab).toHaveClass('text-primary')
+ })
+
+ it('applies custom className', () => {
+ const { container } = render(
+
+ {(tab) => Content: {tab}
}
+
+ )
+
+ expect(container.querySelector('.custom-swipeable')).toBeInTheDocument()
+ })
+
+ it('renders with rounded tab indicator', () => {
+ const { container } = render(
+ {(tab) => Content: {tab}
}
+ )
+ // Indicator uses rounded-lg class
+ const indicator = container.querySelector('.rounded-lg')
+ expect(indicator).toBeInTheDocument()
+ })
+})
diff --git a/src/components/home/content-tabs.tsx b/src/components/home/content-tabs.tsx
new file mode 100644
index 00000000..f2aeacca
--- /dev/null
+++ b/src/components/home/content-tabs.tsx
@@ -0,0 +1,157 @@
+import { useState, useCallback, type ReactNode } from 'react'
+import { cn } from '@/lib/utils'
+import { IconCoins as Coins, IconHistory as History } from '@tabler/icons-react'
+
+interface Tab {
+ id: string
+ label: string
+ icon?: ReactNode
+}
+
+interface ContentTabsProps {
+ tabs?: Tab[]
+ defaultTab?: string
+ activeTab?: string
+ onTabChange?: (tabId: string) => void
+ children: (activeTab: string) => ReactNode
+ className?: string
+}
+
+const DEFAULT_TABS: Tab[] = [
+ { id: 'assets', label: '资产', icon: },
+ { id: 'history', label: '交易', icon: },
+]
+
+/**
+ * 内容区Tab切换组件
+ */
+export function ContentTabs({
+ tabs = DEFAULT_TABS,
+ defaultTab = 'assets',
+ activeTab: controlledActiveTab,
+ onTabChange,
+ children,
+ className,
+}: ContentTabsProps) {
+ const [internalActiveTab, setInternalActiveTab] = useState(defaultTab)
+
+ // 支持受控和非受控模式
+ const activeTab = controlledActiveTab ?? internalActiveTab
+
+ const handleTabClick = useCallback(
+ (tabId: string) => {
+ setInternalActiveTab(tabId)
+ onTabChange?.(tabId)
+ },
+ [onTabChange]
+ )
+
+ return (
+
+ {/* Tab 栏 */}
+
+
+ {tabs.map((tab) => (
+
+ ))}
+
+
+
+ {/* 内容区 */}
+
+ {children(activeTab)}
+
+
+ )
+}
+
+/**
+ * 滑动版本的ContentTabs(支持左右滑动切换)
+ */
+export function SwipeableContentTabs({
+ tabs = DEFAULT_TABS,
+ defaultTab = 'assets',
+ activeTab: controlledActiveTab,
+ onTabChange,
+ children,
+ className,
+}: ContentTabsProps) {
+ const [internalActiveTab, setInternalActiveTab] = useState(defaultTab)
+ const activeTab = controlledActiveTab ?? internalActiveTab
+
+ const handleTabClick = useCallback(
+ (tabId: string) => {
+ setInternalActiveTab(tabId)
+ onTabChange?.(tabId)
+ },
+ [onTabChange]
+ )
+
+ const activeIndex = tabs.findIndex((t) => t.id === activeTab)
+
+ return (
+
+ {/* Tab 栏 */}
+
+
+ {/* 滑动指示器 - 跟随主题色 */}
+
+
+ {tabs.map((tab) => (
+
+ ))}
+
+
+
+ {/* 滑动内容区 */}
+
+
+ {tabs.map((tab) => (
+
+ {children(tab.id)}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/layout/app-layout.tsx b/src/components/layout/app-layout.tsx
deleted file mode 100644
index 742a3f07..00000000
--- a/src/components/layout/app-layout.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { useRouter, useRouterState } from '@tanstack/react-router';
-import { useTranslation } from 'react-i18next';
-import { cn } from '@/lib/utils';
-import { TabBar, type TabItem } from './tab-bar';
-import {
- IconHome as Home,
- IconWallet as Wallet,
- IconSettings as Settings,
- IconArrowLeftRight as ArrowLeftRight,
-} from '@tabler/icons-react';
-import type { CSSProperties } from 'react';
-
-interface AppLayoutProps {
- children: React.ReactNode;
- className?: string;
-}
-
-const tabRoutes: Record = {
- home: '/',
- transfer: '/send',
- wallet: '/wallet',
- settings: '/settings',
-};
-
-const routeToTab: Record = {
- '/': 'home',
- '/send': 'transfer',
- '/receive': 'transfer',
- '/wallet': 'wallet',
- '/settings': 'settings',
-};
-
-export function AppLayout({ children, className }: AppLayoutProps) {
- const router = useRouter();
- const pathname = useRouterState({ select: (s) => s.location.pathname });
- const { t } = useTranslation();
-
- const tabHome = t('a11y.tabHome');
- const tabTransfer = t('a11y.tabTransfer');
- const tabWallet = t('a11y.tabWallet');
- const tabSettings = t('a11y.tabSettings');
-
- // Build tabs with localized labels
- const tabs: TabItem[] = [
- { id: 'home', label: tabHome, icon: , ariaLabel: tabHome },
- { id: 'transfer', label: tabTransfer, icon: , ariaLabel: tabTransfer },
- { id: 'wallet', label: tabWallet, icon: , ariaLabel: tabWallet },
- { id: 'settings', label: tabSettings, icon: , ariaLabel: tabSettings },
- ];
-
- // 判断是否显示 TabBar(某些页面不需要)
- const hideTabBar = ['/wallet/create', '/authorize', '/onboarding'].some((p) =>
- pathname.startsWith(p),
- );
-
- // 获取当前激活的 tab
- const activeTab =
- Object.entries(routeToTab).find(([route]) => pathname === route || pathname.startsWith(route + '/'))?.[1] || 'home';
-
- const handleTabChange = (tabId: string) => {
- const route = tabRoutes[tabId];
- if (route) {
- router.navigate({ to: route });
- }
- };
-
- const mainStyle: CSSProperties & { ['--safe-area-inset-bottom']: string } = {
- '--safe-area-inset-bottom': hideTabBar
- ? 'env(safe-area-inset-bottom)'
- : 'calc(env(safe-area-inset-bottom) + 3.5rem)',
- };
-
- return (
-
- {/* Skip link for keyboard navigation - S4 a11y */}
-
- {t('a11y.skipToMain')}
-
-
- {/* 主内容区域 */}
-
- {children}
-
-
- {/* 底部导航 */}
- {!hideTabBar && (
-
- )}
-
- );
-}
diff --git a/src/components/transaction/transaction-item.tsx b/src/components/transaction/transaction-item.tsx
index 4d8b4879..d1dbcbcb 100644
--- a/src/components/transaction/transaction-item.tsx
+++ b/src/components/transaction/transaction-item.tsx
@@ -1,7 +1,9 @@
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import type { Amount } from '@/types/amount';
+import type { ChainType } from '@/stores';
import { AddressDisplay } from '../wallet/address-display';
+import { ChainIcon } from '../wallet/chain-icon';
import { AmountDisplay, TimeDisplay } from '../common';
import {
IconArrowUp,
@@ -49,12 +51,16 @@ export interface TransactionInfo {
address: string;
timestamp: Date | string;
hash?: string | undefined;
+ /** 链类型,用于显示链图标 */
+ chain?: ChainType | undefined;
}
interface TransactionItemProps {
transaction: TransactionInfo;
onClick?: (() => void) | undefined;
className?: string | undefined;
+ /** 是否显示链图标(右下角小徽章) */
+ showChainIcon?: boolean | undefined;
}
// 颜色按类别归类,图标各不相同
@@ -96,7 +102,7 @@ const statusColors: Record = {
failed: 'text-destructive',
};
-export function TransactionItem({ transaction, onClick, className }: TransactionItemProps) {
+export function TransactionItem({ transaction, onClick, className, showChainIcon }: TransactionItemProps) {
const { t } = useTranslation('transaction');
const typeIcon = typeIcons[transaction.type];
const statusColor = statusColors[transaction.status];
@@ -120,14 +126,23 @@ export function TransactionItem({ transaction, onClick, className }: Transaction
className,
)}
>
- {/* Type Icon */}
-
+
+
+
+ {showChainIcon && transaction.chain && (
+
)}
- >
-
{/* Transaction Info */}
diff --git a/src/components/transaction/transaction-list.tsx b/src/components/transaction/transaction-list.tsx
index 81edb91c..29a76db5 100644
--- a/src/components/transaction/transaction-list.tsx
+++ b/src/components/transaction/transaction-list.tsx
@@ -11,6 +11,8 @@ interface TransactionListProps {
emptyDescription?: string | undefined;
emptyAction?: React.ReactNode | undefined;
className?: string | undefined;
+ /** 是否显示链图标(右下角小徽章) */
+ showChainIcon?: boolean | undefined;
}
function groupByDate(transactions: TransactionInfo[]): Map {
@@ -49,6 +51,7 @@ export function TransactionList({
emptyDescription = '您的交易记录将显示在这里',
emptyAction,
className,
+ showChainIcon = false,
}: TransactionListProps) {
if (loading) {
return ;
@@ -86,6 +89,7 @@ export function TransactionList({
onTransactionClick(tx) })}
/>
))}
diff --git a/src/components/wallet/index.ts b/src/components/wallet/index.ts
index 93785541..bf9491ed 100644
--- a/src/components/wallet/index.ts
+++ b/src/components/wallet/index.ts
@@ -1,4 +1,19 @@
-export { WalletCard, type WalletInfo } from './wallet-card'
+export { WalletCard } from './wallet-card'
+export { WalletSelector } from './wallet-selector'
+
+/**
+ * @deprecated Use Wallet from @/stores for new components.
+ * This interface is kept for backward compatibility with WalletSelector.
+ */
+export interface WalletInfo {
+ id: string
+ name: string
+ address: string
+ balance?: string | undefined
+ fiatValue?: string | undefined
+ chainName?: string | undefined
+ isBackedUp?: boolean | undefined
+}
export { AddressDisplay } from './address-display'
export { ChainIcon, ChainBadge, ChainIconProvider, type ChainType } from './chain-icon'
export { ChainAddressDisplay } from './chain-address-display'
diff --git a/src/components/wallet/wallet-card-carousel.stories.tsx b/src/components/wallet/wallet-card-carousel.stories.tsx
new file mode 100644
index 00000000..bb4aad8e
--- /dev/null
+++ b/src/components/wallet/wallet-card-carousel.stories.tsx
@@ -0,0 +1,180 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import { WalletCardCarousel } from './wallet-card-carousel'
+import type { Wallet } from '@/stores'
+import { fn } from '@storybook/test'
+
+const createMockWallet = (id: string, name: string): Wallet => ({
+ id,
+ name,
+ address: `0x${id.padEnd(40, '0')}`,
+ chain: 'ethereum',
+ chainAddresses: [
+ {
+ chain: 'ethereum',
+ address: `0x${id.padEnd(40, '0')}`,
+ tokens: [],
+ },
+ {
+ chain: 'tron',
+ address: `T${id.padEnd(33, 'A')}`,
+ tokens: [],
+ },
+ ],
+ createdAt: Date.now(),
+ themeHue: 323,
+ tokens: [],
+})
+
+const mockWallets = [
+ createMockWallet('wallet1', '我的钱包'),
+ createMockWallet('wallet2', '工作钱包'),
+ createMockWallet('wallet3', '储蓄钱包'),
+ createMockWallet('wallet4', '测试钱包'),
+]
+
+const chainNames: Record = {
+ ethereum: 'Ethereum',
+ tron: 'Tron',
+ bitcoin: 'Bitcoin',
+ binance: 'BSC',
+ bfmeta: 'BFMeta',
+}
+
+const meta: Meta = {
+ title: 'Wallet/WalletCardCarousel',
+ component: WalletCardCarousel,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'gradient',
+ values: [
+ { name: 'gradient', value: 'linear-gradient(180deg, #1a1a2e 0%, #16213e 100%)' },
+ { name: 'dark', value: '#1a1a2e' },
+ { name: 'light', value: '#f5f5f5' },
+ ],
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ wallets: mockWallets,
+ currentWalletId: 'wallet1',
+ selectedChain: 'ethereum',
+ chainNames,
+ onWalletChange: fn(),
+ onCopyAddress: fn(),
+ onOpenChainSelector: fn(),
+ onOpenSettings: fn(),
+ onOpenWalletList: fn(),
+ },
+}
+
+export const SingleWallet: Story = {
+ args: {
+ wallets: mockWallets.slice(0, 1),
+ currentWalletId: 'wallet1',
+ selectedChain: 'ethereum',
+ chainNames,
+ onWalletChange: fn(),
+ onCopyAddress: fn(),
+ onOpenChainSelector: fn(),
+ onOpenSettings: fn(),
+ onOpenWalletList: fn(),
+ },
+}
+
+export const TwoWallets: Story = {
+ args: {
+ wallets: mockWallets.slice(0, 2),
+ currentWalletId: 'wallet1',
+ selectedChain: 'ethereum',
+ chainNames,
+ onWalletChange: fn(),
+ onCopyAddress: fn(),
+ onOpenChainSelector: fn(),
+ onOpenSettings: fn(),
+ onOpenWalletList: fn(),
+ },
+}
+
+export const TronChain: Story = {
+ args: {
+ wallets: mockWallets,
+ currentWalletId: 'wallet1',
+ selectedChain: 'tron',
+ chainNames,
+ onWalletChange: fn(),
+ onCopyAddress: fn(),
+ onOpenChainSelector: fn(),
+ onOpenSettings: fn(),
+ onOpenWalletList: fn(),
+ },
+}
+
+export const ManyWallets: Story = {
+ args: {
+ wallets: [
+ ...mockWallets,
+ createMockWallet('wallet5', '钱包五'),
+ createMockWallet('wallet6', '钱包六'),
+ createMockWallet('wallet7', '钱包七'),
+ ],
+ currentWalletId: 'wallet1',
+ selectedChain: 'ethereum',
+ chainNames,
+ onWalletChange: fn(),
+ onCopyAddress: fn(),
+ onOpenChainSelector: fn(),
+ onOpenSettings: fn(),
+ onOpenWalletList: fn(),
+ },
+}
+
+export const StartFromMiddle: Story = {
+ args: {
+ wallets: mockWallets,
+ currentWalletId: 'wallet3',
+ selectedChain: 'ethereum',
+ chainNames,
+ onWalletChange: fn(),
+ onCopyAddress: fn(),
+ onOpenChainSelector: fn(),
+ onOpenSettings: fn(),
+ onOpenWalletList: fn(),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: '当前选中的是第三个钱包,轮播会自动定位到该卡片。',
+ },
+ },
+ },
+}
+
+export const Interactive: Story = {
+ args: {
+ wallets: mockWallets,
+ currentWalletId: 'wallet1',
+ selectedChain: 'ethereum',
+ chainNames,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: '左右滑动切换钱包卡片。点击底部的"X个钱包"按钮可以展开钱包列表。',
+ },
+ },
+ },
+}
diff --git a/src/components/wallet/wallet-card-carousel.test.tsx b/src/components/wallet/wallet-card-carousel.test.tsx
new file mode 100644
index 00000000..d1fcd7ba
--- /dev/null
+++ b/src/components/wallet/wallet-card-carousel.test.tsx
@@ -0,0 +1,188 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { WalletCardCarousel } from './wallet-card-carousel'
+import type { Wallet } from '@/stores'
+
+// Mock Swiper
+vi.mock('swiper/react', () => ({
+ Swiper: ({ children, onSlideChange, onSwiper }: any) => {
+ const mockSwiper = { activeIndex: 0, slideTo: vi.fn() }
+ onSwiper?.(mockSwiper)
+ return (
+
+ {children}
+
+
+ )
+ },
+ SwiperSlide: ({ children }: any) => {children}
,
+}))
+
+vi.mock('swiper/modules', () => ({
+ EffectCards: {},
+ Pagination: {},
+}))
+
+vi.mock('swiper/css', () => ({}))
+vi.mock('swiper/css/effect-cards', () => ({}))
+vi.mock('swiper/css/pagination', () => ({}))
+
+// Mock WalletCard
+vi.mock('./wallet-card', () => ({
+ WalletCard: ({ wallet, chainName, onCopyAddress }: any) => (
+
+ {wallet.name}
+ {chainName}
+
+
+ ),
+}))
+
+// Mock useWalletTheme
+vi.mock('@/hooks/useWalletTheme', () => ({
+ useWalletTheme: () => ({
+ getWalletTheme: () => 323,
+ }),
+}))
+
+const createMockWallet = (id: string, name: string): Wallet => ({
+ id,
+ name,
+ address: `0x${id}`,
+ chain: 'ethereum',
+ chainAddresses: [
+ {
+ chain: 'ethereum',
+ address: `0x${id}`,
+ tokens: [],
+ },
+ ],
+ createdAt: Date.now(),
+ themeHue: 323,
+ tokens: [],
+})
+
+describe('WalletCardCarousel', () => {
+ const mockWallets = [
+ createMockWallet('wallet-1', '钱包一'),
+ createMockWallet('wallet-2', '钱包二'),
+ createMockWallet('wallet-3', '钱包三'),
+ ]
+
+ const defaultProps = {
+ wallets: mockWallets,
+ currentWalletId: 'wallet-1',
+ selectedChain: 'ethereum' as const,
+ chainNames: { ethereum: 'Ethereum', tron: 'Tron' },
+ }
+
+ it('renders all wallet cards', () => {
+ render()
+
+ expect(screen.getByTestId('wallet-card-wallet-1')).toBeInTheDocument()
+ expect(screen.getByTestId('wallet-card-wallet-2')).toBeInTheDocument()
+ expect(screen.getByTestId('wallet-card-wallet-3')).toBeInTheDocument()
+ })
+
+ it('renders swiper container', () => {
+ render()
+ expect(screen.getByTestId('swiper-container')).toBeInTheDocument()
+ })
+
+ it('renders wallet count button when multiple wallets', () => {
+ render()
+ expect(screen.getByText('3 个钱包')).toBeInTheDocument()
+ })
+
+ it('does not render wallet count for single wallet', () => {
+ render()
+ expect(screen.queryByText(/个钱包/)).not.toBeInTheDocument()
+ })
+
+ it('calls onWalletChange when slide changes', async () => {
+ const handleWalletChange = vi.fn()
+ render()
+
+ const nextButton = screen.getByTestId('next-slide')
+ await userEvent.click(nextButton)
+
+ expect(handleWalletChange).toHaveBeenCalledWith('wallet-2')
+ })
+
+ it('calls onCopyAddress with correct address', async () => {
+ const handleCopyAddress = vi.fn()
+ render()
+
+ const copyButtons = screen.getAllByRole('button', { name: 'Copy' })
+ await userEvent.click(copyButtons.at(0)!)
+
+ expect(handleCopyAddress).toHaveBeenCalledWith('0xwallet-1')
+ })
+
+ it('calls onOpenWalletList when wallet count clicked', async () => {
+ const handleOpenWalletList = vi.fn()
+ render()
+
+ const walletListButton = screen.getByText('3 个钱包').closest('button')!
+ await userEvent.click(walletListButton)
+
+ expect(handleOpenWalletList).toHaveBeenCalledTimes(1)
+ })
+
+ it('returns null when wallets array is empty', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('passes chain name to wallet cards', () => {
+ render()
+ expect(screen.getAllByText('Ethereum')).toHaveLength(3)
+ })
+
+ it('renders slides for each wallet', () => {
+ render()
+ const slides = screen.getAllByTestId('swiper-slide')
+ expect(slides).toHaveLength(3)
+ })
+
+ it('applies custom className', () => {
+ const { container } = render()
+ expect(container.querySelector('.custom-carousel')).toBeInTheDocument()
+ })
+
+ it('uses address from chainAddresses when available', () => {
+ const baseWallet = mockWallets.at(0)!
+ const walletWithMultiChain: Wallet = {
+ ...baseWallet,
+ chainAddresses: [
+ { chain: 'ethereum', address: '0xETH-ADDRESS', tokens: [] },
+ { chain: 'tron', address: 'TRON-ADDRESS', tokens: [] },
+ ],
+ }
+
+ const handleCopy = vi.fn()
+ render(
+
+ )
+
+ const copyButton = screen.getByRole('button', { name: 'Copy' })
+ fireEvent.click(copyButton)
+
+ expect(handleCopy).toHaveBeenCalledWith('TRON-ADDRESS')
+ })
+})
diff --git a/src/components/wallet/wallet-card-carousel.tsx b/src/components/wallet/wallet-card-carousel.tsx
new file mode 100644
index 00000000..72f479d9
--- /dev/null
+++ b/src/components/wallet/wallet-card-carousel.tsx
@@ -0,0 +1,155 @@
+import { useCallback, useRef, useEffect } from 'react';
+import { Swiper, SwiperSlide } from 'swiper/react';
+import { EffectCards } from 'swiper/modules';
+import type { Swiper as SwiperType } from 'swiper';
+import { WalletCard } from './wallet-card';
+import { useWalletTheme } from '@/hooks/useWalletTheme';
+import { useChainIconUrls } from '@/hooks/useChainIconUrls';
+import { cn } from '@/lib/utils';
+import type { Wallet, ChainType } from '@/stores';
+import { IconWallet, IconPlus } from '@tabler/icons-react';
+
+import 'swiper/css';
+import 'swiper/css/effect-cards';
+
+interface WalletCardCarouselProps {
+ wallets: Wallet[];
+ currentWalletId: string | null;
+ selectedChain: ChainType;
+ /** 每个钱包的链偏好 (walletId -> chainId) */
+ chainPreferences?: Record;
+ chainNames: Record;
+ onWalletChange?: (walletId: string) => void;
+ onCopyAddress?: (address: string) => void;
+ onOpenChainSelector?: (walletId: string) => void;
+ onOpenSettings?: (walletId: string) => void;
+ onOpenWalletList?: () => void;
+ onAddWallet?: () => void;
+ className?: string;
+}
+
+/**
+ * 钱包卡片轮播组件
+ * 使用 Swiper 实现卡片切换效果
+ */
+export function WalletCardCarousel({
+ wallets,
+ currentWalletId,
+ selectedChain,
+ chainPreferences = {},
+ chainNames,
+ onWalletChange,
+ onCopyAddress,
+ onOpenChainSelector,
+ onOpenSettings,
+ onOpenWalletList,
+ onAddWallet,
+ className,
+}: WalletCardCarouselProps) {
+ const swiperRef = useRef(null);
+ const { getWalletTheme } = useWalletTheme();
+ const chainIconUrls = useChainIconUrls();
+
+ // 找到当前钱包的索引
+ const currentIndex = wallets.findIndex((w) => w.id === currentWalletId);
+
+ // 初始化时滑动到当前钱包
+ useEffect(() => {
+ if (swiperRef.current && currentIndex >= 0) {
+ swiperRef.current.slideTo(currentIndex, 0);
+ }
+ }, [currentIndex]);
+
+ // 滑动切换钱包
+ const handleSlideChange = useCallback(
+ (swiper: SwiperType) => {
+ const wallet = wallets[swiper.activeIndex];
+ if (wallet && wallet.id !== currentWalletId) {
+ onWalletChange?.(wallet.id);
+ }
+ },
+ [wallets, currentWalletId, onWalletChange],
+ );
+
+ // 获取钱包的链偏好(每个钱包可以有不同的链偏好)
+ const getWalletChain = (wallet: Wallet): ChainType => {
+ return chainPreferences[wallet.id] ?? wallet.chain ?? selectedChain;
+ };
+
+ // 获取钱包在其偏好链上的地址
+ const getWalletAddress = (wallet: Wallet, chain: ChainType) => {
+ const chainAddr = wallet.chainAddresses.find((ca) => ca.chain === chain);
+ return chainAddr?.address ?? wallet.address;
+ };
+
+ if (wallets.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {/* 左上角:多钱包管理入口(仅多个钱包时显示) */}
+ {wallets.length > 1 && (
+
+ )}
+
+ {/* 右上角:添加钱包 */}
+ {onAddWallet && (
+
+ )}
+
+ {
+ swiperRef.current = swiper;
+ }}
+ onSlideChange={handleSlideChange}
+ initialSlide={currentIndex >= 0 ? currentIndex : 0}
+ className="mx-auto h-[212px] w-[min(92vw,360px)] overflow-visible [&_.swiper-slide]:size-full [&_.swiper-slide]:overflow-visible! [&_.swiper-slide]:rounded-2xl"
+ >
+ {wallets.map((wallet) => {
+ const walletChain = getWalletChain(wallet);
+ const walletAddress = getWalletAddress(wallet, walletChain);
+ return (
+
+ {
+ if (walletAddress) onCopyAddress?.(walletAddress);
+ }}
+ onOpenChainSelector={onOpenChainSelector ? () => onOpenChainSelector(wallet.id) : undefined}
+ onOpenSettings={onOpenSettings ? () => onOpenSettings(wallet.id) : undefined}
+ />
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/wallet/wallet-card.stories.tsx b/src/components/wallet/wallet-card.stories.tsx
index 08602eb9..be4e8de5 100644
--- a/src/components/wallet/wallet-card.stories.tsx
+++ b/src/components/wallet/wallet-card.stories.tsx
@@ -1,94 +1,198 @@
import type { Meta, StoryObj } from '@storybook/react'
-import { WalletCard, type WalletInfo } from './wallet-card'
+import { WalletCard } from './wallet-card'
+import type { Wallet } from '@/stores'
+import { fn } from '@storybook/test'
+
+const createMockWallet = (overrides: Partial = {}): Wallet => ({
+ id: 'wallet-1',
+ name: '我的钱包',
+ address: '0x1234567890abcdef1234567890abcdef12345678',
+ chain: 'ethereum',
+ chainAddresses: [
+ {
+ chain: 'ethereum',
+ address: '0x1234567890abcdef1234567890abcdef12345678',
+ tokens: [],
+ },
+ ],
+ createdAt: Date.now(),
+ themeHue: 323,
+ tokens: [],
+ ...overrides,
+})
const meta: Meta = {
- title: 'Wallet/WalletCard',
+ title: 'Wallet/WalletCard3D',
component: WalletCard,
tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'dark',
+ values: [
+ { name: 'dark', value: '#1a1a2e' },
+ { name: 'light', value: '#f5f5f5' },
+ ],
+ },
+ },
+ argTypes: {
+ themeHue: {
+ control: { type: 'range', min: 0, max: 360, step: 1 },
+ description: 'Theme color hue (oklch)',
+ },
+ chain: {
+ control: 'select',
+ options: ['ethereum', 'tron', 'bitcoin', 'binance', 'bfmeta', 'ccchain'],
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
}
export default meta
type Story = StoryObj
-const mockWallet: WalletInfo = {
- id: '1',
- name: '我的钱包',
- address: '0x1234567890abcdef1234567890abcdef12345678',
- balance: '1,234.56 USDT',
- fiatValue: '1,234.56',
- chainName: 'Ethereum',
- isBackedUp: true,
+export const Default: Story = {
+ args: {
+ wallet: createMockWallet(),
+ chain: 'ethereum',
+ chainName: 'Ethereum',
+ address: '0x1234567890abcdef1234567890abcdef12345678',
+ themeHue: 323,
+ onCopyAddress: fn(),
+ onOpenChainSelector: fn(),
+ onOpenSettings: fn(),
+ },
}
-export const Default: Story = {
+export const PurpleTheme: Story = {
args: {
- wallet: mockWallet,
- onCopyAddress: () => alert('地址已复制'),
- onTransfer: () => alert('转账'),
- onReceive: () => alert('收款'),
+ ...Default.args,
+ themeHue: 323,
},
}
-export const NotBackedUp: Story = {
+export const BlueTheme: Story = {
args: {
- wallet: {
- ...mockWallet,
- isBackedUp: false,
- },
- onCopyAddress: () => alert('地址已复制'),
+ ...Default.args,
+ themeHue: 240,
+ },
+}
+
+export const CyanTheme: Story = {
+ args: {
+ ...Default.args,
+ themeHue: 190,
+ },
+}
+
+export const GreenTheme: Story = {
+ args: {
+ ...Default.args,
+ themeHue: 145,
+ },
+}
+
+export const OrangeTheme: Story = {
+ args: {
+ ...Default.args,
+ themeHue: 30,
+ },
+}
+
+export const TronChain: Story = {
+ args: {
+ wallet: createMockWallet({ name: 'Tron Wallet' }),
+ chain: 'tron',
+ chainName: 'Tron',
+ address: 'TAbcd1234567890abcdef1234567890abcde',
+ themeHue: 0,
+ onCopyAddress: fn(),
+ onOpenChainSelector: fn(),
+ onOpenSettings: fn(),
+ },
+}
+
+export const BitcoinChain: Story = {
+ args: {
+ wallet: createMockWallet({ name: 'BTC Wallet' }),
+ chain: 'bitcoin',
+ chainName: 'Bitcoin',
+ address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
+ themeHue: 30,
+ onCopyAddress: fn(),
+ onOpenChainSelector: fn(),
+ onOpenSettings: fn(),
+ },
+}
+
+export const LongWalletName: Story = {
+ args: {
+ wallet: createMockWallet({ name: '这是一个超长的钱包名称用于测试显示效果' }),
+ chain: 'ethereum',
+ chainName: 'Ethereum',
+ address: '0x1234567890abcdef1234567890abcdef12345678',
+ themeHue: 240,
+ onCopyAddress: fn(),
+ onOpenChainSelector: fn(),
+ onOpenSettings: fn(),
},
}
-export const NoActions: Story = {
+export const NoAddress: Story = {
args: {
- wallet: mockWallet,
- onCopyAddress: () => alert('地址已复制'),
+ wallet: createMockWallet(),
+ chain: 'ethereum',
+ chainName: 'Ethereum',
+ // address is intentionally omitted to test placeholder state
+ themeHue: 323,
+ onCopyAddress: fn(),
+ onOpenChainSelector: fn(),
+ onOpenSettings: fn(),
},
}
-export const DifferentChains: Story = {
+export const AllThemes: Story = {
render: () => (
-
-
-
-
+
+ {[323, 240, 190, 145, 60, 30, 0, 350].map((hue) => (
+
+ ))}
),
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
}
-export const Responsive: Story = {
+export const Interactive: Story = {
args: {
- wallet: mockWallet,
- onCopyAddress: () => alert('地址已复制'),
- onTransfer: () => alert('转账'),
- onReceive: () => alert('收款'),
+ wallet: createMockWallet({ name: '触摸/移动鼠标查看效果' }),
+ chain: 'ethereum',
+ chainName: 'Ethereum',
+ address: '0x1234567890abcdef1234567890abcdef12345678',
+ themeHue: 323,
},
parameters: {
docs: {
description: {
- story: '拖拽容器边缘调整宽度,观察卡片响应式变化:\n- 窄容器:紧凑布局,地址缩写\n- 宽容器:宽松布局,显示完整地址',
+ story: '将鼠标悬停在卡片上并移动,观察3D倾斜和炫光效果。在移动设备上,触摸并拖动卡片。',
},
},
},
diff --git a/src/components/wallet/wallet-card.test.tsx b/src/components/wallet/wallet-card.test.tsx
index 8ea691c7..3b53fcba 100644
--- a/src/components/wallet/wallet-card.test.tsx
+++ b/src/components/wallet/wallet-card.test.tsx
@@ -1,99 +1,173 @@
import { describe, it, expect, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { WalletCard, type WalletInfo } from './wallet-card'
-import { TestI18nProvider } from '@/test/i18n-mock'
-
-// Mock clipboard service
-vi.mock('@/services/clipboard', () => ({
- clipboardService: {
- write: vi.fn().mockResolvedValue(undefined),
- read: vi.fn().mockResolvedValue(''),
- },
-}))
+import { WalletCard } from './wallet-card'
+import type { Wallet } from '@/stores'
-const renderWithI18n = (ui: React.ReactElement) => render(
{ui})
+// Mock useCardInteraction hook
+vi.mock('@/hooks/useCardInteraction', () => ({
+ useCardInteraction: () => ({
+ pointerX: 0,
+ pointerY: 0,
+ isActive: false,
+ bindElement: vi.fn(),
+ style: {},
+ }),
+}))
-const mockWallet: WalletInfo = {
- id: '1',
+const createMockWallet = (overrides: Partial
= {}): Wallet => ({
+ id: 'test-wallet-1',
name: '我的钱包',
address: '0x1234567890abcdef1234567890abcdef12345678',
- balance: '1,234.56 USDT',
- fiatValue: '1,234.56',
- chainName: 'Ethereum',
- isBackedUp: true,
-}
+ chain: 'ethereum',
+ chainAddresses: [
+ {
+ chain: 'ethereum',
+ address: '0x1234567890abcdef1234567890abcdef12345678',
+ tokens: [],
+ },
+ ],
+ createdAt: Date.now(),
+ themeHue: 323,
+ tokens: [],
+ ...overrides,
+})
+
+describe('WalletCard (3D)', () => {
+ const defaultProps = {
+ wallet: createMockWallet(),
+ chain: 'ethereum' as const,
+ chainName: 'Ethereum',
+ address: '0x1234567890abcdef1234567890abcdef12345678',
+ }
-describe('WalletCard', () => {
it('renders wallet name', () => {
- renderWithI18n()
+ render()
expect(screen.getByRole('heading', { name: '我的钱包' })).toBeInTheDocument()
})
- it('renders wallet balance', () => {
- renderWithI18n()
- expect(screen.getByText('1,234.56 USDT')).toBeInTheDocument()
+ it('renders chain name', () => {
+ render()
+ expect(screen.getByText('Ethereum')).toBeInTheDocument()
})
- it('renders fiat value when provided', () => {
- renderWithI18n()
- expect(screen.getByText('≈ $1,234.56')).toBeInTheDocument()
+ it('renders address in AddressDisplay', () => {
+ const { container } = render()
+ // AddressDisplay component handles dynamic truncation based on container width
+ // In tests, we verify the address is passed correctly via title attribute
+ const addressElement = container.querySelector('[title*="0x1234"]')
+ expect(addressElement).toBeInTheDocument()
})
- it('renders chain name when provided', () => {
- renderWithI18n()
- expect(screen.getByText('Ethereum')).toBeInTheDocument()
+ it('renders empty address when not provided', () => {
+ const { container } = render()
+ // AddressDisplay with empty string
+ const addressElement = container.querySelector('[title=""]')
+ expect(addressElement).toBeInTheDocument()
})
- it('shows backup warning when isBackedUp is false', () => {
- renderWithI18n()
- expect(screen.getByText('未备份')).toBeInTheDocument()
- })
+ it('calls onCopyAddress when copy button clicked', async () => {
+ const handleCopy = vi.fn()
+ render()
- it('does not show backup warning when isBackedUp is true', () => {
- renderWithI18n()
- expect(screen.queryByText('未备份')).not.toBeInTheDocument()
+ // Get all buttons: [chain selector, settings, copy]
+ const buttons = screen.getAllByRole('button')
+ // The copy button is the last one (in the bottom row after address)
+ const copyButton = buttons.at(-1)!
+
+ await userEvent.click(copyButton)
+
+ expect(handleCopy).toHaveBeenCalledTimes(1)
})
- it('calls onCopyAddress when address is clicked', async () => {
+ it('shows check icon after copy', async () => {
const handleCopy = vi.fn()
- renderWithI18n()
-
- // AddressDisplay uses aria-label with full address
- const addressButton = screen.getByRole('button', { name: /复制.*0x1234/i })
- await userEvent.click(addressButton)
- expect(handleCopy).toHaveBeenCalledTimes(1)
+ render()
+
+ const buttons = screen.getAllByRole('button')
+ await userEvent.click(buttons.at(-1)!)
+
+ // Check icon should appear
+ await waitFor(() => {
+ expect(screen.getByText((_, el) => el?.classList.contains('text-green-300') ?? false)).toBeInTheDocument()
+ })
+ })
+
+ it('calls onOpenChainSelector when chain button clicked', async () => {
+ const handleOpenChainSelector = vi.fn()
+ render()
+
+ // Chain selector button contains chain name
+ const chainButton = screen.getByRole('button', { name: /Ethereum/i })
+ await userEvent.click(chainButton)
+
+ expect(handleOpenChainSelector).toHaveBeenCalledTimes(1)
+ })
+
+ it('calls onOpenSettings when settings button clicked', async () => {
+ const handleOpenSettings = vi.fn()
+ render()
+
+ // Settings button is in the top-right
+ const buttons = screen.getAllByRole('button')
+ // Second button (after chain selector) should be settings
+ await userEvent.click(buttons.at(1)!)
+
+ expect(handleOpenSettings).toHaveBeenCalledTimes(1)
+ })
+
+ it('applies custom theme hue', () => {
+ const { container } = render()
+ // Theme hue is used in the gradient background, check the style contains the hue
+ const card = container.querySelector('.wallet-card')
+ expect(card).toBeInTheDocument()
})
- it('renders transfer button when onTransfer is provided', () => {
- renderWithI18n( {}} />)
- expect(screen.getByRole('button', { name: '转账' })).toBeInTheDocument()
+ it('applies default theme hue when not specified', () => {
+ const { container } = render()
+ // Default theme hue from wallet is 323
+ const card = container.querySelector('.wallet-card')
+ expect(card).toBeInTheDocument()
})
- it('renders receive button when onReceive is provided', () => {
- renderWithI18n( {}} />)
- expect(screen.getByRole('button', { name: '收款' })).toBeInTheDocument()
+ it('renders with 3D transform styles', () => {
+ const { container } = render()
+
+ const card = container.querySelector('.wallet-card')
+ expect(card).toHaveStyle({ transformStyle: 'preserve-3d' })
+ })
+
+ it('has perspective container', () => {
+ const { container } = render()
+ // Container uses inline style for perspective
+ const perspectiveContainer = container.querySelector('.wallet-card-container')
+ expect(perspectiveContainer).toBeInTheDocument()
+ expect(perspectiveContainer).toHaveStyle({ perspective: '1000px' })
+ })
+
+ it('renders different chain names', () => {
+ render()
+ expect(screen.getByText('Tron')).toBeInTheDocument()
})
- it('calls onTransfer when transfer button is clicked', async () => {
- const handleTransfer = vi.fn()
- renderWithI18n()
-
- await userEvent.click(screen.getByRole('button', { name: '转账' }))
- expect(handleTransfer).toHaveBeenCalledTimes(1)
+ it('handles long wallet names', () => {
+ const longNameWallet = createMockWallet({ name: '这是一个非常长的钱包名称用于测试' })
+ render()
+
+ expect(screen.getByRole('heading', { name: '这是一个非常长的钱包名称用于测试' })).toBeInTheDocument()
})
- it('calls onReceive when receive button is clicked', async () => {
- const handleReceive = vi.fn()
- renderWithI18n()
-
- await userEvent.click(screen.getByRole('button', { name: '收款' }))
- expect(handleReceive).toHaveBeenCalledTimes(1)
+ it('accepts custom className', () => {
+ const { container } = render()
+
+ const perspectiveContainer = container.querySelector('.custom-class')
+ expect(perspectiveContainer).toBeInTheDocument()
})
- it('does not render action buttons when handlers are not provided', () => {
- renderWithI18n()
- expect(screen.queryByRole('button', { name: '转账' })).not.toBeInTheDocument()
- expect(screen.queryByRole('button', { name: '收款' })).not.toBeInTheDocument()
+ it('forwards ref correctly', () => {
+ const ref = vi.fn()
+ render()
+
+ expect(ref).toHaveBeenCalled()
})
})
diff --git a/src/components/wallet/wallet-card.tsx b/src/components/wallet/wallet-card.tsx
index 4fb5f3dc..b1d2dad6 100644
--- a/src/components/wallet/wallet-card.tsx
+++ b/src/components/wallet/wallet-card.tsx
@@ -1,105 +1,379 @@
-import { useTranslation } from 'react-i18next'
-import { cn } from '@/lib/utils'
-import { AddressDisplay } from './address-display'
-
-export interface WalletInfo {
- id: string
- name: string
- address: string
- balance: string
- fiatValue?: string | undefined
- chainName?: string | undefined
- isBackedUp?: boolean | undefined
+import { forwardRef, useCallback, useEffect, useRef, useState, useMemo } from 'react';
+
+// 注册 CSS 自定义属性,使其可动画
+// 只需注册一次,放在模块顶层
+if (typeof window !== 'undefined' && 'registerProperty' in CSS) {
+ const propsToRegister = [
+ { name: '--tilt-x', syntax: '', initialValue: '0' },
+ { name: '--tilt-y', syntax: '', initialValue: '0' },
+ { name: '--tilt-nx', syntax: '', initialValue: '0' },
+ { name: '--tilt-ny', syntax: '', initialValue: '0' },
+ { name: '--tilt-intensity', syntax: '', initialValue: '0' },
+ { name: '--tilt-direction', syntax: '', initialValue: '0' },
+ ];
+
+ for (const prop of propsToRegister) {
+ try {
+ CSS.registerProperty({ ...prop, inherits: true });
+ } catch {
+ // 已注册则忽略
+ }
+ }
}
+import { cn } from '@/lib/utils';
+import { useCardInteraction } from '@/hooks/useCardInteraction';
+import { useMonochromeMask } from '@/hooks/useMonochromeMask';
+import { ChainIcon } from './chain-icon';
+import { AddressDisplay } from './address-display';
+import type { Wallet, ChainType } from '@/stores';
+import {
+ IconCopy as Copy,
+ IconCheck as Check,
+ IconSettings as Settings,
+ IconChevronDown as ChevronDown,
+} from '@tabler/icons-react';
export interface WalletCardProps {
- wallet: WalletInfo
- onCopyAddress?: (() => void) | undefined
- onTransfer?: (() => void) | undefined
- onReceive?: (() => void) | undefined
- className?: string | undefined
+ wallet: Wallet;
+ chain: ChainType;
+ chainName: string;
+ address?: string | undefined;
+ /** 链图标 URL,用于防伪水印 */
+ chainIconUrl?: string | undefined;
+ /** 防伪水印 logo 平铺尺寸(含间距),默认 40px */
+ watermarkLogoSize?: number | undefined;
+ /** 防伪水印 logo 实际尺寸,默认 24px(与 watermarkLogoSize 差值为间距) */
+ watermarkLogoActualSize?: number | undefined;
+ onCopyAddress?: (() => void) | undefined;
+ onOpenChainSelector?: (() => void) | undefined;
+ onOpenSettings?: (() => void) | undefined;
+ className?: string | undefined;
+ themeHue?: number | undefined;
}
-export function WalletCard({
- wallet,
- onCopyAddress,
- onTransfer,
- onReceive,
- className,
-}: WalletCardProps) {
- const { t } = useTranslation(['wallet', 'common'])
+// 静态样式常量 - 避免每次渲染创建新对象
+const TRIANGLE_MASK_SVG = `url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 10 L10 10 L10 0 Z' fill='black'/%3E%3C/svg%3E")`;
+
+export const WalletCard = forwardRef(function WalletCard(
+ {
+ wallet,
+ chain,
+ chainName,
+ address,
+ chainIconUrl,
+ watermarkLogoSize = 40,
+ watermarkLogoActualSize = 24,
+ onCopyAddress,
+ onOpenChainSelector,
+ onOpenSettings,
+ className,
+ themeHue = 323,
+ },
+ ref,
+) {
+ const [copied, setCopied] = useState(false);
+ const cardRef = useRef(null);
+
+ // 将链图标转为单色遮罩(黑白 -> 透明)
+ const monoMaskUrl = useMonochromeMask(chainIconUrl, {
+ size: watermarkLogoActualSize * 2, // 2x for retina
+ invert: false, // 白色区域不透明
+ contrast: 1.8,
+ });
+
+ const { pointerX, pointerY, isActive, bindElement } = useCardInteraction({
+ gyroStrength: 0.15,
+ touchStrength: 0.8,
+ });
+
+ useEffect(() => {
+ bindElement(cardRef.current);
+ }, [bindElement]);
+
+ const handleCopy = useCallback(() => {
+ onCopyAddress?.();
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }, [onCopyAddress]);
+
+ // ============ 基于角度的光影算法 (GPU动画同步) ============
+
+ // 1. 卡片倾斜角度 (度) - 作为 CSS 变量传递,让 GPU 处理过渡
+ const maxTilt = 15;
+ const tiltX = isActive ? pointerY * -maxTilt : 0;
+ const tiltY = isActive ? pointerX * maxTilt : 0;
+
+ // 2. 归一化倾斜值 (-1 到 1) - 用于 CSS calc()
+ const normalizedTiltX = tiltX / maxTilt; // -1 to 1
+ const normalizedTiltY = tiltY / maxTilt; // -1 to 1
+
+ // 3. 倾斜强度 (0 到 1) - 用于透明度等
+ const tiltIntensity = isActive
+ ? Math.min(1, Math.sqrt(normalizedTiltX * normalizedTiltX + normalizedTiltY * normalizedTiltY))
+ : 0;
+
+ // 4. 倾斜方向角 (度) - 用于彩虹旋转
+ const tiltDirection = Math.atan2(-normalizedTiltX, normalizedTiltY) * (180 / Math.PI);
+
+ // CSS 变量 - 所有光影效果都通过这些变量驱动
+ // 过渡动画在 CSS 层面统一处理
+ const cssVars = {
+ '--tilt-x': tiltX, // 倾斜角度 X (度)
+ '--tilt-y': tiltY, // 倾斜角度 Y (度)
+ '--tilt-nx': normalizedTiltX, // 归一化 X (-1~1)
+ '--tilt-ny': normalizedTiltY, // 归一化 Y (-1~1)
+ '--tilt-intensity': tiltIntensity, // 强度 (0~1)
+ '--tilt-direction': tiltDirection, // 方向角 (度)
+ } as React.CSSProperties;
+
+ // 动画配置 - 统一用于 transform 和光影
+ const transitionConfig = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)'; // ease-out-back 回弹效果
+
+ // 缓存背景渐变样式(只依赖 themeHue)
+ const bgGradient = useMemo(
+ () => `linear-gradient(135deg,
+ oklch(0.50 0.20 ${themeHue}) 0%,
+ oklch(0.40 0.22 ${themeHue + 20}) 50%,
+ oklch(0.30 0.18 ${themeHue + 40}) 100%)`,
+ [themeHue],
+ );
+
+ // 缓存 Logo mask 样式(只依赖 monoMaskUrl 和 watermarkLogoSize)
+ const logoMaskStyle = useMemo(
+ () =>
+ monoMaskUrl
+ ? {
+ WebkitMaskImage: `url(${monoMaskUrl})`,
+ WebkitMaskSize: `${watermarkLogoSize}px ${watermarkLogoSize}px`,
+ WebkitMaskRepeat: 'repeat' as const,
+ WebkitMaskPosition: 'center',
+ maskImage: `url(${monoMaskUrl})`,
+ maskSize: `${watermarkLogoSize}px ${watermarkLogoSize}px`,
+ maskRepeat: 'repeat' as const,
+ maskPosition: 'center',
+ }
+ : null,
+ [monoMaskUrl, watermarkLogoSize],
+ );
+
return (
-
+
+ {/* 卡片主体 - CSS 变量驱动所有动画 */}
- {/* Header */}
-
-
-
-
- {wallet.name.charAt(0).toUpperCase()}
-
-
-
-
{wallet.name}
- {wallet.chainName && (
-
{wallet.chainName}
- )}
-
-
-
- {wallet.isBackedUp === false && (
-
- {t('notBackedUp')}
-
- )}
-
+ {/* 1. 主背景渐变 */}
+
- {/* Balance */}
-
-
{wallet.balance}
- {wallet.fiatValue && (
-
≈ ${wallet.fiatValue}
- )}
-
-
- {/* Address */}
-
-
+ {/* Refraction 1: 左下角 */}
+
+ {/* Refraction 2: 右上角 */}
+
- {/* Actions */}
- {(onTransfer || onReceive) && (
-
- {onTransfer && (
-
- )}
- {onReceive && (
-
- )}
+ {/* 3. 防伪层2:Logo水印 (Watermark) + 双层折射 */}
+ {logoMaskStyle && (
+
+ {/* Refraction 1: 左下角 */}
+
+ {/* Refraction 2: 右上角 */}
+
)}
+
+ {/* 4. 表面高光 (Glare) - 基于物理反射 */}
+
+
+ {/* 5. 边框装饰 */}
+
+
+ {/* 卡片内容 */}
+
+ {/* 顶部:链选择器 + 设置 */}
+
+
+
+
+
+
+ {/* 中部:钱包名称 */}
+
+
{wallet.name}
+
+
+ {/* 底部:地址 */}
+
+
+
+ {/* 外层阴影 */}
+
- )
-}
+ );
+});
diff --git a/src/components/wallet/wallet-config.stories.tsx b/src/components/wallet/wallet-config.stories.tsx
new file mode 100644
index 00000000..00349804
--- /dev/null
+++ b/src/components/wallet/wallet-config.stories.tsx
@@ -0,0 +1,159 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import { fn } from '@storybook/test'
+import { useEffect } from 'react'
+import { WalletConfig } from './wallet-config'
+import { walletStore, type Wallet } from '@/stores'
+
+const createMockWallet = (id: string, name: string, themeHue: number): Wallet => ({
+ id,
+ name,
+ address: `0x${id.padEnd(40, '0')}`,
+ chain: 'ethereum',
+ chainAddresses: [
+ { chain: 'ethereum', address: `0x${id.padEnd(40, '0')}`, tokens: [] },
+ { chain: 'tron', address: `T${id.padEnd(33, 'A')}`, tokens: [] },
+ ],
+ createdAt: Date.now(),
+ themeHue,
+ tokens: [],
+})
+
+const mockWallets = [
+ createMockWallet('mock-wallet-1', '我的钱包', 323),
+ createMockWallet('mock-wallet-2', '工作钱包', 200),
+ createMockWallet('mock-wallet-3', '储蓄钱包', 120),
+]
+
+function StoreMockDecorator({ children }: { children: React.ReactNode }) {
+ useEffect(() => {
+ walletStore.setState((prev) => ({
+ ...prev,
+ wallets: mockWallets,
+ currentWalletId: 'mock-wallet-1',
+ chainPreferences: {},
+ }))
+ }, [])
+ return <>{children}>
+}
+
+const meta: Meta
= {
+ title: 'Wallet/WalletConfig',
+ component: WalletConfig,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'light',
+ },
+ },
+ argTypes: {
+ mode: {
+ control: 'select',
+ options: ['edit-only', 'default', 'edit'],
+ description: '组件模式',
+ },
+ walletId: {
+ control: 'text',
+ description: '钱包 ID',
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const EditOnly: Story = {
+ name: 'edit-only 模式(创建流程)',
+ args: {
+ mode: 'edit-only',
+ walletId: 'mock-wallet-1',
+ onEditOnlyComplete: fn(),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: '用于创建/恢复钱包的最后一步,只有确认按钮',
+ },
+ },
+ },
+}
+
+export const Default: Story = {
+ name: 'default 模式(钱包详情)',
+ args: {
+ mode: 'default',
+ walletId: 'mock-wallet-1',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: '钱包详情页,显示功能按钮列表',
+ },
+ },
+ },
+}
+
+export const Edit: Story = {
+ name: 'edit 模式(编辑中)',
+ args: {
+ mode: 'edit',
+ walletId: 'mock-wallet-1',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: '从 default 切换到编辑模式,有保存/取消按钮',
+ },
+ },
+ },
+}
+
+export const WalletNotFound: Story = {
+ name: '钱包未找到',
+ args: {
+ mode: 'default',
+ walletId: 'non-existent-wallet',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: '当钱包 ID 无效时显示未找到提示',
+ },
+ },
+ },
+}
+
+export const MobileView: Story = {
+ name: '移动端视图',
+ args: {
+ mode: 'edit-only',
+ walletId: 'mock-wallet-1',
+ onEditOnlyComplete: fn(),
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ parameters: {
+ viewport: {
+ defaultViewport: 'mobile1',
+ },
+ docs: {
+ description: {
+ story: '移动端宽度下的显示效果',
+ },
+ },
+ },
+}
diff --git a/src/components/wallet/wallet-config.test.tsx b/src/components/wallet/wallet-config.test.tsx
new file mode 100644
index 00000000..f3d25516
--- /dev/null
+++ b/src/components/wallet/wallet-config.test.tsx
@@ -0,0 +1,290 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { WalletConfig } from './wallet-config'
+import type { Wallet } from '@/stores'
+
+// Mock stores
+const mockWallets: Wallet[] = [
+ {
+ id: 'wallet-1',
+ name: '我的钱包',
+ address: '0x1234567890abcdef',
+ chain: 'ethereum',
+ chainAddresses: [
+ { chain: 'ethereum', address: '0x1234567890abcdef', tokens: [] },
+ { chain: 'tron', address: 'TRX123456', tokens: [] },
+ ],
+ createdAt: Date.now(),
+ themeHue: 323,
+ tokens: [],
+ },
+ {
+ id: 'wallet-2',
+ name: '备用钱包',
+ address: '0xabcdef1234567890',
+ chain: 'ethereum',
+ chainAddresses: [{ chain: 'ethereum', address: '0xabcdef1234567890', tokens: [] }],
+ createdAt: Date.now(),
+ themeHue: 200,
+ tokens: [],
+ },
+]
+
+const mockPush = vi.fn()
+const mockUpdateWalletName = vi.fn()
+const mockUpdateWalletThemeHue = vi.fn()
+const mockSetCurrentWallet = vi.fn()
+
+vi.mock('@/stores', () => ({
+ useWallets: () => mockWallets,
+ useSelectedChain: () => 'ethereum',
+ walletActions: {
+ updateWalletName: (...args: unknown[]) => mockUpdateWalletName(...args),
+ updateWalletThemeHue: (...args: unknown[]) => mockUpdateWalletThemeHue(...args),
+ setCurrentWallet: (...args: unknown[]) => mockSetCurrentWallet(...args),
+ },
+ useChainConfigs: () => [
+ { id: 'ethereum', name: 'Ethereum', icon: '/icons/eth.svg' },
+ { id: 'tron', name: 'Tron', icon: '/icons/tron.svg' },
+ ],
+}))
+
+vi.mock('@/stackflow', () => ({
+ useFlow: () => ({ push: mockPush }),
+}))
+
+vi.mock('@/hooks/useWalletTheme', () => ({
+ WALLET_THEME_COLORS: [
+ { hue: 0, color: 'oklch(0.6 0.25 0)', name: '红色' },
+ { hue: 120, color: 'oklch(0.6 0.25 120)', name: '绿色' },
+ { hue: 240, color: 'oklch(0.6 0.25 240)', name: '蓝色' },
+ ],
+}))
+
+vi.mock('@/components/wallet/wallet-card', () => ({
+ WalletCard: ({ wallet, themeHue }: { wallet: Wallet; themeHue: number }) => (
+
+ {wallet.name}
+
+ ),
+}))
+
+describe('WalletConfig', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('mode: default', () => {
+ it('renders wallet card with wallet info', () => {
+ render()
+
+ expect(screen.getByTestId('wallet-card')).toBeInTheDocument()
+ expect(screen.getByTestId('wallet-card')).toHaveTextContent('我的钱包')
+ })
+
+ it('shows action buttons in default mode', () => {
+ render()
+
+ expect(screen.getByRole('button', { name: /editName|编辑/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /exportMnemonic|助记词/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /deleteWallet|删除/i })).toBeInTheDocument()
+ })
+
+ it('navigates to mnemonic export on button click', async () => {
+ render()
+
+ const exportButton = screen.getByRole('button', { name: /exportMnemonic|助记词/i })
+ await userEvent.click(exportButton)
+
+ expect(mockSetCurrentWallet).toHaveBeenCalledWith('wallet-1')
+ expect(mockPush).toHaveBeenCalledWith('SettingsMnemonicActivity', {})
+ })
+
+ it('navigates to delete confirmation on delete click', async () => {
+ render()
+
+ const deleteButton = screen.getByRole('button', { name: /deleteWallet|删除/i })
+ await userEvent.click(deleteButton)
+
+ expect(mockPush).toHaveBeenCalledWith('WalletDeleteJob', { walletId: 'wallet-1' })
+ })
+
+ it('switches to edit mode on edit button click', async () => {
+ render()
+
+ const editButton = screen.getByRole('button', { name: /editName|编辑/i })
+ await userEvent.click(editButton)
+
+ // Should show input and save/cancel buttons
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /save|保存/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /cancel|取消/i })).toBeInTheDocument()
+ })
+ })
+
+ describe('mode: edit', () => {
+ it('shows name input with current wallet name', () => {
+ render()
+
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveValue('我的钱包')
+ })
+
+ it('shows theme color slider', () => {
+ const { container } = render()
+
+ // Should have rainbow gradient slider
+ const slider = container.querySelector('[style*="linear-gradient"]')
+ expect(slider).toBeInTheDocument()
+ })
+
+ it('shows preset color buttons', () => {
+ render()
+
+ // Should have preset color buttons (from mock: red, green, blue)
+ const colorButtons = screen.getAllByRole('button').filter((btn) =>
+ btn.style.backgroundColor?.includes('oklch')
+ )
+ expect(colorButtons.length).toBeGreaterThan(0)
+ })
+
+ it('updates name on input change', async () => {
+ render()
+
+ const input = screen.getByRole('textbox')
+ await userEvent.clear(input)
+ await userEvent.type(input, '新钱包名')
+
+ expect(input).toHaveValue('新钱包名')
+ })
+
+ it('saves changes on save button click', async () => {
+ render()
+
+ const input = screen.getByRole('textbox')
+ await userEvent.clear(input)
+ await userEvent.type(input, '新钱包名')
+
+ const saveButton = screen.getByRole('button', { name: /save|保存/i })
+ await userEvent.click(saveButton)
+
+ expect(mockUpdateWalletName).toHaveBeenCalledWith('wallet-1', '新钱包名')
+ })
+
+ it('cancels edit and reverts changes', async () => {
+ render()
+
+ const input = screen.getByRole('textbox')
+ await userEvent.clear(input)
+ await userEvent.type(input, '新钱包名')
+
+ const cancelButton = screen.getByRole('button', { name: /cancel|取消/i })
+ await userEvent.click(cancelButton)
+
+ // Should show action buttons again (default mode)
+ expect(screen.getByRole('button', { name: /editName|编辑/i })).toBeInTheDocument()
+ })
+
+ it('disables save button when name is empty', async () => {
+ render()
+
+ const input = screen.getByRole('textbox')
+ await userEvent.clear(input)
+
+ const saveButton = screen.getByRole('button', { name: /save|保存/i })
+ expect(saveButton).toBeDisabled()
+ })
+ })
+
+ describe('mode: edit-only', () => {
+ it('shows confirm button instead of save/cancel', () => {
+ render()
+
+ expect(screen.getByRole('button', { name: /confirm|确认/i })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /save|保存/i })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /cancel|取消/i })).not.toBeInTheDocument()
+ })
+
+ it('calls onEditOnlyComplete on confirm', async () => {
+ const onComplete = vi.fn()
+ render()
+
+ const confirmButton = screen.getByRole('button', { name: /confirm|确认/i })
+ await userEvent.click(confirmButton)
+
+ expect(onComplete).toHaveBeenCalled()
+ })
+
+ it('saves name and theme before calling onEditOnlyComplete', async () => {
+ const onComplete = vi.fn()
+ render()
+
+ const input = screen.getByRole('textbox')
+ await userEvent.clear(input)
+ await userEvent.type(input, '新创建钱包')
+
+ const confirmButton = screen.getByRole('button', { name: /confirm|确认/i })
+ await userEvent.click(confirmButton)
+
+ expect(mockUpdateWalletName).toHaveBeenCalledWith('wallet-1', '新创建钱包')
+ expect(onComplete).toHaveBeenCalled()
+ })
+
+ it('disables confirm button when name is empty', async () => {
+ render()
+
+ const input = screen.getByRole('textbox')
+ await userEvent.clear(input)
+
+ const confirmButton = screen.getByRole('button', { name: /confirm|确认/i })
+ expect(confirmButton).toBeDisabled()
+ })
+ })
+
+ describe('wallet not found', () => {
+ it('shows not found message for invalid wallet id', () => {
+ render()
+
+ expect(screen.getByText(/notFound|未找到/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('theme hue editing', () => {
+ it('updates card preview when theme hue changes', async () => {
+ render()
+
+ // Find a preset color button and click it
+ const colorButtons = screen.getAllByRole('button').filter((btn) =>
+ btn.style.backgroundColor?.includes('oklch')
+ )
+
+ if (colorButtons[0]) {
+ await userEvent.click(colorButtons[0])
+ }
+
+ // Card should reflect the new theme hue
+ const card = screen.getByTestId('wallet-card')
+ expect(card).toBeInTheDocument()
+ })
+
+ it('shows markers for existing wallet hues on slider', () => {
+ const { container } = render()
+
+ // Should have markers for wallet-2's hue (200)
+ const markers = container.querySelectorAll('[class*="bg-black"]')
+ expect(markers.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('chain selector', () => {
+ it('opens chain selector on chain button click in card', async () => {
+ // This is tested via the WalletCard's onOpenChainSelector callback
+ // The actual navigation happens when WalletCard calls the callback
+ render()
+
+ // Verify the card is rendered (chain selector is inside card)
+ expect(screen.getByTestId('wallet-card')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/components/wallet/wallet-config.tsx b/src/components/wallet/wallet-config.tsx
new file mode 100644
index 00000000..ae323d86
--- /dev/null
+++ b/src/components/wallet/wallet-config.tsx
@@ -0,0 +1,387 @@
+import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { cn } from '@/lib/utils'
+import { WALLET_THEME_COLORS } from '@/hooks/useWalletTheme'
+import { useChainIconUrls } from '@/hooks/useChainIconUrls'
+import { WalletCard } from '@/components/wallet/wallet-card'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { useWallets, useSelectedChain, walletActions, type ChainType } from '@/stores'
+import { useFlow } from '@/stackflow'
+import {
+ IconCheck,
+ IconCircleKey as KeyRound,
+ IconTrash as Trash2,
+ IconPencilMinus as Edit3,
+} from '@tabler/icons-react'
+
+/**
+ * 计算两个色相之间的距离(0-180)
+ */
+function hueDistance(h1: number, h2: number): number {
+ const diff = Math.abs(h1 - h2)
+ return Math.min(diff, 360 - diff)
+}
+
+/**
+ * 计算趋避权重
+ */
+function calculateAvoidanceWeight(hue: number, existingHues: number[], minDistance = 30): number {
+ if (existingHues.length === 0) return 1
+ let minDist = 180
+ for (const existingHue of existingHues) {
+ const dist = hueDistance(hue, existingHue)
+ if (dist < minDist) minDist = dist
+ }
+ if (minDist < minDistance) {
+ return minDist / minDistance
+ }
+ return 1
+}
+
+/**
+ * 生成带趋避权重的预设颜色
+ */
+function getWeightedPresetColors(existingHues: number[]) {
+ return WALLET_THEME_COLORS.map((color) => ({
+ ...color,
+ weight: calculateAvoidanceWeight(color.hue, existingHues),
+ }))
+}
+
+type WalletConfigMode = 'edit-only' | 'default' | 'edit'
+
+interface WalletConfigProps {
+ mode: WalletConfigMode
+ walletId: string
+ onEditOnlyComplete?: () => void
+ className?: string
+}
+
+/**
+ * 统一的钱包配置组件
+ * - default: 钱包详情页,显示卡片+功能按钮,可切换到 edit
+ * - edit: 从 default 切换来,编辑完成后切回 default
+ * - edit-only: 创建/导入最后一步,编辑完成后触发 onEditOnlyComplete
+ */
+export function WalletConfig({ mode, walletId, onEditOnlyComplete, className }: WalletConfigProps) {
+ const { t } = useTranslation(['wallet', 'onboarding', 'common'])
+ const { push } = useFlow()
+ const wallets = useWallets()
+ const wallet = wallets.find((w) => w.id === walletId)
+ const selectedChain = useSelectedChain()
+ const chainIconUrls = useChainIconUrls()
+
+ // 内部模式状态(default 和 edit 可以互相切换)
+ const [internalMode, setInternalMode] = useState<'default' | 'edit'>(
+ mode === 'edit-only' ? 'edit' : mode
+ )
+
+ // 编辑状态
+ const [editName, setEditName] = useState('')
+ const [editThemeHue, setEditThemeHue] = useState(0)
+
+ // 色条拖拽
+ const sliderRef = useRef(null)
+ const [isDragging, setIsDragging] = useState(false)
+
+ // 排除当前钱包的色相
+ const existingHues = useMemo(
+ () => wallets.filter((w) => w.id !== walletId).map((w) => w.themeHue),
+ [wallets, walletId]
+ )
+ const weightedColors = useMemo(() => getWeightedPresetColors(existingHues), [existingHues])
+
+ // 初始化编辑值
+ useEffect(() => {
+ if (wallet) {
+ setEditName(wallet.name)
+ setEditThemeHue(wallet.themeHue)
+ }
+ }, [wallet])
+
+ // 当前显示的链(使用全局选中的链)
+ const currentChainAddr = useMemo(
+ () => wallet?.chainAddresses.find((ca) => ca.chain === selectedChain) ?? wallet?.chainAddresses[0],
+ [wallet, selectedChain]
+ )
+
+ // 色条拖拽处理
+ const updateHueFromPosition = useCallback((clientX: number) => {
+ if (!sliderRef.current) return
+ const rect = sliderRef.current.getBoundingClientRect()
+ const x = Math.max(0, Math.min(clientX - rect.left, rect.width))
+ const ratio = x / rect.width
+ const newHue = Math.round(ratio * 3600) / 10
+ setEditThemeHue(newHue)
+ }, [])
+
+ const handleSliderMouseDown = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ setIsDragging(true)
+ updateHueFromPosition(e.clientX)
+ }
+
+ const handleSliderTouchStart = (e: React.TouchEvent) => {
+ e.stopPropagation()
+ setIsDragging(true)
+ if (e.touches[0]) {
+ updateHueFromPosition(e.touches[0].clientX)
+ }
+ }
+
+ useEffect(() => {
+ if (!isDragging) return
+
+ const handleMouseMove = (e: MouseEvent) => updateHueFromPosition(e.clientX)
+ const handleTouchMove = (e: TouchEvent) => {
+ if (e.touches[0]) updateHueFromPosition(e.touches[0].clientX)
+ }
+ const handleEnd = () => setIsDragging(false)
+
+ document.addEventListener('mousemove', handleMouseMove)
+ document.addEventListener('mouseup', handleEnd)
+ document.addEventListener('touchmove', handleTouchMove)
+ document.addEventListener('touchend', handleEnd)
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove)
+ document.removeEventListener('mouseup', handleEnd)
+ document.removeEventListener('touchmove', handleTouchMove)
+ document.removeEventListener('touchend', handleEnd)
+ }
+ }, [isDragging, updateHueFromPosition])
+
+ // 打开链选择器
+ const handleOpenChainSelector = useCallback(() => {
+ push('ChainSelectorJob', {})
+ }, [push])
+
+ // 切换到编辑模式
+ const handleStartEdit = useCallback(() => {
+ if (wallet) {
+ setEditName(wallet.name)
+ setEditThemeHue(wallet.themeHue)
+ }
+ setInternalMode('edit')
+ }, [wallet])
+
+ // 保存编辑(edit 模式)
+ const handleSave = useCallback(async () => {
+ if (!wallet) return
+ const trimmedName = editName.trim()
+ if (trimmedName && trimmedName !== wallet.name) {
+ await walletActions.updateWalletName(wallet.id, trimmedName)
+ }
+ if (editThemeHue !== wallet.themeHue) {
+ await walletActions.updateWalletThemeHue(wallet.id, editThemeHue)
+ }
+ setInternalMode('default')
+ }, [wallet, editName, editThemeHue])
+
+ // 取消编辑(edit 模式)
+ const handleCancel = useCallback(() => {
+ if (wallet) {
+ setEditName(wallet.name)
+ setEditThemeHue(wallet.themeHue)
+ }
+ setInternalMode('default')
+ }, [wallet])
+
+ // 确认(edit-only 模式)
+ const handleConfirm = useCallback(async () => {
+ if (!wallet) return
+ const trimmedName = editName.trim()
+ if (trimmedName && trimmedName !== wallet.name) {
+ await walletActions.updateWalletName(wallet.id, trimmedName)
+ }
+ if (editThemeHue !== wallet.themeHue) {
+ await walletActions.updateWalletThemeHue(wallet.id, editThemeHue)
+ }
+ onEditOnlyComplete?.()
+ }, [wallet, editName, editThemeHue, onEditOnlyComplete])
+
+ // 查看助记词
+ const handleExportMnemonic = useCallback(async () => {
+ if (!wallet) return
+ await walletActions.setCurrentWallet(wallet.id)
+ push('SettingsMnemonicActivity', {})
+ }, [wallet, push])
+
+ // 删除钱包
+ const handleDelete = useCallback(() => {
+ if (!wallet) return
+ push('WalletDeleteJob', { walletId: wallet.id })
+ }, [wallet, push])
+
+ if (!wallet) {
+ return (
+
+ {t('wallet:detail.notFound')}
+
+ )
+ }
+
+ // 当前显示的主题色
+ const displayThemeHue = (internalMode === 'edit' || mode === 'edit-only') ? editThemeHue : wallet.themeHue
+ const isEditMode = internalMode === 'edit' || mode === 'edit-only'
+
+ return (
+
+ {/* 卡片预览 */}
+
+
+ {isEditMode ? (
+ <>
+ {/* 名称输入 */}
+
+
+ setEditName(e.target.value)}
+ placeholder={t('wallet:defaultName')}
+ maxLength={20}
+ className="text-center"
+ />
+
+
+ {/* 主题色选择 */}
+
+
+
+ {t('onboarding:theme.customColor')}
+ {editThemeHue.toFixed(1)}°
+
+
+ {existingHues.map((hue, idx) => (
+
+ ))}
+
+
+
+
+ {/* 预设颜色 */}
+
+ {weightedColors.map((color) => {
+ const isSelected = Math.abs(editThemeHue - color.hue) < 0.5
+ const isLowWeight = color.weight < 0.5
+ return (
+
+ )
+ })}
+
+
+
+ {/* 操作按钮 */}
+ {mode === 'edit-only' ? (
+
+ ) : (
+
+
+
+
+ )}
+ >
+ ) : (
+ /* default 模式:功能按钮 */
+
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/wallet/wallet-selector.stories.tsx b/src/components/wallet/wallet-selector.stories.tsx
index f5145832..02f162a9 100644
--- a/src/components/wallet/wallet-selector.stories.tsx
+++ b/src/components/wallet/wallet-selector.stories.tsx
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { WalletSelector } from './wallet-selector';
-import type { WalletInfo } from './wallet-card';
+import type { WalletInfo } from './index';
const meta: Meta = {
title: 'Wallet/WalletSelector',
diff --git a/src/components/wallet/wallet-selector.test.tsx b/src/components/wallet/wallet-selector.test.tsx
index a2b997a1..4e9cabab 100644
--- a/src/components/wallet/wallet-selector.test.tsx
+++ b/src/components/wallet/wallet-selector.test.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { WalletSelector } from './wallet-selector';
-import type { WalletInfo } from './wallet-card';
+import type { WalletInfo } from './index';
import { TestI18nProvider } from '@/test/i18n-mock';
const renderWithI18n = (ui: React.ReactElement) => render({ui});
diff --git a/src/components/wallet/wallet-selector.tsx b/src/components/wallet/wallet-selector.tsx
index bd19558d..fee00be6 100644
--- a/src/components/wallet/wallet-selector.tsx
+++ b/src/components/wallet/wallet-selector.tsx
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { IconCheck as Check } from '@tabler/icons-react';
-import type { WalletInfo } from './wallet-card';
+import type { WalletInfo } from './index';
interface WalletSelectorProps {
/** List of available wallets */
diff --git a/src/hooks/useCardInteraction.test.ts b/src/hooks/useCardInteraction.test.ts
new file mode 100644
index 00000000..5c549e9d
--- /dev/null
+++ b/src/hooks/useCardInteraction.test.ts
@@ -0,0 +1,170 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { useCardInteraction } from './useCardInteraction'
+
+describe('useCardInteraction', () => {
+ let element: HTMLDivElement
+
+ beforeEach(() => {
+ element = document.createElement('div')
+ document.body.appendChild(element)
+ })
+
+ afterEach(() => {
+ document.body.removeChild(element)
+ })
+
+ it('returns initial state', () => {
+ const { result } = renderHook(() => useCardInteraction())
+
+ expect(result.current.pointerX).toBe(0)
+ expect(result.current.pointerY).toBe(0)
+ expect(result.current.isActive).toBe(false)
+ expect(result.current.style).toEqual({
+ '--pointer-x': 0,
+ '--pointer-y': 0,
+ })
+ expect(typeof result.current.bindElement).toBe('function')
+ })
+
+ it('updates pointer position on mouse move', () => {
+ const { result } = renderHook(() => useCardInteraction())
+
+ act(() => {
+ result.current.bindElement(element)
+ })
+
+ // Mock getBoundingClientRect
+ vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
+ left: 0,
+ top: 0,
+ width: 100,
+ height: 100,
+ right: 100,
+ bottom: 100,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ })
+
+ // Simulate pointer enter and move
+ act(() => {
+ element.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
+ element.dispatchEvent(
+ new PointerEvent('pointermove', {
+ bubbles: true,
+ clientX: 75, // 0.75 * 100 - 0.5 = 0.25 normalized
+ clientY: 25, // 0.25 * 100 - 0.5 = -0.25 normalized
+ })
+ )
+ })
+
+ expect(result.current.isActive).toBe(true)
+ // x = ((75 - 0) / 100 - 0.5) * 2 = 0.5
+ // y = ((25 - 0) / 100 - 0.5) * 2 = -0.5
+ expect(result.current.pointerX).toBeCloseTo(0.5, 1)
+ expect(result.current.pointerY).toBeCloseTo(-0.5, 1)
+ })
+
+ it('resets on pointer leave', () => {
+ const { result } = renderHook(() => useCardInteraction())
+
+ act(() => {
+ result.current.bindElement(element)
+ })
+
+ vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
+ left: 0,
+ top: 0,
+ width: 100,
+ height: 100,
+ right: 100,
+ bottom: 100,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ })
+
+ act(() => {
+ element.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
+ element.dispatchEvent(
+ new PointerEvent('pointermove', { bubbles: true, clientX: 75, clientY: 25 })
+ )
+ })
+
+ expect(result.current.isActive).toBe(true)
+
+ act(() => {
+ element.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true }))
+ })
+
+ expect(result.current.isActive).toBe(false)
+ expect(result.current.pointerX).toBe(0)
+ expect(result.current.pointerY).toBe(0)
+ })
+
+ it('respects touchStrength option', () => {
+ const { result } = renderHook(() => useCardInteraction({ touchStrength: 0.5 }))
+
+ act(() => {
+ result.current.bindElement(element)
+ })
+
+ vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
+ left: 0,
+ top: 0,
+ width: 100,
+ height: 100,
+ right: 100,
+ bottom: 100,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ })
+
+ act(() => {
+ element.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
+ element.dispatchEvent(
+ new PointerEvent('pointermove', { bubbles: true, clientX: 100, clientY: 50 })
+ )
+ })
+
+ // At edge (100, 50): normalized x = ((100-0)/100 - 0.5)*2 = 1.0
+ // y = ((50-0)/100 - 0.5)*2 = 0.0
+ // With touchStrength 0.5: x = 1.0 * 0.5 = 0.5, y = 0 * 0.5 = 0
+ expect(result.current.pointerX).toBeCloseTo(0.5, 1)
+ expect(result.current.pointerY).toBeCloseTo(0, 1)
+ })
+
+ it('cleans up event listeners when rebinding to new element', () => {
+ const { result } = renderHook(() => useCardInteraction())
+
+ const addEventListenerSpy = vi.spyOn(element, 'addEventListener')
+ const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener')
+
+ act(() => {
+ result.current.bindElement(element)
+ })
+
+ expect(addEventListenerSpy).toHaveBeenCalled()
+
+ // Bind to null should clean up
+ act(() => {
+ result.current.bindElement(null)
+ })
+
+ // Event listeners should be cleaned up when binding to null
+ expect(removeEventListenerSpy).toHaveBeenCalled()
+ })
+
+ it('handles null element gracefully', () => {
+ const { result } = renderHook(() => useCardInteraction())
+
+ act(() => {
+ result.current.bindElement(null)
+ })
+
+ expect(result.current.pointerX).toBe(0)
+ expect(result.current.pointerY).toBe(0)
+ })
+})
diff --git a/src/hooks/useCardInteraction.ts b/src/hooks/useCardInteraction.ts
new file mode 100644
index 00000000..38a8db5e
--- /dev/null
+++ b/src/hooks/useCardInteraction.ts
@@ -0,0 +1,195 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+interface CardInteractionOptions {
+ /** 重力感应强度 (0-1),默认0.15 */
+ gyroStrength?: number
+ /** 触摸/鼠标强度 (0-1),默认1 */
+ touchStrength?: number
+ /** 是否启用重力感应 */
+ enableGyro?: boolean
+ /** 是否启用触摸 */
+ enableTouch?: boolean
+}
+
+interface CardInteractionState {
+ /** 指针X位置 (-1 到 1) */
+ pointerX: number
+ /** 指针Y位置 (-1 到 1) */
+ pointerY: number
+ /** 是否激活(hover或touch中) */
+ isActive: boolean
+}
+
+/**
+ * 卡片交互 Hook - 结合重力感应和触摸/鼠标
+ * 用于实现3D倾斜效果和炫光追踪
+ */
+export function useCardInteraction(options: CardInteractionOptions = {}) {
+ const {
+ gyroStrength = 0.15,
+ touchStrength = 1,
+ enableGyro = true,
+ enableTouch = true,
+ } = options
+
+ const elementRef = useRef(null)
+ const [state, setState] = useState({
+ pointerX: 0,
+ pointerY: 0,
+ isActive: false,
+ })
+
+ // 重力感应数据
+ const gyroRef = useRef({ x: 0, y: 0 })
+ // 触摸数据
+ const touchRef = useRef({ x: 0, y: 0, active: false })
+
+ // 合并重力和触摸数据
+ const updateState = useCallback(() => {
+ const gyro = gyroRef.current
+ const touch = touchRef.current
+
+ let x = 0
+ let y = 0
+ let active = false
+
+ if (touch.active && enableTouch) {
+ // 触摸优先,但仍叠加轻微重力
+ x = touch.x * touchStrength + gyro.x * gyroStrength * 0.3
+ y = touch.y * touchStrength + gyro.y * gyroStrength * 0.3
+ active = true
+ } else if (enableGyro && (Math.abs(gyro.x) > 0.01 || Math.abs(gyro.y) > 0.01)) {
+ // 重力感应 - 有明显倾斜时也算激活
+ x = gyro.x * gyroStrength
+ y = gyro.y * gyroStrength
+ active = true
+ }
+
+ // 限制范围
+ x = Math.max(-1, Math.min(1, x))
+ y = Math.max(-1, Math.min(1, y))
+
+ setState({
+ pointerX: x,
+ pointerY: y,
+ isActive: active,
+ })
+ }, [gyroStrength, touchStrength, enableGyro, enableTouch])
+
+ // 处理重力感应
+ useEffect(() => {
+ if (!enableGyro) return
+
+ let permissionGranted = false
+
+ const handleOrientation = (event: DeviceOrientationEvent) => {
+ const { beta, gamma } = event
+ if (beta === null || gamma === null) return
+
+ // beta: 前后倾斜 (-180 to 180),gamma: 左右倾斜 (-90 to 90)
+ // 归一化到 -1 到 1
+ gyroRef.current = {
+ x: Math.max(-1, Math.min(1, gamma / 45)),
+ y: Math.max(-1, Math.min(1, (beta - 45) / 45)),
+ }
+ updateState()
+ }
+
+ const requestPermission = async () => {
+ // iOS 13+ 需要请求权限
+ if (
+ typeof DeviceOrientationEvent !== 'undefined' &&
+ 'requestPermission' in DeviceOrientationEvent &&
+ typeof (DeviceOrientationEvent as unknown as { requestPermission: () => Promise }).requestPermission === 'function'
+ ) {
+ try {
+ const permission = await (DeviceOrientationEvent as unknown as { requestPermission: () => Promise }).requestPermission()
+ permissionGranted = permission === 'granted'
+ } catch {
+ permissionGranted = false
+ }
+ } else {
+ permissionGranted = true
+ }
+
+ if (permissionGranted) {
+ window.addEventListener('deviceorientation', handleOrientation)
+ }
+ }
+
+ requestPermission()
+
+ return () => {
+ window.removeEventListener('deviceorientation', handleOrientation)
+ }
+ }, [enableGyro, updateState])
+
+ // 处理鼠标/触摸
+ const handlePointerMove = useCallback(
+ (event: PointerEvent | MouseEvent | TouchEvent) => {
+ if (!enableTouch || !elementRef.current) return
+
+ const element = elementRef.current
+ const rect = element.getBoundingClientRect()
+
+ let clientX: number, clientY: number
+
+ if ('touches' in event && event.touches[0]) {
+ clientX = event.touches[0].clientX
+ clientY = event.touches[0].clientY
+ } else if ('clientX' in event) {
+ clientX = event.clientX
+ clientY = event.clientY
+ } else {
+ return
+ }
+
+ const x = ((clientX - rect.left) / rect.width - 0.5) * 2
+ const y = ((clientY - rect.top) / rect.height - 0.5) * 2
+
+ touchRef.current = { x, y, active: true }
+ updateState()
+ },
+ [enableTouch, updateState]
+ )
+
+ const handlePointerLeave = useCallback(() => {
+ touchRef.current = { x: 0, y: 0, active: false }
+ updateState()
+ }, [updateState])
+
+ // 绑定到元素
+ const bindElement = useCallback(
+ (element: HTMLElement | null) => {
+ // 清理旧绑定
+ if (elementRef.current) {
+ elementRef.current.removeEventListener('pointermove', handlePointerMove as EventListener)
+ elementRef.current.removeEventListener('pointerleave', handlePointerLeave)
+ elementRef.current.removeEventListener('touchmove', handlePointerMove as EventListener)
+ elementRef.current.removeEventListener('touchend', handlePointerLeave)
+ }
+
+ elementRef.current = element
+
+ // 新绑定
+ if (element) {
+ element.addEventListener('pointermove', handlePointerMove as EventListener)
+ element.addEventListener('pointerleave', handlePointerLeave)
+ element.addEventListener('touchmove', handlePointerMove as EventListener, { passive: true })
+ element.addEventListener('touchend', handlePointerLeave)
+ }
+ },
+ [handlePointerMove, handlePointerLeave]
+ )
+
+ return {
+ ...state,
+ bindElement,
+ ref: elementRef,
+ /** CSS变量样式,可直接应用到元素 */
+ style: {
+ '--pointer-x': state.pointerX,
+ '--pointer-y': state.pointerY,
+ } as React.CSSProperties,
+ }
+}
diff --git a/src/hooks/useChainIconUrls.test.ts b/src/hooks/useChainIconUrls.test.ts
new file mode 100644
index 00000000..6bcbafe0
--- /dev/null
+++ b/src/hooks/useChainIconUrls.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect, vi } from 'vitest'
+import { renderHook } from '@testing-library/react'
+import { useChainIconUrls } from './useChainIconUrls'
+
+// Mock useChainConfigs
+vi.mock('@/stores', () => ({
+ useChainConfigs: vi.fn(),
+}))
+
+import { useChainConfigs } from '@/stores'
+
+describe('useChainIconUrls', () => {
+ it('returns empty object when no chain configs', () => {
+ vi.mocked(useChainConfigs).mockReturnValue([])
+
+ const { result } = renderHook(() => useChainIconUrls())
+
+ expect(result.current).toEqual({})
+ })
+
+ it('maps chain ids to icon urls', () => {
+ vi.mocked(useChainConfigs).mockReturnValue([
+ { id: 'ethereum', name: 'Ethereum', symbol: 'ETH', icon: '/icons/eth.svg' },
+ { id: 'tron', name: 'Tron', symbol: 'TRX', icon: '/icons/tron.svg' },
+ { id: 'bfmeta', name: 'BFMeta', symbol: 'BFM', icon: '/icons/bfm.svg' },
+ ] as ReturnType)
+
+ const { result } = renderHook(() => useChainIconUrls())
+
+ expect(result.current).toEqual({
+ ethereum: '/icons/eth.svg',
+ tron: '/icons/tron.svg',
+ bfmeta: '/icons/bfm.svg',
+ })
+ })
+
+ it('excludes chains without icons', () => {
+ vi.mocked(useChainConfigs).mockReturnValue([
+ { id: 'ethereum', name: 'Ethereum', symbol: 'ETH', icon: '/icons/eth.svg' },
+ { id: 'tron', name: 'Tron', symbol: 'TRX', icon: undefined },
+ { id: 'bfmeta', name: 'BFMeta', symbol: 'BFM', icon: '' },
+ ] as ReturnType)
+
+ const { result } = renderHook(() => useChainIconUrls())
+
+ expect(result.current).toEqual({
+ ethereum: '/icons/eth.svg',
+ })
+ })
+
+ it('memoizes result based on chain configs', () => {
+ const configs = [
+ { id: 'ethereum', name: 'Ethereum', symbol: 'ETH', icon: '/icons/eth.svg' },
+ ] as ReturnType
+
+ vi.mocked(useChainConfigs).mockReturnValue(configs)
+
+ const { result, rerender } = renderHook(() => useChainIconUrls())
+ const firstResult = result.current
+
+ // Re-render with same configs
+ rerender()
+ expect(result.current).toBe(firstResult)
+
+ // Update configs
+ vi.mocked(useChainConfigs).mockReturnValue([
+ ...configs,
+ { id: 'tron', name: 'Tron', symbol: 'TRX', icon: '/icons/tron.svg' },
+ ] as ReturnType)
+
+ rerender()
+ expect(result.current).not.toBe(firstResult)
+ expect(result.current).toEqual({
+ ethereum: '/icons/eth.svg',
+ tron: '/icons/tron.svg',
+ })
+ })
+
+ it('handles various icon url formats', () => {
+ vi.mocked(useChainConfigs).mockReturnValue([
+ { id: 'chain1', name: 'Chain 1', symbol: 'C1', icon: '/local/icon.svg' },
+ { id: 'chain2', name: 'Chain 2', symbol: 'C2', icon: 'https://cdn.example.com/icon.png' },
+ { id: 'chain3', name: 'Chain 3', symbol: 'C3', icon: 'data:image/svg+xml;base64,PHN2Zy4uLg==' },
+ ] as ReturnType)
+
+ const { result } = renderHook(() => useChainIconUrls())
+
+ expect(result.current).toEqual({
+ chain1: '/local/icon.svg',
+ chain2: 'https://cdn.example.com/icon.png',
+ chain3: 'data:image/svg+xml;base64,PHN2Zy4uLg==',
+ })
+ })
+})
diff --git a/src/hooks/useChainIconUrls.ts b/src/hooks/useChainIconUrls.ts
new file mode 100644
index 00000000..f149a600
--- /dev/null
+++ b/src/hooks/useChainIconUrls.ts
@@ -0,0 +1,20 @@
+import { useMemo } from 'react';
+import { useChainConfigs } from '@/stores';
+
+/**
+ * 获取链图标 URL 映射
+ * 用于钱包卡片的防伪水印
+ */
+export function useChainIconUrls(): Record {
+ const chainConfigs = useChainConfigs();
+
+ return useMemo(() => {
+ const map: Record = {};
+ for (const config of chainConfigs) {
+ if (config.icon) {
+ map[config.id] = config.icon;
+ }
+ }
+ return map;
+ }, [chainConfigs]);
+}
diff --git a/src/hooks/useMonochromeMask.ts b/src/hooks/useMonochromeMask.ts
new file mode 100644
index 00000000..d57cff93
--- /dev/null
+++ b/src/hooks/useMonochromeMask.ts
@@ -0,0 +1,118 @@
+import { useState, useEffect } from 'react'
+
+/**
+ * 将图标转换为单色遮罩(用于防伪水印)
+ *
+ * 原理:
+ * 1. 加载图标到 canvas
+ * 2. 转换为灰度
+ * 3. 用亮度值作为 alpha 通道(白色=不透明,黑色=透明)
+ * 4. 返回 data URL
+ */
+export function useMonochromeMask(
+ iconUrl: string | undefined,
+ options: {
+ /** 输出图标尺寸 */
+ size?: number
+ /** 是否反转(黑变白,白变黑) */
+ invert?: boolean
+ /** 对比度增强(1=原始,2=高对比度) */
+ contrast?: number
+ } = {}
+): string | null {
+ const { size = 64, invert = false, contrast = 1.5 } = options
+ const [maskUrl, setMaskUrl] = useState(null)
+
+ useEffect(() => {
+ if (!iconUrl) {
+ setMaskUrl(null)
+ return
+ }
+
+ const img = new Image()
+ img.crossOrigin = 'anonymous'
+
+ img.onload = () => {
+ const canvas = document.createElement('canvas')
+ canvas.width = size
+ canvas.height = size
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return
+
+ // 绘制图标(居中)
+ const scale = Math.min(size / img.width, size / img.height) * 0.8
+ const w = img.width * scale
+ const h = img.height * scale
+ const x = (size - w) / 2
+ const y = (size - h) / 2
+
+ ctx.drawImage(img, x, y, w, h)
+
+ // 获取像素数据
+ const imageData = ctx.getImageData(0, 0, size, size)
+ const data = imageData.data
+
+ // 第一遍:找到亮度范围(只考虑有 alpha 的像素)
+ let minLum = 1
+ let maxLum = 0
+ for (let i = 0; i < data.length; i += 4) {
+ const a = data[i + 3]!
+ if (a < 10) continue // 忽略几乎透明的像素
+
+ const r = data[i]!
+ const g = data[i + 1]!
+ const b = data[i + 2]!
+ const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
+ minLum = Math.min(minLum, lum)
+ maxLum = Math.max(maxLum, lum)
+ }
+
+ // 防止除以零
+ const lumRange = maxLum - minLum
+ const hasRange = lumRange > 0.01
+
+ // 第二遍:归一化并转换为遮罩
+ for (let i = 0; i < data.length; i += 4) {
+ const r = data[i]!
+ const g = data[i + 1]!
+ const b = data[i + 2]!
+ const a = data[i + 3]!
+
+ // 计算亮度并归一化到 0~1
+ let luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
+ if (hasRange) {
+ luminance = (luminance - minLum) / lumRange
+ }
+
+ // 应用对比度
+ luminance = ((luminance - 0.5) * contrast + 0.5)
+ luminance = Math.max(0, Math.min(1, luminance))
+
+ // 反转
+ if (invert) {
+ luminance = 1 - luminance
+ }
+
+ // 结合原始 alpha
+ const finalAlpha = luminance * (a / 255) * 255
+
+ // 设置为白色 + 亮度作为 alpha
+ data[i] = 255 // R
+ data[i + 1] = 255 // G
+ data[i + 2] = 255 // B
+ data[i + 3] = finalAlpha // A = luminance
+ }
+
+ ctx.putImageData(imageData, 0, 0)
+ setMaskUrl(canvas.toDataURL('image/png'))
+ }
+
+ img.onerror = () => {
+ setMaskUrl(null)
+ }
+
+ img.src = iconUrl
+ }, [iconUrl, size, invert, contrast])
+
+ return maskUrl
+}
diff --git a/src/hooks/useWalletTheme.test.ts b/src/hooks/useWalletTheme.test.ts
new file mode 100644
index 00000000..ac986774
--- /dev/null
+++ b/src/hooks/useWalletTheme.test.ts
@@ -0,0 +1,116 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { renderHook } from '@testing-library/react'
+import { useWalletTheme, WALLET_THEME_PRESETS } from './useWalletTheme'
+
+// 默认主题色 (purple)
+const DEFAULT_THEME_HUE = WALLET_THEME_PRESETS.purple
+
+// Mock walletStore
+vi.mock('@/stores', () => {
+ let mockState = {
+ wallets: [] as Array<{ id: string; name: string; themeHue?: number }>,
+ currentWalletId: null as string | null,
+ }
+
+ return {
+ walletStore: {
+ getState: () => mockState,
+ setState: (updater: (state: typeof mockState) => typeof mockState) => {
+ mockState = updater(mockState)
+ },
+ subscribe: () => () => {},
+ __reset: () => {
+ mockState = { wallets: [], currentWalletId: null }
+ },
+ __setWallets: (wallets: typeof mockState.wallets) => {
+ mockState.wallets = wallets
+ },
+ },
+ }
+})
+
+// Mock @tanstack/react-store
+vi.mock('@tanstack/react-store', () => ({
+ useStore: (store: any, selector: (state: any) => any) => selector(store.getState()),
+}))
+
+describe('useWalletTheme', () => {
+ beforeEach(async () => {
+ // Reset mock store
+ const { walletStore } = await import('@/stores')
+ ;(walletStore as any).__reset?.()
+
+ // Mock document.documentElement.style
+ vi.spyOn(document.documentElement.style, 'setProperty').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('returns default theme hue for unknown wallet', () => {
+ const { result } = renderHook(() => useWalletTheme())
+
+ const hue = result.current.getWalletTheme('unknown-wallet-id')
+ expect(hue).toBe(DEFAULT_THEME_HUE)
+ })
+
+ it('returns themeHue property', () => {
+ const { result } = renderHook(() => useWalletTheme())
+ expect(typeof result.current.themeHue).toBe('number')
+ })
+
+ it('returns presets object', () => {
+ const { result } = renderHook(() => useWalletTheme())
+ expect(result.current.presets).toBe(WALLET_THEME_PRESETS)
+ })
+
+ it('provides setThemeColor function', () => {
+ const { result } = renderHook(() => useWalletTheme())
+ expect(typeof result.current.setThemeColor).toBe('function')
+ })
+
+ it('provides setThemePreset function', () => {
+ const { result } = renderHook(() => useWalletTheme())
+ expect(typeof result.current.setThemePreset).toBe('function')
+ })
+
+ it('provides getWalletTheme function', () => {
+ const { result } = renderHook(() => useWalletTheme())
+ expect(typeof result.current.getWalletTheme).toBe('function')
+ })
+
+ it('provides all preset colors', () => {
+ expect(WALLET_THEME_PRESETS).toHaveProperty('purple')
+ expect(WALLET_THEME_PRESETS).toHaveProperty('blue')
+ expect(WALLET_THEME_PRESETS).toHaveProperty('cyan')
+ expect(WALLET_THEME_PRESETS).toHaveProperty('green')
+ expect(WALLET_THEME_PRESETS).toHaveProperty('yellow')
+ expect(WALLET_THEME_PRESETS).toHaveProperty('orange')
+ expect(WALLET_THEME_PRESETS).toHaveProperty('red')
+ expect(WALLET_THEME_PRESETS).toHaveProperty('pink')
+ expect(WALLET_THEME_PRESETS).toHaveProperty('magenta')
+ })
+
+ it('preset values are valid hue angles', () => {
+ Object.values(WALLET_THEME_PRESETS).forEach((hue) => {
+ expect(hue).toBeGreaterThanOrEqual(0)
+ expect(hue).toBeLessThanOrEqual(360)
+ })
+ })
+
+ it('default theme is purple', () => {
+ expect(WALLET_THEME_PRESETS.purple).toBe(323)
+ })
+
+ it('getWalletTheme returns wallet themeHue when available', async () => {
+ const { walletStore } = await import('@/stores')
+ ;(walletStore as any).__setWallets([
+ { id: 'wallet-1', name: 'Test', themeHue: 200 },
+ ])
+
+ const { result } = renderHook(() => useWalletTheme())
+ const hue = result.current.getWalletTheme('wallet-1')
+ expect(hue).toBe(200)
+ })
+})
diff --git a/src/hooks/useWalletTheme.ts b/src/hooks/useWalletTheme.ts
new file mode 100644
index 00000000..a9ea202f
--- /dev/null
+++ b/src/hooks/useWalletTheme.ts
@@ -0,0 +1,106 @@
+import { useCallback, useEffect } from 'react'
+import { useStore } from '@tanstack/react-store'
+import { walletStore } from '@/stores'
+
+/** 预设主题色 (oklch hue 角度) */
+export const WALLET_THEME_PRESETS = {
+ purple: 323, // 默认紫色
+ blue: 250, // 蓝色
+ cyan: 200, // 青色
+ green: 145, // 绿色
+ yellow: 85, // 黄色
+ orange: 45, // 橙色
+ red: 25, // 红色
+ pink: 350, // 粉色
+ magenta: 310, // 洋红
+} as const
+
+export type WalletThemePreset = keyof typeof WALLET_THEME_PRESETS
+
+/** 主题色配置(包含名称和展示色) */
+export const WALLET_THEME_COLORS = [
+ { name: '紫色', hue: 323, color: 'oklch(0.6 0.25 323)' },
+ { name: '蓝色', hue: 250, color: 'oklch(0.55 0.25 250)' },
+ { name: '青色', hue: 200, color: 'oklch(0.65 0.2 200)' },
+ { name: '绿色', hue: 145, color: 'oklch(0.6 0.2 145)' },
+ { name: '黄色', hue: 85, color: 'oklch(0.75 0.18 85)' },
+ { name: '橙色', hue: 45, color: 'oklch(0.7 0.2 45)' },
+ { name: '红色', hue: 25, color: 'oklch(0.6 0.25 25)' },
+ { name: '粉色', hue: 350, color: 'oklch(0.7 0.2 350)' },
+ { name: '洋红', hue: 310, color: 'oklch(0.6 0.25 310)' },
+] as const
+
+/**
+ * 基于助记词/密钥稳定派生主题色 hue
+ * 使用简单哈希算法生成 0-360 的色相值
+ */
+export function deriveThemeHue(secret: string): number {
+ let hash = 0
+ for (let i = 0; i < secret.length; i++) {
+ const char = secret.charCodeAt(i)
+ hash = ((hash << 5) - hash) + char
+ hash = hash & hash // Convert to 32bit integer
+ }
+ // Map to 0-360 range
+ return Math.abs(hash) % 360
+}
+
+/**
+ * 将主题色应用到 CSS 变量
+ */
+function applyThemeColor(hue: number) {
+ const root = document.documentElement
+ root.style.setProperty('--primary-hue', String(hue))
+}
+
+/**
+ * 根据钱包ID获取主题色
+ */
+function getThemeHueForWallet(wallets: { id: string; themeHue: number }[], walletId: string | null): number {
+ if (!walletId) return WALLET_THEME_PRESETS.purple
+
+ const wallet = wallets.find((w) => w.id === walletId)
+ return wallet?.themeHue ?? WALLET_THEME_PRESETS.purple
+}
+
+/**
+ * 钱包主题 Hook
+ * 管理当前钱包的主题色,自动应用到全局CSS变量
+ */
+export function useWalletTheme() {
+ const wallets = useStore(walletStore, (s) => s.wallets)
+ const currentWalletId = useStore(walletStore, (s) => s.currentWalletId)
+
+ // 获取当前钱包的主题色
+ const themeHue = getThemeHueForWallet(wallets as { id: string; themeHue: number }[], currentWalletId)
+
+ // 应用主题色
+ useEffect(() => {
+ applyThemeColor(themeHue)
+ }, [themeHue])
+
+ // 设置主题色
+ const setThemeColor = useCallback((walletId: string, hue: number) => {
+ walletStore.setState((state) => ({
+ ...state,
+ wallets: state.wallets.map((w) =>
+ w.id === walletId ? { ...w, themeHue: hue } : w
+ ),
+ }))
+ }, [])
+
+ // 设置预设主题
+ const setThemePreset = useCallback((walletId: string, preset: WalletThemePreset) => {
+ setThemeColor(walletId, WALLET_THEME_PRESETS[preset])
+ }, [setThemeColor])
+
+ return {
+ themeHue,
+ presets: WALLET_THEME_PRESETS,
+ setThemeColor,
+ setThemePreset,
+ /** 获取指定钱包的主题色 */
+ getWalletTheme: (walletId: string) =>
+ getThemeHueForWallet(wallets as { id: string; themeHue: number }[], walletId),
+ }
+}
diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json
index 50761c6b..a92ef6e4 100644
--- a/src/i18n/locales/ar/common.json
+++ b/src/i18n/locales/ar/common.json
@@ -1,5 +1,6 @@
{
"a11y": {
+ "addWallet": "إضافة محفظة",
"addContact": "إضافة جهة اتصال",
"appInfo": "معلومات التطبيق",
"back": "رجوع",
diff --git a/src/i18n/locales/ar/onboarding.json b/src/i18n/locales/ar/onboarding.json
index 7d81b894..5b6e9bc1 100644
--- a/src/i18n/locales/ar/onboarding.json
+++ b/src/i18n/locales/ar/onboarding.json
@@ -107,7 +107,9 @@
"walletLockLabel": "كلمة المرور",
"walletLockMismatch": "كلمات المرور غير متطابقة",
"walletLockPlaceholder": "أدخل كلمة المرور",
- "setWalletLock": "تعيين كلمة المرور"
+ "setWalletLock": "تعيين كلمة المرور",
+ "themeTitle": "اختر سمة البطاقة",
+ "themeSubtitle": "اختر لون سمة لبطاقة محفظتك"
},
"import": {
"complete": "إكمال الاستيراد",
@@ -185,5 +187,14 @@
"noResults": "No chains found",
"selectAll": "Select All",
"deselectAll": "Deselect All"
+ },
+ "theme": {
+ "walletName": "اسم المحفظة",
+ "previewChain": "سلسلة المعاينة",
+ "customColor": "لون مخصص",
+ "presetColors": "ألوان مسبقة",
+ "usedColor": "مستخدم",
+ "similarExists": "يوجد لون مشابه",
+ "auto": "تلقائي"
}
}
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index 79a65a52..111caa59 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -19,7 +19,9 @@
"currency": "العملة",
"chainConfig": "تكوين السلسلة",
"appearance": "المظهر",
- "aboutApp": "حول BFM Pay"
+ "aboutApp": "حول BFM Pay",
+ "clearData": "مسح بيانات التطبيق",
+ "storage": "التخزين"
},
"appearance": {
"title": "المظهر",
@@ -120,5 +122,25 @@
"manualAdded": "تمت إضافة تهيئة السلسلة"
},
"hint": "تلميح: يتم تخزين تهيئة الاشتراك مؤقتًا محليًا. تغيير الرابط يمسح ذاكرة التخزين المؤقت القديمة لتجنب الالتباس."
+ },
+ "clearData": {
+ "title": "مسح بيانات التطبيق",
+ "warning": "ستحذف هذه العملية جميع البيانات المحلية، بما في ذلك المحافظ والإعدادات والذاكرة المؤقتة. لا يمكن التراجع عن هذه العملية!",
+ "item1": "سيتم حذف جميع بيانات المحفظة",
+ "item2": "ستتم استعادة جميع الإعدادات إلى الوضع الافتراضي",
+ "item3": "سيتم إعادة تشغيل التطبيق",
+ "confirm": "تأكيد المسح"
+ },
+ "storage": {
+ "title": "التخزين",
+ "usage": "استخدام التخزين",
+ "basedOn": "بناءً على حصة تخزين المتصفح",
+ "used": "مستخدم",
+ "total": "المساحة الإجمالية",
+ "usagePercent": "نسبة الاستخدام",
+ "available": "المساحة المتاحة",
+ "unavailable": "تعذر الحصول على معلومات التخزين",
+ "clearTitle": "مسح البيانات",
+ "clearDesc": "مسح جميع البيانات المخزنة محليًا، بما في ذلك المحافظ والإعدادات والذاكرة المؤقتة."
}
}
diff --git a/src/i18n/locales/ar/transaction.json b/src/i18n/locales/ar/transaction.json
index 1698c6b1..ba719fb1 100644
--- a/src/i18n/locales/ar/transaction.json
+++ b/src/i18n/locales/ar/transaction.json
@@ -110,6 +110,8 @@
"emptyDesc": "لم يتم العثور على معاملات بالفلاتر الحالية",
"emptyTitle": "لا توجد معاملات",
"filter": {
+ "chainLabel": "السلسلة",
+ "periodLabel": "الفترة",
"allChains": "جميع السلاسل",
"allTime": "الكل",
"days30": "30 يومًا",
@@ -118,7 +120,9 @@
},
"noWallet": "يرجى إنشاء أو استيراد محفظة أولاً",
"title": "سجل المعاملات",
- "totalRecords": "{{count}} سجل"
+ "totalRecords": "{{count}} سجل",
+ "viewAll": "عرض جميع {{count}} سجل",
+ "viewAllChains": "عرض جميع معاملات الشبكة"
},
"incorrectHandlingFee": "Incorrect handling fee",
"initiateTransfer": "Initiate Transfer",
diff --git a/src/i18n/locales/ar/wallet.json b/src/i18n/locales/ar/wallet.json
index d24f29a0..842037e9 100644
--- a/src/i18n/locales/ar/wallet.json
+++ b/src/i18n/locales/ar/wallet.json
@@ -2,6 +2,7 @@
" obtainingTheAddressPassowrdIsEquivalentToOwningTheWalletAsset": "Obtaining the address password is equivalent to owning the wallet asset",
"1AllWalletDataInTheOriginal_{appName}": "1. All wallet data in the original will be retained and the data will be organized. The rules are as follows:",
"add": "إضافة محفظة",
+ "defaultName": "محفظتي",
"chains": {
"title": "إدارة شبكات المحفظة",
"verifyTitle": "التحقق من قفل المحفظة",
@@ -68,6 +69,8 @@
"createdAt": "أُنشئت في {{date}}",
"deleteWallet": "حذف المحفظة",
"editName": "تعديل اسم المحفظة",
+ "editTheme": "تعديل لون السمة",
+ "editTitle": "تعديل المحفظة",
"exportDeveloping": "ميزة تصدير العبارة السرية قيد التطوير",
"exportMnemonic": "تصدير العبارة السرية",
"notFound": "المحفظة غير موجودة",
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 0b67412a..ca5c56b7 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -1,6 +1,7 @@
{
"a11y": {
"addContact": "Add contact",
+ "addWallet": "Add wallet",
"appInfo": "App info",
"back": "Back",
"chainSelector": "Select blockchain network",
diff --git a/src/i18n/locales/en/onboarding.json b/src/i18n/locales/en/onboarding.json
index 48dad797..611fc8bb 100644
--- a/src/i18n/locales/en/onboarding.json
+++ b/src/i18n/locales/en/onboarding.json
@@ -42,6 +42,8 @@
"createFailed": "Failed to create wallet",
"defaultWalletName": "Main Wallet",
"enterWallet": "Enter Wallet",
+ "themeTitle": "Choose Card Theme",
+ "themeSubtitle": "Select a theme color for your wallet card",
"form": {
"agreementPrefix": "I have read and agree to",
"agreementRequired": "Please read and agree to the user agreement",
@@ -185,5 +187,14 @@
"noResults": "No chains found",
"selectAll": "Select All",
"deselectAll": "Deselect All"
+ },
+ "theme": {
+ "walletName": "Wallet Name",
+ "previewChain": "Preview Chain",
+ "customColor": "Custom Color",
+ "presetColors": "Preset Colors",
+ "usedColor": "In Use",
+ "similarExists": "Similar color exists",
+ "auto": "Auto"
}
}
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index c84c3543..3a100133 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -19,7 +19,9 @@
"currency": "Currency",
"chainConfig": "Chain Config",
"appearance": "Appearance",
- "aboutApp": "About BFM Pay"
+ "aboutApp": "About BFM Pay",
+ "clearData": "Clear App Data",
+ "storage": "Storage"
},
"appearance": {
"title": "Appearance",
@@ -120,5 +122,25 @@
"manualAdded": "Chain config added"
},
"hint": "Hint: Subscription configs are cached locally. Changing the URL clears the old cache to avoid confusion."
+ },
+ "clearData": {
+ "title": "Clear App Data",
+ "warning": "This will delete all local data including wallets, settings, and cache. This action cannot be undone!",
+ "item1": "All wallet data will be deleted",
+ "item2": "All settings will be reset to default",
+ "item3": "The app will restart",
+ "confirm": "Confirm Clear"
+ },
+ "storage": {
+ "title": "Storage",
+ "usage": "Storage Usage",
+ "basedOn": "Based on browser storage quota",
+ "used": "Used",
+ "total": "Total",
+ "usagePercent": "Usage",
+ "available": "Available",
+ "unavailable": "Unable to get storage info",
+ "clearTitle": "Clear Data",
+ "clearDesc": "Clear all locally stored data including wallets, settings, and cache."
}
}
diff --git a/src/i18n/locales/en/transaction.json b/src/i18n/locales/en/transaction.json
index 5b6d48ba..72ab60ed 100644
--- a/src/i18n/locales/en/transaction.json
+++ b/src/i18n/locales/en/transaction.json
@@ -149,6 +149,8 @@
"emptyDesc": "No transactions found with current filters",
"emptyTitle": "No transactions",
"filter": {
+ "chainLabel": "Chain",
+ "periodLabel": "Period",
"allChains": "All Chains",
"allTime": "All",
"days30": "30 Days",
@@ -157,7 +159,9 @@
},
"noWallet": "Please create or import a wallet first",
"title": "Transaction History",
- "totalRecords": "{{count}} records"
+ "totalRecords": "{{count}} records",
+ "viewAll": "View all {{count}} records",
+ "viewAllChains": "View all chain transactions"
},
"incorrectHandlingFee": "Incorrect handling fee",
"initiateTransfer": "Initiate Transfer",
diff --git a/src/i18n/locales/en/wallet.json b/src/i18n/locales/en/wallet.json
index 8e0b79a8..137f8cc2 100644
--- a/src/i18n/locales/en/wallet.json
+++ b/src/i18n/locales/en/wallet.json
@@ -1,5 +1,6 @@
{
"add": "Add Wallet",
+ "defaultName": "My Wallet",
"chains": {
"title": "Manage Wallet Networks",
"verifyTitle": "Verify Wallet Lock",
@@ -115,10 +116,12 @@
},
"detail": {
"title": "Wallet Details",
+ "editTitle": "Edit Wallet",
"notFound": "Wallet not found",
"createdAt": "Created on {{date}}",
"chainAddresses": "Chain Addresses",
"editName": "Edit Wallet Name",
+ "editTheme": "Edit Theme Color",
"exportMnemonic": "Export Mnemonic",
"deleteWallet": "Delete Wallet",
"exportDeveloping": "Mnemonic export feature in development",
diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json
index fdaa1465..ffa7e97d 100644
--- a/src/i18n/locales/zh-CN/common.json
+++ b/src/i18n/locales/zh-CN/common.json
@@ -45,7 +45,8 @@
"unknownApp": "未知应用",
"permissions": "权限列表",
"transactionDetails": "交易详情",
- "selectWallet": "选择钱包"
+ "selectWallet": "选择钱包",
+ "addWallet": "添加钱包"
},
"contact": {
"addTitle": "添加联系人",
diff --git a/src/i18n/locales/zh-CN/onboarding.json b/src/i18n/locales/zh-CN/onboarding.json
index 6b6a0106..92dc1ceb 100644
--- a/src/i18n/locales/zh-CN/onboarding.json
+++ b/src/i18n/locales/zh-CN/onboarding.json
@@ -31,6 +31,8 @@
"complete": "完成创建",
"defaultWalletName": "主钱包",
"createFailed": "创建钱包失败",
+ "themeTitle": "选择卡片主题",
+ "themeSubtitle": "为您的钱包卡片选择一个主题色",
"form": {
"walletName": "钱包名称",
"walletNamePlaceholder": "请输入钱包名称",
@@ -185,5 +187,14 @@
"noResults": "未找到匹配的链",
"selectAll": "全选",
"deselectAll": "取消全选"
+ },
+ "theme": {
+ "walletName": "钱包名称",
+ "previewChain": "预览链",
+ "customColor": "自定义颜色",
+ "presetColors": "预设颜色",
+ "usedColor": "已使用",
+ "similarExists": "已有相似颜色",
+ "auto": "自动"
}
}
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 2ecf4825..133a2121 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -19,7 +19,9 @@
"currency": "货币单位",
"chainConfig": "链配置",
"appearance": "外观",
- "aboutApp": "关于 BFM Pay"
+ "aboutApp": "关于 BFM Pay",
+ "clearData": "清空应用数据",
+ "storage": "存储空间"
},
"appearance": {
"title": "外观设置",
@@ -120,5 +122,25 @@
"manualAdded": "已添加链配置"
},
"hint": "提示:订阅配置会被缓存到本地,切换 URL 会清空旧缓存以避免混淆。"
+ },
+ "clearData": {
+ "title": "清空应用数据",
+ "warning": "此操作将删除所有本地数据,包括钱包、设置和缓存。此操作不可撤销!",
+ "item1": "所有钱包数据将被删除",
+ "item2": "所有设置将恢复默认",
+ "item3": "应用将重新启动",
+ "confirm": "确认清空"
+ },
+ "storage": {
+ "title": "存储空间",
+ "usage": "存储使用情况",
+ "basedOn": "基于浏览器存储配额",
+ "used": "已使用",
+ "total": "总空间",
+ "usagePercent": "使用率",
+ "available": "可用空间",
+ "unavailable": "无法获取存储信息",
+ "clearTitle": "清理数据",
+ "clearDesc": "清空所有本地存储的数据,包括钱包、设置和缓存。"
}
}
diff --git a/src/i18n/locales/zh-CN/transaction.json b/src/i18n/locales/zh-CN/transaction.json
index 024445e8..e9f8fbd0 100644
--- a/src/i18n/locales/zh-CN/transaction.json
+++ b/src/i18n/locales/zh-CN/transaction.json
@@ -66,7 +66,11 @@
"totalRecords": "共 {{count}} 条记录",
"emptyTitle": "暂无交易记录",
"emptyDesc": "当前筛选条件下没有交易记录",
+ "viewAll": "查看全部 {{count}} 条记录",
+ "viewAllChains": "查看全部网络交易",
"filter": {
+ "chainLabel": "网络",
+ "periodLabel": "时间",
"allChains": "全部链",
"allTime": "全部",
"days7": "7天",
diff --git a/src/i18n/locales/zh-CN/wallet.json b/src/i18n/locales/zh-CN/wallet.json
index 36bc131c..b191cf17 100644
--- a/src/i18n/locales/zh-CN/wallet.json
+++ b/src/i18n/locales/zh-CN/wallet.json
@@ -1,5 +1,6 @@
{
"add": "添加钱包",
+ "defaultName": "我的钱包",
"chains": {
"title": "管理钱包网络",
"verifyTitle": "验证钱包锁",
@@ -48,10 +49,12 @@
},
"detail": {
"title": "钱包详情",
+ "editTitle": "编辑钱包",
"notFound": "钱包不存在",
"createdAt": "创建于 {{date}}",
"chainAddresses": "链地址",
"editName": "编辑钱包名称",
+ "editTheme": "编辑主题色",
"exportMnemonic": "导出助记词",
"deleteWallet": "删除钱包",
"exportDeveloping": "助记词导出功能开发中",
diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json
index 2ed0ad45..264bc9b8 100644
--- a/src/i18n/locales/zh-TW/common.json
+++ b/src/i18n/locales/zh-TW/common.json
@@ -1,5 +1,6 @@
{
"a11y": {
+ "addWallet": "新增錢包",
"addContact": "新增聯絡人",
"appInfo": "應用程式資訊",
"back": "返回",
diff --git a/src/i18n/locales/zh-TW/onboarding.json b/src/i18n/locales/zh-TW/onboarding.json
index ad15da87..414f204c 100644
--- a/src/i18n/locales/zh-TW/onboarding.json
+++ b/src/i18n/locales/zh-TW/onboarding.json
@@ -107,7 +107,9 @@
"walletLockLabel": "密碼",
"walletLockMismatch": "兩次密碼不一致",
"walletLockPlaceholder": "輸入密碼",
- "setWalletLock": "設定密碼"
+ "setWalletLock": "設定密碼",
+ "themeTitle": "選擇卡片主題",
+ "themeSubtitle": "為您的錢包卡片選擇一個主題色"
},
"import": {
"complete": "完成匯入",
@@ -185,5 +187,14 @@
"noResults": "未找到匹配的鏈",
"selectAll": "全選",
"deselectAll": "取消全選"
+ },
+ "theme": {
+ "walletName": "錢包名稱",
+ "previewChain": "預覽鏈",
+ "customColor": "自訂顏色",
+ "presetColors": "預設顏色",
+ "usedColor": "已使用",
+ "similarExists": "已有相似顏色",
+ "auto": "自動"
}
}
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 4b8f45b3..c30f7452 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -19,7 +19,9 @@
"currency": "貨幣單位",
"chainConfig": "鏈配置",
"appearance": "外觀",
- "aboutApp": "關於 BFM Pay"
+ "aboutApp": "關於 BFM Pay",
+ "clearData": "清空應用程式資料",
+ "storage": "儲存空間"
},
"appearance": {
"title": "外觀設定",
@@ -120,5 +122,25 @@
"manualAdded": "已新增鏈配置"
},
"hint": "提示:訂閱配置會快取到本機,切換 URL 會清空舊快取以避免混淆。"
+ },
+ "clearData": {
+ "title": "清空應用程式資料",
+ "warning": "此操作將刪除所有本機資料,包括錢包、設定和快取。此操作不可撤銷!",
+ "item1": "所有錢包資料將被刪除",
+ "item2": "所有設定將恢復預設",
+ "item3": "應用程式將重新啟動",
+ "confirm": "確認清空"
+ },
+ "storage": {
+ "title": "儲存空間",
+ "usage": "儲存使用情況",
+ "basedOn": "基於瀏覽器儲存配額",
+ "used": "已使用",
+ "total": "總空間",
+ "usagePercent": "使用率",
+ "available": "可用空間",
+ "unavailable": "無法取得儲存資訊",
+ "clearTitle": "清理資料",
+ "clearDesc": "清空所有本機儲存的資料,包括錢包、設定和快取。"
}
}
diff --git a/src/i18n/locales/zh-TW/transaction.json b/src/i18n/locales/zh-TW/transaction.json
index 26143387..6d864779 100644
--- a/src/i18n/locales/zh-TW/transaction.json
+++ b/src/i18n/locales/zh-TW/transaction.json
@@ -110,6 +110,8 @@
"emptyDesc": "目前篩選條件下沒有交易記錄",
"emptyTitle": "暫無交易記錄",
"filter": {
+ "chainLabel": "網路",
+ "periodLabel": "時間",
"allChains": "全部鏈",
"allTime": "全部",
"days30": "30天",
@@ -118,7 +120,9 @@
},
"noWallet": "請先建立或匯入錢包",
"title": "交易記錄",
- "totalRecords": "共 {{count}} 筆記錄"
+ "totalRecords": "共 {{count}} 筆記錄",
+ "viewAll": "查看全部 {{count}} 筆記錄",
+ "viewAllChains": "查看全部網路交易"
},
"incorrectHandlingFee": "手續費不正確",
"initiateTransfer": "發起轉賬",
diff --git a/src/i18n/locales/zh-TW/wallet.json b/src/i18n/locales/zh-TW/wallet.json
index 4f27efe8..fd509299 100644
--- a/src/i18n/locales/zh-TW/wallet.json
+++ b/src/i18n/locales/zh-TW/wallet.json
@@ -2,6 +2,7 @@
" obtainingTheAddressPassowrdIsEquivalentToOwningTheWalletAsset": "獲得地址密碼等同於擁有錢包資產所有權",
"1AllWalletDataInTheOriginal_{appName}": "1. 原 中的錢包資料將全部保留,並進行資料整理,規則如下:",
"add": "添加錢包",
+ "defaultName": "我的錢包",
"chains": {
"title": "管理錢包網路",
"verifyTitle": "驗證錢包鎖",
@@ -68,6 +69,8 @@
"createdAt": "建立於 {{date}}",
"deleteWallet": "刪除錢包",
"editName": "編輯錢包名稱",
+ "editTheme": "編輯主題色",
+ "editTitle": "編輯錢包",
"exportDeveloping": "助記詞匯出功能開發中",
"exportMnemonic": "匯出助記詞",
"notFound": "錢包不存在",
diff --git a/src/main.tsx b/src/main.tsx
index 19f037fa..be9bc33f 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,6 +2,9 @@ import './polyfills'
import { startServiceMain } from './service-main'
import { startFrontendMain } from './frontend-main'
+// 禁用右键菜单(移动端 App 体验)
+document.addEventListener('contextmenu', (e) => e.preventDefault())
+
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Root element not found')
diff --git a/src/pages/authorize/address.test.tsx b/src/pages/authorize/address.test.tsx
index 015fbd97..e94d4f85 100644
--- a/src/pages/authorize/address.test.tsx
+++ b/src/pages/authorize/address.test.tsx
@@ -122,6 +122,7 @@ describe('AddressAuthPage', () => {
name: 'Wallet 1',
address: '0x1234567890abcdef1234567890abcdef12345678',
chain: 'ethereum',
+ themeHue: 323,
chainAddresses: [
{ chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', tokens: [] },
],
@@ -160,6 +161,7 @@ describe('AddressAuthPage', () => {
name: 'Wallet 1',
address: '0x1234567890abcdef1234567890abcdef12345678',
chain: 'ethereum',
+ themeHue: 323,
encryptedMnemonic,
chainAddresses: [{ chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', tokens: [] }],
})
@@ -207,6 +209,7 @@ describe('AddressAuthPage', () => {
name: 'Wallet 1',
address: '0x1234567890abcdef1234567890abcdef12345678',
chain: 'ethereum',
+ themeHue: 323,
encryptedMnemonic,
chainAddresses: [{ chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', tokens: [] }],
})
@@ -251,6 +254,7 @@ describe('AddressAuthPage', () => {
name: 'Wallet 1',
address: '0x1234567890abcdef1234567890abcdef12345678',
chain: 'ethereum',
+ themeHue: 323,
chainAddresses: [{ chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', tokens: [] }],
})
@@ -281,6 +285,7 @@ describe('AddressAuthPage', () => {
name: 'Wallet 1',
address: '0x1234567890abcdef1234567890abcdef12345678',
chain: 'ethereum',
+ themeHue: 323,
chainAddresses: [{ chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', tokens: [] }],
})
diff --git a/src/pages/authorize/address.tsx b/src/pages/authorize/address.tsx
index 0dcfcde4..c688ce1c 100644
--- a/src/pages/authorize/address.tsx
+++ b/src/pages/authorize/address.tsx
@@ -7,7 +7,7 @@ import { PageHeader } from '@/components/layout/page-header'
import { AppInfoCard } from '@/components/authorize/AppInfoCard'
import { PermissionList } from '@/components/authorize/PermissionList'
import { Button } from '@/components/ui/button'
-import { WalletSelector } from '@/components/wallet/wallet-selector'
+import { WalletSelector, type WalletInfo } from '@/components/wallet'
import { ChainAddressSelector, type ChainData } from '@/components/wallet/chain-address-selector'
import type { ChainType as ChainIconType } from '@/components/wallet/chain-icon'
import { cn } from '@/lib/utils'
@@ -44,7 +44,7 @@ function toChainIconType(chainName: string | undefined): ChainIconType | undefin
return chainName
}
-function toWalletSelectorItems(wallets: Wallet[]) {
+function toWalletSelectorItems(wallets: Wallet[]): WalletInfo[] {
return wallets.map((w) => ({
id: w.id,
name: w.name,
diff --git a/src/pages/authorize/signature.test.tsx b/src/pages/authorize/signature.test.tsx
index baba0ba3..69eab4c5 100644
--- a/src/pages/authorize/signature.test.tsx
+++ b/src/pages/authorize/signature.test.tsx
@@ -93,6 +93,7 @@ describe('SignatureAuthPage', () => {
name: 'Wallet 1',
address: '0x1234567890abcdef1234567890abcdef12345678',
chain: 'ethereum',
+ themeHue: 323,
encryptedMnemonic: { ciphertext: 'x', salt: 'y', iv: 'z', iterations: 100000 },
chainAddresses: [{ chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', tokens: [] }],
})
@@ -139,6 +140,7 @@ describe('SignatureAuthPage', () => {
name: 'Wallet 1',
address: '0x1234567890abcdef1234567890abcdef12345678',
chain: 'ethereum',
+ themeHue: 323,
encryptedMnemonic: { ciphertext: 'x', salt: 'y', iv: 'z', iterations: 100000 },
chainAddresses: [{ chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', tokens: [] }],
})
diff --git a/src/pages/history/index.tsx b/src/pages/history/index.tsx
index 0fc5e4fa..36e37f44 100644
--- a/src/pages/history/index.tsx
+++ b/src/pages/history/index.tsx
@@ -11,7 +11,12 @@ import { cn } from '@/lib/utils';
import type { TransactionInfo } from '@/components/transaction/transaction-item';
import type { ChainType } from '@/stores';
-export function TransactionHistoryPage() {
+interface TransactionHistoryPageProps {
+ /** 初始链过滤器,'all' 表示全部链 */
+ initialChain?: ChainType | 'all' | undefined;
+}
+
+export function TransactionHistoryPage({ initialChain }: TransactionHistoryPageProps) {
const { navigate, goBack } = useNavigation();
const currentWallet = useCurrentWallet();
const enabledChains = useEnabledChains();
@@ -35,10 +40,11 @@ export function TransactionHistoryPage() {
})),
], [t, enabledChains]);
- // 初始化时设置默认过滤器为当前选中的网络
+ // 初始化时设置过滤器:优先使用传入的 initialChain,否则使用当前选中的网络
useEffect(() => {
- if (selectedChain && filter.chain !== selectedChain) {
- setFilter({ ...filter, chain: selectedChain });
+ const targetChain = initialChain ?? selectedChain;
+ if (targetChain && filter.chain !== targetChain) {
+ setFilter({ ...filter, chain: targetChain });
}
}, []);
@@ -105,42 +111,52 @@ export function TransactionHistoryPage() {
{/* 过滤器栏 */}
-
+
{/* 链选择器 */}
-
+
+ {t('history.filter.chainLabel')}
+
+
{/* 时间段选择器 */}
-
+
+ {t('history.filter.periodLabel')}
+
+
{/* 结果统计 */}
@@ -157,6 +173,7 @@ export function TransactionHistoryPage() {
onTransactionClick={handleTransactionClick}
emptyTitle={t('transaction:history.emptyTitle')}
emptyDescription={t('transaction:history.emptyDesc')}
+ showChainIcon
/>
diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx
deleted file mode 100644
index b8059f63..00000000
--- a/src/pages/home/index.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useNavigation, useFlow } from '@/stackflow';
-import { TokenList } from '@/components/token/token-list';
-import { GradientButton } from '@/components/common/gradient-button';
-import { Button } from '@/components/ui/button';
-import { LoadingSpinner } from '@/components/common/loading-spinner';
-import { ChainIcon } from '@/components/wallet/chain-icon';
-import { useClipboard, useToast, useHaptics } from '@/services';
-import {
- IconPlus as Plus,
- IconSend as Send,
- IconQrcode as QrCode,
- IconCopy as Copy,
- IconChevronDown as ChevronDown,
- IconCheck as Check,
- IconLineScan as ScanLine,
-} from '@tabler/icons-react';
-
-/** 截断地址显示 */
-function truncateAddress(address: string, startChars = 6, endChars = 4): string {
- if (address.length <= startChars + endChars + 3) return address;
- return `${address.slice(0, startChars)}...${address.slice(-endChars)}`;
-}
-import {
- useCurrentWallet,
- useSelectedChain,
- useCurrentChainAddress,
- useCurrentChainTokens,
- useHasWallet,
- useWalletInitialized,
- type ChainType,
-} from '@/stores';
-
-const CHAIN_NAMES: Record = {
- // 外部链
- ethereum: 'Ethereum',
- bitcoin: 'Bitcoin',
- tron: 'Tron',
- binance: 'BSC',
- // BioForest 链
- bfmeta: 'BFMeta',
- ccchain: 'CCChain',
- pmchain: 'PMChain',
- bfchainv2: 'BFChain V2',
- btgmeta: 'BTGMeta',
- biwmeta: 'BIWMeta',
- ethmeta: 'ETHMeta',
- malibu: 'Malibu',
-};
-
-export function HomePage() {
- const { navigate } = useNavigation();
- const { push } = useFlow();
- const clipboard = useClipboard();
- const toast = useToast();
- const haptics = useHaptics();
- const { t } = useTranslation(['home', 'common']);
-
- const isInitialized = useWalletInitialized();
- const hasWallet = useHasWallet();
- const currentWallet = useCurrentWallet();
- const selectedChain = useSelectedChain();
- const selectedChainName = CHAIN_NAMES[selectedChain] ?? selectedChain;
- const chainAddress = useCurrentChainAddress();
- const tokens = useCurrentChainTokens();
-
- const [copied, setCopied] = useState(false);
-
- const handleCopyAddress = async () => {
- if (chainAddress?.address) {
- await clipboard.write({ text: chainAddress.address });
- await haptics.impact('light');
- setCopied(true);
- toast.show(t('wallet.addressCopied'));
- setTimeout(() => setCopied(false), 2000);
- }
- };
-
- const handleOpenChainSelector = () => {
- push('ChainSelectorJob', {});
- };
-
- if (!isInitialized) {
- return (
-
-
-
- );
- }
-
- if (!hasWallet || !currentWallet) {
- return ;
- }
-
- return (
-
- {/* 钱包卡片 - mpay 风格 */}
-
- {/* 链选择器 */}
-
-
- {/* 钱包名和地址 */}
-
-
{currentWallet.name}
-
-
- {chainAddress?.address ? truncateAddress(chainAddress.address) : '---'}
-
-
-
-
-
- {/* 操作按钮 - mpay 三按钮布局 */}
-
-
- navigate({ to: '/send' })}>
-
- {t('wallet.send')}
-
-
-
- navigate({ to: '/receive' })}>
-
- {t('wallet.receive')}
-
-
-
-
-
- {/* 资产列表 */}
-
-
{t('wallet.assets')}
- ({
- symbol: token.symbol,
- name: token.name,
- chain: selectedChain,
- balance: token.balance,
- fiatValue: token.fiatValue ? String(token.fiatValue) : undefined,
- change24h: token.change24h,
- icon: token.icon,
- }))}
- onTokenClick={(token) => {
- // TODO: Implement token detail page route once available
- console.log('Token clicked:', token.symbol);
- }}
- emptyTitle={t('wallet.noAssets')}
- emptyDescription={t('wallet.noAssetsOnChain', { chain: selectedChainName })}
- />
-
-
- {/* Scanner FAB */}
-
-
- );
-}
-
-function NoWalletView() {
- const { navigate } = useNavigation();
- const { t } = useTranslation(['home', 'common']);
-
- return (
-
-
-
-
{t('welcome.title')}
-
{t('welcome.subtitle')}
-
-
- navigate({ to: '/wallet/create' })}>
- {t('welcome.createWallet')}
-
-
-
-
- );
-}
diff --git a/src/pages/onboarding/recover.tsx b/src/pages/onboarding/recover.tsx
index 94b1f0ca..01aa1814 100644
--- a/src/pages/onboarding/recover.tsx
+++ b/src/pages/onboarding/recover.tsx
@@ -1,4 +1,4 @@
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useEffect } from 'react';
import { useNavigation, useFlow } from '@/stackflow';
import { setSecurityWarningConfirmCallback } from '@/stackflow/activities/sheets';
import { useTranslation } from 'react-i18next';
@@ -10,14 +10,21 @@ import { ChainAddressPreview, type DerivedAddress } from '@/components/onboardin
import { CollisionConfirmDialog } from '@/components/onboarding/collision-confirm-dialog';
import { ImportWalletSuccess } from '@/components/onboarding/import-wallet-success';
import { PatternLockSetup } from '@/components/security/pattern-lock-setup';
+import { ChainSelector, getDefaultSelectedChains } from '@/components/onboarding/chain-selector';
+import { WalletConfig } from '@/components/wallet/wallet-config';
import { Button } from '@/components/ui/button';
+import { GradientButton } from '@/components/common/gradient-button';
+import { IconCircle } from '@/components/common/icon-circle';
+import { LoadingSpinner } from '@/components/common/loading-spinner';
import { useDuplicateDetection } from '@/hooks/use-duplicate-detection';
import { deriveMultiChainKeys, deriveBioforestAddresses } from '@/lib/crypto';
-import { useChainConfigState, useEnabledBioforestChainConfigs, walletActions } from '@/stores';
+import { deriveThemeHue } from '@/hooks/useWalletTheme';
+import { useChainConfigs, useChainConfigState, useEnabledBioforestChainConfigs, walletActions } from '@/stores';
import type { IWalletQuery } from '@/services/wallet/types';
-import { IconAlertCircle as AlertCircle, IconLoader2 as Loader2 } from '@tabler/icons-react';
+import { IconAlertCircle as AlertCircle, IconLoader2 as Loader2, IconCircleCheck as CheckCircle } from '@tabler/icons-react';
+import { ProgressSteps } from '@/components/common/step-indicator';
-type Step = 'keyType' | 'mnemonic' | 'arbitrary' | 'pattern' | 'collision' | 'success';
+type Step = 'keyType' | 'mnemonic' | 'arbitrary' | 'pattern' | 'chains' | 'theme' | 'collision' | 'success';
// Mock wallet query for now - will be replaced with real implementation
const mockWalletQuery: IWalletQuery = {
@@ -34,6 +41,7 @@ export function OnboardingRecoverPage() {
const { navigate, goBack } = useNavigation();
const { push } = useFlow();
const { t } = useTranslation(['onboarding', 'common', 'wallet']);
+ const chainConfigs = useChainConfigs();
const chainConfigSnapshot = useChainConfigState().snapshot;
const enabledBioforestChainConfigs = useEnabledBioforestChainConfigs();
const [step, setStep] = useState('keyType');
@@ -45,11 +53,39 @@ export function OnboardingRecoverPage() {
const [arbitraryDerivedAddresses, setArbitraryDerivedAddresses] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
- const [recoveredWalletName, setRecoveredWalletName] = useState('');
+ const [patternKey, setPatternKey] = useState('');
+ const [createdWalletId, setCreatedWalletId] = useState(null);
+
+ // 链选择状态
+ const [selectedChainIds, setSelectedChainIds] = useState([]);
+ const [initializedSelection, setInitializedSelection] = useState(false);
+
+ // 初始化默认链选择
+ useEffect(() => {
+ if (!initializedSelection && chainConfigs.length > 0) {
+ setSelectedChainIds(getDefaultSelectedChains(chainConfigs));
+ setInitializedSelection(true);
+ }
+ }, [chainConfigs, initializedSelection]);
// Duplicate detection hook
const duplicateDetection = useDuplicateDetection(mockWalletQuery);
+ // 计算进度条当前步骤 (5步: keyType -> mnemonic/arbitrary -> pattern -> chains -> theme)
+ const currentStepIndex = (() => {
+ switch (step) {
+ case 'keyType': return 1;
+ case 'mnemonic':
+ case 'arbitrary': return 2;
+ case 'collision': return 2; // collision 不算独立步骤
+ case 'pattern': return 3;
+ case 'chains': return 4;
+ case 'theme': return 5;
+ case 'success': return 5;
+ default: return 1;
+ }
+ })();
+
const handleBack = useCallback(() => {
switch (step) {
case 'keyType':
@@ -65,9 +101,14 @@ export function OnboardingRecoverPage() {
duplicateDetection.reset();
break;
case 'pattern':
-
setStep(keyType === 'arbitrary' ? 'arbitrary' : 'mnemonic');
break;
+ case 'chains':
+ setStep('pattern');
+ break;
+ case 'theme':
+ setStep('chains');
+ break;
case 'success':
// Can't go back from success
break;
@@ -75,10 +116,14 @@ export function OnboardingRecoverPage() {
}, [step, goBack, duplicateDetection, keyType]);
const goToPatternStep = useCallback(() => {
-
setStep('pattern');
}, []);
+ const handlePatternComplete = useCallback((key: string) => {
+ setPatternKey(key);
+ setStep('chains');
+ }, []);
+
const handleKeyTypeContinue = useCallback(() => {
if (keyType === 'mnemonic') {
setStep('mnemonic');
@@ -141,59 +186,96 @@ export function OnboardingRecoverPage() {
try {
const mnemonicStr = words.join(' ');
- // Generate wallet name (auto-increment pattern)
- // TODO: Use wallet storage service for actual name generation
- const walletName = `钱包 ${Date.now() % 1000}`;
+ // 根据选中的链配置派生地址
+ const selectedConfigs = chainConfigs.filter((config) => selectedChainIds.includes(config.id));
+ const selectedBioforestConfigs = selectedConfigs.filter((config) => config.type === 'bioforest');
+ const selectedBip39Ids = new Set(
+ selectedConfigs.filter((config) => config.type === 'bip39').map((config) => config.id),
+ );
+ const selectedEvmConfigs = selectedConfigs.filter(
+ (config) => config.type === 'evm' || config.type === 'custom',
+ );
+
+ const externalChains: Array<'ethereum' | 'bitcoin' | 'tron'> = [];
+ if (selectedEvmConfigs.length > 0) externalChains.push('ethereum');
+ if (selectedBip39Ids.has('bitcoin')) externalChains.push('bitcoin');
+ if (selectedBip39Ids.has('tron')) externalChains.push('tron');
+
+ const externalKeys = externalChains.length > 0
+ ? deriveMultiChainKeys(mnemonicStr, externalChains, 0)
+ : [];
+
+ const addressByChain = new Map();
+ const ethKey = externalKeys.find((k) => k.chain === 'ethereum');
+ if (ethKey) {
+ if (selectedChainIds.includes('ethereum')) {
+ addressByChain.set('ethereum', ethKey.address);
+ }
+ for (const config of selectedEvmConfigs) {
+ addressByChain.set(config.id, ethKey.address);
+ }
+ }
+
+ const bitcoinKey = externalKeys.find((k) => k.chain === 'bitcoin');
+ if (bitcoinKey && selectedBip39Ids.has('bitcoin')) {
+ addressByChain.set('bitcoin', bitcoinKey.address);
+ }
- // Derive external chain addresses (BIP44)
- const externalKeys = deriveMultiChainKeys(mnemonicStr, ['ethereum', 'bitcoin', 'tron'], 0);
+ const tronKey = externalKeys.find((k) => k.chain === 'tron');
+ if (tronKey && selectedBip39Ids.has('tron')) {
+ addressByChain.set('tron', tronKey.address);
+ }
- // Derive BioForest chain addresses (Ed25519)
- // Use enabled configs if available, otherwise fallback to built-in chains
- const bioforestConfigs = enabledBioforestChainConfigs.length > 0 ? enabledBioforestChainConfigs : undefined;
const bioforestChainAddresses = deriveBioforestAddresses(
mnemonicStr,
- bioforestConfigs,
- ).map((item) => ({
- chain: item.chainId,
- address: item.address,
- tokens: [],
- }));
+ selectedBioforestConfigs.length > 0 ? selectedBioforestConfigs : [],
+ );
+ for (const item of bioforestChainAddresses) {
+ addressByChain.set(item.chainId, item.address);
+ }
- const ethKey = externalKeys.find((k) => k.chain === 'ethereum')!;
+ const chainAddresses = selectedChainIds
+ .map((chainId) => {
+ const address = addressByChain.get(chainId);
+ if (!address) return null;
+ return {
+ chain: chainId,
+ address,
+ tokens: [],
+ };
+ })
+ .filter((item): item is { chain: string; address: string; tokens: [] } => Boolean(item));
+
+ const primaryChain = chainAddresses[0];
+ if (!primaryChain) {
+ throw new Error('No chain addresses derived');
+ }
- // Combine all chain addresses
- const chainAddresses = [
- ...externalKeys.map((key) => ({
- chain: key.chain as 'ethereum' | 'bitcoin' | 'tron',
- address: key.address,
- tokens: [],
- })),
- ...bioforestChainAddresses,
- ];
+ const hue = deriveThemeHue(primaryChain.address);
- // Create wallet with skipBackup=true (recovery doesn't need backup prompt)
- await walletActions.createWallet(
+ const wallet = await walletActions.createWallet(
{
- name: walletName,
+ name: t('wallet:defaultName'),
keyType: 'mnemonic',
- address: ethKey.address,
- chain: 'ethereum',
+ address: primaryChain.address,
+ chain: primaryChain.chain,
chainAddresses,
+ themeHue: hue,
},
mnemonicStr,
- walletPassword
+ walletPassword,
+ hue
);
- setRecoveredWalletName(walletName);
- setStep('success');
+ setCreatedWalletId(wallet.id);
+ setStep('theme');
} catch (error) {
console.error('恢复钱包失败:', error);
} finally {
setIsSubmitting(false);
}
},
- [chainConfigSnapshot, enabledBioforestChainConfigs],
+ [chainConfigs, selectedChainIds, t],
);
const createArbitraryWallet = useCallback(
@@ -204,7 +286,9 @@ export function OnboardingRecoverPage() {
setIsSubmitting(true);
try {
- const walletName = `${t('wallet:wallet')} ${Date.now() % 1000}`;
+ const primary = arbitraryDerivedAddresses[0];
+ if (!primary) throw new Error('No enabled bioforest chains');
+ const hue = deriveThemeHue(primary.address);
const chainAddresses = arbitraryDerivedAddresses.map((item) => ({
chain: item.chainId,
@@ -212,38 +296,40 @@ export function OnboardingRecoverPage() {
tokens: [],
}));
- const primary = chainAddresses[0];
- if (!primary) throw new Error('No enabled bioforest chains');
-
- await walletActions.createWallet(
+ const wallet = await walletActions.createWallet(
{
- name: walletName,
+ name: t('wallet:defaultName'),
keyType: 'arbitrary',
address: primary.address,
- chain: primary.chain,
+ chain: primary.chainId,
chainAddresses,
+ themeHue: hue,
},
secret,
- walletPassword
+ walletPassword,
+ hue
);
- setRecoveredWalletName(walletName);
- setStep('success');
+ setCreatedWalletId(wallet.id);
+ setStep('theme');
} catch (error) {
console.error('导入密钥钱包失败:', error);
} finally {
setIsSubmitting(false);
}
},
- [arbitraryDerivedAddresses, arbitrarySecret],
+ [arbitraryDerivedAddresses, arbitrarySecret, t],
);
- const handlePatternComplete = useCallback(async (key: string) => {
+ const handleEditOnlyComplete = useCallback(() => {
+ navigate({ to: '/', replace: true });
+ }, [navigate]);
+
+ const handleChainsContinue = useCallback(async () => {
if (isSubmitting) return;
-
if (keyType === 'arbitrary') {
- await createArbitraryWallet(key);
+ await createArbitraryWallet(patternKey);
return;
}
@@ -251,8 +337,8 @@ export function OnboardingRecoverPage() {
return;
}
- await createWallet(mnemonic, key);
- }, [createArbitraryWallet, createWallet, isSubmitting, keyType, mnemonic]);
+ await createWallet(mnemonic, patternKey);
+ }, [createArbitraryWallet, createWallet, isSubmitting, keyType, mnemonic, patternKey]);
const handleEnterWallet = useCallback(() => {
navigate({ to: '/' });
@@ -263,6 +349,9 @@ export function OnboardingRecoverPage() {
{step === 'keyType' && (
<>
+
+
@@ -293,6 +385,9 @@ export function OnboardingRecoverPage() {
{step === 'arbitrary' && (
<>
+
+
+
)}
+ {step === 'chains' && (
+ <>
+
+
+
+
+
+
+
{t('onboarding:chainSelector.title')}
+
{t('onboarding:chainSelector.subtitle')}
+
+
+ {chainConfigs.length === 0 ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {t('common:next')}
+
+
+
+ >
+ )}
+
+ {step === 'theme' && createdWalletId && (
+ <>
+
+
+
+
+
+ >
+ )}
+
{step === 'success' && (
)}
diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx
index ae0afa84..385f32c5 100644
--- a/src/pages/settings/index.tsx
+++ b/src/pages/settings/index.tsx
@@ -15,6 +15,7 @@ import {
IconNetwork as Network,
IconLink as Link,
IconInfoCircle as Info,
+ IconDatabase as Database,
} from '@tabler/icons-react';
import { PageHeader } from '@/components/layout/page-header';
import { useCurrentWallet, useLanguage, useCurrency, useTheme, chainConfigStore, chainConfigSelectors } from '@/stores';
@@ -275,6 +276,13 @@ export function SettingsPage() {
// TODO: 关于页面
}}
/>
+
+
}
+ label={t('settings:items.storage')}
+ onClick={() => navigate({ to: '/settings/storage' })}
+ testId="storage-button"
+ />
diff --git a/src/pages/settings/storage.tsx b/src/pages/settings/storage.tsx
new file mode 100644
index 00000000..4f899053
--- /dev/null
+++ b/src/pages/settings/storage.tsx
@@ -0,0 +1,133 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PageHeader } from '@/components/layout/page-header';
+import { Button } from '@/components/ui/button';
+import { useNavigation, useFlow } from '@/stackflow';
+import { IconDatabase, IconTrash, IconLoader2 } from '@tabler/icons-react';
+
+interface StorageEstimate {
+ usage: number;
+ quota: number;
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
+}
+
+export function SettingsStoragePage() {
+ const { t } = useTranslation(['settings', 'common']);
+ const { goBack } = useNavigation();
+ const { push } = useFlow();
+ const [estimate, setEstimate] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const fetchEstimate = useCallback(async () => {
+ setLoading(true);
+ try {
+ if (navigator.storage && navigator.storage.estimate) {
+ const est = await navigator.storage.estimate();
+ setEstimate({
+ usage: est.usage ?? 0,
+ quota: est.quota ?? 0,
+ });
+ }
+ } catch (error) {
+ console.error('Failed to get storage estimate:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchEstimate();
+ }, [fetchEstimate]);
+
+ const usagePercent = estimate ? (estimate.usage / estimate.quota) * 100 : 0;
+
+ return (
+
+
+
+
+ {/* 存储使用情况 */}
+
+
+
+
+
+
+
{t('settings:storage.usage')}
+
+ {t('settings:storage.basedOn')}
+
+
+
+
+ {loading ? (
+
+
+
+ ) : estimate ? (
+
+ {/* 进度条 */}
+
+
+
+
+ {t('settings:storage.used')}: {formatBytes(estimate.usage)}
+
+
+ {t('settings:storage.total')}: {formatBytes(estimate.quota)}
+
+
+
+
+ {/* 详细信息 */}
+
+
+ {t('settings:storage.usagePercent')}
+ {usagePercent.toFixed(2)}%
+
+
+ {t('settings:storage.available')}
+
+ {formatBytes(estimate.quota - estimate.usage)}
+
+
+
+
+ ) : (
+
+ {t('settings:storage.unavailable')}
+
+ )}
+
+
+ {/* 清理数据 */}
+
+
{t('settings:storage.clearTitle')}
+
+ {t('settings:storage.clearDesc')}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/wallet/create.tsx b/src/pages/wallet/create.tsx
index f53bbca8..89ae536d 100644
--- a/src/pages/wallet/create.tsx
+++ b/src/pages/wallet/create.tsx
@@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/common/loading-spinner';
import { MnemonicDisplay } from '@/components/security/mnemonic-display';
import { PatternLockSetup } from '@/components/security/pattern-lock-setup';
import { ChainSelector, getDefaultSelectedChains } from '@/components/onboarding/chain-selector';
+import { WalletConfig } from '@/components/wallet/wallet-config';
import { FormField } from '@/components/common/form-field';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
@@ -22,14 +23,15 @@ import {
} from '@tabler/icons-react';
import { useChainConfigs, walletActions } from '@/stores';
import { generateMnemonic, deriveMultiChainKeys, deriveBioforestAddresses } from '@/lib/crypto';
+import { deriveThemeHue } from '@/hooks/useWalletTheme';
import type { ChainConfig } from '@/services/chain-config';
-type Step = 'pattern' | 'mnemonic' | 'verify' | 'chains';
+type Step = 'pattern' | 'mnemonic' | 'verify' | 'chains' | 'theme';
-const STEPS: Step[] = ['pattern', 'mnemonic', 'verify', 'chains'];
+const STEPS: Step[] = ['pattern', 'mnemonic', 'verify', 'chains', 'theme'];
export function WalletCreatePage() {
- const { navigate, goBack } = useNavigation();
+ const { goBack, navigate } = useNavigation();
const { t } = useTranslation('onboarding');
const chainConfigs = useChainConfigs();
const [step, setStep] = useState('pattern');
@@ -39,6 +41,8 @@ export function WalletCreatePage() {
const [mnemonicCopied, setMnemonicCopied] = useState(false);
const [selectedChainIds, setSelectedChainIds] = useState([]);
const [initializedSelection, setInitializedSelection] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [createdWalletId, setCreatedWalletId] = useState(null);
const currentStepIndex = STEPS.indexOf(step) + 1;
@@ -57,6 +61,8 @@ export function WalletCreatePage() {
setStep('mnemonic');
} else if (step === 'chains') {
setStep('verify');
+ } else if (step === 'theme') {
+ // 不允许从 theme 返回,钱包已创建
} else {
goBack();
}
@@ -77,10 +83,8 @@ export function WalletCreatePage() {
setStep('chains');
};
- const [isCreating, setIsCreating] = useState(false);
-
- const handleComplete = async () => {
- if (isCreating || selectedChainIds.length === 0) return;
+ const handleChainsContinue = async () => {
+ if (isCreating) return;
setIsCreating(true);
try {
@@ -150,32 +154,43 @@ export function WalletCreatePage() {
throw new Error('No chain addresses derived');
}
- await walletActions.createWallet(
+ const themeHue = deriveThemeHue(primaryChain.address);
+
+ const wallet = await walletActions.createWallet(
{
name: t('create.defaultWalletName'),
keyType: 'mnemonic',
address: primaryChain.address,
chain: primaryChain.chain,
chainAddresses,
+ themeHue,
},
mnemonicStr,
- patternKey
+ patternKey,
+ themeHue
);
- navigate({ to: '/' });
+ // 保存钱包ID,进入主题编辑步骤
+ setCreatedWalletId(wallet.id);
+ setStep('theme');
} catch (error) {
console.error(t('create.createFailed'), error);
+ } finally {
setIsCreating(false);
}
};
+ const handleEditOnlyComplete = () => {
+ navigate({ to: '/', replace: true });
+ };
+
return (
{/* 进度指示器 */}
@@ -214,12 +229,22 @@ export function WalletCreatePage() {
selectedChains={selectedChainIds}
selectionCount={selectedChainIds.length}
onSelectionChange={setSelectedChainIds}
- onComplete={handleComplete}
- completeLabel={t('create.complete')}
+ onComplete={handleChainsContinue}
+ completeLabel={t('create.nextStep')}
isSubmitting={isCreating}
/>
)}
+
+ {step === 'theme' && createdWalletId && (
+
+
+
+ )}
);
@@ -394,3 +419,5 @@ function ChainSelectionStep({
);
}
+
+
diff --git a/src/pages/wallet/detail.tsx b/src/pages/wallet/detail.tsx
deleted file mode 100644
index cabcd662..00000000
--- a/src/pages/wallet/detail.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useNavigation, useActivityParams, useFlow } from '@/stackflow';
-import { PageHeader } from '@/components/layout/page-header';
-import { AddressDisplay } from '@/components/wallet/address-display';
-import { ChainIcon } from '@/components/wallet/chain-icon';
-import { Button } from '@/components/ui/button';
-import { Alert } from '@/components/common/alert';
-import { useHaptics } from '@/services';
-import {
- IconCircleKey as KeyRound,
- IconTrash as Trash2,
- IconPencilMinus as Edit3,
- IconShield as Shield,
-} from '@tabler/icons-react';
-import { useWallets, walletActions, type ChainType } from '@/stores';
-
-const CHAIN_NAMES: Record = {
- ethereum: 'Ethereum',
- bitcoin: 'Bitcoin',
- tron: 'Tron',
- binance: 'BSC',
- bfmeta: 'BFMeta',
- ccchain: 'CCChain',
- pmchain: 'PMChain',
- bfchainv2: 'BFChain V2',
- btgmeta: 'BTGMeta',
- biwmeta: 'BIWMeta',
- ethmeta: 'ETHMeta',
- malibu: 'Malibu',
-};
-
-export function WalletDetailPage() {
- const { t } = useTranslation('wallet');
- const { walletId } = useActivityParams<{ walletId: string }>();
- const { goBack, navigate } = useNavigation();
- const { push } = useFlow();
- const haptics = useHaptics();
-
- const wallets = useWallets();
- const wallet = wallets.find((w) => w.id === walletId);
-
- const handleExportMnemonic = useCallback(async () => {
- if (!wallet) return;
-
- await haptics.impact('success');
- await walletActions.setCurrentWallet(wallet.id);
- navigate({ to: '/settings/mnemonic' });
- }, [haptics, navigate, wallet]);
-
- const handleOpenEdit = useCallback(() => {
- if (!wallet) return;
- push("WalletRenameJob", { walletId: wallet.id });
- }, [push, wallet]);
-
- const handleOpenDelete = useCallback(() => {
- if (!wallet) return;
- push("WalletDeleteJob", { walletId: wallet.id });
- }, [push, wallet]);
-
- if (!wallet) {
- return (
-
-
-
-
{t('detail.notFound')}
-
-
- );
- }
-
- return (
-
-
-
-
- {/* Wallet info */}
-
-
-
-
-
-
-
{wallet.name}
-
{t('detail.createdAt', { date: new Date(wallet.createdAt).toLocaleDateString() })}
-
-
-
-
- {/* Chain addresses */}
-
-
{t('detail.chainAddresses')}
- {wallet.chainAddresses.map((chainAddr) => (
-
-
-
-
{CHAIN_NAMES[chainAddr.chain] ?? chainAddr.chain}
-
-
-
- ))}
-
-
- {/* Action buttons */}
-
-
-
-
-
-
-
-
- {/* Security warning */}
-
{t('detail.securityWarning')}
-
-
- );
-}
diff --git a/src/pages/wallet/list.stories.tsx b/src/pages/wallet/list.stories.tsx
index e0a184d5..5ca439fe 100644
--- a/src/pages/wallet/list.stories.tsx
+++ b/src/pages/wallet/list.stories.tsx
@@ -45,6 +45,7 @@ const createMockWallet = (id: string, name: string): Wallet => ({
},
],
createdAt: Date.now() - 86400000 * Number(id), // Days ago
+ themeHue: 323,
})
/**
diff --git a/src/pages/wallet/list.test.tsx b/src/pages/wallet/list.test.tsx
index aeda6c25..9eff4d07 100644
--- a/src/pages/wallet/list.test.tsx
+++ b/src/pages/wallet/list.test.tsx
@@ -47,6 +47,7 @@ const createMockWallet = (id: string, name: string, _isActive: boolean = false):
},
],
createdAt: Date.now(),
+ themeHue: 323,
})
describe('WalletListPage', () => {
diff --git a/src/routes/authorize/index.ts b/src/routes/authorize/index.ts
deleted file mode 100644
index f1e62925..00000000
--- a/src/routes/authorize/index.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { lazy } from 'react'
-import { createRoute } from '@tanstack/react-router'
-import type React from 'react'
-import type { AnyRoute, RouteComponent } from '@tanstack/react-router'
-import type { AddressAuthType } from '@/services/authorize'
-
-type WithSuspense = (
- Component: React.LazyExoticComponent>
-) => RouteComponent
-
-function parseAddressAuthType(value: unknown): AddressAuthType | undefined {
- if (value === 'main' || value === 'network' || value === 'all') return value
- return undefined
-}
-
-export function createAuthorizeRoutes({
- rootRoute,
- withSuspense,
-}: {
- rootRoute: AnyRoute
- withSuspense: WithSuspense
-}) {
- const AddressAuthPage = lazy(() =>
- import('@/pages/authorize/address').then((m) => ({ default: m.AddressAuthPage }))
- )
-
- const SignatureAuthPage = lazy(() =>
- import('@/pages/authorize/signature').then((m) => ({ default: m.SignatureAuthPage }))
- )
-
- const authorizeRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/authorize',
- })
-
- const authorizeAddressRoute = createRoute({
- getParentRoute: () => authorizeRoute,
- path: '/address/$id',
- component: withSuspense(AddressAuthPage),
- validateSearch: (search: Record) => ({
- type: parseAddressAuthType(search.type) ?? 'main',
- chainName: typeof search.chainName === 'string' ? search.chainName : undefined,
- signMessage: typeof search.signMessage === 'string' ? search.signMessage : undefined,
- getMain:
- typeof search.getMain === 'string'
- ? search.getMain
- : typeof search.getMain === 'boolean'
- ? String(search.getMain)
- : undefined,
- }),
- })
-
- const authorizeSignatureRoute = createRoute({
- getParentRoute: () => authorizeRoute,
- path: '/signature/$id',
- component: withSuspense(SignatureAuthPage),
- validateSearch: (search: Record) => ({
- signaturedata:
- typeof search.signaturedata === 'string'
- ? search.signaturedata
- : search.signaturedata !== null && typeof search.signaturedata === 'object'
- ? JSON.stringify(search.signaturedata)
- : undefined,
- }),
- })
-
- return {
- authorizeRoute: authorizeRoute.addChildren([authorizeAddressRoute, authorizeSignatureRoute]),
- }
-}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
deleted file mode 100644
index 49bada41..00000000
--- a/src/routes/index.tsx
+++ /dev/null
@@ -1,297 +0,0 @@
-import { lazy, Suspense } from 'react'
-import {
- createRouter,
- createRootRoute,
- createRoute,
- createHashHistory,
- Outlet,
-} from '@tanstack/react-router'
-import { AppLayout } from '@/components/layout/app-layout'
-import { LoadingSpinner } from '@/components/common/loading-spinner'
-import { createAuthorizeRoutes } from './authorize'
-
-// 懒加载页面组件
-const HomePage = lazy(() => import('@/pages/home').then((m) => ({ default: m.HomePage })))
-const WalletCreatePage = lazy(() => import('@/pages/wallet/create').then((m) => ({ default: m.WalletCreatePage })))
-const WalletDetailPage = lazy(() => import('@/pages/wallet/detail').then((m) => ({ default: m.WalletDetailPage })))
-const WalletListPage = lazy(() => import('@/pages/wallet/list').then((m) => ({ default: m.WalletListPage })))
-const TokenDetailPage = lazy(() => import('@/pages/token/detail').then((m) => ({ default: m.TokenDetailPage })))
-const SendPage = lazy(() => import('@/pages/send').then((m) => ({ default: m.SendPage })))
-const ReceivePage = lazy(() => import('@/pages/receive').then((m) => ({ default: m.ReceivePage })))
-const SettingsPage = lazy(() => import('@/pages/settings').then((m) => ({ default: m.SettingsPage })))
-const LanguagePage = lazy(() => import('@/pages/settings/language').then((m) => ({ default: m.LanguagePage })))
-const ViewMnemonicPage = lazy(() => import('@/pages/settings/view-mnemonic').then((m) => ({ default: m.ViewMnemonicPage })))
-const ChangeWalletLockPage = lazy(() => import('@/pages/settings/change-wallet-lock').then((m) => ({ default: m.ChangeWalletLockPage })))
-const WalletChainsPage = lazy(() => import('@/pages/settings/wallet-chains').then((m) => ({ default: m.WalletChainsPage })))
-const CurrencyPage = lazy(() => import('@/pages/settings/currency').then((m) => ({ default: m.CurrencyPage })))
-const ChainConfigPage = lazy(() => import('@/pages/settings/chain-config').then((m) => ({ default: m.ChainConfigPage })))
-
-// History pages
-const TransactionHistoryPage = lazy(() => import('@/pages/history').then((m) => ({ default: m.TransactionHistoryPage })))
-const TransactionDetailPage = lazy(() => import('@/pages/history/detail').then((m) => ({ default: m.TransactionDetailPage })))
-
-// Onboarding pages
-const OnboardingRecoverPage = lazy(() => import('@/pages/onboarding/recover').then((m) => ({ default: m.OnboardingRecoverPage })))
-const MigrationPage = lazy(() => import('@/pages/onboarding/migrate').then((m) => ({ default: m.MigrationPage })))
-
-// Address Book page
-const AddressBookPage = lazy(() => import('@/pages/address-book').then((m) => ({ default: m.AddressBookPage })))
-
-// Notifications page
-const NotificationCenterPage = lazy(() => import('@/pages/notifications').then((m) => ({ default: m.NotificationCenterPage })))
-
-// Staking pages
-const StakingPage = lazy(() => import('@/pages/staking').then((m) => ({ default: m.StakingPage })))
-
-// Scanner page
-const ScannerPage = lazy(() => import('@/pages/scanner').then((m) => ({ default: m.ScannerPage })))
-
-// Guide pages
-const WelcomeScreen = lazy(() => import('@/pages/guide/WelcomeScreen').then((m) => ({ default: m.WelcomeScreen })))
-
-// 加载中占位
-function PageLoading() {
- return (
-
-
-
- )
-}
-
-// 包装懒加载组件
-function withSuspense(Component: React.LazyExoticComponent) {
- return function SuspenseWrapper() {
- return (
- }>
-
-
- )
- }
-}
-
-// Root Route with AppLayout (main routes)
-const rootRoute = createRootRoute({
- component: () => (
-
-
-
- ),
-})
-
-// 首页
-const indexRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/',
- component: withSuspense(HomePage),
-})
-
-// 钱包路由
-const walletRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/wallet',
-})
-
-const walletCreateRoute = createRoute({
- getParentRoute: () => walletRoute,
- path: '/create',
- component: withSuspense(WalletCreatePage),
-})
-
-const walletDetailRoute = createRoute({
- getParentRoute: () => walletRoute,
- path: '/$walletId',
- component: withSuspense(WalletDetailPage),
-})
-
-const walletListRoute = createRoute({
- getParentRoute: () => walletRoute,
- path: '/list',
- component: withSuspense(WalletListPage),
-})
-
-// 代币路由
-const tokenRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/token/$tokenId',
- component: withSuspense(TokenDetailPage),
-})
-
-// 转账路由 - 支持从扫码器预填地址
-const sendRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/send',
- component: withSuspense(SendPage),
- validateSearch: (search: Record) => ({
- address: typeof search.address === 'string' ? search.address : undefined,
- chain: typeof search.chain === 'string' ? search.chain : undefined,
- amount: typeof search.amount === 'string' ? search.amount : undefined,
- }),
-})
-
-// 收款路由
-const receiveRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/receive',
- component: withSuspense(ReceivePage),
-})
-
-// 设置路由
-const settingsRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/settings',
- component: withSuspense(SettingsPage),
-})
-
-const settingsLanguageRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/settings/language',
- component: withSuspense(LanguagePage),
-})
-
-const settingsMnemonicRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/settings/mnemonic',
- component: withSuspense(ViewMnemonicPage),
-})
-
-const settingsWalletLockRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/settings/wallet-lock',
- component: withSuspense(ChangeWalletLockPage),
-})
-
-const settingsWalletChainsRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/settings/wallet-chains',
- component: withSuspense(WalletChainsPage),
-})
-
-const settingsCurrencyRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/settings/currency',
- component: withSuspense(CurrencyPage),
-})
-
-const settingsChainsRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/settings/chains',
- component: withSuspense(ChainConfigPage),
-})
-
-// History 路由
-const historyRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/history',
- component: withSuspense(TransactionHistoryPage),
-})
-
-const transactionDetailRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/transaction/$txId',
- component: withSuspense(TransactionDetailPage),
-})
-
-// Onboarding 路由
-const onboardingRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/onboarding',
-})
-
-const onboardingRecoverRoute = createRoute({
- getParentRoute: () => onboardingRoute,
- path: '/recover',
- component: withSuspense(OnboardingRecoverPage),
-})
-
-const onboardingMigrateRoute = createRoute({
- getParentRoute: () => onboardingRoute,
- path: '/migrate',
- component: withSuspense(MigrationPage),
-})
-
-// Address Book 路由
-const addressBookRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/address-book',
- component: withSuspense(AddressBookPage),
-})
-
-// Notifications 路由
-const notificationsRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/notifications',
- component: withSuspense(NotificationCenterPage),
-})
-
-// Staking 路由
-const stakingRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/staking',
- component: withSuspense(StakingPage),
-})
-
-// Scanner 路由
-const scannerRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/scanner',
- component: withSuspense(ScannerPage),
-})
-
-// Guide 路由
-const welcomeRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/welcome',
- component: withSuspense(WelcomeScreen),
-})
-
-// Authorize routes (DWEB/Plaoc)
-const { authorizeRoute } = createAuthorizeRoutes({ rootRoute, withSuspense })
-
-// 路由树
-const routeTree = rootRoute.addChildren([
- indexRoute,
- walletRoute.addChildren([
- walletCreateRoute,
- walletDetailRoute,
- walletListRoute,
- ]),
- onboardingRoute.addChildren([
- onboardingRecoverRoute,
- onboardingMigrateRoute,
- ]),
- tokenRoute,
- sendRoute,
- receiveRoute,
- settingsRoute,
- settingsLanguageRoute,
- settingsMnemonicRoute,
- settingsWalletLockRoute,
- settingsWalletChainsRoute,
- settingsCurrencyRoute,
- settingsChainsRoute,
- historyRoute,
- transactionDetailRoute,
- addressBookRoute,
- notificationsRoute,
- stakingRoute,
- scannerRoute,
- welcomeRoute,
- authorizeRoute,
-])
-
-// 使用 hash history,支持部署在任意子路径下
-const hashHistory = createHashHistory()
-
-// 创建路由器
-export const router = createRouter({
- routeTree,
- history: hashHistory,
- defaultPreload: 'intent',
-})
-
-// 类型声明
-declare module '@tanstack/react-router' {
- interface Register {
- router: typeof router
- }
-}
diff --git a/src/services/authorize/__tests__/address-auth.test.ts b/src/services/authorize/__tests__/address-auth.test.ts
index cec79317..0971b34d 100644
--- a/src/services/authorize/__tests__/address-auth.test.ts
+++ b/src/services/authorize/__tests__/address-auth.test.ts
@@ -26,6 +26,7 @@ function createWallet(partial?: Partial): Wallet {
{ chain: 'bfmeta', address: 'c123', tokens: [] },
],
createdAt: Date.now(),
+ themeHue: 323,
tokens: [],
...partial,
}
diff --git a/src/services/authorize/__tests__/integration.test.tsx b/src/services/authorize/__tests__/integration.test.tsx
index 289423f3..e5f13e78 100644
--- a/src/services/authorize/__tests__/integration.test.tsx
+++ b/src/services/authorize/__tests__/integration.test.tsx
@@ -112,6 +112,7 @@ describe('authorize integration (mock-first)', () => {
name: 'Wallet 1',
address: '0x1234567890abcdef1234567890abcdef12345678',
chain: 'ethereum',
+ themeHue: 323,
chainAddresses: [{ chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', tokens: [] }],
})
})
@@ -143,6 +144,7 @@ describe('authorize integration (mock-first)', () => {
name: 'Wallet 1',
address: '0x1234567890abcdef1234567890abcdef12345678',
chain: 'ethereum',
+ themeHue: 323,
encryptedMnemonic,
chainAddresses: [{ chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', tokens: [] }],
})
@@ -192,6 +194,7 @@ describe('authorize integration (mock-first)', () => {
name: 'Wallet 1',
address: '0x1234567890abcdef1234567890abcdef12345678',
chain: 'ethereum',
+ themeHue: 323,
chainAddresses: [{ chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', tokens: [] }],
})
})
@@ -218,6 +221,7 @@ describe('authorize integration (mock-first)', () => {
name: 'Wallet 1',
address: '0x1234567890abcdef1234567890abcdef12345678',
chain: 'ethereum',
+ themeHue: 323,
chainAddresses: [{ chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678', tokens: [] }],
})
})
diff --git a/src/services/migration/migration-service.test.ts b/src/services/migration/migration-service.test.ts
index 86f21600..93be9882 100644
--- a/src/services/migration/migration-service.test.ts
+++ b/src/services/migration/migration-service.test.ts
@@ -100,6 +100,7 @@ describe('migration-service', () => {
tokens: [],
},
],
+ themeHue: 200,
encryptedMnemonic: {
ciphertext: 'encrypted',
iv: 'iv',
diff --git a/src/services/migration/mpay-transformer.ts b/src/services/migration/mpay-transformer.ts
index 8e9d86ae..f69c6f5b 100644
--- a/src/services/migration/mpay-transformer.ts
+++ b/src/services/migration/mpay-transformer.ts
@@ -16,6 +16,16 @@ import type {
import type { Contact } from '@/stores/address-book'
import { decryptMpayData } from './mpay-crypto'
+function deriveThemeHue(secret: string): number {
+ let hash = 0
+ for (let i = 0; i < secret.length; i++) {
+ const char = secret.charCodeAt(i)
+ hash = ((hash << 5) - hash) + char
+ hash = hash & hash
+ }
+ return Math.abs(hash) % 360
+}
+
/**
* mpay 链名到 KeyApp ChainType 的映射
*/
@@ -224,6 +234,7 @@ export async function transformMpayData(
chainAddresses,
encryptedMnemonic,
createdAt: mpayWallet.createTimestamp,
+ themeHue: deriveThemeHue(mpayWallet.mainWalletId),
tokens: [], // deprecated, use chainAddresses[].tokens
}
diff --git a/src/services/wallet-storage/types.ts b/src/services/wallet-storage/types.ts
index fa3d402f..156c0f02 100644
--- a/src/services/wallet-storage/types.ts
+++ b/src/services/wallet-storage/types.ts
@@ -43,6 +43,8 @@ export interface WalletInfo {
encryptedWalletLock?: EncryptedData | undefined
/** 是否已备份 */
isBackedUp: boolean
+ /** 主题色 hue (0-360) */
+ themeHue?: number | undefined
/** 创建时间 */
createdAt: number
/** 更新时间 */
diff --git a/src/stackflow/activities/HistoryActivity.tsx b/src/stackflow/activities/HistoryActivity.tsx
index 31eee778..500f54cc 100644
--- a/src/stackflow/activities/HistoryActivity.tsx
+++ b/src/stackflow/activities/HistoryActivity.tsx
@@ -1,11 +1,19 @@
import type { ActivityComponentType } from "@stackflow/react";
+import { useActivityParams } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
import { TransactionHistoryPage } from "@/pages/history";
+import type { ChainType } from "@/stores";
+
+interface HistoryActivityParams {
+ chain?: ChainType | "all";
+}
export const HistoryActivity: ActivityComponentType = () => {
+ const params = useActivityParams();
+
return (
-
-
+
+
);
};
diff --git a/src/stackflow/activities/MainTabsActivity.tsx b/src/stackflow/activities/MainTabsActivity.tsx
index 08274dbf..111f134a 100644
--- a/src/stackflow/activities/MainTabsActivity.tsx
+++ b/src/stackflow/activities/MainTabsActivity.tsx
@@ -2,9 +2,7 @@ import { useState } from "react";
import type { ActivityComponentType } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
import { TabBar, type TabId } from "../components/TabBar";
-import { HomeTab } from "./tabs/HomeTab";
import { WalletTab } from "./tabs/WalletTab";
-import { TransferTab } from "./tabs/TransferTab";
import { SettingsTab } from "./tabs/SettingsTab";
type MainTabsParams = {
@@ -12,16 +10,15 @@ type MainTabsParams = {
};
export const MainTabsActivity: ActivityComponentType = ({ params }) => {
- const [activeTab, setActiveTab] = useState(params.tab || "home");
+ // 默认显示钱包页(合并了原来的首页)
+ const [activeTab, setActiveTab] = useState(params.tab || "wallet");
return (
{/* Content area */}
- {activeTab === "home" && }
{activeTab === "wallet" && }
- {activeTab === "transfer" && }
{activeTab === "settings" && }
diff --git a/src/stackflow/activities/SettingsStorageActivity.tsx b/src/stackflow/activities/SettingsStorageActivity.tsx
new file mode 100644
index 00000000..5b63b204
--- /dev/null
+++ b/src/stackflow/activities/SettingsStorageActivity.tsx
@@ -0,0 +1,11 @@
+import type { ActivityComponentType } from "@stackflow/react";
+import { AppScreen } from "@stackflow/plugin-basic-ui";
+import { SettingsStoragePage } from "@/pages/settings/storage";
+
+export const SettingsStorageActivity: ActivityComponentType = () => {
+ return (
+
+
+
+ );
+};
diff --git a/src/stackflow/activities/WalletConfigActivity.tsx b/src/stackflow/activities/WalletConfigActivity.tsx
new file mode 100644
index 00000000..d657935d
--- /dev/null
+++ b/src/stackflow/activities/WalletConfigActivity.tsx
@@ -0,0 +1,73 @@
+import type { ActivityComponentType } from "@stackflow/react";
+import { AppScreen } from "@stackflow/plugin-basic-ui";
+import { useTranslation } from 'react-i18next';
+import { ActivityParamsProvider, useActivityParams, useNavigation } from "../hooks";
+import { PageHeader } from '@/components/layout/page-header';
+import { WalletConfig } from '@/components/wallet/wallet-config';
+import { Alert } from '@/components/common/alert';
+import { useWallets } from '@/stores';
+
+type WalletConfigParams = {
+ walletId: string;
+ walletName?: string;
+ mode?: 'default' | 'edit-only';
+};
+
+function WalletConfigContent() {
+ const { t } = useTranslation('wallet');
+ const { walletId, mode = 'default' } = useActivityParams
();
+ const { goBack, navigate } = useNavigation();
+
+ const wallets = useWallets();
+ const wallet = wallets.find((w) => w.id === walletId);
+
+ const handleEditOnlyComplete = () => {
+ navigate({ to: '/', replace: true });
+ };
+
+ if (!wallet) {
+ return (
+
+
+
+
{t('detail.notFound')}
+
+
+ );
+ }
+
+ const title = mode === 'edit-only' ? t('detail.editTitle') : wallet.name;
+
+ return (
+
+
+
+
+
+
+ {mode === 'default' && (
+
+
{t('detail.securityWarning')}
+
+ )}
+
+
+ );
+}
+
+export const WalletConfigActivity: ActivityComponentType = ({ params }) => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/src/stackflow/activities/WalletDetailActivity.tsx b/src/stackflow/activities/WalletDetailActivity.tsx
deleted file mode 100644
index a340ed2d..00000000
--- a/src/stackflow/activities/WalletDetailActivity.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { ActivityComponentType } from "@stackflow/react";
-import { AppScreen } from "@stackflow/plugin-basic-ui";
-import { ActivityParamsProvider } from "../hooks";
-import { WalletDetailPage } from "@/pages/wallet/detail";
-
-type WalletDetailParams = {
- walletId: string;
- walletName?: string;
-};
-
-export const WalletDetailActivity: ActivityComponentType = ({ params }) => {
- return (
-
-
-
-
-
- );
-};
diff --git a/src/stackflow/activities/sheets/ClearDataConfirmJob.tsx b/src/stackflow/activities/sheets/ClearDataConfirmJob.tsx
new file mode 100644
index 00000000..a51b5c87
--- /dev/null
+++ b/src/stackflow/activities/sheets/ClearDataConfirmJob.tsx
@@ -0,0 +1,85 @@
+import { useState, useCallback } from "react";
+import type { ActivityComponentType } from "@stackflow/react";
+import { BottomSheet } from "@/components/layout/bottom-sheet";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { useFlow } from "../../stackflow";
+import { IconAlertTriangle } from "@tabler/icons-react";
+
+function ClearDataConfirmJobContent() {
+ const { t } = useTranslation(["settings", "common"]);
+ const { pop } = useFlow();
+ const [isClearing, setIsClearing] = useState(false);
+
+ const handleConfirm = useCallback(() => {
+ setIsClearing(true);
+ // Navigate to clear.html which handles cleanup in isolation
+ const baseUri = import.meta.env.BASE_URL || '/';
+ window.location.href = `${baseUri}clear.html`;
+ }, []);
+
+ return (
+
+
+ {/* Handle */}
+
+
+ {/* Content */}
+
+ {/* Warning Icon */}
+
+
+ {/* Title */}
+
+
{t("settings:clearData.title")}
+
+ {t("settings:clearData.warning")}
+
+
+
+ {/* Warning List */}
+
+
+ - • {t("settings:clearData.item1")}
+ - • {t("settings:clearData.item2")}
+ - • {t("settings:clearData.item3")}
+
+
+
+ {/* Buttons */}
+
+
+
+
+
+
+ {/* Safe area */}
+
+
+
+ );
+}
+
+export const ClearDataConfirmJob: ActivityComponentType = () => {
+ return ;
+};
diff --git a/src/stackflow/activities/sheets/WalletListJob.tsx b/src/stackflow/activities/sheets/WalletListJob.tsx
new file mode 100644
index 00000000..a3662682
--- /dev/null
+++ b/src/stackflow/activities/sheets/WalletListJob.tsx
@@ -0,0 +1,117 @@
+import type { ActivityComponentType } from "@stackflow/react";
+import { BottomSheet } from "@/components/layout/bottom-sheet";
+import { useTranslation } from "react-i18next";
+import { IconPlus, IconWallet, IconCircleCheckFilled } from "@tabler/icons-react";
+import { cn } from "@/lib/utils";
+import { useFlow } from "../../stackflow";
+import { useWallets, useCurrentWallet, walletActions } from "@/stores";
+import { WALLET_THEME_COLORS, useWalletTheme } from "@/hooks/useWalletTheme";
+
+export const WalletListJob: ActivityComponentType = () => {
+ const { t } = useTranslation(["wallet", "common"]);
+ const { pop, push } = useFlow();
+ const wallets = useWallets();
+ const currentWallet = useCurrentWallet();
+ const currentWalletId = currentWallet?.id;
+ const { getWalletTheme } = useWalletTheme();
+
+ const handleSelectWallet = (walletId: string) => {
+ walletActions.setCurrentWallet(walletId);
+ pop();
+ };
+
+ const handleAddWallet = () => {
+ pop();
+ push("WalletAddJob", {});
+ };
+
+ return (
+
+
+ {/* Handle */}
+
+
+ {/* Title */}
+
+
{t("common:a11y.tabWallet")}
+
+
+ {/* Wallet List */}
+
+ {wallets.map((wallet) => {
+ const isActive = wallet.id === currentWalletId;
+ const address = wallet.chainAddresses[0]?.address;
+ const displayAddress = address
+ ? `${address.slice(0, 6)}...${address.slice(-4)}`
+ : "---";
+ const themeHue = getWalletTheme(wallet.id);
+ const themeColor = WALLET_THEME_COLORS.find(c => c.hue === themeHue) ?? WALLET_THEME_COLORS[0];
+
+ return (
+
+ );
+ })}
+
+
+ {/* Add wallet button */}
+
+
+
+
+ {/* Safe area */}
+
+
+
+ );
+};
diff --git a/src/stackflow/activities/sheets/index.ts b/src/stackflow/activities/sheets/index.ts
index fca52e55..953d8467 100644
--- a/src/stackflow/activities/sheets/index.ts
+++ b/src/stackflow/activities/sheets/index.ts
@@ -8,6 +8,7 @@ export { MnemonicOptionsJob, setMnemonicOptionsCallback } from "./MnemonicOption
export { ContactEditJob } from "./ContactEditJob";
export { ContactPickerJob } from "./ContactPickerJob";
export { WalletAddJob } from "./WalletAddJob";
+export { WalletListJob } from "./WalletListJob";
export { SecurityWarningJob, setSecurityWarningConfirmCallback } from "./SecurityWarningJob";
export { TransferConfirmJob, setTransferConfirmCallback } from "./TransferConfirmJob";
export { TransferWalletLockJob, setTransferWalletLockCallback } from "./TransferWalletLockJob";
@@ -15,3 +16,4 @@ export { FeeEditJob, setFeeEditCallback, type FeeEditConfig, type FeeEditResult
export { ScannerJob, setScannerResultCallback, scanValidators, getValidatorForChain, type ScannerJobParams, type ScannerResultEvent, type ScanValidator } from "./ScannerJob";
export { ContactAddConfirmJob, type ContactAddConfirmJobParams } from "./ContactAddConfirmJob";
export { ContactShareJob, type ContactShareJobParams } from "./ContactShareJob";
+export { ClearDataConfirmJob } from "./ClearDataConfirmJob";
diff --git a/src/stackflow/activities/tabs/HomeTab.tsx b/src/stackflow/activities/tabs/HomeTab.tsx
deleted file mode 100644
index 26e3aa68..00000000
--- a/src/stackflow/activities/tabs/HomeTab.tsx
+++ /dev/null
@@ -1,233 +0,0 @@
-import { useState } from "react";
-import { useTranslation } from "react-i18next";
-import { useFlow } from "../../stackflow";
-import { Button } from "@/components/ui/button";
-import { TokenList } from "@/components/token/token-list";
-import { GradientButton } from "@/components/common/gradient-button";
-import { ChainIcon } from "@/components/wallet/chain-icon";
-import { LoadingSpinner } from "@/components/common/loading-spinner";
-import { useClipboard, useToast, useHaptics } from "@/services";
-import { useBalanceQuery, useSecurityPasswordQuery } from "@/queries";
-import {
- IconSend,
- IconQrcode,
- IconChevronDown,
- IconCheck,
- IconCopy,
- IconLineScan,
- IconChevronRight,
-} from "@tabler/icons-react";
-import {
- useCurrentWallet,
- useSelectedChain,
- useCurrentChainAddress,
- useCurrentChainTokens,
- useHasWallet,
- useWalletInitialized,
- type ChainType,
-} from "@/stores";
-
-const CHAIN_NAMES: Record = {
- ethereum: "Ethereum",
- bitcoin: "Bitcoin",
- tron: "Tron",
- binance: "BSC",
- bfmeta: "BFMeta",
- ccchain: "CCChain",
- pmchain: "PMChain",
- bfchainv2: "BFChain V2",
- btgmeta: "BTGMeta",
- biwmeta: "BIWMeta",
- ethmeta: "ETHMeta",
- malibu: "Malibu",
-};
-
-function truncateAddress(address: string, startChars = 6, endChars = 4): string {
- if (address.length <= startChars + endChars + 3) return address;
- return `${address.slice(0, startChars)}...${address.slice(-endChars)}`;
-}
-
-export function HomeTab() {
- const { push } = useFlow();
- const clipboard = useClipboard();
- const toast = useToast();
- const haptics = useHaptics();
- const { t } = useTranslation(['home', 'common']);
-
- const isInitialized = useWalletInitialized();
- const hasWallet = useHasWallet();
- const currentWallet = useCurrentWallet();
- const selectedChain = useSelectedChain();
- const selectedChainName = CHAIN_NAMES[selectedChain] ?? selectedChain;
- const chainAddress = useCurrentChainAddress();
- const tokens = useCurrentChainTokens();
-
- const [copied, setCopied] = useState(false);
-
- // 使用 TanStack Query 管理余额数据
- // - 30s staleTime: Tab 切换不会重复请求
- // - 60s 轮询: 自动刷新余额
- // - 共享缓存: 多个组件使用同一 key 时共享数据
- const { isFetching: isRefreshing } = useBalanceQuery(currentWallet?.id, selectedChain);
-
- // 查询并缓存安全密码公钥(进入钱包时自动查询)
- useSecurityPasswordQuery(selectedChain, chainAddress?.address);
-
- const handleCopyAddress = async () => {
- if (chainAddress?.address) {
- await clipboard.write({ text: chainAddress.address });
- await haptics.impact("light");
- setCopied(true);
- toast.show(t('home:wallet.addressCopied'));
- setTimeout(() => setCopied(false), 2000);
- }
- };
-
- const handleOpenChainSelector = () => {
- push("ChainSelectorJob", {});
- };
-
- if (!isInitialized) {
- return (
-
-
-
- );
- }
-
- if (!hasWallet || !currentWallet) {
- return ;
- }
-
- return (
-
- {/* Wallet Card */}
-
- {/* Chain Selector */}
-
-
- {/* Wallet Name and Address */}
-
-
{currentWallet.name}
-
-
- {chainAddress?.address ? truncateAddress(chainAddress.address) : "---"}
-
-
-
-
-
- {/* Action Buttons */}
-
- push("SendActivity", {})}
- >
-
- {t('home:wallet.send')}
-
- push("ReceiveActivity", {})}
- >
-
- {t('home:wallet.receive')}
-
-
-
-
- {/* Asset List */}
-
-
{t('home:wallet.assets')}
- ({
- symbol: tk.symbol,
- name: tk.name,
- chain: selectedChain,
- balance: tk.balance,
- decimals: tk.decimals,
- fiatValue: tk.fiatValue ? String(tk.fiatValue) : undefined,
- change24h: tk.change24h,
- icon: tk.icon,
- }))}
- refreshing={isRefreshing}
- onTokenClick={(token) => {
- console.log("Token clicked:", token.symbol);
- }}
- emptyTitle={t('home:wallet.noAssets')}
- emptyDescription={t('home:wallet.noAssetsOnChain', { chain: selectedChainName })}
- />
-
-
- {/* Scanner FAB */}
-
-
- );
-}
-
-function NoWalletView() {
- const { push } = useFlow();
- const { t } = useTranslation('home');
-
- return (
-
-
-
-
-
-
{t('welcome.title')}
-
{t('welcome.subtitle')}
-
-
- push("WalletCreateActivity", {})}
- >
- {t('welcome.createWallet')}
-
-
-
-
- );
-}
diff --git a/src/stackflow/activities/tabs/TransferTab.tsx b/src/stackflow/activities/tabs/TransferTab.tsx
deleted file mode 100644
index bb926b52..00000000
--- a/src/stackflow/activities/tabs/TransferTab.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { useMemo } from "react";
-import { useTranslation } from "react-i18next";
-import { useStore } from "@tanstack/react-store";
-import { useFlow } from "../../stackflow";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
-import { PageHeader } from "@/components/layout/page-header";
-import { TransactionItem } from "@/components/transaction/transaction-item";
-import { useTransactionHistoryQuery } from "@/queries";
-import { addressBookStore, addressBookSelectors, useCurrentWallet, useSelectedChain, type Contact } from "@/stores";
-import { useRecentContactIds } from "@/stores/preferences";
-import { IconSend } from "@tabler/icons-react";
-
-export function TransferTab() {
- const { push } = useFlow();
- const { t } = useTranslation(['transaction', 'common']);
- const currentWallet = useCurrentWallet();
- const selectedChain = useSelectedChain();
- const addressBookState = useStore(addressBookStore);
- const contacts = addressBookState.contacts;
- const recentContactIds = useRecentContactIds();
- // 使用 TanStack Query 管理交易历史
- // - 30s staleTime: Tab 切换不会重复请求
- // - 共享缓存: 多个组件使用同一数据
- const { transactions, isLoading } = useTransactionHistoryQuery(currentWallet?.id);
-
- // 根据 recentContactIds 获取最近使用的联系人(单一数据源:只存 ID,显示时查找)
- const recentContacts = useMemo(() => {
- // 根据 ID 查找联系人,过滤掉已删除的
- const foundContacts = recentContactIds
- .map((id) => contacts.find((c) => c.id === id))
- .filter((c): c is Contact => c !== undefined);
-
- // 如果指定了链类型,使用 getContactsByChain 过滤(基于地址格式检测)
- const filtered = selectedChain
- ? foundContacts.filter((contact) =>
- addressBookSelectors.getContactsByChain({ contacts: [contact], isInitialized: true }, selectedChain).length > 0
- )
- : foundContacts;
-
- return filtered.slice(0, 4);
- }, [recentContactIds, contacts, selectedChain]);
-
- // Helper to get primary address for a contact
- const getPrimaryAddress = (contact: Contact): string => {
- const addr = addressBookSelectors.getDefaultAddress(contact, selectedChain ?? undefined);
- return addr?.address ?? contact.addresses[0]?.address ?? '';
- };
-
- const recentTransactions = useMemo(() => {
- const filtered = selectedChain
- ? transactions.filter((tx) => tx.chain === selectedChain)
- : transactions;
- return filtered.slice(0, 3);
- }, [transactions, selectedChain]);
-
- return (
-
-
-
-
-
-
- {t('transaction:transfer.quickTransfer')}
- {t('transaction:transfer.quickTransferDesc')}
-
-
- {/* Recent contacts */}
-
-
{t('transaction:transfer.recentContacts')}
-
- {recentContacts.length > 0 ? (
- recentContacts.map((contact) => (
-
- ))
- ) : (
-
{t('common:addressBook.noContacts')}
- )}
-
-
-
-
-
-
-
- {/* Transfer history */}
-
-
- {t('transaction:transfer.transferHistory')}
-
-
- {!currentWallet && (
- {t('transaction:history.noWallet')}
- )}
- {currentWallet && isLoading && (
- {t('common:loading')}
- )}
- {currentWallet && !isLoading && recentTransactions.length === 0 && (
- {t('transaction:history.emptyTitle')}
- )}
- {recentTransactions.map((tx) => (
- push("TransactionDetailActivity", { txId: tx.id })}
- />
- ))}
- {currentWallet && recentTransactions.length > 0 && (
-
- )}
-
-
-
-
- );
-}
diff --git a/src/stackflow/activities/tabs/WalletTab.tsx b/src/stackflow/activities/tabs/WalletTab.tsx
index f32321f4..50592ccf 100644
--- a/src/stackflow/activities/tabs/WalletTab.tsx
+++ b/src/stackflow/activities/tabs/WalletTab.tsx
@@ -1,114 +1,297 @@
-import { useCallback } from "react";
+import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useFlow } from "../../stackflow";
+import { TokenList } from "@/components/token/token-list";
+import { TransactionList } from "@/components/transaction/transaction-list";
+import { WalletCardCarousel } from "@/components/wallet/wallet-card-carousel";
+import { SwipeableContentTabs } from "@/components/home/content-tabs";
+import { LoadingSpinner } from "@/components/common/loading-spinner";
+import { GradientButton } from "@/components/common/gradient-button";
import { Button } from "@/components/ui/button";
-import { PageHeader } from "@/components/layout/page-header";
-import { IconPlus, IconWallet, IconCircleCheckFilled, IconSettings } from "@tabler/icons-react";
-import { useWallets, useCurrentWallet, walletActions } from "@/stores";
-import { cn } from "@/lib/utils";
+import { useWalletTheme } from "@/hooks/useWalletTheme";
+import { useClipboard, useToast, useHaptics } from "@/services";
+import { useBalanceQuery, useTransactionHistoryQuery } from "@/queries";
+import {
+ IconPlus,
+ IconSend,
+ IconQrcode,
+ IconLineScan,
+} from "@tabler/icons-react";
+import {
+ useWallets,
+ useCurrentWallet,
+ useSelectedChain,
+ useChainPreferences,
+ useCurrentChainTokens,
+ useHasWallet,
+ useWalletInitialized,
+ walletActions,
+} from "@/stores";
+import type { TransactionInfo } from "@/components/transaction/transaction-item";
+
+const CHAIN_NAMES: Record = {
+ ethereum: "Ethereum",
+ bitcoin: "Bitcoin",
+ tron: "Tron",
+ binance: "BSC",
+ bfmeta: "BFMeta",
+ ccchain: "CCChain",
+ pmchain: "PMChain",
+ bfchainv2: "BFChain V2",
+ btgmeta: "BTGMeta",
+ biwmeta: "BIWMeta",
+ ethmeta: "ETHMeta",
+ malibu: "Malibu",
+};
export function WalletTab() {
const { push } = useFlow();
- const { t } = useTranslation(['wallet', 'common']);
+ const clipboard = useClipboard();
+ const toast = useToast();
+ const haptics = useHaptics();
+ const { t } = useTranslation(["home", "wallet", "common", "transaction"]);
+
+ const isInitialized = useWalletInitialized();
+ const hasWallet = useHasWallet();
const wallets = useWallets();
const currentWallet = useCurrentWallet();
- const currentWalletId = currentWallet?.id;
+ const currentWalletId = currentWallet?.id ?? null;
+ const selectedChain = useSelectedChain();
+ const chainPreferences = useChainPreferences();
+ const selectedChainName = CHAIN_NAMES[selectedChain] ?? selectedChain;
+ const tokens = useCurrentChainTokens();
+
+ // 初始化钱包主题
+ useWalletTheme();
+
+ // 当前内容 Tab
+ const [activeTab, setActiveTab] = useState("assets");
+
+ // 余额查询
+ const { isFetching: isRefreshing } = useBalanceQuery(
+ currentWallet?.id,
+ selectedChain
+ );
+
+ // 交易历史 - 按当前选中的链过滤
+ const { transactions, isLoading: txLoading, setFilter } = useTransactionHistoryQuery(
+ currentWallet?.id
+ );
+
+ // 当链切换时更新交易过滤器
+ useEffect(() => {
+ setFilter((prev) => ({ ...prev, chain: selectedChain }));
+ }, [selectedChain, setFilter]);
+
+ // 复制地址
+ const handleCopyAddress = useCallback(
+ async (address: string) => {
+ await clipboard.write({ text: address });
+ await haptics.impact("light");
+ toast.show(t("home:wallet.addressCopied"));
+ },
+ [clipboard, haptics, toast, t]
+ );
+
+ // 打开链选择器(传入钱包ID以便选择后更新该钱包的偏好)
+ const handleOpenChainSelector = useCallback((walletId: string) => {
+ // 如果不是当前钱包,先切换到该钱包
+ if (walletId !== currentWalletId) {
+ walletActions.setCurrentWallet(walletId);
+ }
+ push("ChainSelectorJob", {});
+ }, [push, currentWalletId]);
- const handleSelectWallet = useCallback((walletId: string) => {
+ // 打开钱包设置
+ const handleOpenWalletSettings = useCallback(
+ (walletId: string) => {
+ const wallet = wallets.find((w) => w.id === walletId);
+ if (wallet) {
+ push("WalletConfigActivity", { walletId, walletName: wallet.name });
+ }
+ },
+ [push, wallets]
+ );
+
+ // 切换钱包
+ const handleWalletChange = useCallback((walletId: string) => {
walletActions.setCurrentWallet(walletId);
}, []);
- const handleWalletSettings = useCallback((e: React.MouseEvent, walletId: string, walletName: string) => {
- e.stopPropagation();
- push("WalletDetailActivity", { walletId, walletName });
+ // 添加钱包
+ const handleAddWallet = useCallback(() => {
+ push("WalletAddJob", {});
+ }, [push]);
+
+ // 打开钱包列表
+ const handleOpenWalletList = useCallback(() => {
+ push("WalletListJob", {});
}, [push]);
+ // 交易点击
+ const handleTransactionClick = useCallback(
+ (tx: TransactionInfo) => {
+ if (tx.id) {
+ push("TransactionDetailActivity", { txId: tx.id });
+ }
+ },
+ [push]
+ );
+
+ if (!isInitialized) {
+ return (
+
+
+
+ );
+ }
+
+ if (!hasWallet || !currentWallet) {
+ return ;
+ }
+
return (
-
-
-
-
- {/* Wallet List */}
-
- {wallets.map((wallet) => {
- const isActive = wallet.id === currentWalletId;
- const address = wallet.chainAddresses[0]?.address;
- const displayAddress = address
- ? `${address.slice(0, 6)}...${address.slice(-4)}`
- : "---";
-
- return (
-
handleSelectWallet(wallet.id)}
- onKeyDown={(e) => e.key === 'Enter' && handleSelectWallet(wallet.id)}
- className={cn(
- "w-full flex items-center gap-3 rounded-xl p-4 text-left transition-all cursor-pointer",
- "active:scale-[0.98]",
- isActive
- ? "bg-primary/10 ring-2 ring-primary"
- : "bg-card hover:bg-muted/80"
- )}
- >
- {/* Icon */}
-
-
-
-
- {/* Info */}
-
-
- {wallet.name}
- {isActive && (
-
- )}
-
-
- {displayAddress}
-
-
-
- {/* Settings button */}
+
+ {/* 钱包卡片轮播 */}
+
+
+
+ {/* 快捷操作按钮 - 颜色跟随主题 */}
+
+
+
+
+
+
+
+ {/* 内容区 Tab 切换 */}
+
+
+ {(tab) =>
+ tab === "assets" ? (
+
+ ({
+ symbol: token.symbol,
+ name: token.name,
+ chain: selectedChain,
+ balance: token.balance,
+ decimals: token.decimals,
+ fiatValue: token.fiatValue
+ ? String(token.fiatValue)
+ : undefined,
+ change24h: token.change24h,
+ icon: token.icon,
+ }))}
+ refreshing={isRefreshing}
+ onTokenClick={(token) => {
+ console.log("Token clicked:", token.symbol);
+ }}
+ emptyTitle={t("home:wallet.noAssets")}
+ emptyDescription={t("home:wallet.noAssetsOnChain", {
+ chain: selectedChainName,
+ })}
+ />
+
+ ) : (
+
+
- );
- })}
-
+ )
+ }
+
+
- {/* Empty state */}
- {wallets.length === 0 && (
-
-
-
-
-
{t('empty')}
-
{t('emptyHint')}
-
- )}
-
- {/* Add wallet button - fixed at bottom */}
-
-
-
+
+ );
+}
+
+function NoWalletView() {
+ const { push } = useFlow();
+ const { t } = useTranslation("home");
+
+ return (
+
+
+
+
+
+
{t("welcome.title")}
+
{t("welcome.subtitle")}
+
+
+ push("WalletCreateActivity", {})}
+ >
+ {t("welcome.createWallet")}
+
+
);
diff --git a/src/stackflow/activities/tabs/index.ts b/src/stackflow/activities/tabs/index.ts
index f9edf67b..3138e95a 100644
--- a/src/stackflow/activities/tabs/index.ts
+++ b/src/stackflow/activities/tabs/index.ts
@@ -1,4 +1,2 @@
-export { HomeTab } from "./HomeTab";
export { WalletTab } from "./WalletTab";
-export { TransferTab } from "./TransferTab";
export { SettingsTab } from "./SettingsTab";
diff --git a/src/stackflow/components/TabBar.tsx b/src/stackflow/components/TabBar.tsx
index 250bd8d2..32165236 100644
--- a/src/stackflow/components/TabBar.tsx
+++ b/src/stackflow/components/TabBar.tsx
@@ -1,15 +1,14 @@
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import {
- IconHome,
IconWallet,
- IconArrowsExchange,
IconSettings,
type Icon,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
-export type TabId = "home" | "wallet" | "transfer" | "settings";
+// 简化为2个tab:钱包(首页)和设置
+export type TabId = "wallet" | "settings";
interface Tab {
id: TabId;
@@ -26,9 +25,7 @@ export function TabBar({ activeTab, onTabChange }: TabBarProps) {
const { t } = useTranslation('common');
const tabConfigs: Tab[] = useMemo(() => [
- { id: "home", label: t('a11y.tabHome'), icon: IconHome },
{ id: "wallet", label: t('a11y.tabWallet'), icon: IconWallet },
- { id: "transfer", label: t('a11y.tabTransfer'), icon: IconArrowsExchange },
{ id: "settings", label: t('a11y.tabSettings'), icon: IconSettings },
], [t]);
@@ -63,4 +60,4 @@ export function TabBar({ activeTab, onTabChange }: TabBarProps) {
);
}
-export const tabIds: TabId[] = ["home", "wallet", "transfer", "settings"];
+export const tabIds: TabId[] = ["wallet", "settings"];
diff --git a/src/stackflow/hooks/use-navigation.ts b/src/stackflow/hooks/use-navigation.ts
index f1bdd347..fb99fca6 100644
--- a/src/stackflow/hooks/use-navigation.ts
+++ b/src/stackflow/hooks/use-navigation.ts
@@ -15,6 +15,7 @@ const routeToActivityMap: Record
= {
"/settings/mnemonic": "SettingsMnemonicActivity",
"/settings/wallet-lock": "SettingsWalletLockActivity",
"/settings/wallet-chains": "SettingsWalletChainsActivity",
+ "/settings/storage": "SettingsStorageActivity",
"/history": "HistoryActivity",
"/scanner": "ScannerActivity",
"/onboarding/recover": "OnboardingRecoverActivity",
@@ -32,7 +33,7 @@ const dynamicRoutePatterns: Array<{
}> = [
{
pattern: /^\/wallet\/([^/]+)$/,
- activity: "WalletDetailActivity",
+ activity: "WalletConfigActivity",
paramExtractor: (match) => ({ walletId: match[1] ?? "" }),
},
{
diff --git a/src/stackflow/stackflow.ts b/src/stackflow/stackflow.ts
index 503ed688..e6d50763 100644
--- a/src/stackflow/stackflow.ts
+++ b/src/stackflow/stackflow.ts
@@ -5,7 +5,7 @@ import { historySyncPlugin } from "@stackflow/plugin-history-sync";
import { MainTabsActivity } from "./activities/MainTabsActivity";
import { WalletListActivity } from "./activities/WalletListActivity";
-import { WalletDetailActivity } from "./activities/WalletDetailActivity";
+import { WalletConfigActivity } from "./activities/WalletConfigActivity";
import { WalletCreateActivity } from "./activities/WalletCreateActivity";
import { SendActivity } from "./activities/SendActivity";
@@ -28,7 +28,8 @@ import { NotificationsActivity } from "./activities/NotificationsActivity";
import { StakingActivity } from "./activities/StakingActivity";
import { WelcomeActivity } from "./activities/WelcomeActivity";
import { SettingsWalletChainsActivity } from "./activities/SettingsWalletChainsActivity";
-import { ChainSelectorJob, WalletRenameJob, WalletDeleteJob, WalletLockConfirmJob, TwoStepSecretConfirmJob, SetTwoStepSecretJob, MnemonicOptionsJob, ContactEditJob, ContactPickerJob, WalletAddJob, SecurityWarningJob, TransferConfirmJob, TransferWalletLockJob, FeeEditJob, ScannerJob, ContactAddConfirmJob, ContactShareJob } from "./activities/sheets";
+import { SettingsStorageActivity } from "./activities/SettingsStorageActivity";
+import { ChainSelectorJob, WalletRenameJob, WalletDeleteJob, WalletLockConfirmJob, TwoStepSecretConfirmJob, SetTwoStepSecretJob, MnemonicOptionsJob, ContactEditJob, ContactPickerJob, WalletAddJob, WalletListJob, SecurityWarningJob, TransferConfirmJob, TransferWalletLockJob, FeeEditJob, ScannerJob, ContactAddConfirmJob, ContactShareJob, ClearDataConfirmJob } from "./activities/sheets";
export const { Stack, useFlow, useStepFlow, activities } = stackflow({
transitionDuration: 350,
@@ -41,7 +42,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({
routes: {
MainTabsActivity: "/",
WalletListActivity: "/wallet/list",
- WalletDetailActivity: "/wallet/:walletId",
+ WalletConfigActivity: "/wallet/:walletId",
WalletCreateActivity: "/wallet/create",
SendActivity: "/send",
ReceiveActivity: "/receive",
@@ -52,6 +53,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({
SettingsMnemonicActivity: "/settings/mnemonic",
SettingsWalletLockActivity: "/settings/wallet-lock",
SettingsWalletChainsActivity: "/settings/wallet-chains",
+ SettingsStorageActivity: "/settings/storage",
HistoryActivity: "/history",
TransactionDetailActivity: "/transaction/:txId",
ScannerActivity: "/scanner",
@@ -73,6 +75,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({
ContactEditJob: "/job/contact-edit",
ContactPickerJob: "/job/contact-picker",
WalletAddJob: "/job/wallet-add",
+ WalletListJob: "/job/wallet-list",
SecurityWarningJob: "/job/security-warning",
TransferConfirmJob: "/job/transfer-confirm",
TransferWalletLockJob: "/job/transfer-wallet-lock",
@@ -80,6 +83,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({
ScannerJob: "/job/scanner",
ContactAddConfirmJob: "/job/contact-add-confirm",
ContactShareJob: "/job/contact-share",
+ ClearDataConfirmJob: "/job/clear-data-confirm",
},
fallbackActivity: () => "MainTabsActivity",
useHash: true,
@@ -88,7 +92,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({
activities: {
MainTabsActivity,
WalletListActivity,
- WalletDetailActivity,
+ WalletConfigActivity,
WalletCreateActivity,
SendActivity,
ReceiveActivity,
@@ -99,6 +103,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({
SettingsMnemonicActivity,
SettingsWalletLockActivity,
SettingsWalletChainsActivity,
+ SettingsStorageActivity,
HistoryActivity,
TransactionDetailActivity,
ScannerActivity,
@@ -120,6 +125,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({
ContactEditJob,
ContactPickerJob,
WalletAddJob,
+ WalletListJob,
SecurityWarningJob,
TransferConfirmJob,
TransferWalletLockJob,
@@ -127,6 +133,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({
ScannerJob,
ContactAddConfirmJob,
ContactShareJob,
+ ClearDataConfirmJob,
},
// Note: Don't set initialActivity when using historySyncPlugin
// The plugin will determine the initial activity based on the URL
diff --git a/src/stores/hooks.ts b/src/stores/hooks.ts
index b0f33590..092fd271 100644
--- a/src/stores/hooks.ts
+++ b/src/stores/hooks.ts
@@ -21,6 +21,11 @@ export function useSelectedChain() {
return useStore(walletStore, (state) => state.selectedChain)
}
+/** 获取各钱包的链偏好设置 */
+export function useChainPreferences() {
+ return useStore(walletStore, (state) => state.chainPreferences)
+}
+
/** 获取当前链的地址信息 */
export function useCurrentChainAddress() {
return useStore(walletStore, (state) => walletSelectors.getCurrentChainAddress(state))
diff --git a/src/stores/index.ts b/src/stores/index.ts
index a46eb676..86e5e1bd 100644
--- a/src/stores/index.ts
+++ b/src/stores/index.ts
@@ -41,6 +41,7 @@ export {
useWallets,
useCurrentWallet,
useSelectedChain,
+ useChainPreferences,
useCurrentChainAddress,
useCurrentChainTokens,
useAvailableChains,
diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts
index a0c3748d..d25ecc09 100644
--- a/src/stores/wallet.ts
+++ b/src/stores/wallet.ts
@@ -7,6 +7,19 @@ import {
type ChainAddressInfo,
} from '@/services/wallet-storage'
+/**
+ * 基于助记词/密钥稳定派生主题色 hue
+ */
+function deriveThemeHue(secret: string): number {
+ let hash = 0
+ for (let i = 0; i < secret.length; i++) {
+ const char = secret.charCodeAt(i)
+ hash = ((hash << 5) - hash) + char
+ hash = hash & hash
+ }
+ return Math.abs(hash) % 360
+}
+
// 类型定义
// 外部链 (BIP44)
export type ExternalChainType = 'ethereum' | 'tron' | 'bitcoin' | 'binance'
@@ -59,6 +72,8 @@ export interface Wallet {
/** 加密后的钱包锁(使用助记词派生密钥加密) */
encryptedWalletLock?: EncryptedData
createdAt: number
+ /** 主题色 hue (0-360) */
+ themeHue: number
/** @deprecated 使用 chainAddresses[].tokens */
tokens: Token[]
}
@@ -148,6 +163,7 @@ function walletInfoToWallet(info: WalletInfo, chainAddresses: ChainAddressInfo[]
return token
}),
})),
+ themeHue: info.themeHue ?? 0,
tokens: [], // deprecated
}
@@ -224,7 +240,8 @@ export const walletActions = {
createWallet: async (
wallet: Omit & { chainAddresses?: ChainAddress[] },
mnemonic: string,
- password: string
+ password: string,
+ themeHue?: number
): Promise => {
const walletId = crypto.randomUUID()
const now = Date.now()
@@ -237,6 +254,7 @@ export const walletActions = {
primaryChain: wallet.chain,
primaryAddress: wallet.address,
isBackedUp: false,
+ themeHue: themeHue ?? deriveThemeHue(mnemonic),
createdAt: now,
updatedAt: now,
}
@@ -280,6 +298,7 @@ export const walletActions = {
chain: wallet.chain,
createdAt: now,
chainAddresses,
+ themeHue: themeHue ?? deriveThemeHue(mnemonic),
tokens: [],
...(savedWalletInfo.encryptedMnemonic ? { encryptedMnemonic: savedWalletInfo.encryptedMnemonic } : {}),
}
@@ -392,6 +411,18 @@ export const walletActions = {
}))
},
+ /** 更新钱包主题色 */
+ updateWalletThemeHue: async (walletId: string, themeHue: number): Promise => {
+ await walletStorageService.updateWallet(walletId, { themeHue })
+
+ walletStore.setState((state) => ({
+ ...state,
+ wallets: state.wallets.map((w) =>
+ w.id === walletId ? { ...w, themeHue } : w
+ ),
+ }))
+ },
+
/** 更新链地址资产(用于余额更新) */
updateChainAssets: async (
walletId: string,
@@ -664,6 +695,7 @@ export const walletActions = {
chainAddresses: wallet.chainAddresses ?? [
{ chain: wallet.chain, address: wallet.address, tokens: [] }
],
+ themeHue: wallet.themeHue,
tokens: [],
...(wallet.encryptedMnemonic ? { encryptedMnemonic: wallet.encryptedMnemonic } : {}),
}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 1c045b50..642fd6f0 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -40,14 +40,51 @@
/* shadcnui */
@custom-variant dark (&:is(.dark *));
+/* 注册 CSS 自定义属性类型 - 支持动画和继承 */
+@property --primary-hue {
+ syntax: '';
+ inherits: true;
+ initial-value: 323;
+}
+
+@property --primary-saturation {
+ syntax: '';
+ inherits: true;
+ initial-value: 0.26;
+}
+
+@property --primary-lightness {
+ syntax: '';
+ inherits: true;
+ initial-value: 0.59;
+}
+
+@property --primary {
+ syntax: '';
+ inherits: true;
+ initial-value: oklch(0.59 0.26 323);
+}
+
+@property --primary-foreground {
+ syntax: '';
+ inherits: true;
+ initial-value: oklch(1 0 0);
+}
+
:root {
+ /* 动态主题色 - 可被钱包主题覆盖 */
+ --primary-hue: 323;
+ --primary-saturation: 0.26;
+ --primary-lightness: 0.59;
+
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
- --primary: oklch(0.59 0.26 323);
+ /* primary 使用动态 hue */
+ --primary: oklch(var(--primary-lightness) var(--primary-saturation) var(--primary-hue));
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
@@ -59,16 +96,18 @@
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
- --chart-1: oklch(0.83 0.13 321);
- --chart-2: oklch(0.75 0.21 322);
- --chart-3: oklch(0.67 0.26 322);
- --chart-4: oklch(0.59 0.26 323);
- --chart-5: oklch(0.52 0.23 324);
+ /* chart 颜色使用动态 hue,通过偏移产生和谐色 */
+ --chart-1: oklch(0.83 0.13 calc(var(--primary-hue) - 2));
+ --chart-2: oklch(0.75 0.21 calc(var(--primary-hue) - 1));
+ --chart-3: oklch(0.67 0.26 calc(var(--primary-hue) - 1));
+ --chart-4: oklch(0.59 0.26 var(--primary-hue));
+ --chart-5: oklch(0.52 0.23 calc(var(--primary-hue) + 1));
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
- --sidebar-primary: oklch(0.59 0.26 323);
- --sidebar-primary-foreground: oklch(0.98 0.02 320);
+ /* sidebar-primary 使用动态 hue */
+ --sidebar-primary: oklch(var(--primary-lightness) var(--primary-saturation) var(--primary-hue));
+ --sidebar-primary-foreground: oklch(0.98 0.02 calc(var(--primary-hue) - 3));
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
@@ -76,13 +115,17 @@
}
.dark {
+ /* dark 模式下调整亮度 */
+ --primary-lightness: 0.67;
+
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.67 0.26 322);
+ /* primary 继承动态 hue */
+ --primary: oklch(var(--primary-lightness) var(--primary-saturation) var(--primary-hue));
--primary-foreground: oklch(0.141 0.005 285.823);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
@@ -94,15 +137,17 @@
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
- --chart-1: oklch(0.83 0.13 321);
- --chart-2: oklch(0.75 0.21 322);
- --chart-3: oklch(0.67 0.26 322);
- --chart-4: oklch(0.59 0.26 323);
- --chart-5: oklch(0.52 0.23 324);
+ /* chart 颜色继承动态 hue */
+ --chart-1: oklch(0.83 0.13 calc(var(--primary-hue) - 2));
+ --chart-2: oklch(0.75 0.21 calc(var(--primary-hue) - 1));
+ --chart-3: oklch(0.67 0.26 calc(var(--primary-hue) - 1));
+ --chart-4: oklch(0.59 0.26 var(--primary-hue));
+ --chart-5: oklch(0.52 0.23 calc(var(--primary-hue) + 1));
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.75 0.21 322);
- --sidebar-primary-foreground: oklch(0.98 0.02 320);
+ /* sidebar-primary 继承动态 hue */
+ --sidebar-primary: oklch(0.75 0.21 var(--primary-hue));
+ --sidebar-primary-foreground: oklch(0.98 0.02 calc(var(--primary-hue) - 3));
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
@@ -212,6 +257,7 @@
/* 动画 */
--animate-slide-in-bottom: slide-in-bottom 0.3s ease-out;
--animate-fade-in: fade-in 0.2s ease-out;
+ --animate-rainbow-flow: rainbow-flow 3s linear infinite;
}
/* 自定义动画 */
@@ -233,6 +279,15 @@
}
}
+@keyframes rainbow-flow {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 100% {
+ background-position: 200% 50%;
+ }
+}
+
@keyframes marquee {
0% {
transform: translateX(0);
@@ -333,3 +388,17 @@
@utility animate-scan {
animation: scan 2s ease-in-out infinite;
}
+
+/* 禁用长按和右键菜单(移动端 App 体验) */
+html, body {
+ -webkit-touch-callout: none; /* iOS 禁用长按弹出菜单 */
+ -webkit-user-select: none; /* 禁用文本选择 */
+ user-select: none;
+ -webkit-tap-highlight-color: transparent; /* 禁用点击高亮 */
+}
+
+/* 允许输入框选择文本 */
+input, textarea, [contenteditable="true"] {
+ -webkit-user-select: text;
+ user-select: text;
+}
diff --git a/vite.config.ts b/vite.config.ts
index 69109c09..65c2b1d5 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -78,6 +78,10 @@ export default defineConfig({
// 确保资源路径使用相对路径
assetsDir: 'assets',
rollupOptions: {
+ input: {
+ main: resolve(__dirname, 'index.html'),
+ clear: resolve(__dirname, 'clear.html'),
+ },
output: {
// 使用 hash 命名避免缓存问题
entryFileNames: 'assets/[name]-[hash].js',