feat: enhance employee CRUD with search, filters, and security module

This commit is contained in:
2026-05-07 13:48:00 +08:00
parent c00db75c13
commit 2d56bc2889
13 changed files with 693 additions and 131 deletions

View File

@@ -1,6 +1,6 @@
# Work Log - 2026-05-06 # Work Log - 2026-05-06
## 05-06 工作 ## 今日工作
### 下午 ### 下午
- **修复了 Windows Git Bash 启动脚本报错问题** - **修复了 Windows Git Bash 启动脚本报错问题**
@@ -9,43 +9,10 @@
- **创建了 work-log 技能** - **创建了 work-log 技能**
- 自动记录工作日志 - 自动记录工作日志
- 按 git 提交生成工作总结
---
# Work Log - 2026-05-07
## 05-07 工作
### 上午
- **完成了后端员工管理模块**
- 员工 CRUD 服务(创建、更新、删除)
- 自动记录修改历史(变更日志)
- 组织架构和角色模型
### 中午
- **完成了前端员工管理页面**
- 表格展示员工列表
- 搜索和分页功能
- 新增/编辑弹窗
- **添加了后端健康检查**
- 后端不可用时显示提示页面
- 支持重试
### 下午
- **重构了项目结构**
- 前后端分离web/ + server/
- 使用 vue-router 路由化导航
- 添加系统安装页面
- **整理了 UI 资源**
- 图片移至 web/UI/ 目录
- 清理旧文档
--- ---
# 待处理 # 待处理
- [ ] 安装 PostgreSQL 并创建数据库 - [ ] 安装 PostgreSQL
- [ ] 测试后端 API 连接 - [ ] 创建 x_financial 数据库

View File

@@ -2,66 +2,8 @@
## 今日工作 ## 今日工作
### 早上 09:00 - 10:00 - **提交 c00db75** (11:50)
- **修复了 Windows 启动脚本报错** - feat: add employee management, backend health check, and UI improvements
- 添加虚拟环境检测函数 venv_valid() - 完成了员工管理模块(后端 + 前端)
- 无效时自动重建虚拟环境 - 添加了后端健康检查
- 整理了 UI 资源
### 早上 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 数据库

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db 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 from app.services.employee import EmployeeService
router = APIRouter() router = APIRouter()
@@ -41,3 +41,21 @@ def get_employee(employee_id: str, db: DbSession) -> EmployeeRead:
if employee is None: if employee is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Employee not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Employee not found")
return employee 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

View 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)

View File

@@ -32,6 +32,7 @@ class Employee(Base):
grade: Mapped[str] = mapped_column(String(20), default="P3", index=True) grade: Mapped[str] = mapped_column(String(20), default="P3", index=True)
cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True) cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True)
finance_owner_name: Mapped[str | None] = mapped_column(String(100), 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) employment_status: Mapped[str] = mapped_column(String(30), default="在职", index=True)
sync_state: Mapped[str] = mapped_column(String(30), default="已同步") sync_state: Mapped[str] = mapped_column(String(30), default="已同步")
spotlight: Mapped[bool] = mapped_column(Boolean, default=False) spotlight: Mapped[bool] = mapped_column(Boolean, default=False)

View File

@@ -94,3 +94,9 @@ class EmployeeRepository:
self.db.commit() self.db.commit()
self.db.refresh(employee) self.db.refresh(employee)
return employee return employee
def save(self, employee: Employee) -> Employee:
self.db.add(employee)
self.db.commit()
self.db.refresh(employee)
return employee

View File

@@ -100,3 +100,25 @@ class EmployeeCreate(BaseModel):
def parsed_join_date(self) -> date | None: def parsed_join_date(self) -> date | None:
return datetime.strptime(self.join_date, "%Y-%m-%d").date() if self.join_date else 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

View File

