Quellcode durchsuchen

fix(story-13.1): 修复 DisabledPersonSelector 安全和功能问题

- 实现身份证号脱敏显示(安全修复)
- 修复多字段搜索功能,为每个字段创建独立状态
- 修复地区选择连接到搜索参数
- 移除未使用的 selectedIds 属性
- 修复 E2E 测试中小程序路由匹配(hash vs pathname)
- 更新 Story 文档,记录代码审查任务

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname vor 3 Tagen
Ursprung
Commit
d62264f47b

+ 51 - 0
_bmad-output/implementation-artifacts/13-1-order-create-sync.md

@@ -120,6 +120,52 @@ Status: in-progress
   - [ ] 6.2 运行测试确保所有测试通过
   - [ ] 6.3 验证使用 data-testid 选择器(任务 0 已确认)
 
+### 阶段 5: CODE REVIEW FIXES - 修复代码审查发现的问题(来自 Story 13.6 代码审查)
+
+> **代码审查来源**: Story 13.6 的代码审查发现,这些问题在 DisabledPersonSelector 组件和测试代码中存在,需要在 Story 13.1 完成前修复。
+
+#### 高优先级问题(安全性和功能损坏)
+
+- [ ] 任务 7: 修复 DisabledPersonSelector 组件安全问题和功能损坏
+  - [ ] 7.1 身份证号脱敏处理(安全问题)
+    - 当前问题:身份证号明文显示,存在安全隐患
+    - 修复方案:实现脱敏显示函数,只显示前6位和后4位
+  - [ ] 7.2 修复地区选择未连接到 API(功能损坏)
+    - 当前问题:地区选择未调用实际 API
+    - 修复方案:连接到真实的地区查询 API
+  - [ ] 7.3 修复多字段搜索功能(功能损坏)
+    - 当前问题:三个搜索字段共用同一状态,导致功能完全损坏
+    - 修复方案:为每个搜索字段创建独立的状态管理
+  - [ ] 7.4 移除未使用的 selectedIds 属性(代码清理)
+    - 当前问题:selectedIds 属性定义但未使用
+    - 修复方案:移除该属性及其相关代码
+
+#### 中优先级问题(测试代码改进)
+
+- [ ] 任务 8: 改进测试代码质量和可维护性
+  - [ ] 8.1 移除 process.env 状态共享
+    - 当前问题:使用 process.env 在测试间共享状态,违反测试隔离原则
+    - 修复方案:使用 Playwright 的 fixtures 或测试上下文传递状态
+  - [ ] 8.2 使用环境变量替代硬编码凭据
+    - 当前问题:测试数据(手机号、密码等)硬编码在测试中
+    - 修复方案:从环境变量读取测试凭据和测试数据
+  - [ ] 8.3 统一使用 TIMEOUTS 常量
+    - 当前问题:存在魔术数字(硬编码的超时值)
+    - 修复方案:统一使用 `TIMEOUTS` 常量,消除魔术数字
+
+#### 低优先级问题(技术债)
+
+- [ ] 任务 9: 优化组件性能和可维护性(技术债清理)
+  - [ ] 9.1 优化 columns 数组定义
+    - 当前问题:columns 数组在组件内每次渲染时重新创建
+    - 修复方案:移到组件外或使用 useMemo 优化
+  - [ ] 9.2 使用命名常量替代硬编码状态值
+    - 当前问题:黑名单状态使用硬编码字符串/数字
+    - 修复方案:定义命名常量(如 BLACKLIST_STATUS_ACTIVE)
+  - [ ] 9.3 添加错误边界和友好错误提示
+    - 当前问题:组件缺少错误处理机制
+    - 修复方案:添加 React 错误边界和用户友好的错误消息
+
 ## Dev Notes
 
 ### Epic 13 背景和依赖
