浏览代码

feat(story-13.2): 完善企业概览统计和组件优化

- company-module: 企业概览统计添加企业名称字段
- yongren-dashboard-ui: 修复 Dashboard 显示真实企业名称
- DisabledPersonSelector: 添加输入验证限制和优化 useCallback 依赖
- E2E测试: 重构跨端测试代码结构,提取登录辅助函数

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 3 天之前
父节点
当前提交
32368fa659

+ 4 - 0
allin-packages/company-module/src/schemas/company-statistics.schema.ts

@@ -4,6 +4,10 @@ import { z } from '@hono/zod-openapi';
  * 企业概览统计响应Schema
  */
 export const CompanyOverviewSchema = z.object({
+  企业名称: z.string().openapi({
+    description: '企业名称',
+    example: '某某科技有限公司'
+  }),
   在职人员数: z.coerce.number().int().nonnegative().openapi({
     description: '在职人员数量(work_status = working)',
     example: 10

+ 6 - 0
allin-packages/company-module/src/services/company.service.ts

@@ -205,6 +205,11 @@ export class CompanyService extends GenericCrudService<Company> {
     const employmentOrderRepo = this.dataSource.getRepository(EmploymentOrder);
     const orderPersonRepo = this.dataSource.getRepository(OrderPerson);
 
+    // 查询企业信息以获取企业名称
+    const company = await this.repository.findOne({
+      where: { id: companyId }
+    });
+
     // 查询在职人员数(order_person.work_status = 'working')
     const workingCount = await orderPersonRepo.count({
       where: {
@@ -253,6 +258,7 @@ export class CompanyService extends GenericCrudService<Company> {
     });
 
     return {
+      企业名称: company?.companyName || '',
       在职人员数: workingCount,
       待入职数: preWorkingCount,
       本月新增数: newHiresThisMonth,

+ 61 - 56
allin-packages/disability-person-management-ui/src/components/DisabledPersonSelector.tsx

@@ -23,8 +23,6 @@ import { Checkbox } from '@d8d/shared-ui-components/components/ui/checkbox';
 import { Alert, AlertDescription } from '@d8d/shared-ui-components/components/ui/alert';
 import { AlertCircle } from 'lucide-react';
 import { AreaSelect } from '@d8d/area-management-ui';
-import { Form } from '@d8d/shared-ui-components/components/ui/form';
-import { useForm } from 'react-hook-form';
 import { disabilityClientManager } from '../api/disabilityClient';
 import type {
   DisabledPersonData,
@@ -43,6 +41,13 @@ const DEFAULT_PAGINATION = {
   pageSize: 10,
 } as const;
 
+// 输入验证最大长度
+const MAX_LENGTH = {
+  NAME: 50,
+  DISABILITY_ID: 50,
+  PHONE: 11,
+} as const;
+
 // 默认搜索参数
 const createDefaultSearchParams = () => ({
   name: '',
@@ -135,9 +140,6 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
   const [showBlacklistConfirm, setShowBlacklistConfirm] = useState(false);
   const [pendingSelection, setPendingSelection] = useState<DisabledPersonData | DisabledPersonData[] | null>(null);
 
-  // 为AreaSelect创建表单上下文
-  const form = useForm();
-
   // 搜索残疾人列表(添加错误处理)
   const { data, isLoading, error } = useQuery({
     queryKey: ['disabled-persons-search', searchParams, searchAreaNames],
@@ -197,7 +199,7 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
     setSearchAreaNames({});
   }, []);
 
-  // 处理选择人员(使用 useCallback 优化)
+  // 处理选择人员(使用 useCallback 优化,使用函数式 setState 避免依赖 selectedPersons
   const handleSelectPerson = useCallback((person: DisabledPersonData) => {
     if (disabledIds.includes(person.id)) {
       return; // 跳过禁用的人员
@@ -214,40 +216,43 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
       onSelect(person);
       onOpenChange(false);
     } else {
-      const isSelected = selectedPersons.some(p => p.id === person.id);
-      let newSelectedPersons: DisabledPersonData[];
-
-      if (isSelected) {
-        newSelectedPersons = selectedPersons.filter(p => p.id !== person.id);
-      } else {
-        newSelectedPersons = [...selectedPersons, person];
-      }
-
-      setSelectedPersons(newSelectedPersons);
+      // 使用函数式 setState,避免依赖 selectedPersons
+      setSelectedPersons(prev => {
+        const isSelected = prev.some(p => p.id === person.id);
+        if (isSelected) {
+          return prev.filter(p => p.id !== person.id);
+        } else {
+          return [...prev, person];
+        }
+      });
     }
-  }, [disabledIds, mode, onSelect, onOpenChange, selectedPersons]);
+  }, [disabledIds, mode, onSelect, onOpenChange]);
 
-  // 处理批量选择(使用 useCallback 优化)
+  // 处理批量选择(使用 useCallback 优化,使用函数式 setState 避免依赖 selectedPersons
   const handleBatchSelect = useCallback(() => {
-    console.debug('[DisabledPersonSelector] handleBatchSelect 被调用, selectedPersons:', selectedPersons.length);
-    if (selectedPersons.length === 0) {
-      console.debug('[DisabledPersonSelector] 没有选中人员,返回');
-      return;
-    }
+    // 使用函数式 setState 获取当前值,避免在依赖数组中包含 selectedPersons
+    setSelectedPersons(currentPersons => {
+      console.debug('[DisabledPersonSelector] handleBatchSelect 被调用, selectedPersons:', currentPersons.length);
+      if (currentPersons.length === 0) {
+        console.debug('[DisabledPersonSelector] 没有选中人员,返回');
+        return currentPersons;
+      }
 
-    // 检查是否有黑名单人员
-    const blacklistPersons = selectedPersons.filter(p => p.isInBlackList === BLACKLIST_STATUS.IN_BLACKLIST);
-    if (blacklistPersons.length > 0) {
-      console.debug('[DisabledPersonSelector] 检测到黑名单人员');
-      setPendingSelection(selectedPersons);
-      setShowBlacklistConfirm(true);
-      return;
-    }
+      // 检查是否有黑名单人员
+      const blacklistPersons = currentPersons.filter(p => p.isInBlackList === BLACKLIST_STATUS.IN_BLACKLIST);
+      if (blacklistPersons.length > 0) {
+        console.debug('[DisabledPersonSelector] 检测到黑名单人员');
+        setPendingSelection(currentPersons);
+        setShowBlacklistConfirm(true);
+        return currentPersons;
+      }
 
-    console.debug('[DisabledPersonSelector] 调用 onSelect, 人员数量:', selectedPersons.length);
-    onSelect(selectedPersons);
-    onOpenChange(false);
-  }, [onSelect, onOpenChange, selectedPersons]);
+      console.debug('[DisabledPersonSelector] 调用 onSelect, 人员数量:', currentPersons.length);
+      onSelect(currentPersons);
+      onOpenChange(false);
+      return currentPersons;
+    });
+  }, [onSelect, onOpenChange]);
 
   // 确认选择黑名单人员(使用 useCallback 优化)
   const handleConfirmBlacklistSelection = useCallback(() => {
@@ -304,6 +309,7 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
                 <Input
                   id="name"
                   placeholder="输入姓名"
+                  maxLength={MAX_LENGTH.NAME}
                   value={searchParams.name || ''}
                   onChange={(e) => setSearchParams(prev => ({ ...prev, name: e.target.value }))}
                   data-testid="search-name-input"
@@ -333,6 +339,7 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
                 <Input
                   id="disabilityId"
                   placeholder="输入残疾证号"
+                  maxLength={MAX_LENGTH.DISABILITY_ID}
                   value={searchParams.disabilityId || ''}
                   onChange={(e) => setSearchParams(prev => ({ ...prev, disabilityId: e.target.value }))}
                   data-testid="search-disability-id-input"
@@ -344,6 +351,7 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
                 <Input
                   id="phone"
                   placeholder="输入联系电话"
+                  maxLength={MAX_LENGTH.PHONE}
                   value={searchParams.phone || ''}
                   onChange={(e) => setSearchParams(prev => ({ ...prev, phone: e.target.value }))}
                   data-testid="search-phone-input"
@@ -354,27 +362,24 @@ const DisabledPersonSelector: React.FC<DisabledPersonSelectorProps> = ({
             {/* 第二行:区域选择器单独一行 */}
             <div className="space-y-2">
               <Label>省份/城市/区县</Label>
-              {/* AreaSelect需要Form上下文,所以包装在Form中 */}
-              <Form {...form}>
-                <AreaSelect
-                  value={areaSelection}
-                  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>
+              <AreaSelect
+                value={areaSelection}
+                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"
+              />
             </div>
 
             {/* 第三行:其他筛选条件和操作按钮 - 响应式网格 */}

+ 1 - 1
mini-ui-packages/yongren-dashboard-ui/src/pages/Dashboard/Dashboard.tsx

@@ -52,7 +52,7 @@ const Dashboard: React.FC = () => {
         pendingAssignments: data.待入职数,
         newHiresThisMonth: data.本月新增数,
         monthlyOrders: data.已完成订单数,
-        companyName: '企业名称'
+        companyName: data.企业名称 || user?.name || '企业名称'
       } as OverviewData
     },
     refetchOnWindowFocus: false

+ 50 - 37
web/tests/e2e/specs/cross-platform/order-create-sync.spec.ts

@@ -1,5 +1,7 @@
 import { TIMEOUTS } from '../../utils/timeouts';
 import { test, expect } from '../../utils/test-setup';
+import { AdminLoginPage } from '../../pages/admin/login.page';
+import { EnterpriseMiniPage } from '../../pages/mini/enterprise-mini.page';
 
 /**
  * 跨端数据同步 E2E 测试
@@ -22,21 +24,49 @@ const TEST_SYNC_TIMEOUT = 3000; // 数据同步等待时间(ms)
 const TEST_COMPANY_NAME = '测试公司_1768346782396'; // 测试公司名称
 const MINI_LOGIN_PHONE = '13800001111'; // 小程序登录手机号
 const MINI_LOGIN_PASSWORD = 'password123'; // 小程序登录密码
+
+/**
+ * 后台登录辅助函数
+ */
+async function loginAdmin(page: any, testUsers: any) {
+  const adminLoginPage = new AdminLoginPage(page);
+  await adminLoginPage.goto();
+  await adminLoginPage.page.getByPlaceholder('请输入用户名').fill(testUsers.admin.username);
+  await adminLoginPage.page.getByPlaceholder('请输入密码').fill(testUsers.admin.password);
+  await adminLoginPage.page.getByRole('button', { name: '登录' }).click();
+  await adminLoginPage.page.waitForURL('**/admin/dashboard', { timeout: TIMEOUTS.PAGE_LOAD });
+  console.debug('[后台] 登录成功');
+}
+
+/**
+ * 企业小程序登录辅助函数
+ */
+async function loginMini(page: any) {
+  const miniPage = new EnterpriseMiniPage(page);
+  await miniPage.goto();
+  await miniPage.login(MINI_LOGIN_PHONE, MINI_LOGIN_PASSWORD);
+  await miniPage.expectLoginSuccess();
+  console.debug('[小程序] 登录成功');
+}
+
+// 测试状态管理(使用闭包替代 process.env)
+let testOrderName: string | null = null;
+
 test.describe('跨端数据同步测试 - 后台创建订单到企业小程序', () => {
-  let orderName: string;
+  // 在所有测试后清理测试数据
+  test.afterAll(async () => {
+    // 清理测试状态
+    testOrderName = null;
+    console.debug('[清理] 测试数据已清理');
+  });
 
   test.describe.serial('后台创建订单', () => {
     test('应该成功登录后台并创建订单', async ({ page: adminPage, testUsers }) => {
       // 记录开始时间
       const startTime = Date.now();
 
-      // 1. 后台登录
-      await adminPage.goto('/admin/login');
-      await adminPage.getByPlaceholder('请输入用户名').fill(testUsers.admin.username);
-      await adminPage.getByPlaceholder('请输入密码').fill(testUsers.admin.password);
-      await adminPage.getByRole('button', { name: '登录' }).click();
-      await adminPage.waitForURL('**/admin/dashboard', { timeout: TIMEOUTS.PAGE_LOAD });
-      console.debug('[后台] 登录成功');
+      // 1. 后台登录(使用辅助函数)
+      await loginAdmin(adminPage, testUsers);
 
       // 2. 导航到订单管理页面
       await adminPage.goto('/admin/orders');
@@ -130,8 +160,8 @@ test.describe('跨端数据同步测试 - 后台创建订单到企业小程序',
       const syncTime = endTime - startTime;
       console.debug(`[后台] 订单创建完成,耗时: ${syncTime}ms`);
 
-      // 保存订单名称到环境变量,供后续测试使用
-      process.env.__TEST_ORDER_NAME__ = orderName;
+      // 保存订单名称到模块级变量,供后续测试使用
+      testOrderName = orderName;
     });
   });
 
@@ -139,32 +169,18 @@ test.describe('跨端数据同步测试 - 后台创建订单到企业小程序',
     test.use({ storageState: undefined }); // 确保使用新的浏览器上下文
 
     test('应该在小程序中显示后台创建的订单', async ({ page: miniPage }) => {
-      // 从环境变量获取订单名称
-      const testOrderName = process.env.__TEST_ORDER_NAME__;
-      if (!testOrderName) {
+      // 从模块级变量获取订单名称
+      const orderNameForTest = testOrderName;
+      if (!orderNameForTest) {
         throw new Error('未找到测试订单名称,请先运行后台创建订单测试');
       }
-      console.debug(`[小程序] 查找订单: ${testOrderName}`);
+      console.debug(`[小程序] 查找订单: ${orderNameForTest}`);
 
       // 等待一段时间,确保数据同步完成
       await new Promise(resolve => setTimeout(resolve, TEST_SYNC_TIMEOUT));
 
-      // 1. 小程序登录
-      await miniPage.goto('/mini');
-      await miniPage.waitForLoadState('networkidle');
-
-      // 填写登录表单(使用企业账号)
-      await miniPage.getByTestId('mini-phone-input').getByPlaceholder('请输入手机号').fill(MINI_LOGIN_PHONE);
-      await miniPage.getByRole('textbox', { name: '请输入密码' }).fill(MINI_LOGIN_PASSWORD);
-      await miniPage.getByTestId('mini-login-button').click();
-      console.debug('[小程序] 登录请求已发送');
-
-      // 等待登录成功(跳转到 dashboard)
-      await miniPage.waitForURL(
-        url => url.pathname.includes('/dashboard') || url.pathname.includes('/pages/yongren/dashboard'),
-        { timeout: TIMEOUTS.PAGE_LOAD }
-      );
-      console.debug('[小程序] 登录成功');
+      // 1. 小程序登录(使用辅助函数)
+      await loginMini(miniPage);
 
       // 2. 导航到订单列表页面
       await miniPage.getByText('订单', { exact: true }).click();
@@ -176,12 +192,12 @@ test.describe('跨端数据同步测试 - 后台创建订单到企业小程序',
 
       // 4. 验证订单出现在小程序列表中
       // 使用多种方法验证订单存在
-      const orderExistsByLocator = await miniPage.locator('text=' + testOrderName).count() > 0;
-      const orderExistsByGetByText = await miniPage.getByText(testOrderName).count() > 0;
+      const orderExistsByLocator = await miniPage.locator('text=' + orderNameForTest).count() > 0;
+      const orderExistsByGetByText = await miniPage.getByText(orderNameForTest).count() > 0;
 
       const orderExists = orderExistsByLocator || orderExistsByGetByText;
       expect(orderExists).toBe(true);
-      console.debug(`[小程序] 订单 "${testOrderName}" 已同步到小程序`);
+      console.debug(`[小程序] 订单 "${orderNameForTest}" 已同步到小程序`);
 
       // 5. 点击查看详情
       const detailButton = miniPage.getByText('查看详情').first();
@@ -191,16 +207,13 @@ test.describe('跨端数据同步测试 - 后台创建订单到企业小程序',
 
       // 6. 验证订单详情信息
       // 验证订单名称显示
-      const orderNameElement = miniPage.getByText(testOrderName);
+      const orderNameElement = miniPage.getByText(orderNameForTest);
       await expect(orderNameElement).toBeVisible();
       console.debug('[小程序] 订单详情显示正确');
 
       // 7. 记录数据同步完成时间
       const syncEndTime = Date.now();
       console.debug(`[小程序] 数据同步验证完成,时间戳: ${syncEndTime}`);
-
-      // 清理环境变量
-      delete process.env.__TEST_ORDER_NAME__;
     });
   });
 });