HR_EMPLOYEE_RECORDS_IMPLEMENTATION.md 20 KB

人事板块 - 员工档案对接真实数据

实施时间

2025-11-19 01:50


📋 一、需求概述

将人事板块的员工档案列表从硬编码假数据切换到 Profile 表的真实数据,并在 Profile.data.hrData 中添加 HR 专用字段。


🗄️ 二、数据表结构

Profile 表(员工信息表)

直接字段

Profile {
  id: string;                    // 员工ID
  name: string;                  // 昵称(企微昵称)
  mobile: string;                // 手机号
  email: string;                 // 邮箱
  roleName: string;              // 角色名称
  department: Pointer<Department>; // 所属部门
  isDeleted: boolean;            // 是否删除
  isDisabled: boolean;           // 是否禁用(离职)
  createdAt: Date;               // 创建时间
}

data 字段(JSON)

Profile.data = {
  // 基础信息
  realname: string;              // 真实姓名
  gender: string;                // 性别
  mobile: string;                // 手机号(备用)
  email: string;                 // 邮箱(备用)
  position: string;              // 职位
  
  // 企微信息
  wxworkInfo: {
    name: string;                // 企微昵称
    mobile: string;              // 企微手机号
    email: string;               // 企微邮箱
    position: string;            // 企微职位
    gender: string;              // 企微性别
    avatar: string;              // 企微头像
    userid: string;              // 企微用户ID
  },
  
  // HR 专用字段(新增)✅
  hrData: {
    employeeId: string;          // 工号
    idCard: string;              // 身份证号
    bankCard: string;            // 银行卡号
    birthDate: Date | string;    // 出生日期
    hireDate: Date | string;     // 入职日期
    gender: string;              // 性别
    position: string;            // 职位
    employmentStatus: string;    // 就业状态
      // - 'active': 在职
      // - 'probation': 试用期
      // - 'inactive': 停薪留职
  }
}

🔄 三、字段映射关系

员工档案显示字段 → Profile 表字段

档案字段 Profile 字段 优先级 说明
姓名 data.realnamewxworkInfo.namename 优先真实姓名 显示真实姓名
工号 data.hrData.employeeId 唯一来源 HR 专用字段
部门 department.name 唯一来源 关联 Department 表
职位 wxworkInfo.positiondata.positionhrData.positionroleName 多来源 企微职位优先
手机号 wxworkInfo.mobiledata.mobilemobile 多来源 企微手机号优先
邮箱 wxworkInfo.emaildata.emailemail 多来源 企微邮箱优先
性别 wxworkInfo.genderdata.genderhrData.gender 多来源 企微性别优先
出生日期 data.hrData.birthDate 唯一来源 HR 专用字段
入职日期 data.hrData.hireDatecreatedAt 两来源 无 hrData 时用创建时间
状态 根据 isDisabled + hrData.employmentStatus 计算 计算字段 见状态映射规则
身份证号 data.hrData.idCard 唯一来源 HR 专用字段,脱敏显示
银行卡号 data.hrData.bankCard 唯一来源 HR 专用字段,脱敏显示

员工状态映射规则

if (profile.isDisabled) {
  status = '离职';
} else if (hrData.employmentStatus === 'probation') {
  status = '试用期';
} else if (hrData.employmentStatus === 'inactive') {
  status = '停薪留职';
} else {
  status = '在职';  // 默认
}

四、主要修改内容

1. 引入 Parse 依赖

文件employee-records.ts

import { FmodeParse, FmodeObject } from 'fmode-ng/parse';

const Parse = FmodeParse.with('nova');

2. 修改数据源

原代码(硬编码假数据):

employees = signal<Employee[]>([
  {
    id: '1',
    name: '张三',
    department: '设计部',
    // ... 硬编码数据
  }
]);

新代码(从 Profile 表加载):

// 员工数据 - 从 Profile 表加载
employees = signal<Employee[]>([]);

