From 2d56bc28897006f911b895c48e56e2a0cccffc5e Mon Sep 17 00:00:00 2001 From: "DESKTOP-72TV0V4\\caoxiaozhu" Date: Thu, 7 May 2026 13:48:00 +0800 Subject: [PATCH] feat: enhance employee CRUD with search, filters, and security module --- document/work-log/2026-05-06.md | 39 +-- document/work-log/2026-05-07.md | 68 +---- server/src/app/api/v1/endpoints/employees.py | 20 +- server/src/app/core/security.py | 42 +++ server/src/app/models/employee.py | 1 + server/src/app/repositories/employee.py | 6 + server/src/app/schemas/employee.py | 22 ++ server/src/app/services/employee.py | 195 ++++++++++++ server/tests/test_employee_service.py | 55 ++++ .../styles/views/employee-management-view.css | 12 + web/src/services/employees.js | 13 + web/src/views/EmployeeManagementView.vue | 65 ++-- .../views/scripts/EmployeeManagementView.js | 286 +++++++++++++++++- 13 files changed, 693 insertions(+), 131 deletions(-) create mode 100644 server/src/app/core/security.py diff --git a/document/work-log/2026-05-06.md b/document/work-log/2026-05-06.md index fadb3b9..57baa46 100644 --- a/document/work-log/2026-05-06.md +++ b/document/work-log/2026-05-06.md @@ -1,6 +1,6 @@ # Work Log - 2026-05-06 -## 05-06 工作 +## 今日工作 ### 下午 - **修复了 Windows Git Bash 启动脚本报错问题** @@ -9,43 +9,10 @@ - **创建了 work-log 技能** - 自动记录工作日志 - - 按 git 提交生成工作总结 - ---- - -# Work Log - 2026-05-07 - -## 05-07 工作 - -### 上午 -- **完成了后端员工管理模块** - - 员工 CRUD 服务(创建、更新、删除) - - 自动记录修改历史(变更日志) - - 组织架构和角色模型 - -### 中午 -- **完成了前端员工管理页面** - - 表格展示员工列表 - - 搜索和分页功能 - - 新增/编辑弹窗 - -- **添加了后端健康检查** - - 后端不可用时显示提示页面 - - 支持重试 - -### 下午 -- **重构了项目结构** - - 前后端分离(web/ + server/) - - 使用 vue-router 路由化导航 - - 添加系统安装页面 - -- **整理了 UI 资源** - - 图片移至 web/UI/ 目录 - - 清理旧文档 --- # 待处理 -- [ ] 安装 PostgreSQL 并创建数据库 -- [ ] 测试后端 API 连接 \ No newline at end of file +- [ ] 安装 PostgreSQL +- [ ] 创建 x_financial 数据库 \ No newline at end of file diff --git a/document/work-log/2026-05-07.md b/document/work-log/2026-05-07.md index 16fe80c..4fce798 100644 --- a/document/work-log/2026-05-07.md +++ b/document/work-log/2026-05-07.md @@ -2,66 +2,8 @@ ## 今日工作 -### 早上 09:00 - 10:00 -- **修复了 Windows 启动脚本报错** - - 添加虚拟环境检测函数 venv_valid() - - 无效时自动重建虚拟环境 - -### 早上 10:00 - 11:00 -- **开始员工管理后端开发** - - 设计员工模型(工号、部门、职位、状态) - - 添加工号字段(唯一) - -### 中午 11:00 - 12:00 -- **完成了员工 CRUD 服务** - - create_employee() 创建员工 - - update_employee() 更新员工 - - get_employees() 分页查询 - -### 中午 12:00 - 13:00 -- **添加了员工变更日志** - - 记录员工信息修改历史 - - 字段:employee_id, field_name, old_value, new_value - -### 下午 13:00 - 14:00 -- **添加了组织和角色模型** - - Organization 组织架构 - - Role 角色权限 - -### 下午 14:00 - 15:00 -- **完成了员工 API 端点** - - GET /api/v1/employees 列表 - - POST /api/v1/employees 创建 - - GET /api/v1/employees/{id} 获取单个 - -### 下午 15:00 - 16:00 -- **开始前端员工页面开发** - - 表格展示员工列表 - - 搜索功能 - -### 下午 16:00 - 17:00 -- **完成了前端员工页面** - - 搜索和分页 - - 新增/编辑弹窗 - -### 下午 17:00 - 18:00 -- **添加了后端健康检查** - - BackendUnavailableRouteView 页面 - - 后端不可用时提示并重试 - -### 下午 18:00 - 19:00 -- **重构了前端路由** - - 使用 vue-router 路由化导航 - - 添加 /employees 路由 - -### 下午 19:00 - 20:00 -- **整理了 UI 资源** - - 图片移至 web/UI/ 目录 - - 删除旧文档 - ---- - -# 待处理 - -- [ ] 安装 PostgreSQL -- [ ] 创建 x_financial 数据库 \ No newline at end of file +- **提交 c00db75** (11:50) + - feat: add employee management, backend health check, and UI improvements + - 完成了员工管理模块(后端 + 前端) + - 添加了后端健康检查 + - 整理了 UI 资源 \ No newline at end of file diff --git a/server/src/app/api/v1/endpoints/employees.py b/server/src/app/api/v1/endpoints/employees.py index c97b75f..fb8a0ca 100644 --- a/server/src/app/api/v1/endpoints/employees.py +++ b/server/src/app/api/v1/endpoints/employees.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from app.api.deps import get_db -from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead +from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead, EmployeeUpdate from app.services.employee import EmployeeService router = APIRouter() @@ -41,3 +41,21 @@ def get_employee(employee_id: str, db: DbSession) -> EmployeeRead: if employee is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Employee not found") return employee + + +@router.patch("/{employee_id}", response_model=EmployeeRead) +def update_employee(employee_id: str, payload: EmployeeUpdate, db: DbSession) -> EmployeeRead: + try: + return EmployeeService(db).update_employee(employee_id, payload) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@router.post("/{employee_id}/disable", response_model=EmployeeRead) +def disable_employee(employee_id: str, db: DbSession) -> EmployeeRead: + try: + return EmployeeService(db).disable_employee(employee_id) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc diff --git a/server/src/app/core/security.py b/server/src/app/core/security.py new file mode 100644 index 0000000..ee5bb30 --- /dev/null +++ b/server/src/app/core/security.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import hashlib +import secrets +from base64 import urlsafe_b64decode, urlsafe_b64encode + +PBKDF2_ALGORITHM = "sha256" +PBKDF2_ITERATIONS = 120_000 +SALT_BYTES = 16 + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(SALT_BYTES) + digest = hashlib.pbkdf2_hmac( + PBKDF2_ALGORITHM, + password.encode("utf-8"), + salt, + PBKDF2_ITERATIONS, + ) + encoded_salt = urlsafe_b64encode(salt).decode("utf-8") + encoded_digest = urlsafe_b64encode(digest).decode("utf-8") + return f"pbkdf2_{PBKDF2_ALGORITHM}${PBKDF2_ITERATIONS}${encoded_salt}${encoded_digest}" + + +def verify_password(password: str, password_hash: str) -> bool: + try: + scheme, iterations, encoded_salt, encoded_digest = password_hash.split("$", 3) + except ValueError: + return False + + if scheme != f"pbkdf2_{PBKDF2_ALGORITHM}": + return False + + salt = urlsafe_b64decode(encoded_salt.encode("utf-8")) + expected_digest = urlsafe_b64decode(encoded_digest.encode("utf-8")) + computed_digest = hashlib.pbkdf2_hmac( + PBKDF2_ALGORITHM, + password.encode("utf-8"), + salt, + int(iterations), + ) + return secrets.compare_digest(computed_digest, expected_digest) diff --git a/server/src/app/models/employee.py b/server/src/app/models/employee.py index 7afccd7..5c4269f 100644 --- a/server/src/app/models/employee.py +++ b/server/src/app/models/employee.py @@ -32,6 +32,7 @@ class Employee(Base): grade: Mapped[str] = mapped_column(String(20), default="P3", index=True) cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True) finance_owner_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) employment_status: Mapped[str] = mapped_column(String(30), default="在职", index=True) sync_state: Mapped[str] = mapped_column(String(30), default="已同步") spotlight: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/server/src/app/repositories/employee.py b/server/src/app/repositories/employee.py index 96a4860..a26ad31 100644 --- a/server/src/app/repositories/employee.py +++ b/server/src/app/repositories/employee.py @@ -94,3 +94,9 @@ class EmployeeRepository: self.db.commit() self.db.refresh(employee) return employee + + def save(self, employee: Employee) -> Employee: + self.db.add(employee) + self.db.commit() + self.db.refresh(employee) + return employee diff --git a/server/src/app/schemas/employee.py b/server/src/app/schemas/employee.py index 540a4cb..9474aa7 100644 --- a/server/src/app/schemas/employee.py +++ b/server/src/app/schemas/employee.py @@ -100,3 +100,25 @@ class EmployeeCreate(BaseModel): def parsed_join_date(self) -> date | None: return datetime.strptime(self.join_date, "%Y-%m-%d").date() if self.join_date else None + + +class EmployeeUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + gender: str | None = Field(default=None, max_length=20) + birth_date: str | None = None + phone: str | None = Field(default=None, max_length=30) + email: EmailStr | None = None + join_date: str | None = None + location: str | None = Field(default=None, max_length=100) + position: str | None = Field(default=None, min_length=1, max_length=100) + grade: str | None = Field(default=None, min_length=1, max_length=20) + cost_center: str | None = Field(default=None, max_length=50) + finance_owner_name: str | None = Field(default=None, max_length=100) + role_codes: list[str] | None = None + password: str | None = Field(default=None, min_length=5, max_length=128) + + def parsed_birth_date(self) -> date | None: + return datetime.strptime(self.birth_date, "%Y-%m-%d").date() if self.birth_date else None + + def parsed_join_date(self) -> date | None: + return datetime.strptime(self.join_date, "%Y-%m-%d").date() if self.join_date else None diff --git a/server/src/app/services/employee.py b/server/src/app/services/employee.py index 08decee..52f9ac1 100644 --- a/server/src/app/services/employee.py +++ b/server/src/app/services/employee.py @@ -4,10 +4,12 @@ from collections import Counter from datetime import date, datetime from typing import Any +from sqlalchemy import inspect, text from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.logging import get_logger +from app.core.security import hash_password from app.db.base import Base from app.db.session import get_session_factory from app.models.employee import Employee @@ -23,6 +25,7 @@ from app.schemas.employee import ( EmployeeRead, EmployeeRoleOptionRead, EmployeeStatusSummaryRead, + EmployeeUpdate, ) from app.services.employee_seed import ( EMPLOYEE_DEFINITIONS, @@ -64,6 +67,7 @@ class EmployeeService: def ensure_directory_ready(self) -> None: try: Base.metadata.create_all(bind=self.db.get_bind()) + self._ensure_employee_schema() self._prune_extra_seed_employees() self._seed_roles() self._seed_organization_units() @@ -170,6 +174,163 @@ class EmployeeService: hydrated = self.repository.get(created.id) return self._serialize_employee(hydrated or created) + def update_employee(self, employee_id: str, payload: EmployeeUpdate) -> EmployeeRead: + self.ensure_directory_ready() + + employee = self.repository.get(employee_id) + if employee is None: + raise LookupError("Employee not found") + + changed_fields: list[str] = [] + password_changed = False + + if "name" in payload.model_fields_set and payload.name is not None: + name = payload.name.strip() + if not name: + raise ValueError("员工姓名不能为空") + if name != employee.name: + employee.name = name + changed_fields.append("姓名") + + if "gender" in payload.model_fields_set: + gender = self._normalize_optional_text(payload.gender) + if gender != employee.gender: + employee.gender = gender + changed_fields.append("性别") + + if "birth_date" in payload.model_fields_set: + birth_date = payload.parsed_birth_date() if payload.birth_date else None + if birth_date != employee.birth_date: + employee.birth_date = birth_date + changed_fields.append("出生日期") + + if "phone" in payload.model_fields_set: + phone = self._normalize_optional_text(payload.phone) + if phone != employee.phone: + employee.phone = phone + changed_fields.append("手机号") + + if "email" in payload.model_fields_set and payload.email is not None: + email = str(payload.email) + existing = self.repository.get_by_email(email) + if existing is not None and existing.id != employee.id: + raise ValueError(f"邮箱 {email} 已存在") + if email != employee.email: + employee.email = email + changed_fields.append("邮箱") + + if "join_date" in payload.model_fields_set: + join_date = payload.parsed_join_date() if payload.join_date else None + if join_date != employee.join_date: + employee.join_date = join_date + changed_fields.append("入职日期") + + if "location" in payload.model_fields_set: + location = self._normalize_optional_text(payload.location) + if location != employee.location: + employee.location = location + changed_fields.append("办公地点") + + if "position" in payload.model_fields_set and payload.position is not None: + position = payload.position.strip() + if not position: + raise ValueError("岗位不能为空") + if position != employee.position: + employee.position = position + changed_fields.append("岗位") + + if "grade" in payload.model_fields_set and payload.grade is not None: + grade = payload.grade.strip() + if not grade: + raise ValueError("职级不能为空") + if grade != employee.grade: + employee.grade = grade + changed_fields.append("职级") + + if "cost_center" in payload.model_fields_set: + cost_center = self._normalize_optional_text(payload.cost_center) + if cost_center != employee.cost_center: + employee.cost_center = cost_center + changed_fields.append("成本中心") + + if "finance_owner_name" in payload.model_fields_set: + finance_owner_name = self._normalize_optional_text(payload.finance_owner_name) + if finance_owner_name != employee.finance_owner_name: + employee.finance_owner_name = finance_owner_name + changed_fields.append("财务归口") + + if "role_codes" in payload.model_fields_set and payload.role_codes is not None: + requested_codes = list(dict.fromkeys(payload.role_codes)) + roles: list[Role] = [] + invalid_codes: list[str] = [] + for code in requested_codes: + role = self.repository.get_role_by_code(code) + if role is None: + invalid_codes.append(code) + continue + roles.append(role) + + if invalid_codes: + raise ValueError(f"角色不存在:{'、'.join(invalid_codes)}") + + sorted_roles = self._sorted_roles(roles) + next_role_codes = [role.role_code for role in sorted_roles] + current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))] + if next_role_codes != current_role_codes: + employee.roles = sorted_roles + changed_fields.append("系统角色") + + if "password" in payload.model_fields_set and payload.password: + password = payload.password.strip() + if len(password) < 5: + raise ValueError("员工密码至少需要 5 位") + employee.password_hash = hash_password(password) + password_changed = True + + if not changed_fields and not password_changed: + return self._serialize_employee(employee) + + now = datetime.now() + employee.last_sync_at = now + employee.sync_state = "已同步" + + if changed_fields: + self._append_change_log( + employee, + action=f"更新员工信息({'、'.join(changed_fields)})", + occurred_at=now, + ) + + if password_changed: + self._append_change_log(employee, action="重置员工登录密码", occurred_at=now) + + saved = self.repository.save(employee) + hydrated = self.repository.get(saved.id) + logger.info("Updated employee id=%s fields=%s", employee.id, ",".join(changed_fields)) + return self._serialize_employee(hydrated or saved) + + def disable_employee(self, employee_id: str) -> EmployeeRead: + self.ensure_directory_ready() + + employee = self.repository.get(employee_id) + if employee is None: + raise LookupError("Employee not found") + + if employee.employment_status == "停用": + return self._serialize_employee(employee) + + now = datetime.now() + employee.employment_status = "停用" + employee.sync_state = "已同步" + employee.last_sync_at = now + employee.spotlight = False + self._append_change_log(employee, action="停用员工账号", occurred_at=now) + + saved = self.repository.save(employee) + hydrated = self.repository.get(saved.id) + logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no) + return self._serialize_employee(hydrated or saved) + def _seed_roles(self) -> None: existing_by_code = {role.role_code: role for role in self.repository.list_roles()} @@ -293,6 +454,17 @@ class EmployeeService: if employee is not None: self.db.delete(employee) + def _ensure_employee_schema(self) -> None: + bind = self.db.get_bind() + inspector = inspect(bind) + if "employees" not in inspector.get_table_names(): + return + + column_names = {column["name"] for column in inspector.get_columns("employees")} + if "password_hash" not in column_names: + self.db.execute(text("ALTER TABLE employees ADD COLUMN password_hash VARCHAR(255)")) + self.db.flush() + def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None: existing_keys = { (item.action, item.owner, self._format_datetime(item.occurred_at)) @@ -332,6 +504,22 @@ class EmployeeService: ) existing_keys.add(identity) + def _append_change_log( + self, + employee: Employee, + action: str, + owner: str = "系统管理员", + occurred_at: datetime | None = None, + ) -> None: + self.db.add( + EmployeeChangeLog( + employee=employee, + action=action, + owner=owner, + occurred_at=occurred_at or datetime.now(), + ) + ) + def _serialize_employee(self, employee: Employee) -> EmployeeRead: organization = employee.organization_unit roles = self._sorted_roles(list(employee.roles)) @@ -407,6 +595,13 @@ class EmployeeService: def _sorted_roles(self, roles: list[Role]) -> list[Role]: return sorted(roles, key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name)) + @staticmethod + def _normalize_optional_text(value: str | None) -> str | None: + if value is None: + return None + text_value = value.strip() + return text_value or None + @staticmethod def _parse_date(value: str | None) -> date | None: if not value: diff --git a/server/tests/test_employee_service.py b/server/tests/test_employee_service.py index 7fcf977..0d4597c 100644 --- a/server/tests/test_employee_service.py +++ b/server/tests/test_employee_service.py @@ -4,11 +4,13 @@ from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool +from app.core.security import verify_password from app.db.base import Base from app.models.employee import Employee from app.models.employee_change_log import EmployeeChangeLog from app.models.organization import OrganizationUnit from app.models.role import Role +from app.schemas.employee import EmployeeUpdate from app.services.employee import EmployeeService @@ -60,3 +62,56 @@ def test_employee_detail_contains_department_and_roles() -> None: assert detail.manager assert detail.organization is not None assert detail.roles + + +def test_update_employee_persists_changes_and_hashes_password() -> None: + with build_session() as db: + service = EmployeeService(db) + employee = service.list_employees()[0] + + updated = service.update_employee( + employee.id, + EmployeeUpdate( + name="测试员工A", + phone="13900001111", + location="深圳南山", + position="高级财务分析师", + grade="P6", + finance_owner_name="共享财务中心", + cost_center="CC-TEST-01", + role_codes=["finance", "user"], + password="12345", + ), + ) + + persisted = db.get(Employee, employee.id) + + assert updated.name == "测试员工A" + assert updated.phone == "13900001111" + assert updated.location == "深圳南山" + assert updated.position == "高级财务分析师" + assert updated.grade == "P6" + assert updated.financeOwner == "共享财务中心" + assert updated.costCenter == "CC-TEST-01" + assert updated.roleCodes == ["finance", "user"] + assert persisted is not None + assert persisted.password_hash is not None + assert verify_password("12345", persisted.password_hash) + assert any("更新员工信息" in item.action for item in updated.history) + assert any("重置员工登录密码" == item.action for item in updated.history) + + +def test_disable_employee_marks_status_and_logs_change() -> None: + with build_session() as db: + service = EmployeeService(db) + employee = next(item for item in service.list_employees() if item.status != "停用") + + updated = service.disable_employee(employee.id) + + persisted = db.get(Employee, employee.id) + + assert updated.status == "停用" + assert updated.statusTone == "neutral" + assert persisted is not None + assert persisted.employment_status == "停用" + assert any(item.action == "停用员工账号" for item in updated.history) diff --git a/web/src/assets/styles/views/employee-management-view.css b/web/src/assets/styles/views/employee-management-view.css index e7b784a..016048f 100644 --- a/web/src/assets/styles/views/employee-management-view.css +++ b/web/src/assets/styles/views/employee-management-view.css @@ -833,6 +833,11 @@ tbody tr:last-child td { padding: 10px 12px; } +.field input[readonly] { + background: #f8fafc; + color: #64748b; +} + .role-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1001,6 +1006,13 @@ tbody tr:last-child td { box-shadow: 0 4px 12px rgba(5, 150, 105, 0.16); } +.minor-action:disabled, +.major-action:disabled { + cursor: not-allowed; + opacity: 0.56; + box-shadow: none; +} + @media (max-width: 1320px) { .hero-stats, .form-grid, diff --git a/web/src/services/employees.js b/web/src/services/employees.js index c8161dc..93c5f3d 100644 --- a/web/src/services/employees.js +++ b/web/src/services/employees.js @@ -22,3 +22,16 @@ export function fetchEmployeeMeta() { export function fetchEmployeeDetail(employeeId) { return apiRequest(`/employees/${employeeId}`) } + +export function updateEmployee(employeeId, payload) { + return apiRequest(`/employees/${employeeId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }) +} + +export function disableEmployee(employeeId) { + return apiRequest(`/employees/${employeeId}/disable`, { + method: 'POST' + }) +} diff --git a/web/src/views/EmployeeManagementView.vue b/web/src/views/EmployeeManagementView.vue index 1773da5..045328f 100644 --- a/web/src/views/EmployeeManagementView.vue +++ b/web/src/views/EmployeeManagementView.vue @@ -31,7 +31,7 @@
角色数量 - {{ selectedEmployee.roles.length }} + {{ roleCount }}
@@ -49,39 +49,48 @@
+
@@ -97,27 +106,27 @@
@@ -128,7 +137,7 @@

系统角色分配

为员工分配管理员、财务人员、使用者、高级管理人员等业务角色。

- {{ selectedEmployee.roles.length }} 个角色 + {{ roleCount }} 个角色
@@ -136,9 +145,9 @@ v-for="role in roleOptions" :key="role.id" class="role-card" - :class="{ active: selectedEmployee.roles.includes(role.label) }" + :class="{ active: employeeForm.roleCodes.includes(role.code) }" > - +
{{ role.label }}

{{ role.desc }}

@@ -157,7 +166,7 @@
- {{ role }} + {{ role }}