feat: enhance employee CRUD with search, filters, and security module
This commit is contained in:
@@ -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 连接
|
||||
- [ ] 安装 PostgreSQL
|
||||
- [ ] 创建 x_financial 数据库
|
||||
@@ -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 数据库
|
||||
- **提交 c00db75** (11:50)
|
||||
- feat: add employee management, backend health check, and UI improvements
|
||||
- 完成了员工管理模块(后端 + 前端)
|
||||
- 添加了后端健康检查
|
||||
- 整理了 UI 资源
|
||||
@@ -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
|
||||
|
||||
42
server/src/app/core/security.py
Normal file
42
server/src/app/core/security.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span>角色数量</span>
|
||||
<strong>{{ selectedEmployee.roles.length }}</strong>
|
||||
<strong>{{ roleCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -49,39 +49,48 @@
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>员工姓名</span>
|
||||
<input :value="selectedEmployee.name" />
|
||||
<input v-model="employeeForm.name" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>员工编号</span>
|
||||
<input :value="selectedEmployee.employeeNo" />
|
||||
<input v-model="employeeForm.employeeNo" readonly />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>性别</span>
|
||||
<input :value="selectedEmployee.gender" />
|
||||
<input v-model="employeeForm.gender" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>年龄</span>
|
||||
<input :value="selectedEmployee.age" />
|
||||
<input :value="detailAge" readonly />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>出生日期</span>
|
||||
<input :value="selectedEmployee.birthDate" />
|
||||
<input v-model="employeeForm.birthDate" type="date" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>手机号</span>
|
||||
<input :value="selectedEmployee.phone" />
|
||||
<input v-model="employeeForm.phone" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>邮箱</span>
|
||||
<input :value="selectedEmployee.email" />
|
||||
<input v-model="employeeForm.email" type="email" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>密码设置</span>
|
||||
<input
|
||||
v-model="employeeForm.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="留空则不修改"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>入职日期</span>
|
||||
<input :value="selectedEmployee.joinDate" />
|
||||
<input v-model="employeeForm.joinDate" type="date" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>办公地点</span>
|
||||
<input :value="selectedEmployee.location" />
|
||||
<input v-model="employeeForm.location" />
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
@@ -97,27 +106,27 @@
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>所属部门</span>
|
||||
<input :value="selectedEmployee.department" />
|
||||
<input v-model="employeeForm.department" readonly />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>岗位</span>
|
||||
<input :value="selectedEmployee.position" />
|
||||
<input v-model="employeeForm.position" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>职级</span>
|
||||
<input :value="selectedEmployee.grade" />
|
||||
<input v-model="employeeForm.grade" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>直属上级</span>
|
||||
<input :value="selectedEmployee.manager" />
|
||||
<input v-model="employeeForm.manager" readonly />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>财务归口</span>
|
||||
<input :value="selectedEmployee.financeOwner" />
|
||||
<input v-model="employeeForm.financeOwner" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>成本中心</span>
|
||||
<input :value="selectedEmployee.costCenter" />
|
||||
<input v-model="employeeForm.costCenter" />
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
@@ -128,7 +137,7 @@
|
||||
<h3>系统角色分配</h3>
|
||||
<p>为员工分配管理员、财务人员、使用者、高级管理人员等业务角色。</p>
|
||||
</div>
|
||||
<span class="count-badge">{{ selectedEmployee.roles.length }} 个角色</span>
|
||||
<span class="count-badge">{{ roleCount }} 个角色</span>
|
||||
</div>
|
||||
|
||||
<div class="role-grid">
|
||||
@@ -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) }"
|
||||
>
|
||||
<input type="checkbox" :checked="selectedEmployee.roles.includes(role.label)" />
|
||||
<input v-model="employeeForm.roleCodes" type="checkbox" :value="role.code" />
|
||||
<div class="role-copy">
|
||||
<strong>{{ role.label }}</strong>
|
||||
<p>{{ role.desc }}</p>
|
||||
@@ -157,7 +166,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag-list">
|
||||
<span v-for="role in selectedEmployee.roles" :key="role">{{ role }}</span>
|
||||
<span v-for="role in selectedRoleLabels" :key="role">{{ role }}</span>
|
||||
</div>
|
||||
<ul class="bullet-list">
|
||||
<li v-for="item in selectedEmployee.permissions" :key="item">{{ item }}</li>
|
||||
@@ -195,23 +204,19 @@
|
||||
</div>
|
||||
|
||||
<footer class="detail-actions">
|
||||
<button class="back-action" type="button" @click="selectedEmployee = null">
|
||||
<button class="back-action" type="button" @click="closeEmployeeDetail">
|
||||
<i class="mdi mdi-arrow-left"></i>
|
||||
<span>返回员工列表</span>
|
||||
</button>
|
||||
|
||||
<div class="detail-action-group">
|
||||
<button class="minor-action" type="button">
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>保存草稿</span>
|
||||
</button>
|
||||
<button class="minor-action" type="button">
|
||||
<button class="minor-action" type="button" :disabled="disableActionDisabled" @click="disableEmployeeAccount">
|
||||
<i class="mdi mdi-account-cancel-outline"></i>
|
||||
<span>停用账号</span>
|
||||
<span>{{ selectedEmployee.status === '停用' ? '账号已停用' : actionState === 'disable' ? '停用中...' : '停用账号' }}</span>
|
||||
</button>
|
||||
<button class="major-action" type="button">
|
||||
<button class="major-action" type="button" :disabled="actionBusy" @click="saveEmployeeChanges">
|
||||
<i class="mdi mdi-check-circle-outline"></i>
|
||||
<span>保存并生效</span>
|
||||
<span>{{ actionState === 'save' ? '保存中...' : '保存并生效' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -450,7 +455,7 @@
|
||||
v-for="employee in visibleEmployees"
|
||||
:key="employee.id"
|
||||
:class="{ spotlight: employee.spotlight }"
|
||||
@click="selectedEmployee = employee"
|
||||
@click="openEmployeeDetail(employee)"
|
||||
>
|
||||
<td>
|
||||
<div class="employee-cell">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { fetchEmployeeMeta, fetchEmployees } from '../../services/employees.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { disableEmployee, fetchEmployeeMeta, fetchEmployees, updateEmployee } from '../../services/employees.js'
|
||||
|
||||
const DEFAULT_STATUS_TABS = ['全部员工', '在职', '试用中', '停用']
|
||||
const FALLBACK_ROLE_OPTIONS = [
|
||||
@@ -42,6 +43,92 @@ const FALLBACK_ROLE_OPTIONS = [
|
||||
}
|
||||
]
|
||||
|
||||
function createEmployeeForm() {
|
||||
return {
|
||||
name: '',
|
||||
employeeNo: '',
|
||||
gender: '',
|
||||
birthDate: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
joinDate: '',
|
||||
location: '',
|
||||
position: '',
|
||||
grade: '',
|
||||
department: '',
|
||||
manager: '',
|
||||
financeOwner: '',
|
||||
costCenter: '',
|
||||
roleCodes: [],
|
||||
password: ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmployeeForm(employee) {
|
||||
if (!employee) {
|
||||
return createEmployeeForm()
|
||||
}
|
||||
|
||||
return {
|
||||
name: employee.name || '',
|
||||
employeeNo: employee.employeeNo || '',
|
||||
gender: employee.gender || '',
|
||||
birthDate: employee.birthDate || '',
|
||||
phone: employee.phone || '',
|
||||
email: employee.email || '',
|
||||
joinDate: employee.joinDate || '',
|
||||
location: employee.location || '',
|
||||
position: employee.position || '',
|
||||
grade: employee.grade || '',
|
||||
department: employee.department || '',
|
||||
manager: employee.manager || '',
|
||||
financeOwner: employee.financeOwner || '',
|
||||
costCenter: employee.costCenter || '',
|
||||
roleCodes: [...(employee.roleCodes || [])],
|
||||
password: ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function normalizeNullableText(value) {
|
||||
const text = normalizeText(value)
|
||||
return text || null
|
||||
}
|
||||
|
||||
function sameValues(left, right) {
|
||||
if (left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function calculateAgeFromDate(dateString) {
|
||||
if (!dateString) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const birthDate = new Date(`${dateString}T00:00:00`)
|
||||
if (Number.isNaN(birthDate.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birthDate.getFullYear()
|
||||
const hasBirthdayPassed =
|
||||
today.getMonth() > birthDate.getMonth() ||
|
||||
(today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate())
|
||||
|
||||
if (!hasBirthdayPassed) {
|
||||
age -= 1
|
||||
}
|
||||
|
||||
return age >= 0 ? String(age) : ''
|
||||
}
|
||||
|
||||
function matchKeyword(employee, keyword) {
|
||||
if (!keyword) {
|
||||
return true
|
||||
@@ -114,8 +201,10 @@ export default {
|
||||
name: 'EmployeeManagementView',
|
||||
emits: ['overview-change'],
|
||||
setup(_, { emit }) {
|
||||
const { toast } = useToast()
|
||||
const activeTab = ref(DEFAULT_STATUS_TABS[0])
|
||||
const selectedEmployee = ref(null)
|
||||
const employeeForm = ref(createEmployeeForm())
|
||||
const roleOptions = ref([...FALLBACK_ROLE_OPTIONS])
|
||||
const employees = ref([])
|
||||
const searchKeyword = ref('')
|
||||
@@ -127,11 +216,26 @@ export default {
|
||||
const pageSize = ref(10)
|
||||
const pageSizes = [10, 20, 50]
|
||||
const pageSizeOpen = ref(false)
|
||||
const actionState = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const tabs = computed(() => buildStatusTabs(employees.value))
|
||||
const employeeSummary = computed(() => buildEmployeeSummary(employees.value))
|
||||
const detailAge = computed(() => calculateAgeFromDate(employeeForm.value.birthDate))
|
||||
const roleCount = computed(() => employeeForm.value.roleCodes.length)
|
||||
const selectedRoleLabels = computed(() =>
|
||||
roleOptions.value
|
||||
.filter((role) => employeeForm.value.roleCodes.includes(role.code))
|
||||
.map((role) => role.label)
|
||||
)
|
||||
const actionBusy = computed(() => actionState.value === 'save' || actionState.value === 'disable')
|
||||
const disableActionDisabled = computed(
|
||||
() =>
|
||||
actionBusy.value ||
|
||||
!selectedEmployee.value ||
|
||||
selectedEmployee.value.status === '停用'
|
||||
)
|
||||
|
||||
const departmentOptions = computed(() =>
|
||||
uniqueSorted(employees.value.map((item) => item.department))
|
||||
@@ -211,6 +315,14 @@ export default {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
selectedEmployee,
|
||||
(employee) => {
|
||||
employeeForm.value = buildEmployeeForm(employee)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(filteredEmployees, () => {
|
||||
currentPage.value = 1
|
||||
pageSizeOpen.value = false
|
||||
@@ -282,6 +394,167 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function openEmployeeDetail(employee) {
|
||||
selectedEmployee.value = employee
|
||||
}
|
||||
|
||||
function closeEmployeeDetail() {
|
||||
selectedEmployee.value = null
|
||||
employeeForm.value = createEmployeeForm()
|
||||
actionState.value = ''
|
||||
}
|
||||
|
||||
function buildUpdatePayload() {
|
||||
const current = selectedEmployee.value
|
||||
const form = employeeForm.value
|
||||
const payload = {}
|
||||
|
||||
if (!current) {
|
||||
return payload
|
||||
}
|
||||
|
||||
const nextName = normalizeText(form.name)
|
||||
if (nextName && nextName !== current.name) {
|
||||
payload.name = nextName
|
||||
}
|
||||
|
||||
const nextGender = normalizeNullableText(form.gender)
|
||||
if (nextGender !== (current.gender || null)) {
|
||||
payload.gender = nextGender
|
||||
}
|
||||
|
||||
const nextBirthDate = normalizeNullableText(form.birthDate)
|
||||
if (nextBirthDate !== (current.birthDate || null)) {
|
||||
payload.birth_date = nextBirthDate
|
||||
}
|
||||
|
||||
const nextPhone = normalizeNullableText(form.phone)
|
||||
if (nextPhone !== (current.phone || null)) {
|
||||
payload.phone = nextPhone
|
||||
}
|
||||
|
||||
const nextEmail = normalizeText(form.email)
|
||||
if (nextEmail && nextEmail !== current.email) {
|
||||
payload.email = nextEmail
|
||||
}
|
||||
|
||||
const nextJoinDate = normalizeNullableText(form.joinDate)
|
||||
if (nextJoinDate !== (current.joinDate || null)) {
|
||||
payload.join_date = nextJoinDate
|
||||
}
|
||||
|
||||
const nextLocation = normalizeNullableText(form.location)
|
||||
if (nextLocation !== (current.location || null)) {
|
||||
payload.location = nextLocation
|
||||
}
|
||||
|
||||
const nextPosition = normalizeText(form.position)
|
||||
if (nextPosition && nextPosition !== current.position) {
|
||||
payload.position = nextPosition
|
||||
}
|
||||
|
||||
const nextGrade = normalizeText(form.grade)
|
||||
if (nextGrade && nextGrade !== current.grade) {
|
||||
payload.grade = nextGrade
|
||||
}
|
||||
|
||||
const nextFinanceOwner = normalizeNullableText(form.financeOwner)
|
||||
if (nextFinanceOwner !== (current.financeOwner || null)) {
|
||||
payload.finance_owner_name = nextFinanceOwner
|
||||
}
|
||||
|
||||
const nextCostCenter = normalizeNullableText(form.costCenter)
|
||||
if (nextCostCenter !== (current.costCenter || null)) {
|
||||
payload.cost_center = nextCostCenter
|
||||
}
|
||||
|
||||
const nextRoleCodes = [...form.roleCodes].sort()
|
||||
const currentRoleCodes = [...(current.roleCodes || [])].sort()
|
||||
if (!sameValues(nextRoleCodes, currentRoleCodes)) {
|
||||
payload.role_codes = form.roleCodes
|
||||
}
|
||||
|
||||
const nextPassword = normalizeText(form.password)
|
||||
if (nextPassword) {
|
||||
payload.password = nextPassword
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async function saveEmployeeChanges() {
|
||||
if (!selectedEmployee.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalizeText(employeeForm.value.name)) {
|
||||
toast('员工姓名不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalizeText(employeeForm.value.email)) {
|
||||
toast('邮箱不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalizeText(employeeForm.value.position)) {
|
||||
toast('岗位不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalizeText(employeeForm.value.grade)) {
|
||||
toast('职级不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizeText(employeeForm.value.password) && normalizeText(employeeForm.value.password).length < 5) {
|
||||
toast('员工密码至少需要 5 位。')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = buildUpdatePayload()
|
||||
if (!Object.keys(payload).length) {
|
||||
toast('未检测到需要保存的变更。')
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'save'
|
||||
|
||||
try {
|
||||
const updated = await updateEmployee(selectedEmployee.value.id, payload)
|
||||
selectedEmployee.value = updated
|
||||
await loadEmployees()
|
||||
toast('员工信息已保存并生效。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '员工信息保存失败,请稍后重试。')
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function disableEmployeeAccount() {
|
||||
if (!selectedEmployee.value || disableActionDisabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.confirm(`确认停用 ${selectedEmployee.value.name} 的账号吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'disable'
|
||||
|
||||
try {
|
||||
const updated = await disableEmployee(selectedEmployee.value.id)
|
||||
selectedEmployee.value = updated
|
||||
await loadEmployees()
|
||||
toast('员工账号已停用。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '停用账号失败,请稍后重试。')
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
@@ -339,6 +612,13 @@ export default {
|
||||
return {
|
||||
tabs,
|
||||
activeTab,
|
||||
employeeForm,
|
||||
detailAge,
|
||||
roleCount,
|
||||
selectedRoleLabels,
|
||||
actionState,
|
||||
actionBusy,
|
||||
disableActionDisabled,
|
||||
selectedEmployee,
|
||||
roleOptions,
|
||||
employees,
|
||||
@@ -360,6 +640,10 @@ export default {
|
||||
totalCount,
|
||||
totalPages,
|
||||
resetFilters,
|
||||
openEmployeeDetail,
|
||||
closeEmployeeDetail,
|
||||
saveEmployeeChanges,
|
||||
disableEmployeeAccount,
|
||||
changePageSize,
|
||||
togglePageSizeOpen,
|
||||
toggleFilterPopover,
|
||||
|
||||
Reference in New Issue
Block a user