Orders.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. import React, { useState, useMemo, useCallback } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { format } from 'date-fns';
  4. import { Search, Filter, X, Eye } from 'lucide-react';
  5. import { orderClient } from '@/client/api';
  6. import type { InferResponseType } from 'hono/client';
  7. import { Button } from '@/client/components/ui/button';
  8. import { Input } from '@/client/components/ui/input';
  9. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
  10. import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
  11. import { Badge } from '@/client/components/ui/badge';
  12. import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
  13. import { DataTablePagination } from '@/client/admin/components/DataTablePagination';
  14. import { Skeleton } from '@/client/components/ui/skeleton';
  15. import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
  16. import { OrderStatus, PaymentStatus } from '@d8d/server/share/order.types';
  17. // 使用RPC方式提取类型
  18. type OrderResponse = InferResponseType<typeof orderClient.$get, 200>['data'][0];
  19. export const OrdersPage = () => {
  20. const [searchParams, setSearchParams] = useState({
  21. page: 1,
  22. pageSize: 10,
  23. search: ''
  24. });
  25. const [filters, setFilters] = useState({
  26. status: undefined as OrderStatus | undefined,
  27. paymentStatus: undefined as PaymentStatus | undefined
  28. });
  29. const [showFilters, setShowFilters] = useState(false);
  30. const [detailDialogOpen, setDetailDialogOpen] = useState(false);
  31. const [selectedOrder, setSelectedOrder] = useState<OrderResponse | null>(null);
  32. // 获取订单列表
  33. const { data: ordersData, isLoading } = useQuery({
  34. queryKey: ['orders', searchParams, filters],
  35. queryFn: async () => {
  36. const res = await orderClient.$get({
  37. query: {
  38. page: searchParams.page,
  39. pageSize: searchParams.pageSize,
  40. keyword: searchParams.search,
  41. filters: JSON.stringify({
  42. status: filters.status,
  43. paymentStatus: filters.paymentStatus
  44. })
  45. }
  46. });
  47. if (res.status !== 200) {
  48. throw new Error('获取订单列表失败');
  49. }
  50. return await res.json();
  51. }
  52. });
  53. // 获取订单统计
  54. const { data: statsData } = useQuery({
  55. queryKey: ['order-stats'],
  56. queryFn: async () => {
  57. const res = await orderClient.stats.$get();
  58. if (res.status !== 200) {
  59. throw new Error('获取订单统计失败');
  60. }
  61. return await res.json();
  62. }
  63. });
  64. const orders = ordersData?.data || [];
  65. const totalCount = ordersData?.pagination?.total || 0;
  66. const stats = statsData || {
  67. total: 0,
  68. pendingPayment: 0,
  69. waitingDeparture: 0,
  70. inProgress: 0,
  71. completed: 0,
  72. cancelled: 0
  73. };
  74. // 防抖搜索函数
  75. const debounce = (func: Function, delay: number) => {
  76. let timeoutId: NodeJS.Timeout;
  77. return (...args: any[]) => {
  78. clearTimeout(timeoutId);
  79. timeoutId = setTimeout(() => func(...args), delay);
  80. };
  81. };
  82. // 使用useCallback包装防抖搜索
  83. const debouncedSearch = useCallback(
  84. debounce((search: string) => {
  85. setSearchParams(prev => ({ ...prev, search, page: 1 }));
  86. }, 300),
  87. []
  88. );
  89. // 处理搜索输入变化
  90. const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  91. const search = e.target.value;
  92. setSearchParams(prev => ({ ...prev, search }));
  93. debouncedSearch(search);
  94. };
  95. // 处理搜索表单提交
  96. const handleSearch = (e: React.FormEvent) => {
  97. e.preventDefault();
  98. setSearchParams(prev => ({ ...prev, page: 1 }));
  99. };
  100. // 处理分页
  101. const handlePageChange = (page: number, pageSize: number) => {
  102. setSearchParams(prev => ({ ...prev, page, pageSize }));
  103. };
  104. // 处理过滤条件变化
  105. const handleFilterChange = (newFilters: Partial<typeof filters>) => {
  106. setFilters(prev => ({ ...prev, ...newFilters }));
  107. setSearchParams(prev => ({ ...prev, page: 1 }));
  108. };
  109. // 重置所有过滤条件
  110. const resetFilters = () => {
  111. setFilters({
  112. status: undefined,
  113. paymentStatus: undefined
  114. });
  115. setSearchParams(prev => ({ ...prev, page: 1 }));
  116. };
  117. // 检查是否有活跃的过滤条件
  118. const hasActiveFilters = useMemo(() => {
  119. return filters.status !== undefined || filters.paymentStatus !== undefined;
  120. }, [filters]);
  121. // 打开订单详情对话框
  122. const handleViewOrder = (order: OrderResponse) => {
  123. setSelectedOrder(order);
  124. setDetailDialogOpen(true);
  125. };
  126. // 获取订单状态对应的颜色
  127. const getStatusColor = (status: OrderStatus) => {
  128. switch (status) {
  129. case OrderStatus.PENDING_PAYMENT:
  130. return 'secondary';
  131. case OrderStatus.WAITING_DEPARTURE:
  132. return 'default';
  133. case OrderStatus.IN_PROGRESS:
  134. return 'default';
  135. case OrderStatus.COMPLETED:
  136. return 'default';
  137. case OrderStatus.CANCELLED:
  138. return 'destructive';
  139. default:
  140. return 'default';
  141. }
  142. };
  143. // 获取支付状态对应的颜色
  144. const getPaymentStatusColor = (status: PaymentStatus) => {
  145. switch (status) {
  146. case PaymentStatus.PENDING:
  147. return 'secondary';
  148. case PaymentStatus.PAID:
  149. return 'default';
  150. case PaymentStatus.FAILED:
  151. return 'destructive';
  152. case PaymentStatus.REFUNDED:
  153. return 'secondary';
  154. default:
  155. return 'default';
  156. }
  157. };
  158. // 渲染表格部分的骨架屏
  159. const renderTableSkeleton = () => (
  160. <div className="space-y-2">
  161. {Array.from({ length: 5 }).map((_, index) => (
  162. <div key={index} className="flex space-x-4">
  163. <Skeleton className="h-4 flex-1" />
  164. <Skeleton className="h-4 flex-1" />
  165. <Skeleton className="h-4 flex-1" />
  166. <Skeleton className="h-4 flex-1" />
  167. <Skeleton className="h-4 flex-1" />
  168. <Skeleton className="h-4 flex-1" />
  169. <Skeleton className="h-4 flex-1" />
  170. <Skeleton className="h-4 w-16" />
  171. </div>
  172. ))}
  173. </div>
  174. );
  175. return (
  176. <div className="space-y-4">
  177. <div className="flex justify-between items-center">
  178. <h1 className="text-2xl font-bold">订单管理</h1>
  179. </div>
  180. {/* 订单统计面板 */}
  181. <div className="grid grid-cols-2 md:grid-cols-6 gap-4">
  182. <Card data-testid="total-orders-card">
  183. <CardHeader className="p-4">
  184. <CardTitle className="text-sm font-medium">总订单数</CardTitle>
  185. <CardDescription className="text-2xl font-bold" data-testid="total-orders-count">{stats.total}</CardDescription>
  186. </CardHeader>
  187. </Card>
  188. <Card data-testid="pending-payment-card">
  189. <CardHeader className="p-4">
  190. <CardTitle className="text-sm font-medium">待支付</CardTitle>
  191. <CardDescription className="text-2xl font-bold" data-testid="pending-payment-count">{stats.pendingPayment}</CardDescription>
  192. </CardHeader>
  193. </Card>
  194. <Card data-testid="waiting-departure-card">
  195. <CardHeader className="p-4">
  196. <CardTitle className="text-sm font-medium">待出发</CardTitle>
  197. <CardDescription className="text-2xl font-bold" data-testid="waiting-departure-count">{stats.waitingDeparture}</CardDescription>
  198. </CardHeader>
  199. </Card>
  200. <Card data-testid="in-progress-card">
  201. <CardHeader className="p-4">
  202. <CardTitle className="text-sm font-medium">行程中</CardTitle>
  203. <CardDescription className="text-2xl font-bold" data-testid="in-progress-count">{stats.inProgress}</CardDescription>
  204. </CardHeader>
  205. </Card>
  206. <Card data-testid="completed-card">
  207. <CardHeader className="p-4">
  208. <CardTitle className="text-sm font-medium">已完成</CardTitle>
  209. <CardDescription className="text-2xl font-bold" data-testid="completed-count">{stats.completed}</CardDescription>
  210. </CardHeader>
  211. </Card>
  212. <Card data-testid="cancelled-card">
  213. <CardHeader className="p-4">
  214. <CardTitle className="text-sm font-medium">已取消</CardTitle>
  215. <CardDescription className="text-2xl font-bold" data-testid="cancelled-count">{stats.cancelled}</CardDescription>
  216. </CardHeader>
  217. </Card>
  218. </div>
  219. <Card>
  220. <CardHeader>
  221. <CardTitle>订单列表</CardTitle>
  222. <CardDescription>
  223. 管理系统中的所有订单,共 {totalCount} 个订单
  224. </CardDescription>
  225. </CardHeader>
  226. <CardContent>
  227. <div className="mb-4 space-y-4">
  228. <form onSubmit={handleSearch} className="flex gap-2">
  229. <div className="relative flex-1 max-w-sm">
  230. <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  231. <Input
  232. placeholder="搜索订单号、用户信息..."
  233. value={searchParams.search}
  234. onChange={handleSearchChange}
  235. className="pl-8"
  236. />
  237. </div>
  238. <Button type="submit" variant="outline">
  239. 搜索
  240. </Button>
  241. <Button
  242. type="button"
  243. variant="outline"
  244. onClick={() => setShowFilters(!showFilters)}
  245. className="flex items-center gap-2"
  246. >
  247. <Filter className="h-4 w-4" />
  248. 高级筛选
  249. {hasActiveFilters && (
  250. <Badge variant="secondary" className="ml-1">
  251. {Object.values(filters).filter(v => v !== undefined).length}
  252. </Badge>
  253. )}
  254. </Button>
  255. {hasActiveFilters && (
  256. <Button
  257. type="button"
  258. variant="ghost"
  259. onClick={resetFilters}
  260. className="flex items-center gap-2"
  261. >
  262. <X className="h-4 w-4" />
  263. 重置
  264. </Button>
  265. )}
  266. </form>
  267. {showFilters && (
  268. <div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 border rounded-lg bg-muted/50">
  269. {/* 订单状态筛选 */}
  270. <div className="space-y-2">
  271. <label className="text-sm font-medium">订单状态</label>
  272. <Select
  273. value={filters.status || 'all'}
  274. onValueChange={(value) =>
  275. handleFilterChange({
  276. status: value === 'all' ? undefined : value as OrderStatus
  277. })
  278. }
  279. >
  280. <SelectTrigger>
  281. <SelectValue placeholder="选择订单状态" />
  282. </SelectTrigger>
  283. <SelectContent>
  284. <SelectItem value="all">全部状态</SelectItem>
  285. <SelectItem value={OrderStatus.PENDING_PAYMENT}>待支付</SelectItem>
  286. <SelectItem value={OrderStatus.WAITING_DEPARTURE}>待出发</SelectItem>
  287. <SelectItem value={OrderStatus.IN_PROGRESS}>行程中</SelectItem>
  288. <SelectItem value={OrderStatus.COMPLETED}>已完成</SelectItem>
  289. <SelectItem value={OrderStatus.CANCELLED}>已取消</SelectItem>
  290. </SelectContent>
  291. </Select>
  292. </div>
  293. {/* 支付状态筛选 */}
  294. <div className="space-y-2">
  295. <label className="text-sm font-medium">支付状态</label>
  296. <Select
  297. value={filters.paymentStatus || 'all'}
  298. onValueChange={(value) =>
  299. handleFilterChange({
  300. paymentStatus: value === 'all' ? undefined : value as PaymentStatus
  301. })
  302. }
  303. >
  304. <SelectTrigger>
  305. <SelectValue placeholder="选择支付状态" />
  306. </SelectTrigger>
  307. <SelectContent>
  308. <SelectItem value="all">全部状态</SelectItem>
  309. <SelectItem value={PaymentStatus.PENDING}>待支付</SelectItem>
  310. <SelectItem value={PaymentStatus.PAID}>已支付</SelectItem>
  311. <SelectItem value={PaymentStatus.FAILED}>支付失败</SelectItem>
  312. <SelectItem value={PaymentStatus.REFUNDED}>已退款</SelectItem>
  313. </SelectContent>
  314. </Select>
  315. </div>
  316. </div>
  317. )}
  318. {/* 过滤条件标签 */}
  319. {hasActiveFilters && (
  320. <div className="flex flex-wrap gap-2">
  321. {filters.status && (
  322. <Badge variant="secondary" className="flex items-center gap-1">
  323. 订单状态: {filters.status}
  324. <X
  325. className="h-3 w-3 cursor-pointer"
  326. onClick={() => handleFilterChange({ status: undefined })}
  327. />
  328. </Badge>
  329. )}
  330. {filters.paymentStatus && (
  331. <Badge variant="secondary" className="flex items-center gap-1">
  332. 支付状态: {filters.paymentStatus}
  333. <X
  334. className="h-3 w-3 cursor-pointer"
  335. onClick={() => handleFilterChange({ paymentStatus: undefined })}
  336. />
  337. </Badge>
  338. )}
  339. </div>
  340. )}
  341. </div>
  342. <div className="rounded-md border">
  343. <Table>
  344. <TableHeader>
  345. <TableRow>
  346. <TableHead>订单号</TableHead>
  347. <TableHead>用户</TableHead>
  348. <TableHead>路线</TableHead>
  349. <TableHead>乘客数量</TableHead>
  350. <TableHead>订单金额</TableHead>
  351. <TableHead>订单状态</TableHead>
  352. <TableHead>支付状态</TableHead>
  353. <TableHead>创建时间</TableHead>
  354. <TableHead className="text-right">操作</TableHead>
  355. </TableRow>
  356. </TableHeader>
  357. <TableBody>
  358. {isLoading ? (
  359. // 显示表格骨架屏
  360. <TableRow>
  361. <TableCell colSpan={9} className="p-4">
  362. {renderTableSkeleton()}
  363. </TableCell>
  364. </TableRow>
  365. ) : (
  366. // 显示实际订单数据
  367. orders.map((order) => (
  368. <TableRow key={order.id}>
  369. <TableCell className="font-medium">#{order.id}</TableCell>
  370. <TableCell>
  371. {order.user?.username || '未知用户'}
  372. {order.user?.phone && ` (${order.user.phone})`}
  373. </TableCell>
  374. <TableCell>{order.route?.name || '未知路线'}</TableCell>
  375. <TableCell>{order.passengerCount}</TableCell>
  376. <TableCell>¥{order.totalAmount}</TableCell>
  377. <TableCell>
  378. <Badge variant={getStatusColor(order.status)}>
  379. {order.status}
  380. </Badge>
  381. </TableCell>
  382. <TableCell>
  383. <Badge variant={getPaymentStatusColor(order.paymentStatus)}>
  384. {order.paymentStatus}
  385. </Badge>
  386. </TableCell>
  387. <TableCell>
  388. {format(new Date(order.createdAt), 'yyyy-MM-dd HH:mm')}
  389. </TableCell>
  390. <TableCell className="text-right">
  391. <div className="flex justify-end gap-2">
  392. <Button
  393. variant="ghost"
  394. size="icon"
  395. onClick={() => handleViewOrder(order)}
  396. >
  397. <Eye className="h-4 w-4" />
  398. </Button>
  399. </div>
  400. </TableCell>
  401. </TableRow>
  402. ))
  403. )}
  404. </TableBody>
  405. </Table>
  406. </div>
  407. <DataTablePagination
  408. currentPage={searchParams.page}
  409. totalCount={totalCount}
  410. pageSize={searchParams.pageSize}
  411. onPageChange={handlePageChange}
  412. />
  413. </CardContent>
  414. </Card>
  415. {/* 订单详情对话框 */}
  416. <Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
  417. <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
  418. <DialogHeader>
  419. <DialogTitle>订单详情</DialogTitle>
  420. <DialogDescription>
  421. 查看订单的详细信息
  422. </DialogDescription>
  423. </DialogHeader>
  424. {selectedOrder && (
  425. <div className="space-y-4">
  426. <div className="grid grid-cols-2 gap-4">
  427. <div>
  428. <h4 className="font-medium">订单信息</h4>
  429. <div className="text-sm text-muted-foreground space-y-1 mt-2">
  430. <div>订单号: #{selectedOrder.id}</div>
  431. <div>订单状态: {selectedOrder.status}</div>
  432. <div>支付状态: {selectedOrder.paymentStatus}</div>
  433. <div>创建时间: {format(new Date(selectedOrder.createdAt), 'yyyy-MM-dd HH:mm:ss')}</div>
  434. </div>
  435. </div>
  436. <div>
  437. <h4 className="font-medium">用户信息</h4>
  438. <div className="text-sm text-muted-foreground space-y-1 mt-2">
  439. <div>用户名: {selectedOrder.user?.username || '未知'}</div>
  440. <div>手机号: {selectedOrder.user?.phone || '未知'}</div>
  441. </div>
  442. </div>
  443. </div>
  444. <div>
  445. <h4 className="font-medium">路线信息</h4>
  446. <div className="text-sm text-muted-foreground space-y-1 mt-2">
  447. <div>路线名称: {selectedOrder.route?.name || '未知'}</div>
  448. <div>路线描述: {selectedOrder.route?.description || '无描述'}</div>
  449. </div>
  450. </div>
  451. <div>
  452. <h4 className="font-medium">订单详情</h4>
  453. <div className="text-sm text-muted-foreground space-y-1 mt-2">
  454. <div>乘客数量: {selectedOrder.passengerCount}</div>
  455. <div>订单金额: ¥{selectedOrder.totalAmount}</div>
  456. </div>
  457. </div>
  458. {selectedOrder.passengerSnapshots && selectedOrder.passengerSnapshots.length > 0 && (
  459. <div>
  460. <h4 className="font-medium">乘客信息</h4>
  461. <div className="text-sm text-muted-foreground space-y-1 mt-2">
  462. {selectedOrder.passengerSnapshots.map((passenger: any, index: number) => (
  463. <div key={index} className="border-b pb-2">
  464. <div>乘客 {index + 1}: {passenger.name || '未知'}</div>
  465. <div>身份证: {passenger.idNumber || '未知'}</div>
  466. <div>手机号: {passenger.phone || '未知'}</div>
  467. </div>
  468. ))}
  469. </div>
  470. </div>
  471. )}
  472. </div>
  473. )}
  474. <DialogFooter>
  475. <Button onClick={() => setDetailDialogOpen(false)}>
  476. 关闭
  477. </Button>
  478. </DialogFooter>
  479. </DialogContent>
  480. </Dialog>
  481. </div>
  482. );
  483. };