Jelajahi Sumber

已完成用户搜索和高级过滤功能的集成测试验证

yourname 2 bulan lalu
induk
melakukan
67e1c41c7e

+ 4 - 1
.claude/settings.local.json

@@ -11,7 +11,10 @@
       "Bash(npx vitest:*)",
       "Bash(pnpm run lint:*)",
       "Bash(npx eslint:*)",
-      "Bash(npm run lint)"
+      "Bash(npm run lint)",
+      "Bash(npm test)",
+      "Bash(npm run lint:*)",
+      "Bash(pnpm run test:*)"
     ],
     "deny": [],
     "ask": []

+ 1 - 1
package.json

@@ -102,9 +102,9 @@
     "@eslint/js": "^9.35.0",
     "@playwright/test": "^1.55.0",
     "@tailwindcss/vite": "^4.1.11",
+    "@testing-library/jest-dom": "^6.8.0",
     "@testing-library/react": "^16.3.0",
     "@testing-library/user-event": "^14.6.1",
-    "@testing-library/jest-dom": "^6.6.3",
     "@types/bcrypt": "^6.0.0",
     "@types/debug": "^4.1.12",
     "@types/jsonwebtoken": "^9.0.10",

+ 60 - 0
pnpm-lock.yaml

@@ -219,6 +219,9 @@ importers:
       '@tailwindcss/vite':
         specifier: ^4.1.11
         version: 4.1.11(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))
+      '@testing-library/jest-dom':
+        specifier: ^6.8.0
+        version: 6.8.0
       '@testing-library/react':
         specifier: ^16.3.0
         version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -297,6 +300,9 @@ importers:
 
 packages:
 
+  '@adobe/css-tools@4.4.4':
+    resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
+
   '@ampproject/remapping@2.3.0':
     resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
     engines: {node: '>=6.0.0'}
@@ -1597,6 +1603,10 @@ packages:
     resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
     engines: {node: '>=18'}
 
+  '@testing-library/jest-dom@6.8.0':
+    resolution: {integrity: sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==}
+    engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
   '@testing-library/react@16.3.0':
     resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==}
     engines: {node: '>=18'}
@@ -2035,6 +2045,9 @@ packages:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
 
+  css.escape@1.5.1:
+    resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
   cssom@0.3.8:
     resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==}
 
@@ -2192,6 +2205,9 @@ packages:
   dom-accessibility-api@0.5.16:
     resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
 
+  dom-accessibility-api@0.6.3:
+    resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
   dom-helpers@5.2.1:
     resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
 
@@ -2588,6 +2604,10 @@ packages:
     resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
     engines: {node: '>=0.8.19'}
 
+  indent-string@4.0.0:
+    resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+    engines: {node: '>=8'}
+
   inherits@2.0.4:
     resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
 
@@ -2963,6 +2983,10 @@ packages:
     resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
     engines: {node: '>= 0.6'}
 
+  min-indent@1.0.1:
+    resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+    engines: {node: '>=4'}
+
   minimatch@3.1.2:
     resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
 
@@ -3274,6 +3298,10 @@ packages:
       react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
       react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
 
+  redent@3.0.0:
+    resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+    engines: {node: '>=8'}
+
   reflect-metadata@0.2.2:
     resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
 
@@ -3474,6 +3502,10 @@ packages:
     resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
     engines: {node: '>=12'}
 
+  strip-indent@3.0.0:
+    resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+    engines: {node: '>=8'}
+
   strip-json-comments@3.1.1:
     resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
     engines: {node: '>=8'}
@@ -3908,6 +3940,8 @@ packages:
 
 snapshots:
 
+  '@adobe/css-tools@4.4.4': {}
+
   '@ampproject/remapping@2.3.0':
     dependencies:
       '@jridgewell/gen-mapping': 0.3.12
@@ -5068,6 +5102,15 @@ snapshots:
       picocolors: 1.1.1
       pretty-format: 27.5.1
 