@@ -4,10 +4,12 @@ from collections import Counter
from datetime import date, datetime from datetime import date, datetime
from typing import Any from typing import Any
from sqlalchemy import inspect, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.core.logging import get_logger from app.core.logging import get_logger
from app.core.security import hash_password
from app.db.base import Base from app.db.base import Base
from app.db.session import get_session_factory from app.db.session import get_session_factory
from app.models.employee import Employee from app.models.employee import Employee
@@ -23,6 +25,7 @@ from app.schemas.employee import (
EmployeeRead, EmployeeRead,
EmployeeRoleOptionRead, EmployeeRoleOptionRead,
EmployeeStatusSummaryRead, EmployeeStatusSummaryRead,
EmployeeUpdate,
) )
from app.services.employee_seed import ( from app.services.employee_seed import (
EMPLOYEE_DEFINITIONS, EMPLOYEE_DEFINITIONS,
@@ -64,6 +67,7 @@ class EmployeeService:
def ensure_directory_ready(self) -> None: def ensure_directory_ready(self) -> None:
try: try:
Base.metadata.create_all(bind=self.db.get_bind()) Base.metadata.create_all(bind=self.db.get_bind())
self._ensure_employee_schema()
self._prune_extra_seed_employees() self._prune_extra_seed_employees()
self._seed_roles() self._seed_roles()
self._seed_organization_units() self._seed_organization_units()
@@ -170,6 +174,163 @@ class EmployeeService:
hydrated = self.repository.get(created.id) hydrated = self.repository.get(created.id)
return self._serialize_employee(hydrated or created) 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: def _seed_roles(self) -> None:
existing_by_code = {role.role_code: role for role in self.repository.list_roles()} 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: if employee is not None:
self.db.delete(employee) 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: def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None:
existing_keys = { existing_keys = {
(item.action, item.owner, self._format_datetime(item.occurred_at)) (item.action, item.owner, self._format_datetime(item.occurred_at))
@@ -332,6 +504,22 @@ class EmployeeService:
) )
existing_keys.add(identity) 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: def _serialize_employee(self, employee: Employee) -> EmployeeRead:
organization = employee.organization_unit organization = employee.organization_unit
roles = self._sorted_roles(list(employee.roles)) roles = self._sorted_roles(list(employee.roles))
@@ -407,6 +595,13 @@ class EmployeeService:
def _sorted_roles(self, roles: list[Role]) -> list[Role]: 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)) 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 @staticmethod
def _parse_date(value: str | None) -> date | None: def _parse_date(value: str | None) -> date | None:
if not value: if not value:

View File

@@ -4,11 +4,13 @@ from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.core.security import verify_password
from app.db.base import Base from app.db.base import Base
from app.models.employee import Employee from app.models.employee import Employee
from app.models.employee_change_log import EmployeeChangeLog from app.models.employee_change_log import EmployeeChangeLog
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.role import Role from app.models.role import Role
from app.schemas.employee import EmployeeUpdate
from app.services.employee import EmployeeService from app.services.employee import EmployeeService
@@ -60,3 +62,56 @@ def test_employee_detail_contains_department_and_roles() -> None:
assert detail.manager assert detail.manager
assert detail.organization is not None assert detail.organization is not None
assert detail.roles 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)

View File

