RouteForm.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. import React from 'react';
  2. import { useForm } from 'react-hook-form';
  3. import { zodResolver } from '@hookform/resolvers/zod';
  4. import { Button } from '@/client/components/ui/button';
  5. import { Input } from '@/client/components/ui/input';
  6. import { Textarea } from '@/client/components/ui/textarea';
  7. import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
  8. import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
  9. import { MapPin, DollarSign, Users, Car } from 'lucide-react';
  10. import { format } from 'date-fns';
  11. import { createRouteSchema, updateRouteSchema, VehicleType } from '@/server/modules/routes/route.schema';
  12. import type { CreateRouteInput, UpdateRouteInput } from '@/server/modules/routes/route.schema';
  13. import { ActivitySelect } from './ActivitySelect';
  14. // 将ISO日期时间格式化为 datetime-local 输入框需要的格式
  15. const formatDateTimeForInput = (dateString: string): string => {
  16. const date = new Date(dateString);
  17. // 使用 date-fns 转换为 YYYY-MM-DDTHH:mm 格式
  18. return format(date, "yyyy-MM-dd'T'HH:mm");
  19. };
  20. interface RouteFormProps {
  21. initialData?: UpdateRouteInput & { id?: number };
  22. onSubmit: (data: CreateRouteInput | UpdateRouteInput) => Promise<void>;
  23. onCancel: () => void;
  24. isLoading?: boolean;
  25. }
  26. export const RouteForm: React.FC<RouteFormProps> = ({
  27. initialData,
  28. onSubmit,
  29. onCancel,
  30. isLoading = false
  31. }) => {
  32. const isEditing = !!initialData?.id;
  33. const form = useForm<CreateRouteInput | UpdateRouteInput>({
  34. resolver: zodResolver(isEditing ? updateRouteSchema : createRouteSchema),
  35. defaultValues: initialData ? {
  36. name: initialData.name || '',
  37. description: initialData.description || '',
  38. startPoint: initialData.startPoint || '',
  39. endPoint: initialData.endPoint || '',
  40. pickupPoint: initialData.pickupPoint || '',
  41. dropoffPoint: initialData.dropoffPoint || '',
  42. departureTime: initialData.departureTime ? formatDateTimeForInput(initialData.departureTime) : '',
  43. vehicleType: initialData.vehicleType as VehicleType || VehicleType.BUS,
  44. price: initialData.price || 0,
  45. seatCount: initialData.seatCount || 1,
  46. availableSeats: initialData.availableSeats || 1,
  47. activityId: initialData.activityId || 0,
  48. isDisabled: initialData.isDisabled
  49. } : {
  50. name: '',
  51. description: '',
  52. startPoint: '',
  53. endPoint: '',
  54. pickupPoint: '',
  55. dropoffPoint: '',
  56. departureTime: '',
  57. vehicleType: VehicleType.BUS,
  58. price: 0,
  59. seatCount: 1,
  60. availableSeats: 1,
  61. activityId: 0,
  62. }
  63. });
  64. const handleSubmit = async (data: CreateRouteInput | UpdateRouteInput) => {
  65. try {
  66. await onSubmit(data);
  67. } catch (error) {
  68. console.error('表单提交失败:', error);
  69. }
  70. };
  71. return (
  72. <Form {...form}>
  73. <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
  74. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  75. {/* 路线名称 */}
  76. <FormField
  77. control={form.control}
  78. name="name"
  79. render={({ field }) => (
  80. <FormItem>
  81. <FormLabel>路线名称 *</FormLabel>
  82. <FormControl>
  83. <Input
  84. placeholder="请输入路线名称"
  85. data-testid="route-name-input"
  86. {...field}
  87. />
  88. </FormControl>
  89. <FormDescription>
  90. 路线的显示名称,最多255个字符
  91. </FormDescription>
  92. <FormMessage />
  93. </FormItem>
  94. )}
  95. />
  96. {/* 车型 */}
  97. <FormField
  98. control={form.control}
  99. name="vehicleType"
  100. render={({ field }) => (
  101. <FormItem>
  102. <FormLabel>车型 *</FormLabel>
  103. <Select onValueChange={field.onChange} defaultValue={field.value}>
  104. <FormControl>
  105. <SelectTrigger>
  106. <SelectValue placeholder="选择车型" />
  107. </SelectTrigger>
  108. </FormControl>
  109. <SelectContent>
  110. <SelectItem value={VehicleType.BUS}>
  111. <div className="flex items-center gap-2">
  112. <Car className="h-4 w-4 text-blue-500" />
  113. <span>大巴</span>
  114. </div>
  115. </SelectItem>
  116. <SelectItem value={VehicleType.MINIBUS}>
  117. <div className="flex items-center gap-2">
  118. <Car className="h-4 w-4 text-green-500" />
  119. <span>中巴</span>
  120. </div>
  121. </SelectItem>
  122. <SelectItem value={VehicleType.CAR}>
  123. <div className="flex items-center gap-2">
  124. <Car className="h-4 w-4 text-orange-500" />
  125. <span>小车</span>
  126. </div>
  127. </SelectItem>
  128. </SelectContent>
  129. </Select>
  130. <FormDescription>
  131. 选择路线的车型
  132. </FormDescription>
  133. <FormMessage />
  134. </FormItem>
  135. )}
  136. />
  137. </div>
  138. {/* 路线描述 */}
  139. <FormField
  140. control={form.control}
  141. name="description"
  142. render={({ field }) => (
  143. <FormItem>
  144. <FormLabel>路线描述</FormLabel>
  145. <FormControl>
  146. <Textarea
  147. placeholder="请输入路线描述(可选)"
  148. className="min-h-[100px]"
  149. {...field}
  150. value={field.value || ''}
  151. />
  152. </FormControl>
  153. <FormDescription>
  154. 路线的详细描述,最多1000个字符
  155. </FormDescription>
  156. <FormMessage />
  157. </FormItem>
  158. )}
  159. />
  160. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  161. {/* 出发地 */}
  162. <FormField
  163. control={form.control}
  164. name="startPoint"
  165. render={({ field }) => (
  166. <FormItem>
  167. <FormLabel>出发地 *</FormLabel>
  168. <FormControl>
  169. <div className="relative">
  170. <MapPin className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  171. <Input
  172. placeholder="请输入出发地"
  173. className="pl-8"
  174. data-testid="start-point-input"
  175. {...field}
  176. />
  177. </div>
  178. </FormControl>
  179. <FormDescription>
  180. 路线的出发地点
  181. </FormDescription>
  182. <FormMessage />
  183. </FormItem>
  184. )}
  185. />
  186. {/* 目的地 */}
  187. <FormField
  188. control={form.control}
  189. name="endPoint"
  190. render={({ field }) => (
  191. <FormItem>
  192. <FormLabel>目的地 *</FormLabel>
  193. <FormControl>
  194. <div className="relative">
  195. <MapPin className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  196. <Input
  197. placeholder="请输入目的地"
  198. className="pl-8"
  199. data-testid="end-point-input"
  200. {...field}
  201. />
  202. </div>
  203. </FormControl>
  204. <FormDescription>
  205. 路线的目的地
  206. </FormDescription>
  207. <FormMessage />
  208. </FormItem>
  209. )}
  210. />
  211. </div>
  212. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  213. {/* 上车点 */}
  214. <FormField
  215. control={form.control}
  216. name="pickupPoint"
  217. render={({ field }) => (
  218. <FormItem>
  219. <FormLabel>上车点 *</FormLabel>
  220. <FormControl>
  221. <div className="relative">
  222. <MapPin className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  223. <Input
  224. placeholder="请输入上车点"
  225. className="pl-8"
  226. data-testid="pickup-point-input"
  227. {...field}
  228. />
  229. </div>
  230. </FormControl>
  231. <FormDescription>
  232. 乘客上车的地点
  233. </FormDescription>
  234. <FormMessage />
  235. </FormItem>
  236. )}
  237. />
  238. {/* 下车点 */}
  239. <FormField
  240. control={form.control}
  241. name="dropoffPoint"
  242. render={({ field }) => (
  243. <FormItem>
  244. <FormLabel>下车点 *</FormLabel>
  245. <FormControl>
  246. <div className="relative">
  247. <MapPin className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  248. <Input
  249. placeholder="请输入下车点"
  250. className="pl-8"
  251. data-testid="dropoff-point-input"
  252. {...field}
  253. />
  254. </div>
  255. </FormControl>
  256. <FormDescription>
  257. 乘客下车的地点
  258. </FormDescription>
  259. <FormMessage />
  260. </FormItem>
  261. )}
  262. />
  263. </div>
  264. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  265. {/* 出发时间 */}
  266. <FormField
  267. control={form.control}
  268. name="departureTime"
  269. render={({ field }) => (
  270. <FormItem>
  271. <FormLabel>出发时间 *</FormLabel>
  272. <FormControl>
  273. <Input
  274. type="datetime-local"
  275. data-testid="departure-time-input"
  276. {...field}
  277. />
  278. </FormControl>
  279. <FormDescription>
  280. 路线的出发时间
  281. </FormDescription>
  282. <FormMessage />
  283. </FormItem>
  284. )}
  285. />
  286. {/* 价格 */}
  287. <FormField
  288. control={form.control}
  289. name="price"
  290. render={({ field }) => (
  291. <FormItem>
  292. <FormLabel>价格 *</FormLabel>
  293. <FormControl>
  294. <div className="relative">
  295. <DollarSign className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  296. <Input
  297. type="number"
  298. step="0.01"
  299. min="0"
  300. placeholder="0.00"
  301. className="pl-8"
  302. data-testid="price-input"
  303. {...field}
  304. onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
  305. />
  306. </div>
  307. </FormControl>
  308. <FormDescription>
  309. 路线的价格(元)
  310. </FormDescription>
  311. <FormMessage />
  312. </FormItem>
  313. )}
  314. />
  315. </div>
  316. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  317. {/* 座位数 */}
  318. <FormField
  319. control={form.control}
  320. name="seatCount"
  321. render={({ field }) => (
  322. <FormItem>
  323. <FormLabel>总座位数 *</FormLabel>
  324. <FormControl>
  325. <div className="relative">
  326. <Users className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  327. <Input
  328. type="number"
  329. min="1"
  330. max="1000"
  331. placeholder="1"
  332. className="pl-8"
  333. data-testid="seat-count-input"
  334. {...field}
  335. onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
  336. />
  337. </div>
  338. </FormControl>
  339. <FormDescription>
  340. 车辆的总座位数
  341. </FormDescription>
  342. <FormMessage />
  343. </FormItem>
  344. )}
  345. />
  346. {/* 可用座位数 */}
  347. <FormField
  348. control={form.control}
  349. name="availableSeats"
  350. render={({ field }) => (
  351. <FormItem>
  352. <FormLabel>可用座位数 *</FormLabel>
  353. <FormControl>
  354. <div className="relative">
  355. <Users className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  356. <Input
  357. type="number"
  358. min="0"
  359. max="1000"
  360. placeholder="0"
  361. className="pl-8"
  362. data-testid="available-seats-input"
  363. {...field}
  364. onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
  365. />
  366. </div>
  367. </FormControl>
  368. <FormDescription>
  369. 当前可用的座位数
  370. </FormDescription>
  371. <FormMessage />
  372. </FormItem>
  373. )}
  374. />
  375. </div>
  376. {/* 关联活动 */}
  377. <FormField
  378. control={form.control}
  379. name="activityId"
  380. render={({ field }) => (
  381. <FormItem>
  382. <FormLabel>关联活动 *</FormLabel>
  383. <FormControl>
  384. <ActivitySelect
  385. value={field.value}
  386. onValueChange={field.onChange}
  387. placeholder="请选择关联活动"
  388. data-testid="activity-select"
  389. />
  390. </FormControl>
  391. <FormDescription>
  392. 选择此路线关联的活动
  393. </FormDescription>
  394. <FormMessage />
  395. </FormItem>
  396. )}
  397. />
  398. {/* 状态选择(仅在编辑时显示) */}
  399. {isEditing && (
  400. <FormField
  401. control={form.control}
  402. name="isDisabled"
  403. render={({ field }) => (
  404. <FormItem>
  405. <FormLabel>路线状态</FormLabel>
  406. <Select onValueChange={(value) => field.onChange(parseInt(value))} defaultValue={field.value?.toString()}>
  407. <FormControl>
  408. <SelectTrigger>
  409. <SelectValue placeholder="选择路线状态" />
  410. </SelectTrigger>
  411. </FormControl>
  412. <SelectContent>
  413. <SelectItem value="0">启用</SelectItem>
  414. <SelectItem value="1">禁用</SelectItem>
  415. </SelectContent>
  416. </Select>
  417. <FormDescription>
  418. 控制路线是否对用户可见
  419. </FormDescription>
  420. <FormMessage />
  421. </FormItem>
  422. )}
  423. />
  424. )}
  425. {/* 操作按钮 */}
  426. <div className="flex justify-end gap-4 pt-6">
  427. <Button
  428. type="button"
  429. variant="outline"
  430. onClick={onCancel}
  431. disabled={isLoading}
  432. >
  433. 取消
  434. </Button>
  435. <Button
  436. type="submit"
  437. disabled={isLoading}
  438. >
  439. {isLoading ? '保存中...' : isEditing ? '更新路线' : '创建路线'}
  440. </Button>
  441. </div>
  442. </form>
  443. </Form>
  444. );
  445. };