|
|
@@ -5,7 +5,7 @@ description: "小程序页面的开发指令"
|
|
|
# 小程序页面开发指令
|
|
|
|
|
|
## 概述
|
|
|
-本指令规范了基于Taro + React + Tailwind CSS的小程序页面开发流程,包含tabbar页和非tabbar页的创建标准和最佳实践。
|
|
|
+本指令规范了基于Taro + React + Tailwind CSS的小程序页面开发流程,包含tabbar页和非tabbar页的创建标准和最佳实践,涵盖了认证、RPC调用、React Query v5使用等核心功能。
|
|
|
|
|
|
## 页面类型分类
|
|
|
|
|
|
@@ -119,6 +119,456 @@ page {
|
|
|
/* 自定义样式 */
|
|
|
```
|
|
|
|
|
|
+## 高级功能模板
|
|
|
+
|
|
|
+### 1. 带认证的页面模板
|
|
|
+```typescript
|
|
|
+// mini/src/pages/[需要认证的页面]/index.tsx
|
|
|
+import { View, Text } from '@tarojs/components'
|
|
|
+import { useEffect } from 'react'
|
|
|
+import { useAuth } from '@/utils/auth'
|
|
|
+import Taro from '@tarojs/taro'
|
|
|
+import { Navbar } from '@/components/ui/navbar'
|
|
|
+
|
|
|
+export default function ProtectedPage() {
|
|
|
+ const { user, isLoading, isLoggedIn } = useAuth()
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!isLoading && !isLoggedIn) {
|
|
|
+ Taro.navigateTo({ url: '/pages/login/index' })
|
|
|
+ }
|
|
|
+ }, [isLoading, isLoggedIn])
|
|
|
+
|
|
|
+ if (isLoading) {
|
|
|
+ return (
|
|
|
+ <View className="flex-1 flex items-center justify-center">
|
|
|
+ <View className="i-heroicons-arrow-path-20-solid animate-spin w-8 h-8 text-blue-500" />
|
|
|
+ </View>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!user) return null
|
|
|
+
|
|
|
+ return (
|
|
|
+ <View className="min-h-screen bg-gray-50">
|
|
|
+ <Navbar title="受保护页面" leftIcon="" />
|
|
|
+ <View className="px-4 py-4">
|
|
|
+ <Text>欢迎, {user.username}</Text>
|
|
|
+ </View>
|
|
|
+ </View>
|
|
|
+ )
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 带API调用的页面模板
|
|
|
+```typescript
|
|
|
+// mini/src/pages/[数据展示页面]/index.tsx
|
|
|
+import { View, ScrollView } from '@tarojs/components'
|
|
|
+import { useQuery } from '@tanstack/react-query'
|
|
|
+import { userClient } from '@/api'
|
|
|
+import { InferResponseType } from 'hono'
|
|
|
+import Taro from '@tarojs/taro'
|
|
|
+
|
|
|
+type UserListResponse = InferResponseType<typeof userClient.$get, 200>
|
|
|
+
|
|
|
+export default function UserListPage() {
|
|
|
+ const { data, isLoading, error } = useQuery<UserListResponse>({
|
|
|
+ queryKey: ['users'],
|
|
|
+ queryFn: async () => {
|
|
|
+ const response = await userClient.$get({})
|
|
|
+ if (response.status !== 200) {
|
|
|
+ throw new Error('获取用户列表失败')
|
|
|
+ }
|
|
|
+ return response.json()
|
|
|
+ },
|
|
|
+ staleTime: 5 * 60 * 1000, // 5分钟
|
|
|
+ })
|
|
|
+
|
|
|
+ if (isLoading) {
|
|
|
+ return (
|
|
|
+ <View className="flex-1 flex items-center justify-center">
|
|
|
+ <View className="i-heroicons-arrow-path-20-solid animate-spin w-8 h-8 text-blue-500" />
|
|
|
+ </View>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ if (error) {
|
|
|
+ return (
|
|
|
+ <View className="flex-1 flex items-center justify-center">
|
|
|
+ <Text className="text-red-500">{error.message}</Text>
|
|
|
+ </View>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <ScrollView className="h-screen">
|
|
|
+ <Navbar title="用户列表" leftIcon="" />
|
|
|
+ <View className="px-4 py-4">
|
|
|
+ {data?.data.map(user => (
|
|
|
+ <View key={user.id} className="bg-white rounded-lg p-4 mb-3">
|
|
|
+ <Text>{user.username}</Text>
|
|
|
+ </View>
|
|
|
+ ))}
|
|
|
+ </View>
|
|
|
+ </ScrollView>
|
|
|
+ )
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 3. 带表单提交的页面模板
|
|
|
+```typescript
|
|
|
+// mini/src/pages/[表单页面]/index.tsx
|
|
|
+import { View } from '@tarojs/components'
|
|
|
+import { useState } from 'react'
|
|
|
+import { useForm } from 'react-hook-form'
|
|
|
+import { zodResolver } from '@hookform/resolvers/zod'
|
|
|
+import { z } from 'zod'
|
|
|
+import { useMutation } from '@tanstack/react-query'
|
|
|
+import { userClient } from '@/api'
|
|
|
+import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
|
|
|
+import { Input } from '@/components/ui/input'
|
|
|
+import { Button } from '@/components/ui/button'
|
|
|
+import Taro from '@tarojs/taro'
|
|
|
+
|
|
|
+const formSchema = z.object({
|
|
|
+ username: z.string().min(3, '用户名至少3个字符'),
|
|
|
+ email: z.string().email('请输入有效的邮箱地址'),
|
|
|
+ phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号')
|
|
|
+})
|
|
|
+
|
|
|
+type FormData = z.infer<typeof formSchema>
|
|
|
+
|
|
|
+export default function CreateUserPage() {
|
|
|
+ const [loading, setLoading] = useState(false)
|
|
|
+
|
|
|
+ const form = useForm<FormData>({
|
|
|
+ resolver: zodResolver(formSchema),
|
|
|
+ defaultValues: {
|
|
|
+ username: '',
|
|
|
+ email: '',
|
|
|
+ phone: ''
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const mutation = useMutation({
|
|
|
+ mutationFn: async (data: FormData) => {
|
|
|
+ const response = await userClient.$post({ json: data })
|
|
|
+ if (response.status !== 201) {
|
|
|
+ throw new Error('创建用户失败')
|
|
|
+ }
|
|
|
+ return response.json()
|
|
|
+ },
|
|
|
+ onSuccess: () => {
|
|
|
+ Taro.showToast({
|
|
|
+ title: '创建成功',
|
|
|
+ icon: 'success'
|
|
|
+ })
|
|
|
+ Taro.navigateBack()
|
|
|
+ },
|
|
|
+ onError: (error) => {
|
|
|
+ Taro.showToast({
|
|
|
+ title: error.message || '创建失败',
|
|
|
+ icon: 'none'
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const onSubmit = async (data: FormData) => {
|
|
|
+ setLoading(true)
|
|
|
+ try {
|
|
|
+ await mutation.mutateAsync(data)
|
|
|
+ } finally {
|
|
|
+ setLoading(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <View className="min-h-screen bg-gray-50">
|
|
|
+ <Navbar title="创建用户" leftIcon="" />
|
|
|
+ <View className="px-4 py-4">
|
|
|
+ <Form {...form}>
|
|
|
+ <View className="space-y-4">
|
|
|
+ <FormField
|
|
|
+ control={form.control}
|
|
|
+ name="username"
|
|
|
+ render={({ field }) => (
|
|
|
+ <FormItem>
|
|
|
+ <FormLabel>用户名</FormLabel>
|
|
|
+ <FormControl>
|
|
|
+ <Input placeholder="请输入用户名" {...field} />
|
|
|
+ </FormControl>
|
|
|
+ <FormMessage />
|
|
|
+ </FormItem>
|
|
|
+ )}
|
|
|
+ />
|
|
|
+ <Button
|
|
|
+ className="w-full"
|
|
|
+ onClick={form.handleSubmit(onSubmit)}
|
|
|
+ disabled={loading}
|
|
|
+ >
|
|
|
+ {loading ? '创建中...' : '创建用户'}
|
|
|
+ </Button>
|
|
|
+ </View>
|
|
|
+ </Form>
|
|
|
+ </View>
|
|
|
+ </View>
|
|
|
+ )
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 认证功能使用
|
|
|
+
|
|
|
+### 1. useAuth Hook 使用规范
|
|
|
+```typescript
|
|
|
+import { useAuth } from '@/utils/auth'
|
|
|
+
|
|
|
+// 在页面或组件中使用
|
|
|
+const {
|
|
|
+ user, // 当前用户信息
|
|
|
+ login, // 登录函数
|
|
|
+ logout, // 登出函数
|
|
|
+ register, // 注册函数
|
|
|
+ updateUser, // 更新用户信息
|
|
|
+ isLoading, // 加载状态
|
|
|
+ isLoggedIn // 是否已登录
|
|
|
+} = useAuth()
|
|
|
+
|
|
|
+// 使用示例
|
|
|
+const handleLogin = async (formData) => {
|
|
|
+ try {
|
|
|
+ await login(formData)
|
|
|
+ Taro.switchTab({ url: '/pages/index/index' })
|
|
|
+ } catch (error) {
|
|
|
+ console.error('登录失败:', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 页面权限控制
|
|
|
+```typescript
|
|
|
+// 在需要认证的页面顶部
|
|
|
+const { user, isLoading, isLoggedIn } = useAuth()
|
|
|
+
|
|
|
+useEffect(() => {
|
|
|
+ if (!isLoading && !isLoggedIn) {
|
|
|
+ Taro.navigateTo({ url: '/pages/login/index' })
|
|
|
+ }
|
|
|
+}, [isLoading, isLoggedIn])
|
|
|
+
|
|
|
+// 或者使用路由守卫模式
|
|
|
+```
|
|
|
+
|
|
|
+## RPC客户端调用规范
|
|
|
+
|
|
|
+### 1. 客户端导入
|
|
|
+```typescript
|
|
|
+// 从api.ts导入对应的客户端
|
|
|
+import { authClient, userClient, fileClient } from '@/api'
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 类型提取
|
|
|
+```typescript
|
|
|
+import { InferResponseType, InferRequestType } from 'hono'
|
|
|
+
|
|
|
+// 响应类型提取
|
|
|
+type UserResponse = InferResponseType<typeof userClient.$get, 200>
|
|
|
+type UserDetailResponse = InferResponseType<typeof userClient[':id']['$get'], 200>
|
|
|
+
|
|
|
+// 请求类型提取
|
|
|
+type CreateUserRequest = InferRequestType<typeof userClient.$post>['json']
|
|
|
+type UpdateUserRequest = InferRequestType<typeof userClient[':id']['$put']>['json']
|
|
|
+```
|
|
|
+
|
|
|
+### 3. 调用示例
|
|
|
+```typescript
|
|
|
+// GET请求 - 列表
|
|
|
+const response = await userClient.$get({
|
|
|
+ query: {
|
|
|
+ page: 1,
|
|
|
+ pageSize: 10,
|
|
|
+ keyword: 'search'
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// GET请求 - 单条
|
|
|
+const response = await userClient[':id'].$get({
|
|
|
+ param: { id: userId }
|
|
|
+})
|
|
|
+
|
|
|
+// POST请求
|
|
|
+const response = await userClient.$post({
|
|
|
+ json: {
|
|
|
+ username: 'newuser',
|
|
|
+ email: 'user@example.com'
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// PUT请求
|
|
|
+const response = await userClient[':id'].$put({
|
|
|
+ param: { id: userId },
|
|
|
+ json: { username: 'updated' }
|
|
|
+})
|
|
|
+
|
|
|
+// DELETE请求
|
|
|
+const response = await userClient[':id'].$delete({
|
|
|
+ param: { id: userId }
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+## React Query v5使用规范
|
|
|
+
|
|
|
+### 1. 查询配置
|
|
|
+```typescript
|
|
|
+const { data, isLoading, error, refetch } = useQuery({
|
|
|
+ queryKey: ['users', page, keyword], // 唯一的查询键
|
|
|
+ queryFn: async () => {
|
|
|
+ const response = await userClient.$get({
|
|
|
+ query: { page, pageSize: 10, keyword }
|
|
|
+ })
|
|
|
+ if (response.status !== 200) {
|
|
|
+ throw new Error('获取数据失败')
|
|
|
+ }
|
|
|
+ return response.json()
|
|
|
+ },
|
|
|
+ staleTime: 5 * 60 * 1000, // 5分钟
|
|
|
+ cacheTime: 10 * 60 * 1000, // 10分钟
|
|
|
+ retry: 3, // 重试3次
|
|
|
+ enabled: !!keyword, // 条件查询
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 变更操作
|
|
|
+```typescript
|
|
|
+const queryClient = useQueryClient()
|
|
|
+
|
|
|
+const mutation = useMutation({
|
|
|
+ mutationFn: async (data: CreateUserRequest) => {
|
|
|
+ const response = await userClient.$post({ json: data })
|
|
|
+ if (response.status !== 201) {
|
|
|
+ throw new Error('创建失败')
|
|
|
+ }
|
|
|
+ return response.json()
|
|
|
+ },
|
|
|
+ onSuccess: () => {
|
|
|
+ // 成功后刷新相关查询
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
|
+ Taro.showToast({ title: '创建成功', icon: 'success' })
|
|
|
+ },
|
|
|
+ onError: (error) => {
|
|
|
+ Taro.showToast({
|
|
|
+ title: error.message || '操作失败',
|
|
|
+ icon: 'none'
|
|
|
+ })
|
|
|
+ }
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+### 3. 分页查询
|
|
|
+```typescript
|
|
|
+const useUserList = (page: number, pageSize: number = 10) => {
|
|
|
+ return useQuery({
|
|
|
+ queryKey: ['users', page, pageSize],
|
|
|
+ queryFn: async () => {
|
|
|
+ const response = await userClient.$get({
|
|
|
+ query: { page, pageSize }
|
|
|
+ })
|
|
|
+ return response.json()
|
|
|
+ },
|
|
|
+ keepPreviousData: true, // 保持上一页数据
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 表单处理规范
|
|
|
+
|
|
|
+### 1. 表单Schema定义
|
|
|
+```typescript
|
|
|
+// 在schemas目录下定义
|
|
|
+import { z } from 'zod'
|
|
|
+
|
|
|
+export const userSchema = z.object({
|
|
|
+ username: z.string()
|
|
|
+ .min(3, '用户名至少3个字符')
|
|
|
+ .max(20, '用户名最多20个字符')
|
|
|
+ .regex(/^\S+$/, '用户名不能包含空格'),
|
|
|
+ email: z.string().email('请输入有效的邮箱地址'),
|
|
|
+ phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号')
|
|
|
+})
|
|
|
+
|
|
|
+export type UserFormData = z.infer<typeof userSchema>
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 表单使用
|
|
|
+```typescript
|
|
|
+import { useForm } from 'react-hook-form'
|
|
|
+import { zodResolver } from '@hookform/resolvers/zod'
|
|
|
+import { userSchema, type UserFormData } from '@/schemas/user.schema'
|
|
|
+
|
|
|
+const form = useForm<UserFormData>({
|
|
|
+ resolver: zodResolver(userSchema),
|
|
|
+ defaultValues: {
|
|
|
+ username: '',
|
|
|
+ email: '',
|
|
|
+ phone: ''
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 表单提交
|
|
|
+const onSubmit = async (data: UserFormData) => {
|
|
|
+ try {
|
|
|
+ await mutation.mutateAsync(data)
|
|
|
+ } catch (error) {
|
|
|
+ console.error('表单提交失败:', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 错误处理规范
|
|
|
+
|
|
|
+### 1. 统一的错误处理
|
|
|
+```typescript
|
|
|
+const handleApiError = (error: any) => {
|
|
|
+ const message = error.response?.data?.message || error.message || '操作失败'
|
|
|
+
|
|
|
+ if (error.response?.status === 401) {
|
|
|
+ Taro.showModal({
|
|
|
+ title: '未登录',
|
|
|
+ content: '请先登录',
|
|
|
+ success: () => {
|
|
|
+ Taro.navigateTo({ url: '/pages/login/index' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } else if (error.response?.status === 403) {
|
|
|
+ Taro.showToast({ title: '权限不足', icon: 'none' })
|
|
|
+ } else if (error.response?.status === 404) {
|
|
|
+ Taro.showToast({ title: '资源不存在', icon: 'none' })
|
|
|
+ } else if (error.response?.status >= 500) {
|
|
|
+ Taro.showToast({ title: '服务器错误,请稍后重试', icon: 'none' })
|
|
|
+ } else {
|
|
|
+ Taro.showToast({ title: message, icon: 'none' })
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 页面级错误处理
|
|
|
+```typescript
|
|
|
+const { data, isLoading, error } = useQuery({
|
|
|
+ // ...查询配置
|
|
|
+})
|
|
|
+
|
|
|
+if (error) {
|
|
|
+ return (
|
|
|
+ <View className="flex-1 flex items-center justify-center">
|
|
|
+ <View className="text-center">
|
|
|
+ <View className="i-heroicons-exclamation-triangle-20-solid w-12 h-12 text-red-500 mx-auto mb-4" />
|
|
|
+ <Text className="text-gray-600 mb-4">{error.message}</Text>
|
|
|
+ <Button onClick={() => refetch()}>重新加载</Button>
|
|
|
+ </View>
|
|
|
+ </View>
|
|
|
+ )
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
## 页面模板示例
|
|
|
|
|
|
### 1. TabBar页面标准结构
|
|
|
@@ -297,9 +747,78 @@ Taro.navigateTo({ url: '/pages/login/index' })
|
|
|
|
|
|
// 返回上一页
|
|
|
Taro.navigateBack()
|
|
|
+
|
|
|
+// 重定向(清除当前页面历史)
|
|
|
+Taro.redirectTo({ url: '/pages/login/index' })
|
|
|
+
|
|
|
+// 重新启动应用
|
|
|
+Taro.reLaunch({ url: '/pages/index/index' })
|
|
|
```
|
|
|
|
|
|
### 2. 用户交互
|
|
|
```typescript
|
|
|
// 显示提示
|
|
|
-Taro.showToast({
|
|
|
+Taro.showToast({
|
|
|
+ title: '操作成功',
|
|
|
+ icon: 'success',
|
|
|
+ duration: 2000
|
|
|
+})
|
|
|
+
|
|
|
+// 显示加载
|
|
|
+Taro.showLoading({
|
|
|
+ title: '加载中...',
|
|
|
+ mask: true
|
|
|
+})
|
|
|
+Taro.hideLoading()
|
|
|
+
|
|
|
+// 显示确认对话框
|
|
|
+Taro.showModal({
|
|
|
+ title: '确认操作',
|
|
|
+ content: '确定要执行此操作吗?',
|
|
|
+ success: (res) => {
|
|
|
+ if (res.confirm) {
|
|
|
+ // 用户点击确认
|
|
|
+ }
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 显示操作菜单
|
|
|
+Taro.showActionSheet({
|
|
|
+ itemList: ['选项1', '选项2', '选项3'],
|
|
|
+ success: (res) => {
|
|
|
+ console.log('用户选择了', res.tapIndex)
|
|
|
+ }
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+### 3. 本地存储
|
|
|
+```typescript
|
|
|
+// 存储数据
|
|
|
+Taro.setStorageSync('key', 'value')
|
|
|
+Taro.setStorageSync('user', JSON.stringify(user))
|
|
|
+
|
|
|
+// 获取数据
|
|
|
+const value = Taro.getStorageSync('key')
|
|
|
+const user = JSON.parse(Taro.getStorageSync('user') || '{}')
|
|
|
+
|
|
|
+// 移除数据
|
|
|
+Taro.removeStorageSync('key')
|
|
|
+
|
|
|
+// 清空所有数据
|
|
|
+Taro.clearStorageSync()
|
|
|
+```
|
|
|
+
|
|
|
+### 4. 设备信息
|
|
|
+```typescript
|
|
|
+// 获取系统信息
|
|
|
+const systemInfo = Taro.getSystemInfoSync()
|
|
|
+const { screenWidth, screenHeight, windowWidth, windowHeight, statusBarHeight } = systemInfo
|
|
|
+
|
|
|
+// 获取用户位置
|
|
|
+Taro.getLocation({
|
|
|
+ type: 'wgs84',
|
|
|
+ success: (res) => {
|
|
|
+ console.log('纬度:', res.latitude)
|
|
|
+ console.log('经度:', res.longitude)
|
|
|
+ }
|
|
|
+})
|