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',