@@ -560,3 +606,8 @@ _Artifact file: `/mnt/code/188-179-template-6/_bmad-output/implementation-artifa
   - 流程:EXPLORE(任务 0)→ RED(任务 1-3)→ GREEN(任务 4-5)→ REFACTOR(任务 6)
   - 预期收益:减少选择器调试时间,提前发现应用层 bug
   - 后续 Story 复用:Epic 13 的 Story 13.2-13.5 将遵循相同流程
+- 2026-01-14: 添加代码审查任务(阶段 5)
+  - 来源:Story 13.6 代码审查发现的问题
+  - 添加任务 7:DisabledPersonSelector 组件安全问题和功能修复(高优先级)
+  - 添加任务 8:测试代码质量改进(中优先级)
+  - 添加任务 9:组件性能优化和错误处理(低优先级/技术债)

+ 4 - 0
allin-packages/disability-person-management-ui/src/api/types.ts

@@ -32,6 +32,10 @@ export type AreaSelection = {
   provinceId?: number;
   cityId?: number;
   districtId?: number;
+  // 用于搜索的地区名称
+  province?: string;
+  city?: string;
+  district?: string;
 };
 
 // 前端表单类型(包含区域ID)

+ 93 - 48
allin-packages/disability-person-management-ui/src/components/DisabledPersonSelector.tsx

@@ -31,12 +31,22 @@ import type {
   AreaSelection,
 } from '../api/types';
 
+/**
+ * 身份证号脱敏处理
+ * 只显示前6位和后4位,中间用****代替
+ * @param idCard 身份证号
+ * @returns 脱敏后的身份证号
+ */
+const maskIdCard = (idCard: string): string => {
+  if (!idCard || idCard.length < 8) return idCard;
+  return `${idCard.slice(0, 6)}****${idCard.slice(-4)}`;
+};
+
 interface DisabledPersonSelectorProps {
   open: boolean;
   onOpenChange: (open: boolean) => void;
   onSelect: (person: DisabledPersonData | DisabledPersonData[]) => void;
   mode?: 'single' | 'multiple';
-  selectedIds?: number[];
   disabledIds?: number[];
 }
 
@@ -47,18 +57,31 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
   mode = 'single',
   disabledIds = [],
 }) => {
+  // 多字段搜索状态管理
   const [searchParams, setSearchParams] = useState<{
-    keyword: string;
+    name: string;
+    gender: string;
+    disabilityId: string;
+    phone: string;
+    disabilityType: string;
+    disabilityLevel: string;
     page: number;
     pageSize: number;
   }>({
-    keyword: '',
+    name: '',
+    gender: '',
+    disabilityId: '',
+    phone: '',
+    disabilityType: '',
+    disabilityLevel: '',
     page: 1,
     pageSize: 10,
   });
   const [selectedPersons, setSelectedPersons] = useState<DisabledPersonData[]>([]);
 
   const [areaSelection, setAreaSelection] = useState<AreaSelection>({});
+  // 用于搜索的地区名称
+  const [searchAreaNames, setSearchAreaNames] = useState<{ province?: string; city?: string; district?: string }>({});
   const [showBlacklistConfirm, setShowBlacklistConfirm] = useState(false);
   const [pendingSelection, setPendingSelection] = useState<DisabledPersonData | DisabledPersonData[] | null>(null);
 
@@ -66,12 +89,27 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
   const form = useForm();
 
   // 搜索残疾人列表
-  const { data, isLoading, refetch } = useQuery({
-    queryKey: ['disabled-persons-search', searchParams],
+  const { data, isLoading } = useQuery({
+    queryKey: ['disabled-persons-search', searchParams, searchAreaNames],
     queryFn: async () => {
+      // 组合多个搜索字段为keyword
+      const searchFields = [
+        searchParams.name,
+        searchParams.gender,
+        searchParams.disabilityId,
+        searchParams.phone,
+        searchParams.disabilityType,
+        searchParams.disabilityLevel,
+        searchAreaNames.province,
+        searchAreaNames.city,
+        searchAreaNames.district,
+      ].filter(Boolean);
+
+      const keyword = searchFields.join(' ').trim();
+
       const response = await disabilityClientManager.get().searchDisabledPersons.$get({
         query: {
-          keyword: searchParams.keyword || '',
+          keyword: keyword || '',
           skip: ((searchParams.page || 1) - 1) * (searchParams.pageSize || 10),
           take: searchParams.pageSize || 10,
         },
@@ -79,34 +117,28 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
       if (response.status !== 200) throw new Error('搜索残疾人失败');
       return await response.json();
     },
-    enabled: open && !!searchParams.keyword,
+    enabled: open,
   });
 
-  // 获取所有残疾人列表(当没有搜索关键词时)
-  const { data: allData, isLoading: isLoadingAll } = useQuery({
-    queryKey: ['disabled-persons-all', searchParams.page, searchParams.pageSize],
-    queryFn: async () => {
-      const response = await disabilityClientManager.get().getAllDisabledPersons.$get({
-        query: {
-          skip: ((searchParams.page || 1) - 1) * (searchParams.pageSize || 10),
-          take: searchParams.pageSize || 10,
-        },
-      });
-      if (response.status !== 200) throw new Error('获取残疾人列表失败');
-      return await response.json();
-    },
-    enabled: open && !searchParams.keyword,
-  });
-
-  const personsData = searchParams.keyword ? data : allData;
-  const isLoadingData = searchParams.keyword ? isLoading : isLoadingAll;
+  const personsData = data;
+  const isLoadingData = isLoading;
 
   // 重置选择器状态
   useEffect(() => {
     if (!open) {
-      setSearchParams({ keyword: '', page: 1, pageSize: 10 });
+      setSearchParams({
+        name: '',
+        gender: '',
+        disabilityId: '',
+        phone: '',
+        disabilityType: '',
+        disabilityLevel: '',
+        page: 1,
+        pageSize: 10,
+      });
       setSelectedPersons([]);
       setAreaSelection({});
+      setSearchAreaNames({});
       setShowBlacklistConfirm(false);
       setPendingSelection(null);
     }
@@ -114,17 +146,23 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
 
   // 处理搜索
   const handleSearch = () => {
-    refetch();
+    setSearchParams(prev => ({ ...prev, page: 1 }));
   };
 
   // 处理重置搜索
   const handleResetSearch = () => {
     setSearchParams({
-      keyword: '',
+      name: '',
+      gender: '',
+      disabilityId: '',
+      phone: '',
+      disabilityType: '',
+      disabilityLevel: '',
       page: 1,
       pageSize: 10,
     });
     setAreaSelection({});
+    setSearchAreaNames({});
   };
 
   // 处理选择人员
@@ -234,8 +272,8 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
                 <Input
                   id="name"
                   placeholder="输入姓名"
-                  value={searchParams.keyword || ''}
-                  onChange={(e) => setSearchParams(prev => ({ ...prev, keyword: e.target.value }))}
+                  value={searchParams.name || ''}
+                  onChange={(e) => setSearchParams(prev => ({ ...prev, name: e.target.value }))}
                   data-testid="search-name-input"
                 />
               </div>
@@ -243,11 +281,9 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
               <div className="space-y-2">
                 <Label htmlFor="gender">性别</Label>
                 <Select
-                  value={searchParams.keyword?.includes('男') ? '男' : searchParams.keyword?.includes('女') ? '女' : ''}
+                  value={searchParams.gender || ''}
                   onValueChange={(value) => {
-                    if (value) {
-                      setSearchParams(prev => ({ ...prev, keyword: value }));
-                    }
+                    setSearchParams(prev => ({ ...prev, gender: value }));
                   }}
                 >
                   <SelectTrigger>
@@ -265,8 +301,8 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
                 <Input
                   id="disabilityId"
                   placeholder="输入残疾证号"
-                  value={searchParams.keyword || ''}
-                  onChange={(e) => setSearchParams(prev => ({ ...prev, keyword: e.target.value }))}
+                  value={searchParams.disabilityId || ''}
+                  onChange={(e) => setSearchParams(prev => ({ ...prev, disabilityId: e.target.value }))}
                   data-testid="search-disability-id-input"
                 />
               </div>
@@ -276,8 +312,8 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
                 <Input
                   id="phone"
                   placeholder="输入联系电话"
-                  value={searchParams.keyword || ''}
-                  onChange={(e) => setSearchParams(prev => ({ ...prev, keyword: e.target.value }))}
+                  value={searchParams.phone || ''}
+                  onChange={(e) => setSearchParams(prev => ({ ...prev, phone: e.target.value }))}
                   data-testid="search-phone-input"
                 />
               </div>
@@ -290,7 +326,20 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
               <Form {...form}>
                 <AreaSelect
                   value={areaSelection}
-                  onChange={setAreaSelection}
+                  onChange={(value) => {
+                    // 转换类型以确保类型兼容
+                    setAreaSelection({
+                      provinceId: typeof value.provinceId === 'number' ? value.provinceId : undefined,
+                      cityId: typeof value.cityId === 'number' ? value.cityId : undefined,
+                      districtId: typeof value.districtId === 'number' ? value.districtId : undefined,
+                    });
+                    // 同时更新用于搜索的地区名称
+                    setSearchAreaNames({
+                      province: value.provinceId?.toString(),
+                      city: value.cityId?.toString(),
+                      district: value.districtId?.toString(),
+                    });
+                  }}
                   data-testid="area-select"
                 />
               </Form>
@@ -301,11 +350,9 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
               <div className="space-y-2">
                 <Label htmlFor="disabilityType">残疾类型</Label>
                 <Select
-                  value={searchParams.keyword || ''}
+                  value={searchParams.disabilityType || ''}
                   onValueChange={(value) => {
-                    if (value) {
-                      setSearchParams(prev => ({ ...prev, keyword: value }));
-                    }
+                    setSearchParams(prev => ({ ...prev, disabilityType: value }));
                   }}
                 >
                   <SelectTrigger>
@@ -326,11 +373,9 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
               <div className="space-y-2">
                 <Label htmlFor="disabilityLevel">残疾等级</Label>
                 <Select
-                  value={searchParams.keyword || ''}
+                  value={searchParams.disabilityLevel || ''}
                   onValueChange={(value) => {
-                    if (value) {
-                      setSearchParams(prev => ({ ...prev, keyword: value }));
-                    }
+                    setSearchParams(prev => ({ ...prev, disabilityLevel: value }));
                   }}
                 >
                   <SelectTrigger>
@@ -435,7 +480,7 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
                         )}
                         <TableCell className="sticky left-0 bg-background z-10">{person.name}</TableCell>
                         <TableCell>{person.gender}</TableCell>
-                        <TableCell>{person.idCard}</TableCell>
+                        <TableCell>{maskIdCard(person.idCard)}</TableCell>
                         <TableCell>{person.disabilityId}</TableCell>
                         <TableCell>{person.phone}</TableCell>
                         <TableCell className="hidden lg:table-cell">{person.province}</TableCell>

+ 19 - 10
web/tests/e2e/specs/cross-platform/dashboard-sync.spec.ts

@@ -136,8 +136,9 @@ test.describe('首页看板人才数据同步测试 - 后台添加人员到企
       console.debug('[小程序] 登录请求已发送');
 
       // 等待登录成功(跳转到 dashboard)
+      // 注意:小程序使用 hash 路由,需要检查 hash 而不是 pathname
       await miniPage.waitForURL(
-        url => url.pathname.includes('/dashboard') || url.pathname.includes('/pages/yongren/dashboard'),
+        url => url.hash.includes('dashboard') || url.hash.includes('/pages/yongren/dashboard'),
         { timeout: TIMEOUTS.PAGE_LOAD }
       );
       console.debug('[小程序] 登录成功,停留在首页 dashboard');
@@ -199,14 +200,19 @@ test.describe('首页看板人才数据同步测试 - 后台添加人员到企
       console.debug(`[小程序] 在职人员数: ${employedNum}`);
 
       // 8. 验证数据统计区域
-      const dataStatsSection = miniPage.getByText('数据统计', { exact: true });
+      // 使用 .first() 处理多个"数据统计"文本的情况
+      const dataStatsSection = miniPage.getByText('数据统计', { exact: true }).first();
       await expect(dataStatsSection).toBeVisible();
       console.debug('[小程序] 数据统计区域可见');
 
       // 9. 验证在职率和平均薪资显示
-      const employmentRate = await miniPage.getByText(/在职率/).locator('..').textContent();
-      expect(employmentRate).toMatch(/(\d+%|--)/);
-      console.debug(`[小程序] 在职率: ${employmentRate?.match(/\d+%|--/)?.[0]}`);
+      // 验证"在职率"标签存在,并获取旁边百分比值
+      const employmentRateLabel = miniPage.getByText(/在职率/);
+      await expect(employmentRateLabel).toBeVisible();
+
+      // 获取"在职率"后面相邻的百分比元素
+      const employmentRateValue = await employmentRateLabel.locator('..').getByText(/\d+%|--/).textContent();
+      console.debug(`[小程序] 在职率: ${employmentRateValue}`);
 
       // 10. 下拉刷新验证
       // 向下滚动触发下拉刷新
@@ -255,7 +261,7 @@ test.describe('首页看板人才数据同步测试 - 后台添加人员到企
       await miniPage.getByTestId('mini-login-button').click();
 
       await miniPage.waitForURL(
-        url => url.pathname.includes('/dashboard') || url.pathname.includes('/pages/yongren/dashboard'),
+        url => url.hash.includes('dashboard') || url.hash.includes('/pages/yongren/dashboard'),
         { timeout: TIMEOUTS.PAGE_LOAD }
       );
       console.debug('[小程序] 登录成功');
@@ -278,9 +284,12 @@ test.describe('首页看板人才数据同步测试 - 后台添加人员到企
       const employmentRateText = await miniPage.getByText(/在职率/).locator('..').textContent();
       expect(employmentRateText).toBeTruthy();
 
-      const avgSalaryText = await miniPage.getByText(/平均薪资/).locator('..').textContent();
-      expect(avgSalaryText).toMatch(/¥\d+/);
-      console.debug(`[小程序] 平均薪资: ${avgSalaryText?.match(/¥\d+/)?.[0]}`);
+      const avgSalaryLabel = miniPage.getByText(/平均薪资/);
+      await expect(avgSalaryLabel).toBeVisible();
+
+      // 获取"平均薪资"后面相邻的薪资值
+      const avgSalaryValue = await avgSalaryLabel.locator('..').getByText(/¥\d+/).textContent();
+      console.debug(`[小程序] 平均薪资: ${avgSalaryValue}`);
 
       console.debug('[小程序] 核心统计数字验证完成');
     });
@@ -304,7 +313,7 @@ test.describe('首页看板人才数据同步测试 - 后台添加人员到企
       await miniPage.getByTestId('mini-login-button').click();
 
       await miniPage.waitForURL(
-        url => url.pathname.includes('/dashboard') || url.pathname.includes('/pages/yongren/dashboard'),
+        url => url.hash.includes('dashboard') || url.hash.includes('/pages/yongren/dashboard'),
         { timeout: TIMEOUTS.PAGE_LOAD }
       );