// 加载状态
loading = signal(false);
loadError = signal('');

3. 新增数据加载方法

文件employee-records.ts

/**
 * 从 Profile 表加载真实的员工数据
 */
async loadEmployees(): Promise<void> {
  this.loading.set(true);
  this.loadError.set('');
  
  try {
    console.log('📋 [员工档案] 开始加载员工数据...');
    
    // 查询所有 Profile 记录
    const query = new Parse.Query('Profile');
    query.notEqualTo('isDeleted', true);
    query.include('department');
    query.ascending('name');
    query.limit(1000);
    
    const profiles = await query.find();
    console.log(`✅ [员工档案] 查询到 ${profiles.length} 个员工`);
    
    // 转换为 Employee 格式
    const employeeList: Employee[] = profiles.map(profile => {
      const data = profile.get('data') || {};
      const wxworkInfo = data.wxworkInfo || {};
      const hrData = data.hrData || {}; // HR 相关数据
      
      // 获取部门名称
      const department = profile.get('department');
      const departmentName = department?.get ? department.get('name') : '';
      
      // 手机号优先级:企微 > data.mobile > mobile 字段
      const phone = wxworkInfo.mobile || data.mobile || profile.get('mobile') || '';
      
      // 员工状态映射
      let status: EmployeeStatus = '在职';
      if (profile.get('isDisabled')) {
        status = '离职';
      } else if (hrData.employmentStatus === 'probation') {
        status = '试用期';
      } else if (hrData.employmentStatus === 'inactive') {
        status = '停薪留职';
      }
      
      return {
        id: profile.id,
        name: data.realname || wxworkInfo.name || profile.get('name') || '未知',
        department: departmentName || '未分配',
        position: wxworkInfo.position || data.position || hrData.position || profile.get('roleName') || '',
        employeeId: hrData.employeeId || '', // 工号
        phone: phone,
        email: wxworkInfo.email || data.email || profile.get('email') || '',
        gender: wxworkInfo.gender || data.gender || hrData.gender || '',
        birthDate: hrData.birthDate ? new Date(hrData.birthDate) : undefined,
        hireDate: hrData.hireDate ? new Date(hrData.hireDate) : (profile.get('createdAt') || new Date()),
        status: status,
        idCard: hrData.idCard || '', // 身份证号
        bankCard: hrData.bankCard || '' // 银行卡号
      };
    });
    
    this.employees.set(employeeList);
    console.log(`✅ [员工档案] 员工数据加载完成,共 ${employeeList.length} 个`);
    
  } catch (error) {
    console.error('❌ [员工档案] 加载员工数据失败:', error);
    this.loadError.set('加载员工数据失败,请刷新重试');
    this.showSnackBar('加载员工数据失败,请刷新重试', 'error');
  } finally {
    this.loading.set(false);
  }
}

4. 新增 HR 数据保存方法

文件employee-records.ts

/**
 * 保存员工 HR 数据到 Profile.data.hrData
 */
async saveEmployeeHRData(profileId: string, hrData: any): Promise<void> {
  try {
    const query = new Parse.Query('Profile');
    const profile = await query.get(profileId);
    
    const currentData = profile.get('data') || {};
    profile.set('data', {
      ...currentData,
      hrData: {
        ...currentData.hrData,
        ...hrData
      }
    });
    
    await profile.save();
    console.log(`✅ [员工档案] 保存员工 HR 数据成功: ${profileId}`);
  } catch (error) {
    console.error('❌ [员工档案] 保存员工 HR 数据失败:', error);
    throw error;
  }
}

5. 修改新增员工逻辑

原代码(更新本地数组):

dialogRef.afterClosed().subscribe(result => {
  if (result) {
    const newEmployee: Employee = {
      id: (this.employees().length + 1).toString(),
      ...result
    };
    this.employees.update(employees => [...employees, newEmployee]);
    this.showSnackBar('员工添加成功');
  }
});

