Kaynağa Gözat

✨ feat(passenger): 实现乘客表单验证功能

- 使用react-hook-form和zod实现现代化表单验证
- 添加姓名验证:必填、长度限制(2-50字符)
- 添加证件类型验证:必填、有效枚举值
- 添加证件号码验证:必填、根据证件类型进行格式验证
- 添加手机号验证:必填、中国手机号格式验证
- 实现实时错误提示和表单状态管理

📝 docs(story): 更新乘客管理故事文档

- 标记"实现表单验证"任务为已完成
- 添加表单验证功能到变更日志
- 记录表单验证功能实现时间和相关文件
- 补充表单验证完成的详细说明和检查项
yourname 3 ay önce
ebeveyn
işleme
15185ead1d

+ 11 - 1
docs/stories/005.006.story.md

@@ -30,7 +30,7 @@ Approved
   - [x] 实现乘客列表显示(包含姓名、证件类型、手机号)
   - [x] 实现搜索功能(按姓名、手机号、证件号搜索)
   - [x] 实现模态框添加/编辑乘客功能
-  - [ ] 实现表单验证(姓名、证件类型、证件号码、手机号)
+  - [x] 实现表单验证(姓名、证件类型、证件号码、手机号)
   - [x] 实现删除乘客功能(确认对话框)
   - [x] 实现设置默认乘客功能
   - [x] 集成真实的后端API替换模拟数据