@@ -833,6 +833,11 @@ tbody tr:last-child td {
padding: 10px 12px; padding: 10px 12px;
} }
.field input[readonly] {
background: #f8fafc;
color: #64748b;
}
.role-grid { .role-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); 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); 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) { @media (max-width: 1320px) {
.hero-stats, .hero-stats,
.form-grid, .form-grid,

View File

@@ -22,3 +22,16 @@ export function fetchEmployeeMeta() {
export function fetchEmployeeDetail(employeeId) { export function fetchEmployeeDetail(employeeId) {
return apiRequest(`/employees/${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'
})
}

View File

@@ -31,7 +31,7 @@
</div> </div>
<div class="hero-stat"> <div class="hero-stat">
<span>角色数量</span> <span>角色数量</span>
<strong>{{ selectedEmployee.roles.length }}</strong> <strong>{{ roleCount }}</strong>
</div> </div>
</div> </div>
</section> </section>
@@ -49,39 +49,48 @@
<div class="form-grid"> <div class="form-grid">
<label class="field"> <label class="field">
<span>员工姓名</span> <span>员工姓名</span>
<input :value="selectedEmployee.name" /> <input v-model="employeeForm.name" />
</label> </label>
<label class="field"> <label class="field">
<span>员工编号</span> <span>员工编号</span>
<input :value="selectedEmployee.employeeNo" /> <input v-model="employeeForm.employeeNo" readonly />
</label> </label>
<label class="field"> <label class="field">
<span>性别</span> <span>性别</span>
<input :value="selectedEmployee.gender" /> <input v-model="employeeForm.gender" />
</label> </label>
<label class="field"> <label class="field">
<span>年龄</span> <span>年龄</span>
<input :value="selectedEmployee.age" /> <input :value="detailAge" readonly />
</label> </label>
<label class="field"> <label class="field">
<span>出生日期</span> <span>出生日期</span>
<input :value="selectedEmployee.birthDate" /> <input v-model="employeeForm.birthDate" type="date" />
</label> </label>
<label class="field"> <label class="field">
<span>手机号</span> <span>手机号</span>
<input :value="selectedEmployee.phone" /> <input v-model="employeeForm.phone" />
</label> </label>
<label class="field"> <label class="field">
<span>邮箱</span> <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>
<label class="field"> <label class="field">
<span>入职日期</span> <span>入职日期</span>
<input :value="selectedEmployee.joinDate" /> <input v-model="employeeForm.joinDate" type="date" />
</label> </label>
<label class="field"> <label class="field">
<span>办公地点</span> <span>办公地点</span>
<input :value="selectedEmployee.location" /> <input v-model="employeeForm.location" />
</label> </label>
</div> </div>
</article> </article>
@@ -97,27 +106,27 @@
<div class="form-grid"> <div class="form-grid">
<label class="field"> <label class="field">
<span>所属部门</span> <span>所属部门</span>
<input :value="selectedEmployee.department" /> <input v-model="employeeForm.department" readonly />
</label> </label>
<label class="field"> <label class="field">
<span>岗位</span> <span>岗位</span>
<input :value="selectedEmployee.position" /> <input v-model="employeeForm.position" />
</label> </label>
<label class="field"> <label class="field">
<span>职级</span> <span>职级</span>
<input :value="selectedEmployee.grade" /> <input v-model="employeeForm.grade" />
</label> </label>
<label class="field"> <label class="field">
<span>直属上级</span> <span>直属上级</span>
<input :value="selectedEmployee.manager" /> <input v-model="employeeForm.manager" readonly />
</label> </label>
<label class="field"> <label class="field">
<span>财务归口</span> <span>财务归口</span>
<input :value="selectedEmployee.financeOwner" /> <input v-model="employeeForm.financeOwner" />
</label> </label>
<label class="field"> <label class="field">
<span>成本中心</span> <span>成本中心</span>
<input :value="selectedEmployee.costCenter" /> <input v-model="employeeForm.costCenter" />
</label> </label>
</div> </div>
</article> </article>
@@ -128,7 +137,7 @@
<h3>系统角色分配</h3> <h3>系统角色分配</h3>
<p>为员工分配管理员财务人员使用者高级管理人员等业务角色</p> <p>为员工分配管理员财务人员使用者高级管理人员等业务角色</p>
</div> </div>
<span class="count-badge">{{ selectedEmployee.roles.length }} 个角色</span> <span class="count-badge">{{ roleCount }} 个角色</span>
</div> </div>
<div class="role-grid"> <div class="role-grid">
@@ -136,9 +145,9 @@
v-for="role in roleOptions" v-for="role in roleOptions"
:key="role.id" :key="role.id"
class="role-card" 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"> <div class="role-copy">
<strong>{{ role.label }}</strong> <strong>{{ role.label }}</strong>
<p>{{ role.desc }}</p> <p>{{ role.desc }}</p>
@@ -157,7 +166,7 @@
</div> </div>
</div> </div>
<div class="tag-list"> <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> </div>
<ul class="bullet-list"> <ul class="bullet-list">
<li v-for="item in selectedEmployee.permissions" :key="item">{{ item }}</li> <li v-for="item in selectedEmployee.permissions" :key="item">{{ item }}</li>
@@ -195,23 +204,19 @@
</div> </div>
<footer class="detail-actions"> <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> <i class="mdi mdi-arrow-left"></i>
<span>返回员工列表</span> <span>返回员工列表</span>
</button> </button>
<div class="detail-action-group"> <div class="detail-action-group">
<button class="minor-action" type="button"> <button class="minor-action" type="button" :disabled="disableActionDisabled" @click="disableEmployeeAccount">
<i class="mdi mdi-content-save-outline"></i>
<span>保存草稿</span>
</button>
<button class="minor-action" type="button">
<i class="mdi mdi-account-cancel-outline"></i> <i class="mdi mdi-account-cancel-outline"></i>
<span>停用账号</span> <span>{{ selectedEmployee.status === '停用' ? '账号已停用' : actionState === 'disable' ? '停用中...' : '停用账号' }}</span>
</button> </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> <i class="mdi mdi-check-circle-outline"></i>
<span>保存并生效</span> <span>{{ actionState === 'save' ? '保存中...' : '保存并生效' }}</span>
</button> </button>
</div> </div>
</footer> </footer>
@@ -450,7 +455,7 @@
v-for="employee in visibleEmployees" v-for="employee in visibleEmployees"
:key="employee.id" :key="employee.id"
:class="{ spotlight: employee.spotlight }" :class="{ spotlight: employee.spotlight }"
@click="selectedEmployee = employee" @click="openEmployeeDetail(employee)"
> >
<td> <td>
<div class="employee-cell"> <div class="employee-cell">

View File

@@ -1,6 +1,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' 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 DEFAULT_STATUS_TABS = ['全部员工', '在职', '试用中', '停用']
const FALLBACK_ROLE_OPTIONS = [ 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) { function matchKeyword(employee, keyword) {
if (!keyword) { if (!keyword) {
return true return true
@@ -114,8 +201,10 @@ export default {
name: 'EmployeeManagementView', name: 'EmployeeManagementView',
emits: ['overview-change'], emits: ['overview-change'],
setup(_, { emit }) { setup(_, { emit }) {
const { toast } = useToast()
const activeTab = ref(DEFAULT_STATUS_TABS[0]) const activeTab = ref(DEFAULT_STATUS_TABS[0])
const selectedEmployee = ref(null) const selectedEmployee = ref(null)
const employeeForm = ref(createEmployeeForm())
const roleOptions = ref([...FALLBACK_ROLE_OPTIONS]) const roleOptions = ref([...FALLBACK_ROLE_OPTIONS])
const employees = ref([]) const employees = ref([])
const searchKeyword = ref('') const searchKeyword = ref('')
@@ -127,11 +216,26 @@ export default {
const pageSize = ref(10) const pageSize = ref(10)
const pageSizes = [10, 20, 50] const pageSizes = [10, 20, 50]
const pageSizeOpen = ref(false) const pageSizeOpen = ref(false)
const actionState = ref('')
const loading = ref(false) const loading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const tabs = computed(() => buildStatusTabs(employees.value)) const tabs = computed(() => buildStatusTabs(employees.value))
const employeeSummary = computed(() => buildEmployeeSummary(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(() => const departmentOptions = computed(() =>
uniqueSorted(employees.value.map((item) => item.department)) uniqueSorted(employees.value.map((item) => item.department))
@@ -211,6 +315,14 @@ export default {
{ immediate: true } { immediate: true }
) )
watch(
selectedEmployee,
(employee) => {
employeeForm.value = buildEmployeeForm(employee)
},
{ immediate: true }
)
watch(filteredEmployees, () => { watch(filteredEmployees, () => {
currentPage.value = 1 currentPage.value = 1
pageSizeOpen.value = false 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() { async function loadEmployees() {
loading.value = true loading.value = true
errorMessage.value = '' errorMessage.value = ''
@@ -339,6 +612,13 @@ export default {
return { return {
tabs, tabs,
activeTab, activeTab,
employeeForm,
detailAge,
roleCount,
selectedRoleLabels,
actionState,
actionBusy,
disableActionDisabled,
selectedEmployee, selectedEmployee,
roleOptions, roleOptions,
employees, employees,
@@ -360,6 +640,10 @@ export default {
totalCount, totalCount,
totalPages, totalPages,
resetFilters, resetFilters,
openEmployeeDetail,
closeEmployeeDetail,
saveEmployeeChanges,
disableEmployeeAccount,
changePageSize, changePageSize,
togglePageSizeOpen, togglePageSizeOpen,
toggleFilterPopover, toggleFilterPopover,