新代码(保存到 Profile 表):

dialogRef.afterClosed().subscribe(async (result) => {
  if (result) {
    try {
      // 创建新的 Profile 记录
      const ProfileClass = Parse.Object.extend('Profile');
      const newProfile = new ProfileClass();
      
      newProfile.set('name', result.name);
      newProfile.set('mobile', result.phone);
      newProfile.set('email', result.email);
      newProfile.set('roleName', result.position);
      
      // 设置部门(如果有)
      if (result.department) {
        const deptQuery = new Parse.Query('Department');
        deptQuery.equalTo('name', result.department);
        const dept = await deptQuery.first();
        if (dept) {
          newProfile.set('department', dept);
        }
      }
      
      // 设置 HR 数据
      newProfile.set('data', {
        realname: result.name,
        gender: result.gender,
        email: result.email,
        mobile: result.phone,
        position: result.position,
        hrData: {
          employeeId: result.employeeId,
          idCard: result.idCard,
          bankCard: result.bankCard,
          birthDate: result.birthDate,
          hireDate: result.hireDate,
          gender: result.gender,
          position: result.position,
          employmentStatus: 'active'
        }
      });
      
      await newProfile.save();
      
      // 重新加载员工列表
      await this.loadEmployees();
      this.showSnackBar('员工添加成功');
    } catch (error) {
      console.error('❌ [员工档案] 添加员工失败:', error);
      this.showSnackBar('添加员工失败,请重试', 'error');
    }
  }
});

6. 修改编辑员工逻辑

类似的修改,将本地数组更新改为 Profile 表更新:

dialogRef.afterClosed().subscribe(async (result) => {
  if (result) {
    try {
      // 更新 Profile 记录
      const query = new Parse.Query('Profile');
      const profile = await query.get(employee.id);
      
      profile.set('name', result.name);
      profile.set('mobile', result.phone);
      profile.set('email', result.email);
      profile.set('roleName', result.position);
      
      // 更新部门
      if (result.department) {
        const deptQuery = new Parse.Query('Department');
        deptQuery.equalTo('name', result.department);
        const dept = await deptQuery.first();
        if (dept) {
          profile.set('department', dept);
        }
      }
      
      // 更新 data 字段
      const currentData = profile.get('data') || {};
      profile.set('data', {
        ...currentData,
        realname: result.name,
        gender: result.gender,
        email: result.email,
        mobile: result.phone,
        position: result.position,
        hrData: {
          ...currentData.hrData,
          employeeId: result.employeeId,
          idCard: result.idCard,
          bankCard: result.bankCard,
          birthDate: result.birthDate,
          hireDate: result.hireDate,
          gender: result.gender,
          position: result.position
        }
      });
      
      await profile.save();
      
      // 重新加载员工列表
      await this.loadEmployees();
      this.showSnackBar('员工信息更新成功');
    } catch (error) {
      console.error('❌ [员工档案] 更新员工失败:', error);
      this.showSnackBar('更新员工失败,请重试', 'error');
    }
  }
});

7. 在 ngOnInit 中加载数据

ngOnInit() {
  this.loadEmployees();
}

8. 优化错误提示

showSnackBar(message: string, type: 'success' | 'error' = 'success') {
  this.snackBar.open(message, '关闭', {
    duration: 3000,
    horizontalPosition: 'center',
    verticalPosition: 'top',
    panelClass: type === 'error' ? 'hr-snackbar-error' : 'hr-snackbar'
  });
}

🎯 五、新增的 HR 字段

Profile.data.hrData 结构

{
  employeeId: string;          // 工号(如:DS001, CS002)
  idCard: string;              // 身份证号(18位)
  bankCard: string;            // 银行卡号(16-19位)
  birthDate: Date | string;    // 出生日期(ISO 格式)
  hireDate: Date | string;     // 入职日期(ISO 格式)
  gender: string;              // 性别('男' 或 '女')
  position: string;            // 职位名称
  employmentStatus: string;    // 就业状态
    // - 'active': 在职
    // - 'probation': 试用期
    // - 'inactive': 停薪留职
}

