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
## 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 数据库

View File

@@ -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 资源

View File

@@ -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

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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

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

View File

@@ -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,

View File

@@ -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'
})
}

View File

@@ -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">

View File

@@ -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,