|
@@ -0,0 +1,1236 @@
|
|
|
|
|
+# Mini UI包开发规范
|
|
|
|
|
+
|
|
|
|
|
+## 版本信息
|
|
|
|
|
+| 版本 | 日期 | 描述 | 作者 |
|
|
|
|
|
+|------|------|------|------|
|
|
|
|
|
+| 1.1 | 2025-12-26 | 添加图标使用规范(Heroicons) | Bob (Scrum Master) |
|
|
|
|
|
+| 1.0 | 2025-12-26 | 基于史诗011和017经验创建Mini UI包开发规范 | Bob (Scrum Master) |
|
|
|
|
|
+
|
|
|
|
|
+## 概述
|
|
|
|
|
+
|
|
|
|
|
+本文档专门针对Taro小程序UI包(mini-ui-packages)的开发规范,基于史诗011(用人方小程序)和史诗017(人才小程序)的实施经验总结。Mini UI包与Web UI包有显著的差异,特别是在布局、组件行为和平台特性方面。
|
|
|
|
|
+
|
|
|
|
|
+**适用范围:**
|
|
|
|
|
+- `mini-ui-packages/` 目录下的所有UI包
|
|
|
|
|
+- 使用Taro框架的小程序项目
|
|
|
|
|
+- 所有基于`@tarojs/components`的组件开发
|
|
|
|
|
+
|
|
|
|
|
+## Taro小程序核心布局规范
|
|
|
|
|
+
|
|
|
|
|
+### 1. View组件的默认布局行为
|
|
|
|
|
+
|
|
|
|
|
+**重要**: 在Taro小程序中,`<View>` 组件内的子元素默认是**横向布局**(`flex-row`),这与Web开发的div默认垂直布局行为完全不同。
|
|
|
|
|
+
|
|
|
|
|
+#### 1.1 垂直布局规范
|
|
|
|
|
+
|
|
|
|
|
+**问题**: View容器默认横向布局,导致子元素横向排列
|
|
|
|
|
+
|
|
|
|
|
+**解决方案**: 必须显式添加 `flex flex-col` 类才能实现垂直布局
|
|
|
|
|
+
|
|
|
|
|
+**正确示例**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { View, Text } from '@tarojs/components'
|
|
|
|
|
+
|
|
|
|
|
+// ✅ 正确: 使用 flex flex-col 实现垂直布局
|
|
|
|
|
+<View className="flex flex-col">
|
|
|
|
|
+ <Text>姓名: 张三</Text>
|
|
|
|
|
+ <Text>性别: 男</Text>
|
|
|
|
|
+ <Text>年龄: 35</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+
|
|
|
|
|
+// ❌ 错误: 缺少 flex flex-col,子元素会横向排列
|
|
|
|
|
+<View>
|
|
|
|
|
+ <Text>姓名: 张三</Text>
|
|
|
|
|
+ <Text>性别: 男</Text>
|
|
|
|
|
+ <Text>年龄: 35</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 1.2 信息卡片布局模式
|
|
|
|
|
+
|
|
|
|
|
+**来源**: 史诗011.003经验总结
|
|
|
|
|
+
|
|
|
|
|
+**标准模式**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { View, Text } from '@tarojs/components'
|
|
|
|
|
+
|
|
|
|
|
+export function PersonalBasicInfo({ personalInfo }: { personalInfo: PersonalInfoResponse }) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <View className="bg-white rounded-lg p-4">
|
|
|
|
|
+ <Text className="text-lg font-semibold mb-4">个人基本信息</Text>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 垂直布局的信息列表 - 必须使用 flex flex-col */}
|
|
|
|
|
+ <View className="flex flex-col space-y-2">
|
|
|
|
|
+ <View className="flex justify-between">
|
|
|
|
|
+ <Text className="text-gray-600">姓名</Text>
|
|
|
|
|
+ <Text>{personalInfo.name}</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+
|
|
|
|
|
+ <View className="flex justify-between">
|
|
|
|
|
+ <Text className="text-gray-600">性别</Text>
|
|
|
|
|
+ <Text>{personalInfo.gender}</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+
|
|
|
|
|
+ <View className="flex justify-between">
|
|
|
|
|
+ <Text className="text-gray-600">年龄</Text>
|
|
|
|
|
+ <Text>{personalInfo.age}</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 更多字段... */}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**关键点**:
|
|
|
|
|
+1. **信息列表容器**必须使用 `flex flex-col` 实现垂直布局
|
|
|
|
|
+2. **每个信息项**使用 `flex justify-between` 实现标签和值的左右分布
|
|
|
|
|
+3. 使用 `space-y-2` 或 `space-y-3` 添加垂直间距
|
|
|
|
|
+4. **重要**: 记住在所有需要垂直排列的 View 上添加 `flex flex-col`
|
|
|
|
|
+
|
|
|
|
|
+### 2. Text组件的默认内联行为
|
|
|
|
|
+
|
|
|
|
|
+**问题**: Text组件默认是内联显示(类似Web中的`<span>`),不会自动换行
|
|
|
|
|
+
|
|
|
|
|
+**影响**: 导致多个Text组件在同一行显示,即使它们在不同的代码行上
|
|
|
|
|
+
|
|
|
|
|
+**解决方案**: 使用`flex flex-col`强制Text组件垂直排列
|
|
|
|
|
+
|
|
|
|
|
+**实际案例** (来源: 史诗011.003):
|
|
|
|
|
+```typescript
|
|
|
|
|
+// ❌ 错误: Text组件会内联显示在同一行
|
|
|
|
|
+<View>
|
|
|
|
|
+ <Text>统计1</Text>
|
|
|
|
|
+ <Text>统计2</Text>
|
|
|
|
|
+ <Text>统计3</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+
|
|
|
|
|
+// ✅ 正确: 使用 flex flex-col 强制垂直排列
|
|
|
|
|
+<View className="flex flex-col">
|
|
|
|
|
+ <Text>统计1</Text>
|
|
|
|
|
+ <Text>统计2</Text>
|
|
|
|
|
+ <Text>统计3</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3. 常见布局模式
|
|
|
|
|
+
|
|
|
|
|
+#### 3.1 卡片容器布局
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+<View className="bg-white rounded-lg p-4 shadow-sm">
|
|
|
|
|
+ <Text className="text-lg font-semibold mb-4">卡片标题</Text>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 内容区域 - 垂直布局 */}
|
|
|
|
|
+ <View className="flex flex-col space-y-3">
|
|
|
|
|
+ {items.map(item => (
|
|
|
|
|
+ <View key={item.id} className="flex justify-between border-b pb-2">
|
|
|
|
|
+ <Text className="text-gray-600">{item.label}</Text>
|
|
|
|
|
+ <Text>{item.value}</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </View>
|
|
|
|
|
+</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 3.2 列表项布局
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+<View className="flex flex-col space-y-2">
|
|
|
|
|
+ {list.map(item => (
|
|
|
|
|
+ <View key={item.id} className="bg-white rounded-lg p-3">
|
|
|
|
|
+ {/* 列表项标题 */}
|
|
|
|
|
+ <Text className="font-semibold mb-2">{item.title}</Text>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 列表项内容 - 垂直布局 */}
|
|
|
|
|
+ <View className="flex flex-col space-y-1">
|
|
|
|
|
+ <Text className="text-sm text-gray-600">{item.description}</Text>
|
|
|
|
|
+ <Text className="text-xs text-gray-400">{item.date}</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ ))}
|
|
|
|
|
+</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 3.3 网格布局
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+{/* 2列网格 */}
|
|
|
|
|
+<View className="grid grid-cols-2 gap-3">
|
|
|
|
|
+ {items.map(item => (
|
|
|
|
|
+ <View key={item.id} className="bg-white rounded-lg p-3">
|
|
|
|
|
+ <View className="flex flex-col">
|
|
|
|
|
+ <Text className="font-semibold">{item.title}</Text>
|
|
|
|
|
+ <Text className="text-sm text-gray-600">{item.value}</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ ))}
|
|
|
|
|
+</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 图标使用规范
|
|
|
|
|
+
|
|
|
|
|
+### 3.1 图标系统概述
|
|
|
|
|
+
|
|
|
|
|
+**项目使用的图标库**: Heroicons (UnoCSS图标集)
|
|
|
|
|
+
|
|
|
|
|
+**图标类命名规范**: `i-heroicons-{icon-name}-{size}-{style}`
|
|
|
|
|
+
|
|
|
|
|
+**重要**: **不要使用emoji**,必须使用Heroicons图标类。
|
|
|
|
|
+
|
|
|
|
|
+### 3.2 图标类使用规范
|
|
|
|
|
+
|
|
|
|
|
+#### 3.2.1 基础图标使用
|
|
|
|
|
+
|
|
|
|
|
+**正确示例**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { View } from '@tarojs/components'
|
|
|
|
|
+
|
|
|
|
|
+// ✅ 正确: 使用Heroicons图标类
|
|
|
|
|
+<View className="i-heroicons-chevron-left-20-solid w-5 h-5 text-gray-600" />
|
|
|
|
|
+<View className="i-heroicons-user-20-solid w-6 h-6 text-blue-500" />
|
|
|
|
|
+<View className="i-heroicons-bell-20-solid w-4 h-4 text-gray-400" />
|
|
|
|
|
+
|
|
|
|
|
+// ❌ 错误: 使用emoji
|
|
|
|
|
+<Text>🔔</Text>
|
|
|
|
|
+<Text>👤</Text>
|
|
|
|
|
+<View>←</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 3.2.2 图标类命名格式
|
|
|
|
|
+
|
|
|
|
|
+**格式**: `i-heroicons-{图标名称}-{尺寸}-{样式}`
|
|
|
|
|
+
|
|
|
|
|
+**常用图标名称**:
|
|
|
|
|
+- `chevron-left` - 左箭头
|
|
|
|
|
+- `chevron-right` - 右箭头
|
|
|
|
|
+- `user` - 用户
|
|
|
|
|
+- `bell` - 通知铃
|
|
|
|
|
+- `document-text` - 文档
|
|
|
|
|
+- `chart-bar` - 图表
|
|
|
|
|
+- `building-office` - 建筑/企业
|
|
|
|
|
+- `calendar` - 日历
|
|
|
|
|
+- `phone` - 电话
|
|
|
|
|
+- `lock-closed` - 锁
|
|
|
|
|
+- `camera` - 相机
|
|
|
|
|
+- `qr-code` - 二维码
|
|
|
|
|
+- `device-phone-mobile` - 手机
|
|
|
|
|
+- `arrow-right-on-rectangle` - 登出/外跳
|
|
|
|
|
+- `arrow-left-on-rectangle` - 登入/内跳
|
|
|
|
|
+- `exclamation-triangle` - 警告
|
|
|
|
|
+- `exclamation-circle` - 提示
|
|
|
|
|
+- `photo` - 图片
|
|
|
|
|
+- `arrow-path` - 加载中
|
|
|
|
|
+
|
|
|
|
|
+**尺寸选项**:
|
|
|
|
|
+- `20` - 20x20 (推荐用于小程序)
|
|
|
|
|
+- `24` - 24x24
|
|
|
|
|
+
|
|
|
|
|
+**样式选项**:
|
|
|
|
|
+- `solid` - 实心图标(推荐)
|
|
|
|
|
+- `outline` - 轮廓图标
|
|
|
|
|
+
|
|
|
|
|
+#### 3.2.3 图标尺寸和颜色
|
|
|
|
|
+
|
|
|
|
|
+**尺寸类**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+<View className="i-heroicons-user-20-solid w-4 h-4" /> // 16px
|
|
|
|
|
+<View className="i-heroicons-user-20-solid w-5 h-5" /> // 20px
|
|
|
|
|
+<View className="i-heroicons-user-20-solid w-6 h-6" /> // 24px
|
|
|
|
|
+<View className="i-heroicons-user-20-solid w-8 h-8" /> // 32px
|
|
|
|
|
+<View className="i-heroicons-user-20-solid text-xl" /> // 使用Tailwind文本尺寸
|
|
|
|
|
+<View className="i-heroicons-user-20-solid text-2xl" /> // 使用Tailwind文本尺寸
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**颜色类**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+<View className="i-heroicons-user-20-solid text-gray-400" /> // 灰色
|
|
|
|
|
+<View className="i-heroicons-user-20-solid text-blue-500" /> // 蓝色
|
|
|
|
|
+<View className="i-heroicons-user-20-solid text-green-500" /> // 绿色
|
|
|
|
|
+<View className="i-heroicons-user-20-solid text-red-500" /> // 红色
|
|
|
|
|
+<View className="i-heroicons-user-20-solid text-white" /> // 白色
|
|
|
|
|
+<View className="i-heroicons-user-20-solid text-yellow-600" /> // 黄色
|
|
|
|
|
+<View className="i-heroicons-user-20-solid text-purple-500" /> // 紫色
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 3.2.4 常见使用场景
|
|
|
|
|
+
|
|
|
|
|
+**导航栏返回按钮**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+<View className="i-heroicons-chevron-left-20-solid w-5 h-5" />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**功能入口图标**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+<View className="flex flex-col items-center">
|
|
|
|
|
+ <View className="i-heroicons-user-20-solid text-blue-500 text-lg mb-1" />
|
|
|
|
|
+ <Text className="text-sm">个人信息</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+
|
|
|
|
|
+<View className="flex flex-col items-center">
|
|
|
|
|
+ <View className="i-heroicons-document-text-20-solid text-green-500 text-lg mb-1" />
|
|
|
|
|
+ <Text className="text-sm">考勤记录</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+
|
|
|
|
|
+<View className="flex flex-col items-center">
|
|
|
|
|
+ <View className="i-heroicons-chart-bar-20-solid text-purple-500 text-lg mb-1" />
|
|
|
|
|
+ <Text className="text-sm">薪资查询</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+
|
|
|
|
|
+<View className="flex flex-col items-center">
|
|
|
|
|
+ <View className="i-heroicons-building-office-2-20-solid text-yellow-600 text-lg mb-1" />
|
|
|
|
|
+ <Text className="text-sm">企业信息</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**状态指示器**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 加载中
|
|
|
|
|
+<View className="i-heroicons-arrow-path-20-solid animate-spin w-5 h-5" />
|
|
|
|
|
+
|
|
|
|
|
+// 成功/提示
|
|
|
|
|
+<View className="i-heroicons-check-circle-20-solid text-green-500 w-6 h-6" />
|
|
|
|
|
+
|
|
|
|
|
+// 警告
|
|
|
|
|
+<View className="i-heroicons-exclamation-triangle-20-solid text-orange-500 w-6 h-6" />
|
|
|
|
|
+
|
|
|
|
|
+// 错误
|
|
|
|
|
+<View className="i-heroicons-x-circle-20-solid text-red-500 w-6 h-6" />
|
|
|
|
|
+
|
|
|
|
|
+// 信息
|
|
|
|
|
+<View className="i-heroicons-information-circle-20-solid text-blue-500 w-6 h-6" />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**输入框图标**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+<View className="flex items-center">
|
|
|
|
|
+ <View className="i-heroicons-phone-20-solid text-gray-400 mr-3 w-5 h-5" />
|
|
|
|
|
+ <Input placeholder="请输入手机号" />
|
|
|
|
|
+</View>
|
|
|
|
|
+
|
|
|
|
|
+<View className="flex items-center">
|
|
|
|
|
+ <View className="i-heroicons-lock-closed-20-solid text-gray-400 mr-3 w-5 h-5" />
|
|
|
|
|
+ <Input placeholder="请输入密码" type="password" />
|
|
|
|
|
+</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**二维码按钮**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+<View className="flex items-center">
|
|
|
|
|
+ <Text>张三</Text>
|
|
|
|
|
+ <View className="i-heroicons-qr-code-20-solid text-white text-lg ml-2" />
|
|
|
|
|
+</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 3.2.5 动画效果
|
|
|
|
|
+
|
|
|
|
|
+**旋转动画**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+<View className="i-heroicons-arrow-path-20-solid animate-spin w-5 h-5" />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**脉冲动画**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+<View className="i-heroicons-bell-20-solid animate-pulse w-6 h-6" />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3.3 Navbar图标规范
|
|
|
|
|
+
|
|
|
|
|
+**返回按钮图标**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+leftIcon = 'i-heroicons-chevron-left-20-solid'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**示例**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+<Navbar
|
|
|
|
|
+ title="页面标题"
|
|
|
|
|
+ leftIcon="i-heroicons-chevron-left-20-solid"
|
|
|
|
|
+ leftText="返回"
|
|
|
|
|
+ onClickLeft={() => Taro.navigateBack()}
|
|
|
|
|
+/>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3.4 TabBar图标规范
|
|
|
|
|
+
|
|
|
|
|
+**使用iconClass属性**(推荐):
|
|
|
|
|
+```typescript
|
|
|
|
|
+const tabList = [
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '首页',
|
|
|
|
|
+ iconClass: 'i-heroicons-home-20-solid',
|
|
|
|
|
+ selectedIconClass: 'i-heroicons-home-20-solid',
|
|
|
|
|
+ pagePath: '/pages/index/index'
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '考勤',
|
|
|
|
|
+ iconClass: 'i-heroicons-calendar-20-solid',
|
|
|
|
|
+ selectedIconClass: 'i-heroicons-calendar-20-solid',
|
|
|
|
|
+ pagePath: '/pages/attendance/index'
|
|
|
|
|
+ }
|
|
|
|
|
+]
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3.5 图标查找参考
|
|
|
|
|
+
|
|
|
|
|
+**Heroicons官方图标库**: https://heroicons.com/
|
|
|
|
|
+
|
|
|
|
|
+**使用UnoCSS图标集**: 项目使用UnoCSS的Heroicons图标集,所有图标名称遵循Heroicons命名规范。
|
|
|
|
|
+
|
|
|
|
|
+**查找图标的方法**:
|
|
|
|
|
+1. 访问 Heroicons 官网查找所需图标
|
|
|
|
|
+2. 记录图标名称(如 `user`, `chevron-left`)
|
|
|
|
|
+3. 使用格式: `i-heroicons-{图标名称}-20-solid`
|
|
|
|
|
+4. 添加尺寸和颜色类
|
|
|
|
|
+
|
|
|
|
|
+### 3.6 常见错误避免
|
|
|
|
|
+
|
|
|
|
|
+**错误示例**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+// ❌ 错误1: 使用emoji
|
|
|
|
|
+<Text>🔔 通知</Text>
|
|
|
|
|
+<View>👤</View>
|
|
|
|
|
+
|
|
|
|
|
+// ❌ 错误2: 使用文本符号
|
|
|
|
|
+<Text>← 返回</Text>
|
|
|
|
|
+<View>→</View>
|
|
|
|
|
+
|
|
|
|
|
+// ❌ 错误3: 使用其他图标库
|
|
|
|
|
+<View className="fa fa-user" />
|
|
|
|
|
+<View className="material-icons">person</View>
|
|
|
|
|
+
|
|
|
|
|
+// ❌ 错误4: 忘记添加尺寸类
|
|
|
|
|
+<View className="i-heroicons-user-20-solid" /> {/* 没有尺寸,可能不显示 */}
|
|
|
|
|
+
|
|
|
|
|
+// ❌ 错误5: 图标类名拼写错误
|
|
|
|
|
+<View className="i-heroicon-user-20-solid" /> {/* 缺少s */}
|
|
|
|
|
+<View className="i-heroicons-user-20-solid" /> {/* ✅ 正确 */}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**正确示例**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+// ✅ 正确1: 使用Heroicons图标类
|
|
|
|
|
+<View className="i-heroicons-bell-20-solid w-5 h-5" />
|
|
|
|
|
+<View className="i-heroicons-user-20-solid w-6 h-6" />
|
|
|
|
|
+
|
|
|
|
|
+// ✅ 正确2: 添加尺寸和颜色
|
|
|
|
|
+<View className="i-heroicons-chevron-left-20-solid w-5 h-5 text-gray-600" />
|
|
|
|
|
+
|
|
|
|
|
+// ✅ 正确3: 图标+文本组合
|
|
|
|
|
+<View className="flex items-center">
|
|
|
|
|
+ <View className="i-heroicons-phone-20-solid text-blue-500 w-5 h-5 mr-2" />
|
|
|
|
|
+ <Text>联系电话</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## Taro组件使用规范
|
|
|
|
|
+
|
|
|
|
|
+### 4.1 基础组件导入
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { View, Text, Image, Button, ScrollView } from '@tarojs/components'
|
|
|
|
|
+import Taro from '@tarojs/taro'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.2 Image组件规范
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+<Image
|
|
|
|
|
+ src={imageUrl}
|
|
|
|
|
+ mode="aspectFill" // 或 aspectFit, widthFix
|
|
|
|
|
+ className="w-full h-32 rounded-lg"
|
|
|
|
|
+ lazyLoad // 懒加载
|
|
|
|
|
+ onClick={handleClick}
|
|
|
|
|
+/>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**mode模式说明**:
|
|
|
|
|
+- `aspectFill`: 保持纵横比缩放图片,确保图片填充整个容器(可能裁剪)
|
|
|
|
|
+- `aspectFit`: 保持纵横比缩放图片,确保图片完全显示(可能有空白)
|
|
|
|
|
+- `widthFix`: 宽度不变,高度自动变化,保持原图宽高比
|
|
|
|
|
+
|
|
|
|
|
+### 4.3 ScrollView组件规范
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+<ScrollView
|
|
|
|
|
+ scrollY // 垂直滚动
|
|
|
|
|
+ className="h-full"
|
|
|
|
|
+ onScrollToLower={handleLoadMore}
|
|
|
|
|
+ lowerThreshold={100}
|
|
|
|
|
+>
|
|
|
|
|
+ <View className="flex flex-col">
|
|
|
|
|
+ {items.map(item => (
|
|
|
|
|
+ <View key={item.id}>{item.content}</View>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </View>
|
|
|
|
|
+</ScrollView>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.4 Button组件规范
|
|
|
|
|
+
|
|
|
|
|
+**注意**: Taro的Button组件有默认样式,如需自定义样式建议使用View
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// ✅ 推荐: 使用View实现自定义按钮
|
|
|
|
|
+<View
|
|
|
|
|
+ className="bg-blue-500 text-white py-2 px-4 rounded text-center"
|
|
|
|
|
+ onClick={handleClick}
|
|
|
|
|
+>
|
|
|
|
|
+ <Text>确定</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+
|
|
|
|
|
+// ⚠️ 谨慎使用: Taro Button组件有平台默认样式
|
|
|
|
|
+<Button onClick={handleClick}>确定</Button>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## Navbar导航栏集成规范
|
|
|
|
|
+
|
|
|
|
|
+### 5.1 Navbar组件来源
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { Navbar } from '@d8d/mini-shared-ui-components/components/navbar'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 5.2 页面层级划分
|
|
|
|
|
+
|
|
|
|
|
+**TabBar页面(一级,无返回按钮)**:
|
|
|
|
|
+- 首页/个人主页
|
|
|
|
|
+- 列表页
|
|
|
|
|
+- 个人信息页
|
|
|
|
|
+- 设置页
|
|
|
|
|
+
|
|
|
|
|
+**非TabBar页面(二级,带返回按钮)**:
|
|
|
|
|
+- 详情页
|
|
|
|
|
+- 编辑页
|
|
|
|
|
+- 从其他页面跳转来的页面
|
|
|
|
|
+
|
|
|
|
|
+### 5.3 Navbar配置规范
|
|
|
|
|
+
|
|
|
|
|
+**TabBar页面(无返回按钮)**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+<Navbar
|
|
|
|
|
+ title="页面标题"
|
|
|
|
|
+ leftIcon=""
|
|
|
|
|
+ leftText=""
|
|
|
|
|
+ onClickLeft={() => {}}
|
|
|
|
|
+ placeholder
|
|
|
|
|
+ fixed
|
|
|
|
|
+/>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**非TabBar页面(带返回按钮)**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+import Taro from '@tarojs/taro'
|
|
|
|
|
+
|
|
|
|
|
+<Navbar
|
|
|
|
|
+ title="页面标题"
|
|
|
|
|
+ leftIcon="i-heroicons-chevron-left-20-solid"
|
|
|
|
|
+ leftText="返回"
|
|
|
|
|
+ onClickLeft={() => Taro.navigateBack()}
|
|
|
|
|
+ placeholder
|
|
|
|
|
+ fixed
|
|
|
|
|
+/>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 5.4 完整页面结构示例
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { View, ScrollView } from '@tarojs/components'
|
|
|
|
|
+import { Navbar } from '@d8d/mini-shared-ui-components/components/navbar'
|
|
|
|
|
+
|
|
|
|
|
+export function MyPage() {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <View className="h-screen bg-gray-100">
|
|
|
|
|
+ {/* Navbar导航栏 */}
|
|
|
|
|
+ <Navbar
|
|
|
|
|
+ title="页面标题"
|
|
|
|
|
+ leftIcon=""
|
|
|
|
|
+ leftText=""
|
|
|
|
|
+ onClickLeft={() => {}}
|
|
|
|
|
+ placeholder
|
|
|
|
|
+ fixed
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ {/* 页面内容 */}
|
|
|
|
|
+ <ScrollView scrollY className="h-full">
|
|
|
|
|
+ <View className="flex flex-col space-y-3 p-4">
|
|
|
|
|
+ {/* 页面内容 */}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </ScrollView>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 照片预览功能实现
|
|
|
|
|
+
|
|
|
|
|
+### 6.1 使用Taro.previewImage
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import Taro from '@tarojs/taro'
|
|
|
|
|
+import { View, Image, Text } from '@tarojs/components'
|
|
|
|
|
+
|
|
|
|
|
+interface DocumentPhotoItemProps {
|
|
|
|
|
+ type: string
|
|
|
|
|
+ url: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function DocumentPhotoItem({ type, url }: DocumentPhotoItemProps) {
|
|
|
|
|
+ const handlePreview = () => {
|
|
|
|
|
+ Taro.previewImage({
|
|
|
|
|
+ current: url, // 当前显示图片的http链接
|
|
|
|
|
+ urls: [url] // 需要预览的图片http链接列表
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <View onClick={handlePreview} className="flex flex-col">
|
|
|
|
|
+ <Image
|
|
|
|
|
+ src={url}
|
|
|
|
|
+ mode="aspectFill"
|
|
|
|
|
+ className="w-full h-32 rounded-lg"
|
|
|
|
|
+ />
|
|
|
|
|
+ <Text>{type}</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**多图片预览**:
|
|
|
|
|
+```typescript
|
|
|
|
|
+const handlePreview = (currentIndex: number) => {
|
|
|
|
|
+ Taro.previewImage({
|
|
|
|
|
+ current: images[currentIndex].url,
|
|
|
|
|
+ urls: images.map(img => img.url)
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 数据脱敏规范
|
|
|
|
|
+
|
|
|
|
|
+### 7.1 银行卡号脱敏
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+/**
|
|
|
|
|
+ * 脱敏银行卡号
|
|
|
|
|
+ * @param cardNumber 完整银行卡号
|
|
|
|
|
+ * @returns 脱敏后的银行卡号(如:**** **** **** 1234)
|
|
|
|
|
+ */
|
|
|
|
|
+export function maskCardNumber(cardNumber: string): string {
|
|
|
|
|
+ if (!cardNumber || cardNumber.length < 4) {
|
|
|
|
|
+ return '****'
|
|
|
|
|
+ }
|
|
|
|
|
+ const last4 = cardNumber.slice(-4)
|
|
|
|
|
+ return `**** **** **** ${last4}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 使用示例
|
|
|
|
|
+<View className="flex justify-between">
|
|
|
|
|
+ <Text className="text-gray-600">银行卡号</Text>
|
|
|
|
|
+ <Text>{maskCardNumber(bankCard.cardNumber)}</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 7.2 身份证号脱敏
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+/**
|
|
|
|
|
+ * 脱敏身份证号
|
|
|
|
|
+ * @param idCard 完整身份证号
|
|
|
|
|
+ * @returns 脱敏后的身份证号(如:3301**********1234)
|
|
|
|
|
+ */
|
|
|
|
|
+export function maskIdCard(idCard: string): string {
|
|
|
|
|
+ if (!idCard || idCard.length < 8) {
|
|
|
|
|
+ return '********'
|
|
|
|
|
+ }
|
|
|
|
|
+ const prefix = idCard.slice(0, 4)
|
|
|
|
|
+ const suffix = idCard.slice(-4)
|
|
|
|
|
+ return `${prefix}**********${suffix}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 使用示例
|
|
|
|
|
+<View className="flex justify-between">
|
|
|
|
|
+ <Text className="text-gray-600">身份证号</Text>
|
|
|
|
|
+ <Text>{maskIdCard(personalInfo.idCard)}</Text>
|
|
|
|
|
+</View>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 7.3 手机号脱敏
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+/**
|
|
|
|
|
+ * 脱敏手机号
|
|
|
|
|
+ * @param phone 完整手机号
|
|
|
|
|
+ * @returns 脱敏后的手机号(如:138****5678)
|
|
|
|
|
+ */
|
|
|
|
|
+export function maskPhone(phone: string): string {
|
|
|
|
|
+ if (!phone || phone.length < 7) {
|
|
|
|
|
+ return '****'
|
|
|
|
|
+ }
|
|
|
|
|
+ return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## Mini UI包结构规范
|
|
|
|
|
+
|
|
|
|
|
+### 8.1 标准目录结构
|
|
|
|
|
+
|
|
|
|
|
+```text
|
|
|
|
|
+mini-ui-packages/<package-name>/
|
|
|
|
|
+├── src/
|
|
|
|
|
+│ ├── pages/ # 页面组件
|
|
|
|
|
+│ │ └── PageName/
|
|
|
|
|
+│ │ ├── PageName.tsx
|
|
|
|
|
+│ │ └── index.ts
|
|
|
|
|
+│ ├── components/ # UI组件
|
|
|
|
|
+│ │ ├── ComponentName.tsx
|
|
|
|
|
+│ │ └── index.ts
|
|
|
|
|
+│ ├── api/ # API客户端
|
|
|
|
|
+│ │ ├── client.ts
|
|
|
|
|
+│ │ └── index.ts
|
|
|
|
|
+│ ├── utils/ # 工具函数
|
|
|
|
|
+│ │ ├── helpers.ts
|
|
|
|
|
+│ │ └── index.ts
|
|
|
|
|
+│ └── index.ts # 主入口
|
|
|
|
|
+├── tests/ # 测试文件
|
|
|
|
|
+│ ├── pages/
|
|
|
|
|
+│ │ └── PageName/
|
|
|
|
|
+│ │ └── PageName.test.tsx
|
|
|
|
|
+│ └── components/
|
|
|
|
|
+│ └── ComponentName.test.tsx
|
|
|
|
|
+├── package.json
|
|
|
|
|
+├── jest.config.cjs # Jest配置
|
|
|
|
|
+└── tsconfig.json
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 8.2 package.json配置
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "name": "@d8d/<package-name>",
|
|
|
|
|
+ "version": "1.0.0",
|
|
|
|
|
+ "type": "module",
|
|
|
|
|
+ "main": "src/index.ts",
|
|
|
|
|
+ "types": "src/index.ts",
|
|
|
|
|
+ "exports": {
|
|
|
|
|
+ ".": {
|
|
|
|
|
+ "types": "./src/index.ts",
|
|
|
|
|
+ "import": "./src/index.ts"
|
|
|
|
|
+ },
|
|
|
|
|
+ "./api": {
|
|
|
|
|
+ "types": "./src/api/index.ts",
|
|
|
|
|
+ "import": "./src/api/index.ts"
|
|
|
|
|
+ },
|
|
|
|
|
+ "./pages/<PageName>/<PageName>": {
|
|
|
|
|
+ "types": "./src/pages/<PageName>/<PageName>.tsx",
|
|
|
|
|
+ "import": "./src/pages/<PageName>/<PageName>.tsx"
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ "dependencies": {
|
|
|
|
|
+ "@d8d/mini-shared-ui-components": "workspace:*",
|
|
|
|
|
+ "@tarojs/components": "^4.1.4",
|
|
|
|
|
+ "@tarojs/taro": "^4.1.4",
|
|
|
|
|
+ "react": "^19.1.0"
|
|
|
|
|
+ },
|
|
|
|
|
+ "devDependencies": {
|
|
|
|
|
+ "@testing-library/react": "^16.3.0",
|
|
|
|
|
+ "jest": "^30.2.0",
|
|
|
|
|
+ "ts-jest": "^29.4.5"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 数据获取规范
|
|
|
|
|
+
|
|
|
|
|
+### 9.1 使用React Query管理服务端状态
|
|
|
|
|
+
|
|
|
|
|
+**重要**: Mini UI包必须使用React Query (`@tanstack/react-query`) 管理服务端状态,而不是手动使用`useState` + `useEffect`。
|
|
|
|
|
+
|
|
|
|
|
+**原因**:
|
|
|
|
|
+- 符合项目技术栈要求(见`component-architecture.md`)
|
|
|
|
|
+- 自动处理加载状态、错误状态、缓存
|
|
|
|
|
+- 更好的类型推断和RPC集成
|
|
|
|
|
+- 统一的数据获取模式
|
|
|
|
|
+
|
|
|
|
|
+#### 9.1.1 基本用法
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { useQuery } from '@tanstack/react-query'
|
|
|
|
|
+import { apiClient } from '../api'
|
|
|
|
|
+
|
|
|
|
|
+const MyPage: React.FC = () => {
|
|
|
|
|
+ // ✅ 正确: 使用React Query
|
|
|
|
|
+ const { data, isLoading, error } = useQuery({
|
|
|
|
|
+ queryKey: ['resource-name'],
|
|
|
|
|
+ queryFn: async () => {
|
|
|
|
|
+ const res = await apiClient.resource.$get()
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ throw new Error('获取数据失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ return await res.json()
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ❌ 错误: 不要使用useState + useEffect手动管理状态
|
|
|
|
|
+ // const [data, setData] = useState(null)
|
|
|
|
|
+ // const [loading, setLoading] = useState(true)
|
|
|
|
|
+ // useEffect(() => {
|
|
|
|
|
+ // const fetchData = async () => {
|
|
|
|
|
+ // setLoading(true)
|
|
|
|
|
+ // const res = await apiClient.resource.$get()
|
|
|
|
|
+ // const data = await res.json()
|
|
|
|
|
+ // setData(data)
|
|
|
|
|
+ // setLoading(false)
|
|
|
|
|
+ // }
|
|
|
|
|
+ // fetchData()
|
|
|
|
|
+ // }, [])
|
|
|
|
|
+
|
|
|
|
|
+ if (isLoading) return <div>加载中...</div>
|
|
|
|
|
+ if (error) return <div>加载失败</div>
|
|
|
|
|
+
|
|
|
|
|
+ return <div>{/* 渲染数据 */}</div>
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 9.1.2 多个独立查询
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+const MyPage: React.FC = () => {
|
|
|
|
|
+ // 多个独立的查询可以并行执行
|
|
|
|
|
+ const { data: statusData, isLoading: statusLoading } = useQuery({
|
|
|
|
|
+ queryKey: ['employment-status'],
|
|
|
|
|
+ queryFn: async () => {
|
|
|
|
|
+ const res = await apiClient.employment.status.$get()
|
|
|
|
|
+ if (!res.ok) throw new Error('获取就业状态失败')
|
|
|
|
|
+ return await res.json()
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const { data: recordsData, isLoading: recordsLoading } = useQuery({
|
|
|
|
|
+ queryKey: ['salary-records'],
|
|
|
|
|
+ queryFn: async () => {
|
|
|
|
|
+ const res = await apiClient.employment['salary-records'].$get({
|
|
|
|
|
+ query: { take: 3 }
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!res.ok) throw new Error('获取薪资记录失败')
|
|
|
|
|
+ const data = await res.json()
|
|
|
|
|
+ return data.data || []
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const { data: historyData, isLoading: historyLoading } = useQuery({
|
|
|
|
|
+ queryKey: ['employment-history'],
|
|
|
|
|
+ queryFn: async () => {
|
|
|
|
|
+ const res = await apiClient.employment.history.$get({
|
|
|
|
|
+ query: { take: 20 }
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!res.ok) throw new Error('获取就业历史失败')
|
|
|
|
|
+ const data = await res.json()
|
|
|
|
|
+ return data.data || []
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 每个查询有独立的loading状态
|
|
|
|
|
+ return (
|
|
|
|
|
+ <View>
|
|
|
|
|
+ <StatusCard data={statusData} loading={statusLoading} />
|
|
|
|
|
+ <RecordsCard data={recordsData} loading={recordsLoading} />
|
|
|
|
|
+ <HistoryCard data={historyData} loading={historyLoading} />
|
|
|
|
|
+ </View>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 9.1.3 错误处理
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+const MyPage: React.FC = () => {
|
|
|
|
|
+ const { data, isLoading, error } = useQuery({
|
|
|
|
|
+ queryKey: ['resource'],
|
|
|
|
|
+ queryFn: async () => {
|
|
|
|
|
+ const res = await apiClient.resource.$get()
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ throw new Error('获取数据失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ return await res.json()
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 使用useEffect处理错误
|
|
|
|
|
+ React.useEffect(() => {
|
|
|
|
|
+ if (error) {
|
|
|
|
|
+ Taro.showToast({
|
|
|
|
|
+ title: error.message,
|
|
|
|
|
+ icon: 'none'
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [error])
|
|
|
|
|
+
|
|
|
|
|
+ if (isLoading) return <div>加载中...</div>
|
|
|
|
|
+
|
|
|
|
|
+ return <div>{/* 渲染数据 */}</div>
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 9.1.4 数据修改 (useMutation)
|
|
|
|
|
+
|
|
|
|
|
+对于需要修改服务端数据的操作(POST、PUT、DELETE),使用`useMutation`:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
+import { apiClient } from '../api'
|
|
|
|
|
+
|
|
|
|
|
+const MyPage: React.FC = () => {
|
|
|
|
|
+ const queryClient = useQueryClient()
|
|
|
|
|
+
|
|
|
|
|
+ // 数据修改mutation
|
|
|
|
|
+ const mutation = useMutation({
|
|
|
|
|
+ mutationFn: async (formData: MyFormData) => {
|
|
|
|
|
+ const res = await apiClient.resource.$post({
|
|
|
|
|
+ json: formData
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ throw new Error('操作失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ return await res.json()
|
|
|
|
|
+ },
|
|
|
|
|
+ onSuccess: (data) => {
|
|
|
|
|
+ // 成功后刷新相关查询
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['resource-list'] })
|
|
|
|
|
+ Taro.showToast({
|
|
|
|
|
+ title: '操作成功',
|
|
|
|
|
+ icon: 'success'
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: (error) => {
|
|
|
|
|
+ Taro.showToast({
|
|
|
|
|
+ title: error.message,
|
|
|
|
|
+ icon: 'none'
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const handleSubmit = (formData: MyFormData) => {
|
|
|
|
|
+ mutation.mutate(formData)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <View>
|
|
|
|
|
+ <button onClick={() => handleSubmit(formData)}>
|
|
|
|
|
+ {mutation.isPending ? '提交中...' : '提交'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**关键点**:
|
|
|
|
|
+- 使用`mutationFn`定义异步操作
|
|
|
|
|
+- 使用`onSuccess`处理成功逻辑,通常需要`invalidateQueries`刷新数据
|
|
|
|
|
+- 使用`onError`处理错误
|
|
|
|
|
+- 使用`isPending`判断加载状态
|
|
|
|
|
+
|
|
|
|
|
+#### 9.1.5 无限滚动查询 (useInfiniteQuery)
|
|
|
|
|
+
|
|
|
|
|
+对于分页列表数据,使用`useInfiniteQuery`实现无限滚动:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { useInfiniteQuery } from '@tanstack/react-query'
|
|
|
|
|
+import { apiClient } from '../api'
|
|
|
|
|
+
|
|
|
|
|
+const MyPage: React.FC = () => {
|
|
|
|
|
+ // 无限滚动查询
|
|
|
|
|
+ const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
|
|
|
|
+ queryKey: ['infinite-list'],
|
|
|
|
|
+ queryFn: async ({ pageParam = 0 }) => {
|
|
|
|
|
+ const res = await apiClient.items.$get({
|
|
|
|
|
+ query: { skip: pageParam * 20, take: 20 }
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ throw new Error('获取数据失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ const data = await res.json()
|
|
|
|
|
+ return {
|
|
|
|
|
+ items: data.data || [],
|
|
|
|
|
+ nextCursor: pageParam + 1
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ initialPageParam: 0,
|
|
|
|
|
+ getNextPageParam: (lastPage) => lastPage.nextCursor
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 扁平化所有页的数据
|
|
|
|
|
+ const allItems = data?.pages.flatMap(page => page.items) || []
|
|
|
|
|
+
|
|
|
|
|
+ const handleLoadMore = () => {
|
|
|
|
|
+ if (hasNextPage && !isFetchingNextPage) {
|
|
|
|
|
+ fetchNextPage()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <ScrollView
|
|
|
|
|
+ scrollY
|
|
|
|
|
+ onScrollToLower={handleLoadMore}
|
|
|
|
|
+ >
|
|
|
|
|
+ {allItems.map(item => (
|
|
|
|
|
+ <View key={item.id}>{item.name}</View>
|
|
|
|
|
+ ))}
|
|
|
|
|
+
|
|
|
|
|
+ {isFetchingNextPage && <Text>加载更多...</Text>}
|
|
|
|
|
+ {!hasNextPage && allItems.length > 0 && <Text>没有更多数据了</Text>}
|
|
|
|
|
+ </ScrollView>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**关键点**:
|
|
|
|
|
+- `pageParam`用于传递分页参数
|
|
|
|
|
+- `getNextPageParam`决定是否有下一页
|
|
|
|
|
+- 使用`pages.flatMap`合并所有页数据
|
|
|
|
|
+- 使用`fetchNextPage`加载下一页
|
|
|
|
|
+- 使用`hasNextPage`和`isFetchingNextPage`控制加载状态
|
|
|
|
|
+
|
|
|
|
|
+## 测试规范
|
|
|
|
|
+
|
|
|
|
|
+### 10.1 Jest配置
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+module.exports = {
|
|
|
|
|
+ preset: 'ts-jest',
|
|
|
|
|
+ testEnvironment: 'jsdom',
|
|
|
|
|
+ setupFilesAfterEnv: ['@d8d/mini-testing-utils/testing/setup'],
|
|
|
|
|
+ moduleNameMapper: {
|
|
|
|
|
+ '^@/(.*)$': '<rootDir>/src/$1',
|
|
|
|
|
+ '^~/(.*)$': '<rootDir>/tests/$1',
|
|
|
|
|
+ '^@tarojs/taro$': '@d8d/mini-testing-utils/testing/taro-api-mock.ts',
|
|
|
|
|
+ '\\.(css|less|scss)$': '@d8d/mini-testing-utils/testing/style-mock.js'
|
|
|
|
|
+ },
|
|
|
|
|
+ testMatch: [
|
|
|
|
|
+ '<rootDir>/tests/**/*.test.{ts,tsx}'
|
|
|
|
|
+ ],
|
|
|
|
|
+ transform: {
|
|
|
|
|
+ '^.+\\.(ts|tsx)$': 'ts-jest'
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 10.2 组件测试示例
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { render, screen } from '@testing-library/react'
|
|
|
|
|
+import { View, Text } from '@tarojs/components'
|
|
|
|
|
+import { MyComponent } from '../MyComponent'
|
|
|
|
|
+
|
|
|
|
|
+describe('MyComponent', () => {
|
|
|
|
|
+ it('渲染组件并验证垂直布局', () => {
|
|
|
|
|
+ render(<MyComponent />)
|
|
|
|
|
+
|
|
|
|
|
+ // 验证组件包含 flex flex-col 类
|
|
|
|
|
+ const container = screen.getByTestId('my-container')
|
|
|
|
|
+ expect(container.className).toContain('flex flex-col')
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 10.3 页面集成测试(使用React Query)
|
|
|
|
|
+
|
|
|
|
|
+**重要**: 页面集成测试必须使用真实的React Query,不要mock React Query。
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import React from 'react'
|
|
|
|
|
+import { render, screen, waitFor } from '@testing-library/react'
|
|
|
|
|
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
|
|
|
+import MyPage from '../pages/MyPage'
|
|
|
|
|
+
|
|
|
|
|
+// Mock API client
|
|
|
|
|
+jest.mock('../api', () => ({
|
|
|
|
|
+ apiClient: {
|
|
|
|
|
+ resource: {
|
|
|
|
|
+ $get: jest.fn()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}))
|
|
|
|
|
+
|
|
|
|
|
+const { apiClient } = require('../api')
|
|
|
|
|
+
|
|
|
|
|
+const createTestQueryClient = () => new QueryClient({
|
|
|
|
|
+ defaultOptions: {
|
|
|
|
|
+ queries: { retry: false, staleTime: Infinity },
|
|
|
|
|
+ mutations: { retry: false }
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const renderWithQueryClient = (component: React.ReactElement) => {
|
|
|
|
|
+ const queryClient = createTestQueryClient()
|
|
|
|
|
+ return render(
|
|
|
|
|
+ <QueryClientProvider client={queryClient}>
|
|
|
|
|
+ {component}
|
|
|
|
|
+ </QueryClientProvider>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+describe('MyPage', () => {
|
|
|
|
|
+ beforeEach(() => {
|
|
|
|
|
+ jest.clearAllMocks()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('应该显示加载状态', async () => {
|
|
|
|
|
+ // Mock API为pending状态
|
|
|
|
|
+ apiClient.resource.$get.mockImplementation(() => new Promise(() => {}))
|
|
|
|
|
+
|
|
|
|
|
+ renderWithQueryClient(<MyPage />)
|
|
|
|
|
+
|
|
|
|
|
+ expect(screen.getByText('加载中...')).toBeInTheDocument()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('应该成功加载并显示数据', async () => {
|
|
|
|
|
+ const mockData = { name: '测试数据' }
|
|
|
|
|
+ apiClient.resource.$get.mockResolvedValue({
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ json: async () => mockData
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ renderWithQueryClient(<MyPage />)
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => {
|
|
|
|
|
+ expect(screen.getByText('测试数据')).toBeInTheDocument()
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 常见问题和解决方案
|
|
|
|
|
+
|
|
|
|
|
+### 11.1 布局问题
|
|
|
|
|
+
|
|
|
|
|
+**问题**: 元素横向排列而不是垂直排列
|
|
|
|
|
+- **原因**: View容器默认是flex-row
|
|
|
|
|
+- **解决**: 添加`flex flex-col`类
|
|
|
|
|
+
|
|
|
|
|
+**问题**: Text组件在同一行显示
|
|
|
|
|
+- **原因**: Text组件默认是内联显示
|
|
|
|
|
+- **解决**: 父容器添加`flex flex-col`类
|
|
|
|
|
+
|
|
|
|
|
+### 11.2 样式问题
|
|
|
|
|
+
|
|
|
|
|
+**问题**: Tailwind样式不生效
|
|
|
|
|
+- **原因**: 类名冲突或拼写错误
|
|
|
|
|
+- **解决**: 检查类名拼写,确保使用正确的Tailwind类名
|
|
|
|
|
+
|
|
|
|
|
+**问题**: 样式在不同平台表现不一致
|
|
|
|
|
+- **原因**: 不同小程序平台的样式引擎差异
|
|
|
|
|
+- **解决**: 使用Taro提供的跨平台样式方案,避免使用平台特有样式
|
|
|
|
|
+
|
|
|
|
|
+### 11.3 API问题
|
|
|
|
|
+
|
|
|
|
|
+**问题**: RPC客户端类型错误
|
|
|
|
|
+- **原因**: API路径映射错误或类型推断不正确
|
|
|
|
|
+- **解决**: 验证后端路由定义,使用RPC推断类型
|
|
|
|
|
+
|
|
|
|
|
+### 11.4 React Query问题
|
|
|
|
|
+
|
|
|
|
|
+**问题**: 测试中React Query不工作
|
|
|
|
|
+- **原因**: 忘记使用QueryClientProvider包裹
|
|
|
|
|
+- **解决**: 使用renderWithQueryClient包装组件
|
|
|
|
|
+
|
|
|
|
|
+**问题**: queryKey冲突导致数据混乱
|
|
|
|
|
+- **原因**: 不同查询使用了相同的queryKey
|
|
|
|
|
+- **解决**: 为每个查询使用唯一的queryKey
|
|
|
|
|
+
|
|
|
|
|
+**问题**: mutation后数据没有更新
|
|
|
|
|
+- **原因**: 忘记调用invalidateQueries
|
|
|
|
|
+- **解决**: 在onSuccess回调中刷新相关查询
|
|
|
|
|
+
|
|
|
|
|
+**问题**: 无限滚动一直触发加载
|
|
|
|
|
+- **原因**: getNextPageParam返回逻辑错误
|
|
|
|
|
+- **解决**: 正确判断是否还有下一页,返回undefined或nextCursor
|
|
|
|
|
+
|
|
|
|
|
+## 最佳实践
|
|
|
|
|
+
|
|
|
|
|
+### 12.1 组件开发
|
|
|
|
|
+
|
|
|
|
|
+1. **始终使用flex flex-col实现垂直布局**
|
|
|
|
|
+2. **为每个View添加语义化的className**
|
|
|
|
|
+3. **使用data-testid属性便于测试**
|
|
|
|
|
+4. **组件props使用TypeScript接口定义**
|
|
|
|
|
+5. **使用相对路径导入包内模块**
|
|
|
|
|
+6. **使用React Query管理服务端状态**
|
|
|
|
|
+7. **为每个query使用唯一的queryKey**
|
|
|
|
|
+
|
|
|
|
|
+### 12.2 数据获取
|
|
|
|
|
+
|
|
|
|
|
+1. **使用useQuery获取数据,不要使用useState + useEffect**
|
|
|
|
|
+2. **在queryFn中检查response.ok,失败时throw Error**
|
|
|
|
|
+3. **使用useEffect处理错误,显示Toast提示**
|
|
|
|
|
+4. **多个独立查询使用不同的queryKey**
|
|
|
|
|
+5. **测试中使用真实的QueryClientProvider**
|
|
|
|
|
+
|
|
|
|
|
+### 12.3 数据修改
|
|
|
|
|
+
|
|
|
|
|
+1. **使用useMutation处理POST/PUT/DELETE操作**
|
|
|
|
|
+2. **在mutationFn中检查response.ok,失败时throw Error**
|
|
|
|
|
+3. **使用onSuccess刷新相关查询(invalidateQueries)**
|
|
|
|
|
+4. **使用onError显示错误提示**
|
|
|
|
|
+5. **使用isPending显示加载状态,避免重复提交**
|
|
|
|
|
+
|
|
|
|
|
+### 12.4 无限滚动
|
|
|
|
|
+
|
|
|
|
|
+1. **使用useInfiniteQuery处理分页列表**
|
|
|
|
|
+2. **使用pages.flatMap合并所有页数据**
|
|
|
|
|
+3. **正确实现getNextPageParam判断是否有下一页**
|
|
|
|
|
+4. **使用hasNextPage和isFetchingNextPage控制加载状态**
|
|
|
|
|
+5. **在ScrollView的onScrollToLower中触发fetchNextPage**
|
|
|
|
|
+
|
|
|
|
|
+### 12.5 性能优化
|
|
|
|
|
+
|
|
|
|
|
+1. **使用Image组件的lazyLoad属性**
|
|
|
|
|
+2. **列表数据使用虚拟滚动**
|
|
|
|
|
+3. **避免不必要的重渲染**
|
|
|
|
|
+4. **使用React.memo优化组件性能**
|
|
|
|
|
+5. **利用React Query的缓存机制减少重复请求**
|
|
|
|
|
+
|
|
|
|
|
+### 12.6 代码质量
|
|
|
|
|
+
|
|
|
|
|
+1. **遵循项目编码标准**
|
|
|
|
|
+2. **编写单元测试和集成测试**
|
|
|
|
|
+3. **使用TypeScript严格模式**
|
|
|
|
|
+4. **运行pnpm typecheck确保类型正确**
|
|
|
|
|
+5. **使用ESLint进行代码检查**
|
|
|
|
|
+
|
|
|
|
|
+## 参考实现
|
|
|
|
|
+
|
|
|
|
|
+### 13.1 用人方小程序UI包
|
|
|
|
|
+
|
|
|
|
|
+- `mini-ui-packages/yongren-dashboard-ui`
|
|
|
|
|
+- `mini-ui-packages/yongren-order-management-ui`
|
|
|
|
|
+- `mini-ui-packages/yongren-talent-management-ui`
|
|
|
|
|
+
|
|
|
|
|
+### 13.2 人才小程序UI包
|
|
|
|
|
+
|
|
|
|
|
+- `mini-ui-packages/rencai-dashboard-ui`
|
|
|
|
|
+- `mini-ui-packages/rencai-personal-info-ui`
|
|
|
|
|
+- `mini-ui-packages/rencai-employment-ui` - 使用React Query的参考实现
|
|
|
|
|
+- `mini-ui-packages/rencai-auth-ui`
|
|
|
|
|
+
|
|
|
|
|
+### 13.3 共享组件
|
|
|
|
|
+
|
|
|
|
|
+- `mini-ui-packages/mini-shared-ui-components`
|
|
|
|
|
+
|
|
|
|
|
+## 版本历史
|
|
|
|
|
+
|
|
|
|
|
+| 版本 | 日期 | 变更说明 | 作者 |
|
|
|
|
|
+|------|------|----------|------|
|
|
|
|
|
+| 1.3 | 2025-12-28 | 添加useMutation和useInfiniteQuery规范,完善React Query最佳实践 | James (Claude Code) |
|
|
|
|
|
+| 1.2 | 2025-12-28 | 添加React Query数据获取规范,更新测试规范章节 | James (Claude Code) |
|
|
|
|
|
+| 1.1 | 2025-12-26 | 添加图标使用规范(Heroicons) | Bob (Scrum Master) |
|
|
|
|
|
+| 1.0 | 2025-12-26 | 基于史诗011和017经验创建Mini UI包开发规范 | Bob (Scrum Master) |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+**重要提醒**:
|
|
|
|
|
+1. 本规范专门针对Taro小程序UI包开发,与Web UI包开发规范(`ui-package-standards.md`)不同
|
|
|
|
|
+2. `flex flex-col`是Taro小程序中最常用的布局类,请务必牢记
|
|
|
|
|
+3. **使用React Query管理服务端状态**,不要使用useState + useEffect手动管理
|
|
|
|
|
+4. 所有Mini UI包的开发都应遵循本规范
|