字段用途说明

1. 工号 (employeeId)

用途:唯一标识员工,用于考勤、薪资等
格式:部门缩写 + 序号(如:DS001)
示例:
  - DS001: 设计部001号
  - CS002: 客服部002号
  - FE001: 前端开发001号

2. 身份证号 (idCard)

用途:员工身份验证,用于社保、公积金等
格式:18位身份证号
安全:界面脱敏显示(前6位+****+后4位)
示例:110105199001011234
显示:110105********1234

3. 银行卡号 (bankCard)

用途:工资发放
格式:16-19位银行卡号
安全:界面脱敏显示(前4位+****+后4位)
示例:6222021234567890
显示:6222 **** **** 7890

4. 出生日期 (birthDate)

用途:员工年龄计算、生日提醒
格式:Date 对象或 ISO 字符串
示例:new Date('1990-01-01')

5. 入职日期 (hireDate)

用途:工龄计算、试用期管理
格式:Date 对象或 ISO 字符串
示例:new Date('2022-01-15')
备注:无此字段时使用 Profile.createdAt

6. 就业状态 (employmentStatus)

值:
  - 'active': 在职(正式员工)
  - 'probation': 试用期
  - 'inactive': 停薪留职
  - 无值:默认为在职

影响:
  - isDisabled = true → 状态显示为"离职"
  - employmentStatus = 'probation' → 状态显示为"试用期"
  - employmentStatus = 'inactive' → 状态显示为"停薪留职"
  - 其他 → 状态显示为"在职"

📊 六、数据流程图

页面加载
    ↓
ngOnInit()
    ↓
loadEmployees()
    ↓
查询 Profile 表
  ├─ 条件:isDeleted != true
  ├─ 包含:department
  └─ 排序:name 升序
    ↓
获取 profiles[]
    ↓
遍历每个 profile
    ↓
提取字段
  ├─ 基础字段:name, mobile, email
  ├─ data.realname
  ├─ data.wxworkInfo.*
  └─ data.hrData.* ← 新增的 HR 字段
    ↓
转换为 Employee 对象
    ↓
设置到 employees signal
    ↓
UI 自动更新

🔐 七、安全特性

敏感信息脱敏

身份证号脱敏

maskIdCard(id: string): string {
  if (!id) return '';
  if (id.length >= 18) return `${id.slice(0, 6)}********${id.slice(-4)}`;
  if (id.length > 6) return `${id.slice(0, 3)}****${id.slice(-2)}`;
  return id;
}

// 示例
110105199001011234 → 110105********1234

银行卡号脱敏

maskBankCard(card: string): string {
  if (!card) return '';
  const compact = card.replace(/\s+/g, '');
  if (compact.length <= 8) return compact;
  const first4 = compact.slice(0, 4);
  const last4 = compact.slice(-4);
  return `${first4} **** **** ${last4}`;
}

// 示例
6222021234567890 → 6222 **** **** 7890

点击展开/收起

toggleSensitive(id: string) {
  const list = this.sensitiveExpandedIds();
  if (list.includes(id)) {
    this.sensitiveExpandedIds.set(list.filter(x => x !== id));
  } else {
    this.sensitiveExpandedIds.set([...list, id]);
  }
}

八、修改后的优势

1. 真实数据

✅ 从 Profile 表加载真实员工信息
✅ 与系统其他模块(项目、设计师等)数据一致
✅ 支持企微数据自动同步

2. 数据完整性

✅ 新增 HR 专用字段(工号、身份证、银行卡等)
✅ 数据存储在 Profile.data.hrData 中
✅ 不影响现有字段,向后兼容

3. 安全性

