Activities.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import React from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { Button } from '@/client/components/ui/button';
  4. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
  5. import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
  6. import { DataTablePagination } from '../components/DataTablePagination';
  7. import { Plus, Edit, Trash2, Calendar, Search, Filter, Power } from 'lucide-react';
  8. import { useState, useCallback } from 'react';
  9. import { activityClient } from '@/client/api';
  10. import type { InferResponseType, InferRequestType } from 'hono/client';
  11. import { Input } from '@/client/components/ui/input';
  12. import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
  13. import { Badge } from '@/client/components/ui/badge';
  14. import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
  15. import { ActivityForm } from '../components/ActivityForm';
  16. import type { CreateActivityInput, UpdateActivityInput } from '@/server/modules/activities/activity.schema';
  17. // 类型提取规范
  18. type ActivityResponse = InferResponseType<typeof activityClient.$get, 200>['data'][0];
  19. type CreateActivityRequest = InferRequestType<typeof activityClient.$post>['json'];
  20. type UpdateActivityRequest = InferRequestType<typeof activityClient[':id']['$put']>['json'];
  21. // 统一操作处理函数
  22. const handleOperation = async (operation: () => Promise<any>) => {
  23. try {
  24. await operation();
  25. // toast.success('操作成功');
  26. console.log('操作成功');
  27. } catch (error) {
  28. console.error('操作失败:', error);
  29. // toast.error('操作失败,请重试');
  30. throw error;
  31. }
  32. };
  33. // 防抖搜索函数
  34. const debounce = (func: Function, delay: number) => {
  35. let timeoutId: NodeJS.Timeout;
  36. return (...args: any[]) => {
  37. clearTimeout(timeoutId);
  38. timeoutId = setTimeout(() => func(...args), delay);
  39. };
  40. };
  41. export const ActivitiesPage: React.FC = () => {
  42. const queryClient = useQueryClient();
  43. const [page, setPage] = useState(1);
  44. const [pageSize, setPageSize] = useState(20);
  45. const [keyword, setKeyword] = useState('');
  46. const [typeFilter, setTypeFilter] = useState<string>('all');
  47. const [isFormOpen, setIsFormOpen] = useState(false);
  48. const [editingActivity, setEditingActivity] = useState<ActivityResponse | null>(null);
  49. // 防抖搜索
  50. const debouncedSearch = useCallback(
  51. debounce((searchKeyword: string) => {
  52. setKeyword(searchKeyword);
  53. setPage(1); // 搜索时重置到第一页
  54. }, 300),
  55. []
  56. );
  57. // 获取活动列表 - 使用RPC客户端
  58. const { data, isLoading, error } = useQuery({
  59. queryKey: ['activities', page, pageSize, keyword, typeFilter],
  60. queryFn: async () => {
  61. const query: any = {
  62. page,
  63. pageSize
  64. };
  65. if (keyword) {
  66. query.keyword = keyword;
  67. }
  68. if (typeFilter !== 'all') {
  69. query.filters = JSON.stringify({ type: typeFilter });
  70. }
  71. const res = await activityClient.$get({
  72. query
  73. });
  74. if (res.status !== 200) throw new Error('获取活动列表失败');
  75. return await res.json();
  76. },
  77. staleTime: 5 * 60 * 1000, // 5分钟缓存
  78. });
  79. // 创建活动 - 使用RPC客户端
  80. const createMutation = useMutation({
  81. mutationFn: async (data: CreateActivityRequest) => {
  82. await handleOperation(async () => {
  83. const res = await activityClient.$post({ json: data });
  84. if (res.status !== 201) throw new Error('创建活动失败');
  85. });
  86. },
  87. onSuccess: () => {
  88. queryClient.invalidateQueries({ queryKey: ['activities'] });
  89. setIsFormOpen(false);
  90. },
  91. });
  92. // 更新活动 - 使用RPC客户端
  93. const updateMutation = useMutation({
  94. mutationFn: async ({ id, data }: { id: number; data: UpdateActivityRequest }) => {
  95. await handleOperation(async () => {
  96. const res = await activityClient[':id'].$put({
  97. param: { id },
  98. json: data
  99. });
  100. if (res.status !== 200) throw new Error('更新活动失败');
  101. });
  102. },
  103. onSuccess: () => {
  104. queryClient.invalidateQueries({ queryKey: ['activities'] });
  105. setIsFormOpen(false);
  106. setEditingActivity(null);
  107. },
  108. });
  109. // 启用/禁用活动 - 使用RPC客户端
  110. const toggleStatusMutation = useMutation({
  111. mutationFn: async ({ id, isDisabled }: { id: number; isDisabled: number }) => {
  112. await handleOperation(async () => {
  113. const res = await activityClient[':id'].$put({
  114. param: { id },
  115. json: { isDisabled }
  116. });
  117. if (res.status !== 200) throw new Error('更新活动状态失败');
  118. });
  119. },
  120. onSuccess: () => {
  121. queryClient.invalidateQueries({ queryKey: ['activities'] });
  122. },
  123. });
  124. // 删除活动 - 使用RPC客户端
  125. const deleteMutation = useMutation({
  126. mutationFn: async (id: number) => {
  127. await handleOperation(async () => {
  128. const res = await activityClient[':id'].$delete({
  129. param: { id }
  130. });
  131. if (res.status !== 204) throw new Error('删除活动失败');
  132. });
  133. },
  134. onSuccess: () => {
  135. queryClient.invalidateQueries({ queryKey: ['activities'] });
  136. },
  137. });
  138. // 处理表单提交
  139. const handleFormSubmit = async (data: CreateActivityInput | UpdateActivityInput) => {
  140. if (editingActivity) {
  141. await updateMutation.mutateAsync({
  142. id: editingActivity.id,
  143. data: data as UpdateActivityRequest
  144. });
  145. } else {
  146. await createMutation.mutateAsync(data as CreateActivityRequest);
  147. }
  148. };
  149. // 打开创建表单
  150. const handleCreate = () => {
  151. setEditingActivity(null);
  152. setIsFormOpen(true);
  153. };
  154. // 打开编辑表单
  155. const handleEdit = (activity: ActivityResponse) => {
  156. setEditingActivity(activity);
  157. setIsFormOpen(true);
  158. };
  159. // 关闭表单
  160. const handleFormClose = () => {
  161. setIsFormOpen(false);
  162. setEditingActivity(null);
  163. };
  164. // 切换活动状态
  165. const handleToggleStatus = (activity: ActivityResponse) => {
  166. const newStatus = activity.isDisabled === 0 ? 1 : 0;
  167. const statusText = newStatus === 0 ? '启用' : '禁用';
  168. if (confirm(`确定要${statusText}这个活动吗?`)) {
  169. toggleStatusMutation.mutate({
  170. id: activity.id,
  171. isDisabled: newStatus
  172. });
  173. }
  174. };
  175. if (error) {
  176. return (
  177. <div className="p-6">
  178. <Card>
  179. <CardContent className="pt-6">
  180. <div className="text-center text-red-500">
  181. 加载活动数据失败: {error.message}
  182. </div>
  183. </CardContent>
  184. </Card>
  185. </div>
  186. );
  187. }
  188. return (
  189. <div className="p-6">
  190. <div className="flex items-center justify-between mb-6">
  191. <div>
  192. <h1 className="text-3xl font-bold tracking-tight">活动管理</h1>
  193. <p className="text-muted-foreground">
  194. 管理旅行活动,包括去程和返程活动
  195. </p>
  196. </div>
  197. <Button onClick={handleCreate}>
  198. <Plus className="h-4 w-4 mr-2" />
  199. 新建活动
  200. </Button>
  201. </div>
  202. <Card>
  203. <CardHeader>
  204. <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
  205. <div>
  206. <CardTitle>活动列表</CardTitle>
  207. <CardDescription>
  208. 当前共有 {data?.pagination.total || 0} 个活动
  209. </CardDescription>
  210. </div>
  211. <div className="flex gap-2">
  212. {/* 搜索框 */}
  213. <div className="relative w-full sm:w-64">
  214. <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  215. <Input
  216. placeholder="搜索活动名称或描述..."
  217. className="pl-8"
  218. onChange={(e) => debouncedSearch(e.target.value)}
  219. />
  220. </div>
  221. {/* 类型筛选 */}
  222. <Select value={typeFilter} onValueChange={setTypeFilter}>
  223. <SelectTrigger className="w-32">
  224. <Filter className="h-4 w-4 mr-2" />
  225. <SelectValue placeholder="类型" />
  226. </SelectTrigger>
  227. <SelectContent>
  228. <SelectItem value="all">全部类型</SelectItem>
  229. <SelectItem value="departure">去程</SelectItem>
  230. <SelectItem value="return">返程</SelectItem>
  231. </SelectContent>
  232. </Select>
  233. </div>
  234. </div>
  235. </CardHeader>
  236. <CardContent>
  237. {/* 筛选标签 */}
  238. {(keyword || typeFilter !== 'all') && (
  239. <div className="flex flex-wrap gap-2 mb-4">
  240. {keyword && (
  241. <Badge variant="secondary" className="flex items-center gap-1">
  242. 搜索: {keyword}
  243. <button
  244. onClick={() => {
  245. setKeyword('');
  246. const input = document.querySelector('input[placeholder="搜索活动名称或描述..."]') as HTMLInputElement;
  247. if (input) input.value = '';
  248. }}
  249. className="ml-1 hover:text-red-500"
  250. >
  251. ×
  252. </button>
  253. </Badge>
  254. )}
  255. {typeFilter !== 'all' && (
  256. <Badge variant="secondary" className="flex items-center gap-1">
  257. 类型: {typeFilter === 'departure' ? '去程' : '返程'}
  258. <button
  259. onClick={() => setTypeFilter('all')}
  260. className="ml-1 hover:text-red-500"
  261. >
  262. ×
  263. </button>
  264. </Badge>
  265. )}
  266. </div>
  267. )}
  268. <div className="rounded-md border">
  269. <Table>
  270. <TableHeader>
  271. <TableRow>
  272. <TableHead>活动名称</TableHead>
  273. <TableHead>类型</TableHead>
  274. <TableHead>开始时间</TableHead>
  275. <TableHead>结束时间</TableHead>
  276. <TableHead>状态</TableHead>
  277. <TableHead className="text-right">操作</TableHead>
  278. </TableRow>
  279. </TableHeader>
  280. <TableBody>
  281. {isLoading ? (
  282. <TableRow>
  283. <TableCell colSpan={6} className="text-center py-4">
  284. 加载中...
  285. </TableCell>
  286. </TableRow>
  287. ) : data?.data && data.data.length > 0 ? (
  288. data.data.map((activity: ActivityResponse) => (
  289. <TableRow key={activity.id}>
  290. <TableCell>
  291. <div className="flex items-center gap-2">
  292. <Calendar className="h-4 w-4 text-blue-500" />
  293. <span>{activity.name}</span>
  294. </div>
  295. </TableCell>
  296. <TableCell>
  297. <span className={`px-2 py-1 rounded-full text-xs ${
  298. activity.type === 'departure'
  299. ? 'bg-blue-100 text-blue-800'
  300. : 'bg-green-100 text-green-800'
  301. }`}>
  302. {activity.type === 'departure' ? '去程' : '返程'}
  303. </span>
  304. </TableCell>
  305. <TableCell>
  306. {new Date(activity.startDate).toLocaleString('zh-CN')}
  307. </TableCell>
  308. <TableCell>
  309. {new Date(activity.endDate).toLocaleString('zh-CN')}
  310. </TableCell>
  311. <TableCell>
  312. <span className={`px-2 py-1 rounded-full text-xs ${
  313. activity.isDisabled === 0
  314. ? 'bg-green-100 text-green-800'
  315. : 'bg-red-100 text-red-800'
  316. }`}>
  317. {activity.isDisabled === 0 ? '启用' : '禁用'}
  318. </span>
  319. </TableCell>
  320. <TableCell className="text-right">
  321. <div className="flex justify-end gap-2">
  322. <Button
  323. variant={activity.isDisabled === 0 ? "outline" : "default"}
  324. size="sm"
  325. onClick={() => handleToggleStatus(activity)}
  326. disabled={toggleStatusMutation.isPending}
  327. >
  328. <Power className="h-4 w-4 mr-1" />
  329. {activity.isDisabled === 0 ? '禁用' : '启用'}
  330. </Button>
  331. <Button
  332. variant="outline"
  333. size="sm"
  334. onClick={() => handleEdit(activity)}
  335. >
  336. <Edit className="h-4 w-4" />
  337. </Button>
  338. <Button
  339. variant="destructive"
  340. size="sm"
  341. onClick={() => {
  342. if (confirm('确定要删除这个活动吗?')) {
  343. deleteMutation.mutate(activity.id);
  344. }
  345. }}
  346. >
  347. <Trash2 className="h-4 w-4" />
  348. </Button>
  349. </div>
  350. </TableCell>
  351. </TableRow>
  352. ))
  353. ) : (
  354. <TableRow>
  355. <TableCell colSpan={6} className="text-center py-4">
  356. 暂无活动数据
  357. </TableCell>
  358. </TableRow>
  359. )}
  360. </TableBody>
  361. </Table>
  362. </div>
  363. {data && (
  364. <DataTablePagination
  365. currentPage={data.pagination.current}
  366. totalCount={data.pagination.total}
  367. pageSize={data.pagination.pageSize}
  368. onPageChange={(page, pageSize) => {
  369. setPage(page);
  370. setPageSize(pageSize);
  371. }}
  372. />
  373. )}
  374. </CardContent>
  375. </Card>
  376. {/* 活动表单对话框 */}
  377. <Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
  378. <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
  379. <DialogHeader>
  380. <DialogTitle>
  381. {editingActivity ? '编辑活动' : '创建活动'}
  382. </DialogTitle>
  383. <DialogDescription>
  384. {editingActivity ? '修改活动信息' : '创建新的旅行活动'}
  385. </DialogDescription>
  386. </DialogHeader>
  387. <ActivityForm
  388. initialData={editingActivity ? {
  389. id: editingActivity.id,
  390. name: editingActivity.name,
  391. description: editingActivity.description,
  392. type: editingActivity.type,
  393. startDate: editingActivity.startDate,
  394. endDate: editingActivity.endDate,
  395. isDisabled: editingActivity.isDisabled
  396. } : undefined}
  397. onSubmit={handleFormSubmit}
  398. onCancel={handleFormClose}
  399. isLoading={createMutation.isPending || updateMutation.isPending}
  400. />
  401. </DialogContent>
  402. </Dialog>
  403. </div>
  404. );
  405. };