@@ -228,6 +228,7 @@ export const passengerRoutes = createCrudRoutes({
 ## Change Log
 | Date | Version | Description | Author |
 |------|---------|-------------|--------|
+| 2025-10-22 | 1.5 | 实现完整的表单验证功能,使用react-hook-form和zod | James (Developer) |
 | 2025-10-22 | 1.4 | 乘客管理主页面任务完成,状态更新 | James (Developer) |
 | 2025-10-22 | 1.3 | 添加Navbar和Dialog样式规范要求 | Winston (Architect) |
 | 2025-10-21 | 1.2 | 故事验证通过,状态更新为Approved | Sarah (Product Owner) |
@@ -247,6 +248,9 @@ export const passengerRoutes = createCrudRoutes({
 - 页面文件:[mini/src/pages/passengers/passengers.tsx](mini/src/pages/passengers/passengers.tsx)
 - 路由集成完成:2025-10-22 14:35:00
 - 个人中心入口添加:2025-10-22 14:40:00
+- 表单验证功能实现完成:2025-10-22 15:20:00
+- 验证文件:[mini/src/pages/passengers/passengers.tsx](mini/src/pages/passengers/passengers.tsx)
+- 使用react-hook-form和zod进行现代化表单验证
 
 ### Completion Notes List
 - ✅ 用户端乘客API路由已创建并完整实现
@@ -263,6 +267,12 @@ export const passengerRoutes = createCrudRoutes({
 - ✅ 集成真实后端API,使用React Query进行状态管理
 - ✅ 遵循Navbar和Dialog样式规范,保持用户体验一致性
 - ✅ 乘客页面已集成到小程序路由和个人中心菜单
+- ✅ 实现了完整的表单验证功能,使用react-hook-form和zod进行现代化验证
+- ✅ 姓名验证:必填、长度限制(2-50字符)
+- ✅ 证件类型验证:必填、有效枚举值
+- ✅ 证件号码验证:必填、根据证件类型进行格式验证
+- ✅ 手机号验证:必填、中国手机号格式验证
+- ✅ 实时错误提示和表单状态管理
 
 ### File List
 - [src/server/api/passengers/index.ts](src/server/api/passengers/index.ts) - 用户端乘客API路由文件

+ 160 - 90
mini/src/pages/passengers/passengers.tsx

@@ -2,12 +2,15 @@ import { useState } from 'react'
 import { View, Text, ScrollView, Picker } from '@tarojs/components'
 import Taro from '@tarojs/taro'
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { z } from 'zod'
 import { Navbar, NavbarPresets } from '@/components/ui/navbar'
 import { Button } from '@/components/ui/button'
 import { Card, CardContent } from '@/components/ui/card'
 import { Input as ShadcnInput } from '@/components/ui/input'
 import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
-import { Label } from '@/components/ui/label'
+import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
 import { useAuth } from '@/utils/auth'
 import { passengerClient } from '@/api'
 import type { InferResponseType } from 'hono/client'
@@ -16,6 +19,46 @@ import { IdType } from '@/types/passenger.types'
 // 使用RPC方式提取类型
 type Passenger = InferResponseType<typeof passengerClient.$get, 200>['data'][0]
 
+// 证件号码验证函数
+const validateIdNumber = (idNumber: string, idType: IdType) => {
+  switch (idType) {
+    case IdType.ID_CARD:
+      return /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/.test(idNumber)
+    case IdType.HONG_KONG_MACAO_PASS:
+      return /^[HMhm]{1}([0-9]{10}|[0-9]{8})$/.test(idNumber)
+    case IdType.TAIWAN_PASS:
+      return /^[0-9]{8}$/.test(idNumber)
+    case IdType.PASSPORT:
+      return /^[EeKkGgDdSsPp]([0-9]{8}|[0-9]{7})$/.test(idNumber)
+    case IdType.OTHER:
+      return idNumber.length >= 1 && idNumber.length <= 30
+    default:
+      return true
+  }
+}
+
+// 乘客表单验证schema
+const passengerFormSchema = z.object({
+  name: z
+    .string()
+    .min(1, '请输入姓名')
+    .min(2, '姓名至少2个字符')
+    .max(50, '姓名不能超过50个字符'),
+  idType: z.nativeEnum(IdType, {
+    message: '请选择证件类型'
+  }),
+  idNumber: z
+    .string()
+    .min(1, '请输入证件号码')
+    .max(30, '证件号码不能超过30个字符'),
+  phone: z
+    .string()
+    .min(1, '请输入手机号')
+    .regex(/^1[3-9]\d{9}$/, '手机号格式不正确')
+})
+
+type PassengerFormData = z.infer<typeof passengerFormSchema>
+
 const PassengersPage: React.FC = () => {
   const { user } = useAuth()
   const queryClient = useQueryClient()
@@ -24,11 +67,16 @@ const PassengersPage: React.FC = () => {
   const [showSearchBar, setShowSearchBar] = useState(false)
   const [showAddModal, setShowAddModal] = useState(false)
   const [editingPassenger, setEditingPassenger] = useState<Passenger | null>(null)
-  const [formData, setFormData] = useState({
-    name: '',
-    idType: IdType.ID_CARD,
-    idNumber: '',
-    phone: ''
+
+  // 初始化react-hook-form
+  const form = useForm<PassengerFormData>({
+    resolver: zodResolver(passengerFormSchema),
+    defaultValues: {
+      name: '',
+      idType: IdType.ID_CARD,
+      idNumber: '',
+      phone: ''
+    }
   })
 
   // 证件类型选项
@@ -41,7 +89,7 @@ const PassengersPage: React.FC = () => {
   ]
 
   // 获取当前证件类型的索引
-  const currentIdTypeIndex = idTypeOptions.findIndex(option => option.value === formData.idType)
+  const currentIdTypeIndex = idTypeOptions.findIndex(option => option.value === form.watch('idType'))
 
   // 获取乘客列表
   const { data: passengersResponse, isLoading } = useQuery({
@@ -136,7 +184,7 @@ const PassengersPage: React.FC = () => {
 
   // 重置表单
   const resetForm = () => {
-    setFormData({
+    form.reset({
       name: '',
       idType: IdType.ID_CARD,
       idNumber: '',
@@ -164,7 +212,7 @@ const PassengersPage: React.FC = () => {
 
   // 编辑乘客
   const editPassenger = (passenger: Passenger) => {
-    setFormData({
+    form.reset({
       name: passenger.name,
       idType: passenger.idType,
       idNumber: passenger.idNumber,
@@ -196,30 +244,20 @@ const PassengersPage: React.FC = () => {
   }
 
   // 保存乘客
-  const savePassenger = () => {
-    // 基本验证
-    if (!formData.name.trim()) {
-      Taro.showToast({ title: '请输入姓名', icon: 'none' })
-      return
-    }
-
-    if (!formData.idNumber.trim()) {
-      Taro.showToast({ title: '请输入证件号码', icon: 'none' })
-      return
-    }
-
-    if (!formData.phone.trim()) {
-      Taro.showToast({ title: '请输入手机号', icon: 'none' })
+  const onSubmit = (data: PassengerFormData) => {
+    // 额外的证件号码格式验证
+    if (!validateIdNumber(data.idNumber, data.idType)) {
+      Taro.showToast({ title: '证件号码格式不正确', icon: 'none' })
       return
     }
 
     if (editingPassenger) {
       updateMutation.mutate({
         id: editingPassenger.id,
-        data: formData
+        data: data
       })
     } else {
-      addMutation.mutate(formData)
+      addMutation.mutate(data)
     }
   }
 
@@ -401,78 +439,110 @@ const PassengersPage: React.FC = () => {
             </DialogTitle>
           </DialogHeader>
 
-          <ScrollView className="max-h-60vh overflow-y-auto p-1">
-            <View className="space-y-4">
-              <View className="space-y-2">
-                <Label htmlFor="name">姓名</Label>
-                <ShadcnInput
-                  id="name"
-                  placeholder="请输入姓名"
-                  value={formData.name}
-                  onChange={(value) => setFormData({ ...formData, name: value })}
+          <Form {...form}>
+            <ScrollView className="max-h-60vh overflow-y-auto p-1">
+              <View className="space-y-4">
+                <FormField
+                  control={form.control}
+                  name="name"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>姓名</FormLabel>
+                      <FormControl>
+                        <ShadcnInput
+                          placeholder="请输入姓名"
+                          {...field}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
                 />
-              </View>
 
-              <View className="space-y-2">
-                <Label htmlFor="idType">证件类型</Label>
-                <Picker
-                  mode="selector"
-                  range={idTypeOptions.map(option => option.label)}
-                  value={currentIdTypeIndex}
-                  onChange={(e) => {
-                    const index = Number(e.detail.value)
-                    setFormData({ ...formData, idType: idTypeOptions[index].value })
-                  }}
-                >
-                  <View className="border border-gray-300 rounded-lg px-3 py-2 bg-white">
-                    <Text className="text-gray-900">
-                      {idTypeOptions[currentIdTypeIndex]?.label || '选择证件类型'}
-                    </Text>
-                  </View>
-                </Picker>
-              </View>
+                <FormField
+                  control={form.control}
+                  name="idType"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>证件类型</FormLabel>
+                      <FormControl>
+                        <Picker
+                          mode="selector"
+                          range={idTypeOptions.map(option => option.label)}
+                          value={currentIdTypeIndex}
+                          onChange={(e) => {
+                            const index = Number(e.detail.value)
+                            field.onChange(idTypeOptions[index].value)
+                          }}
+                        >
+                          <View className="border border-gray-300 rounded-lg px-3 py-2 bg-white">
+                            <Text className="text-gray-900">
+                              {idTypeOptions[currentIdTypeIndex]?.label || '选择证件类型'}
+                            </Text>
+                          </View>
+                        </Picker>
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
 
-              <View className="space-y-2">
-                <Label htmlFor="idNumber">证件号码</Label>
-                <ShadcnInput
-                  id="idNumber"
-                  placeholder="请输入证件号码"
-                  value={formData.idNumber}
-                  onChange={(value) => setFormData({ ...formData, idNumber: value })}
+                <FormField
+                  control={form.control}
+                  name="idNumber"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>证件号码</FormLabel>
+                      <FormControl>
+                        <ShadcnInput
+                          placeholder="请输入证件号码"
+                          {...field}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
                 />
-              </View>
 
-              <View className="space-y-2">
-                <Label htmlFor="phone">手机号</Label>
-                <ShadcnInput
-                  id="phone"
-                  placeholder="请输入手机号"
-                  value={formData.phone}
-                  onChange={(value) => setFormData({ ...formData, phone: value })}
+                <FormField
+                  control={form.control}
+                  name="phone"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>手机号</FormLabel>
+                      <FormControl>
+                        <ShadcnInput
+                          placeholder="请输入手机号"
+                          {...field}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
                 />
               </View>
-            </View>
-          </ScrollView>
-
-          <DialogFooter className="pt-4 border-t border-gray-100 bg-gray-50/50 flex flex-row gap-4">
-            <Button
-              variant="outline"
-              onClick={() => setShowAddModal(false)}
-              className="flex-1"
-            >
-              取消
-            </Button>
-            <Button
-              onClick={savePassenger}
-              disabled={addMutation.isPending || updateMutation.isPending}
-              className="flex-1"
-            >
-              {addMutation.isPending || updateMutation.isPending ? (
-                <View className="i-heroicons-arrow-path-20-solid animate-spin w-4 h-4 mr-2" />
-              ) : null}
-              {editingPassenger ? '更新' : '保存'}
-            </Button>
-          </DialogFooter>
+            </ScrollView>
+
+            <DialogFooter className="pt-4 border-t border-gray-100 bg-gray-50/50 flex flex-row gap-4">
+              <Button
+                variant="outline"
+                onClick={() => setShowAddModal(false)}
+                className="flex-1"
+              >
+                取消
+              </Button>
+              <Button
+                onClick={() => form.handleSubmit(onSubmit)()}
+                disabled={addMutation.isPending || updateMutation.isPending}
+                className="flex-1"
+              >
+                {addMutation.isPending || updateMutation.isPending ? (
+                  <View className="i-heroicons-arrow-path-20-solid animate-spin w-4 h-4 mr-2" />
+                ) : null}
+                {editingPassenger ? '更新' : '保存'}
+              </Button>
+            </DialogFooter>
+          </Form>
         </DialogContent>
       </Dialog>
     </View>