✅ 敏感信息默认脱敏显示
✅ 点击展开才显示完整信息
✅ 符合数据安全规范

4. 可维护性

✅ 统一数据来源(Profile 表)
✅ 字段优先级清晰
✅ 详细的日志输出
✅ 完善的错误处理

🧪 九、测试步骤

1. 加载员工列表

1. 打开人事板块 → 员工档案
2. 查看控制台日志
   ✅ "📋 [员工档案] 开始加载员工数据..."
   ✅ "✅ [员工档案] 查询到 X 个员工"
   ✅ "✅ [员工档案] 员工数据加载完成,共 X 个"
3. 验证表格数据显示
   - 姓名、部门、职位
   - 工号、手机号、邮箱
   - 身份证号(脱敏)
   - 银行卡号(脱敏)
   - 入职日期、状态

2. 测试敏感信息展开/收起

1. 默认状态:身份证号和银行卡号脱敏
2. 点击行右侧的眼睛图标
3. 验证:完整信息显示
4. 再次点击:恢复脱敏显示

3. 测试新增员工

1. 点击"+ 创建档案"按钮
2. 填写表单(包括工号、身份证号、银行卡号)
3. 点击"保存"
4. 验证:
   ✅ 控制台显示"✅ [员工档案] 员工添加成功"
   ✅ 列表自动刷新,新员工出现
   ✅ Profile 表中新增记录
   ✅ data.hrData 字段正确保存

4. 测试编辑员工

1. 点击员工行的"编辑"按钮
2. 修改信息(如工号、手机号等)
3. 点击"保存"
4. 验证:
   ✅ 控制台显示"✅ [员工档案] 员工信息更新成功"
   ✅ 列表自动刷新,显示最新信息
   ✅ Profile 表中数据已更新

5. 测试筛选功能

1. 在搜索框输入员工姓名
2. 选择部门筛选
3. 选择职位筛选
4. 选择状态筛选
5. 验证:列表正确过滤

🚨 十、注意事项

1. 数据迁移

⚠️ 现有 Profile 记录没有 hrData 字段
⚠️ 需要人工或脚本批量补充
⚠️ 无 hrData 时字段显示为空

建议:
- 分批次补充员工 HR 数据
- 优先补充在职员工
- 可以通过编辑功能逐个补充

2. 权限控制

⚠️ 敏感信息(身份证、银行卡)需要权限控制
⚠️ 当前所有登录用户都可以查看

建议:
- 添加角色权限检查
- 只有 HR 和管理员可以查看完整信息
- 其他角色只能看脱敏信息

3. 数据验证

⚠️ 当前没有强制校验身份证号和银行卡号格式

建议:
- 添加身份证号格式验证(18位数字)
- 添加银行卡号格式验证(16-19位数字)
- 添加手机号格式验证(11位数字)

4. 性能优化

⚠️ 当前一次性加载所有员工(limit 1000)

建议:
- 员工数量超过100时,考虑分页加载
- 添加虚拟滚动
- 添加缓存机制

📚 十一、相关文档

1. Profile 表结构说明
   - 位置:数据库设计文档

2. 企微数据同步机制
   - 位置:ProfileService 文档

3. HR 模块整体架构
   - 位置:人事系统设计文档

🎯 十二、后续优化

优先级 P0(必须)

✅ 已完成:对接 Profile 表真实数据
✅ 已完成:新增 HR 专用字段
✅ 已完成:敏感信息脱敏

优先级 P1(重要)

🚧 数据批量导入功能
🚧 身份证号和银行卡号格式验证
🚧 HR 角色权限控制
🚧 离职流程完善

优先级 P2(次要)

⏳ 员工照片上传
⏳ 合同管理
⏳ 考勤记录
⏳ 薪资管理
⏳ 绩效评估

文档版本:v1.0
最后更新:2025-11-19 01:50
维护人:Cascade AI Assistant
状态:✅ 已完成对接真实数据