+  '@testing-library/jest-dom@6.8.0':
+    dependencies:
+      '@adobe/css-tools': 4.4.4
+      aria-query: 5.3.0
+      css.escape: 1.5.1
+      dom-accessibility-api: 0.6.3
+      picocolors: 1.1.1
+      redent: 3.0.0
+
   '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
     dependencies:
       '@babel/runtime': 7.28.2
@@ -5594,6 +5637,8 @@ snapshots:
       shebang-command: 2.0.0
       which: 2.0.2
 
+  css.escape@1.5.1: {}
+
   cssom@0.3.8:
     optional: true
 
@@ -5725,6 +5770,8 @@ snapshots:
 
   dom-accessibility-api@0.5.16: {}
 
+  dom-accessibility-api@0.6.3: {}
+
   dom-helpers@5.2.1:
     dependencies:
       '@babel/runtime': 7.28.2
@@ -6249,6 +6296,8 @@ snapshots:
 
   imurmurhash@0.1.4: {}
 
+  indent-string@4.0.0: {}
+
   inherits@2.0.4: {}
 
   input-otp@1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
@@ -6628,6 +6677,8 @@ snapshots:
     dependencies:
       mime-db: 1.52.0
 
+  min-indent@1.0.1: {}
+
   minimatch@3.1.2:
     dependencies:
       brace-expansion: 1.1.12
@@ -6929,6 +6980,11 @@ snapshots:
       tiny-invariant: 1.3.3
       victory-vendor: 36.9.2
 
+  redent@3.0.0:
+    dependencies:
+      indent-string: 4.0.0
+      strip-indent: 3.0.0
+
   reflect-metadata@0.2.2: {}
 
   reflect.getprototypeof@1.0.10:
@@ -7195,6 +7251,10 @@ snapshots:
     dependencies:
       ansi-regex: 6.1.0
 
+  strip-indent@3.0.0:
+    dependencies:
+      min-indent: 1.0.1
+
   strip-json-comments@3.1.1: {}
 
   strip-literal@3.0.0:

+ 234 - 17
src/client/admin/pages/Users.tsx

@@ -1,7 +1,7 @@
-import React, { useState } from 'react';
+import React, { useState, useMemo } from 'react';
 import { useQuery } from '@tanstack/react-query';
 import { format } from 'date-fns';
-import { Plus, Search, Edit, Trash2 } from 'lucide-react';
+import { Plus, Search, Edit, Trash2, Filter, X } from 'lucide-react';
 import { userClient } from '@/client/api';
 import type { InferRequestType, InferResponseType } from 'hono/client';
 import { Button } from '@/client/components/ui/button';
@@ -19,6 +19,10 @@ import { Skeleton } from '@/client/components/ui/skeleton';
 import { Switch } from '@/client/components/ui/switch';
 import { DisabledStatus } from '@/share/types';
 import { CreateUserDto, UpdateUserDto } from '@/server/modules/users/user.schema';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
+import { Popover, PopoverContent, PopoverTrigger } from '@/client/components/ui/popover';
+import { Calendar } from '@/client/components/ui/calendar';
+import { cn } from '@/client/lib/utils';
 
 // 使用RPC方式提取类型
 type CreateUserRequest = InferRequestType<typeof userClient.$post>['json'];
@@ -36,10 +40,16 @@ export const UsersPage = () => {
   const [searchParams, setSearchParams] = useState({
     page: 1,
     limit: 10,
-    search: ''
+    keyword: ''
   });
+  const [filters, setFilters] = useState({
+    isDisabled: undefined as number | undefined,
+    roleIds: [] as number[],
+    createdAt: undefined as { gte?: string; lte?: string } | undefined
+  });
+  const [showFilters, setShowFilters] = useState(false);
   const [isModalOpen, setIsModalOpen] = useState(false);
-  const [editingUser, setEditingUser] = useState<any>(null);
+  const [editingUser, setEditingUser] = useState<UserResponse | null>(null);
   const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
   const [userToDelete, setUserToDelete] = useState<number | null>(null);
 
@@ -72,13 +82,28 @@ export const UsersPage = () => {
   });
 
   const { data: usersData, isLoading, refetch } = useQuery({
-    queryKey: ['users', searchParams],
+    queryKey: ['users', searchParams, filters],
     queryFn: async () => {
+      const filterParams: Record<string, unknown> = {};
+
+      if (filters.isDisabled !== undefined) {
+        filterParams.isDisabled = filters.isDisabled;
+      }
+
+      if (filters.roleIds.length > 0) {
+        filterParams['roles.id'] = filters.roleIds;
+      }
+
+      if (filters.createdAt) {
+        filterParams.createdAt = filters.createdAt;
+      }
+
       const res = await userClient.$get({
         query: {
           page: searchParams.page,
           pageSize: searchParams.limit,
-          keyword: searchParams.search
+          keyword: searchParams.keyword,
+          filters: Object.keys(filterParams).length > 0 ? JSON.stringify(filterParams) : undefined
         }
       });
       if (res.status !== 200) {
@@ -102,6 +127,30 @@ export const UsersPage = () => {
     setSearchParams(prev => ({ ...prev, page, limit }));
   };
 
+  // 处理过滤条件变化
+  const handleFilterChange = (newFilters: Partial<typeof filters>) => {
+    setFilters(prev => ({ ...prev, ...newFilters }));
+    setSearchParams(prev => ({ ...prev, page: 1 }));
+  };
+
+  // 重置所有过滤条件
+  const resetFilters = () => {
+    setFilters({
+      isDisabled: undefined,
+      roleIds: [],
+      createdAt: undefined
+    });
+    setSearchParams(prev => ({ ...prev, page: 1 }));
+  };
+
+  // 检查是否有活跃的过滤条件
+  const hasActiveFilters = useMemo(() => {
+    return filters.isDisabled !== undefined ||
+           filters.roleIds.length > 0 ||
+           filters.createdAt !== undefined;
+  }, [filters]);
+
+
   // 打开创建用户对话框
   const handleCreateUser = () => {
     setEditingUser(null);
@@ -145,8 +194,7 @@ export const UsersPage = () => {
       toast.success('用户创建成功');
       setIsModalOpen(false);
       refetch();
-    } catch (error) {
-      console.error('创建用户失败:', error);
+    } catch {
       toast.error('创建失败,请重试');
     }
   };
@@ -166,8 +214,7 @@ export const UsersPage = () => {
       toast.success('用户更新成功');
       setIsModalOpen(false);
       refetch();
-    } catch (error) {
-      console.error('更新用户失败:', error);
+    } catch {
       toast.error('更新失败,请重试');
     }
   };
@@ -190,8 +237,7 @@ export const UsersPage = () => {
       }
       toast.success('用户删除成功');
       refetch();
-    } catch (error) {
-      console.error('删除用户失败:', error);
+    } catch {
       toast.error('删除失败,请重试');
     } finally {
       setDeleteDialogOpen(false);
@@ -245,21 +291,192 @@ export const UsersPage = () => {
           </CardDescription>
         </CardHeader>
         <CardContent>
-          <div className="mb-4">
+          <div className="mb-4 space-y-4">
             <form onSubmit={handleSearch} className="flex gap-2">
               <div className="relative flex-1 max-w-sm">
                 <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
                 <Input
                   placeholder="搜索用户名、昵称或邮箱..."
-                  value={searchParams.search}
-                  onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
+                  value={searchParams.keyword}
+                  onChange={(e) => setSearchParams(prev => ({ ...prev, keyword: e.target.value }))}
                   className="pl-8"
                 />
               </div>
               <Button type="submit" variant="outline">
                 搜索
               </Button>
+              <Button
+                type="button"
+                variant="outline"
+                onClick={() => setShowFilters(!showFilters)}
+                className="flex items-center gap-2"
+              >
+                <Filter className="h-4 w-4" />
+                高级筛选
+                {hasActiveFilters && (
+                  <Badge variant="secondary" className="ml-1">
+                    {Object.values(filters).filter(v =>
+                      v !== undefined &&
+                      (!Array.isArray(v) || v.length > 0)
+                    ).length}
+                  </Badge>
+                )}
+              </Button>
+              {hasActiveFilters && (
+                <Button
+                  type="button"
+                  variant="ghost"
+                  onClick={resetFilters}
+                  className="flex items-center gap-2"
+                >
+                  <X className="h-4 w-4" />
+                  重置
+                </Button>
+              )}
             </form>
+
+            {showFilters && (
+              <div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 border rounded-lg bg-muted/50">
+                {/* 状态筛选 */}
+                <div className="space-y-2">
+                  <label className="text-sm font-medium">用户状态</label>
+                  <Select
+                    value={filters.isDisabled?.toString() || ''}
+                    onValueChange={(value) =>
+                      handleFilterChange({
+                        isDisabled: value === '' ? undefined : parseInt(value)
+                      })
+                    }
+                  >
+                    <SelectTrigger>
+                      <SelectValue placeholder="选择状态" />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="">全部状态</SelectItem>
+                      <SelectItem value="0">启用</SelectItem>
+                      <SelectItem value="1">禁用</SelectItem>
+                    </SelectContent>
+                  </Select>
+                </div>
+
+                {/* 角色筛选 */}
+                <div className="space-y-2">
+                  <label className="text-sm font-medium">用户角色</label>
+                  <Select
+                    value=""
+                    onValueChange={(value) => {
+                      const roleId = parseInt(value);
+                      if (!filters.roleIds.includes(roleId)) {
+                        handleFilterChange({
+                          roleIds: [...filters.roleIds, roleId]
+                        });
+                      }
+                    }}
+                  >
+                    <SelectTrigger>
+                      <SelectValue placeholder="选择角色" />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="1">管理员</SelectItem>
+                      <SelectItem value="2">普通用户</SelectItem>
+                    </SelectContent>
+                  </Select>
+                  {filters.roleIds.length > 0 && (
+                    <div className="flex flex-wrap gap-2 mt-2">
+                      {filters.roleIds.map(roleId => (
+                        <Badge
+                          key={roleId}
+                          variant="secondary"
+                          className="flex items-center gap-1"
+                        >
+                          {roleId === 1 ? '管理员' : '普通用户'}
+                          <X
+                            className="h-3 w-3 cursor-pointer"
+                            onClick={() => handleFilterChange({
+                              roleIds: filters.roleIds.filter(id => id !== roleId)
+                            })}
+                          />
+                        </Badge>
+                      ))}
+                    </div>
+                  )}
+                </div>
+
+                {/* 创建时间筛选 */}
+                <div className="space-y-2">
+                  <label className="text-sm font-medium">创建时间</label>
+                  <Popover>
+                    <PopoverTrigger asChild>
+                      <Button
+                        variant="outline"
+                        className={cn(
+                          "w-full justify-start text-left font-normal",
+                          !filters.createdAt && "text-muted-foreground"
+                        )}
+                      >
+                        {filters.createdAt ?
+                          `${filters.createdAt.gte || ''} 至 ${filters.createdAt.lte || ''}` :
+                          '选择日期范围'
+                        }
+                      </Button>
+                    </PopoverTrigger>
+                    <PopoverContent className="w-auto p-0" align="start">
+                      <Calendar
+                        mode="range"
+                        selected={{
+                          from: filters.createdAt?.gte ? new Date(filters.createdAt.gte) : undefined,
+                          to: filters.createdAt?.lte ? new Date(filters.createdAt.lte) : undefined
+                        }}
+                        onSelect={(range) => {
+                          handleFilterChange({
+                            createdAt: range?.from && range?.to ? {
+                              gte: format(range.from, 'yyyy-MM-dd'),
+                              lte: format(range.to, 'yyyy-MM-dd')
+                            } : undefined
+                          });
+                        }}
+                        initialFocus
+                      />
+                    </PopoverContent>
+                  </Popover>
+                </div>
+              </div>
+            )}
+
+            {/* 过滤条件标签 */}
+            {hasActiveFilters && (
+              <div className="flex flex-wrap gap-2">
+                {filters.isDisabled !== undefined && (
+                  <Badge variant="secondary" className="flex items-center gap-1">
+                    状态: {filters.isDisabled === 0 ? '启用' : '禁用'}
+                    <X
+                      className="h-3 w-3 cursor-pointer"
+                      onClick={() => handleFilterChange({ isDisabled: undefined })}
+                    />
+                  </Badge>
+                )}
+                {filters.roleIds.map(roleId => (
+                  <Badge key={roleId} variant="secondary" className="flex items-center gap-1">
+                    角色: {roleId === 1 ? '管理员' : '普通用户'}
+                    <X
+                      className="h-3 w-3 cursor-pointer"
+                      onClick={() => handleFilterChange({
+                        roleIds: filters.roleIds.filter(id => id !== roleId)
+                      })}
+                    />
+                  </Badge>
+                ))}
+                {filters.createdAt && (
+                  <Badge variant="secondary" className="flex items-center gap-1">
+                    创建时间: {filters.createdAt.gte || ''} 至 {filters.createdAt.lte || ''}
+                    <X
+                      className="h-3 w-3 cursor-pointer"
+                      onClick={() => handleFilterChange({ createdAt: undefined })}
+                    />
+                  </Badge>
+                )}
+              </div>
+            )}
           </div>
 
           <div className="rounded-md border">
@@ -285,10 +502,10 @@ export const UsersPage = () => {
                     <TableCell>{user.name || '-'}</TableCell>
                     <TableCell>
                       <Badge
-                        variant={user.roles?.some((role: any) => role.name === 'admin') ? 'destructive' : 'default'}
+                        variant={user.roles?.some((role) => role.name === 'admin') ? 'destructive' : 'default'}
                         className="capitalize"
                       >
-                        {user.roles?.some((role: any) => role.name === 'admin') ? '管理员' : '普通用户'}
+                        {user.roles?.some((role) => role.name === 'admin') ? '管理员' : '普通用户'}
                       </Badge>
                     </TableCell>
                     <TableCell>

+ 32 - 19
src/server/api/__integration_tests__/users.integration.test.ts

@@ -27,6 +27,17 @@ vi.mock('../../middleware/auth.middleware', () => ({
   authMiddleware: vi.fn().mockImplementation((_c, next) => next())
 }));
 
+// Mock 通用CRUD服务
+vi.mock('../../utils/generic-crud.service', () => ({
+  GenericCrudService: vi.fn().mockImplementation(() => ({
+    getList: vi.fn().mockResolvedValue([[], 0]),
+    getById: vi.fn().mockResolvedValue(null),
+    create: vi.fn().mockResolvedValue({ id: 1, username: 'testuser' }),
+    update: vi.fn().mockResolvedValue({ affected: 1 }),
+    delete: vi.fn().mockResolvedValue({ affected: 1 })
+  }))
+}));
+
 describe('Users API Integration Tests', () => {
   let app: OpenAPIHono;
   let apiClient: ApiClient;
@@ -53,13 +64,13 @@ describe('Users API Integration Tests', () => {
 
   describe('GET /users', () => {
     it('应该返回用户列表和分页信息', async () => {
-      // 模拟用户服务返回数据
-      const mockUserService = require('../../modules/users/user.service').UserService();
+      // 模拟通用CRUD服务返回数据
+      const mockCrudService = require('../../utils/generic-crud.service').GenericCrudService();
       const mockUsers = [
         { id: 1, username: 'user1', email: 'user1@example.com' },
         { id: 2, username: 'user2', email: 'user2@example.com' }
       ];
-      mockUserService.getUsersWithPagination.mockResolvedValue([mockUsers, 2]);
+      mockCrudService.getList.mockResolvedValue([mockUsers, 2]);
 
       const response = await apiClient.get('/users?page=1&pageSize=10');
 
@@ -85,36 +96,38 @@ describe('Users API Integration Tests', () => {
     });
 
     it('应该支持关键词搜索', async () => {
-      const mockUserService = require('../../modules/users/user.service').UserService();
-      mockUserService.getUsersWithPagination.mockResolvedValue([[], 0]);
+      const mockCrudService = require('../../utils/generic-crud.service').GenericCrudService();
+      mockCrudService.getList.mockResolvedValue([[], 0]);
 
       const response = await apiClient.get('/users?page=1&pageSize=10&keyword=admin');
 
       expect(response.status).toBe(200);
-      expect(mockUserService.getUsersWithPagination).toHaveBeenCalledWith({
-        page: 1,
-        pageSize: 10,
-        keyword: 'admin'
-      });
+      expect(mockCrudService.getList).toHaveBeenCalledWith(
+        expect.objectContaining({
+          page: 1,
+          pageSize: 10,
+          keyword: 'admin'
+        })
+      );
     });
   });
 
   describe('GET /users/:id', () => {
     it('应该返回特定用户信息', async () => {
       const mockUser = { id: 1, username: 'testuser', email: 'test@example.com' };
-      const mockUserService = require('../../modules/users/user.service').UserService();
-      mockUserService.getUserById.mockResolvedValue(mockUser);
+      const mockCrudService = require('../../utils/generic-crud.service').GenericCrudService();
+      mockCrudService.getById.mockResolvedValue(mockUser);
 
       const response = await apiClient.get('/users/1');
 
       expect(response.status).toBe(200);
       expect(response.data).toEqual(mockUser);
-      expect(mockUserService.getUserById).toHaveBeenCalledWith(1);
+      expect(mockCrudService.getById).toHaveBeenCalledWith(1);
     });
 
     it('应该在用户不存在时返回404', async () => {
-      const mockUserService = require('../../modules/users/user.service').UserService();
-      mockUserService.getUserById.mockResolvedValue(null);
+      const mockCrudService = require('../../utils/generic-crud.service').GenericCrudService();
+      mockCrudService.getById.mockResolvedValue(null);
 
       const response = await apiClient.get('/users/999');
 
@@ -138,8 +151,8 @@ describe('Users API Integration Tests', () => {
 
   describe('错误处理', () => {
     it('应该在服务错误时返回500状态码', async () => {
-      const mockUserService = require('../../modules/users/user.service').UserService();
-      mockUserService.getUsersWithPagination.mockRejectedValue(new Error('Database error'));
+      const mockCrudService = require('../../utils/generic-crud.service').GenericCrudService();
+      mockCrudService.getList.mockRejectedValue(new Error('Database error'));
 
       const response = await apiClient.get('/users?page=1&pageSize=10');
 
@@ -151,8 +164,8 @@ describe('Users API Integration Tests', () => {
     });
 
     it('应该在未知错误时返回通用错误消息', async () => {
-      const mockUserService = require('../../modules/users/user.service').UserService();
-      mockUserService.getUsersWithPagination.mockRejectedValue('Unknown error');
+      const mockCrudService = require('../../utils/generic-crud.service').GenericCrudService();
+      mockCrudService.getList.mockRejectedValue('Unknown error');
 
       const response = await apiClient.get('/users?page=1&pageSize=10');
 

+ 175 - 0
src/server/api/users/custom.ts

@@ -0,0 +1,175 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from '@hono/zod-openapi';
+import { UserService } from '../../modules/users/user.service';
+import { authMiddleware } from '../../middleware/auth.middleware';
+import { ErrorSchema } from '../../utils/errorHandler';
+import { AppDataSource } from '../../data-source';
+import { AuthContext } from '../../types/context';
+import { CreateUserDto, UpdateUserDto, UserSchema } from '../../modules/users/user.schema';
+
+// 创建用户路由 - 自定义业务逻辑(密码加密等)
+const createUserRoute = createRoute({
+  method: 'post',
+  path: '/',
+  middleware: [authMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': { schema: CreateUserDto }
+      }
+    }
+  },
+  responses: {
+    201: {
+      description: '用户创建成功',
+      content: {
+        'application/json': { schema: UserSchema }
+      }
+    },
+    400: {
+      description: '参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '创建用户失败',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 更新用户路由 - 自定义业务逻辑
+const updateUserRoute = createRoute({
+  method: 'put',
+  path: '/{id}',
+  middleware: [authMiddleware],
+  request: {
+    params: z.object({
+      id: z.coerce.number().openapi({
+        param: { name: 'id', in: 'path' },
+        example: 1,
+        description: '用户ID'
+      })
+    }),
+    body: {
+      content: {
+        'application/json': { schema: UpdateUserDto }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '用户更新成功',
+      content: {
+        'application/json': { schema: UserSchema }
+      }
+    },
+    400: {
+      description: '参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    404: {
+      description: '用户不存在',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '更新用户失败',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 删除用户路由 - 自定义业务逻辑
+const deleteUserRoute = createRoute({
+  method: 'delete',
+  path: '/{id}',
+  middleware: [authMiddleware],
+  request: {
+    params: z.object({
+      id: z.coerce.number().openapi({
+        param: { name: 'id', in: 'path' },
+        example: 1,
+        description: '用户ID'
+      })
+    })
+  },
+  responses: {
+    204: { description: '用户删除成功' },
+    404: {
+      description: '用户不存在',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '删除用户失败',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+const app = new OpenAPIHono<AuthContext>()
+  .openapi(createUserRoute, async (c) => {
+    try {
+      const data = c.req.valid('json');
+      const userService = new UserService(AppDataSource);
+      const result = await userService.createUser(data);
+
+      return c.json(result, 201);
+    } catch (error) {
+      if (error instanceof z.ZodError) {
+        return c.json({
+          code: 400,
+          message: '参数错误',
+          errors: error.issues
+        }, 400);
+      }
+      return c.json({
+        code: 500,
+        message: error instanceof Error ? error.message : '创建用户失败'
+      }, 500);
+    }
+  })
+  .openapi(updateUserRoute, async (c) => {
+    try {
+      const { id } = c.req.valid('param');
+      const data = c.req.valid('json');
+      const userService = new UserService(AppDataSource);
+      const result = await userService.updateUser(id, data);
+
+      if (!result) {
+        return c.json({ code: 404, message: '用户不存在' }, 404);
+      }
+
+      return c.json(result, 200);
+    } catch (error) {
+      if (error instanceof z.ZodError) {
+        return c.json({
+          code: 400,
+          message: '参数错误',
+          errors: error.issues
+        }, 400);
+      }
+      return c.json({
+        code: 500,
+        message: error instanceof Error ? error.message : '更新用户失败'
+      }, 500);
+    }
+  })
+  .openapi(deleteUserRoute, async (c) => {
+    try {
+      const { id } = c.req.valid('param');
+      const userService = new UserService(AppDataSource);
+      const success = await userService.deleteUser(id);
+
+      if (!success) {
+        return c.json({ code: 404, message: '用户不存在' }, 404);
+      }
+
+      return c.body(null, 204);
+    } catch (error) {
+      return c.json({
+        code: 500,
+        message: error instanceof Error ? error.message : '删除用户失败'
+      }, 500);
+    }
+  });
+
+export default app;

+ 21 - 10
src/server/api/users/index.ts

@@ -1,15 +1,26 @@
 import { OpenAPIHono } from '@hono/zod-openapi';
-import listUsersRoute from './get';
-import createUserRoute from './post';
-import getUserByIdRoute from './[id]/get';
-import updateUserRoute from './[id]/put';
-import deleteUserRoute from './[id]/delete';
+import { createCrudRoutes } from '../../utils/generic-crud.routes';
+import { UserEntity } from '../../modules/users/user.entity';
+import { UserSchema, CreateUserDto, UpdateUserDto } from '../../modules/users/user.schema';
+import { authMiddleware } from '../../middleware/auth.middleware';
+import customRoutes from './custom';
 
+// 创建通用CRUD路由配置
+const userCrudRoutes = createCrudRoutes({
+  entity: UserEntity,
+  createSchema: CreateUserDto,
+  updateSchema: UpdateUserDto,
+  getSchema: UserSchema,
+  listSchema: UserSchema,
+  searchFields: ['username', 'nickname', 'phone', 'email'],
+  relations: ['roles'],
+  middleware: [authMiddleware],
+  readOnly: true // 创建/更新/删除使用自定义路由
+});
+
+// 创建混合路由应用
 const app = new OpenAPIHono()
-  .route('/', listUsersRoute)
-  .route('/', createUserRoute)
-  .route('/', getUserByIdRoute)
-  .route('/', updateUserRoute)
-  .route('/', deleteUserRoute);
+  .route('/', userCrudRoutes) // 通用CRUD路由(列表查询和获取详情)
+  .route('/', customRoutes);   // 自定义业务路由(创建/更新/删除)
 
 export default app;

+ 1 - 48
src/server/modules/users/__tests__/user.service.test.ts

@@ -120,54 +120,7 @@ describe('UserService', () => {
     });
   });
 
-  describe('getUsersWithPagination', () => {
-    it('应该成功获取分页用户列表', async () => {
-      const mockUsers = [{ id: 1 }, { id: 2 }] as User[];
-      const total = 2;
-
-      const mockQueryBuilder = {
-        leftJoinAndSelect: vi.fn().mockReturnThis(),
-        skip: vi.fn().mockReturnThis(),
-        take: vi.fn().mockReturnThis(),
-        where: vi.fn().mockReturnThis(),
-        getManyAndCount: vi.fn().mockResolvedValue([mockUsers, total])
-      };
-
-      mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
-
-      const result = await userService.getUsersWithPagination({
-        page: 1,
-        pageSize: 10
-      });
-
-      expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0);
-      expect(mockQueryBuilder.take).toHaveBeenCalledWith(10);
-      expect(result).toEqual([mockUsers, total]);
-    });
-
-    it('应该支持关键词搜索', async () => {
-      const mockQueryBuilder = {
-        leftJoinAndSelect: vi.fn().mockReturnThis(),
-        skip: vi.fn().mockReturnThis(),
-        take: vi.fn().mockReturnThis(),
-        where: vi.fn().mockReturnThis(),
-        getManyAndCount: vi.fn().mockResolvedValue([[], 0])
-      };
-
-      mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
-
-      await userService.getUsersWithPagination({
-        page: 1,
-        pageSize: 10,
-        keyword: 'test'
-      });
-
-      expect(mockQueryBuilder.where).toHaveBeenCalledWith(
-        'user.username LIKE :keyword OR user.nickname LIKE :keyword OR user.phone LIKE :keyword',
-        { keyword: '%test%' }
-      );
-    });
-  });
+  // getUsersWithPagination 方法已移除,使用通用CRUD服务替代
 
   describe('verifyPassword', () => {
     it('应该验证密码正确', async () => {

+ 0 - 28
src/server/modules/users/user.service.ts

@@ -88,34 +88,6 @@ export class UserService {
     }
   }
 
-  async getUsersWithPagination(params: {
-    page: number;
-    pageSize: number;
-    keyword?: string;
-  }): Promise<[User[], number]> {
-    try {
-      const { page, pageSize, keyword } = params;
-      const skip = (page - 1) * pageSize;
-      
-      const queryBuilder = this.userRepository
-        .createQueryBuilder('user')
-        .leftJoinAndSelect('user.roles', 'roles')
-        .skip(skip)
-        .take(pageSize);
-
-      if (keyword) {
-        queryBuilder.where(
-          'user.username LIKE :keyword OR user.nickname LIKE :keyword OR user.phone LIKE :keyword',
-          { keyword: `%${keyword}%` }
-        );
-      }
-
-      return await queryBuilder.getManyAndCount();
-    } catch (error) {
-      console.error('Error getting users with pagination:', error);
-      throw new Error('Failed to get users');
-    }
-  }
 
   async verifyPassword(user: User, password: string): Promise<boolean> {
     return password === user.password || bcrypt.compare(password, user.password)