feat: 增强员工管理与报销单全流程功能
- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点 - 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力 - 报销单提交补充身份回填、部门信息透传及预审结果展示优化 - 认证流程增加部门信息(departmentName)并在schema中同步扩展 - 用户Agent服务增加部门关联与报销单回填逻辑 - 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能 - 前端审批中心、审计、差旅报销等视图交互与样式优化 - 新增TableLoadingState共享组件及员工导入测试用例
This commit is contained in:
@@ -2,12 +2,19 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead, EmployeeUpdate
|
||||
from app.schemas.employee import (
|
||||
EmployeeCreate,
|
||||
EmployeeImportResultRead,
|
||||
EmployeeMetaRead,
|
||||
EmployeeRead,
|
||||
EmployeeUpdate,
|
||||
)
|
||||
from app.services.employee import EmployeeService
|
||||
|
||||
router = APIRouter()
|
||||
@@ -44,6 +51,67 @@ def list_employees(
|
||||
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/import-template",
|
||||
summary="下载员工导入模板",
|
||||
description="下载固定格式的员工 Excel 导入模板。",
|
||||
)
|
||||
def download_employee_import_template(db: DbSession) -> Response:
|
||||
content = EmployeeService(db).build_import_template()
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="employee-import-template.xlsx"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出员工 Excel",
|
||||
description="按筛选条件导出员工目录 Excel 文件。",
|
||||
)
|
||||
def export_employees(
|
||||
db: DbSession,
|
||||
status_filter: Annotated[
|
||||
str | None,
|
||||
Query(alias="status", description="员工状态筛选值。"),
|
||||
] = None,
|
||||
keyword: Annotated[
|
||||
str | None,
|
||||
Query(description="姓名、工号、邮箱等关键字模糊查询。"),
|
||||
] = None,
|
||||
) -> Response:
|
||||
content = EmployeeService(db).export_employees(status=status_filter, keyword=keyword)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="employee-export.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import",
|
||||
response_model=EmployeeImportResultRead,
|
||||
summary="导入员工 Excel",
|
||||
description="按模板批量导入员工。全部校验通过后才写入数据库,任一行有错则整批不导入。",
|
||||
)
|
||||
async def import_employees(
|
||||
db: DbSession,
|
||||
file: Annotated[UploadFile, File(description="待导入的员工 Excel 文件。")],
|
||||
) -> EmployeeImportResultRead:
|
||||
filename = (file.filename or "").lower()
|
||||
if not filename.endswith(".xlsx"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="当前仅支持上传 .xlsx 格式的员工表格。",
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
return EmployeeService(db).import_employees(content)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=EmployeeRead,
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.schemas.reimbursement import (
|
||||
ExpenseClaimItemActionResponse,
|
||||
ExpenseClaimItemUpdate,
|
||||
ExpenseClaimRead,
|
||||
ExpenseClaimReturnPayload,
|
||||
ReimbursementCreate,
|
||||
ReimbursementRead,
|
||||
)
|
||||
@@ -415,11 +416,11 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
return claim
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
summary="删除个人报销草稿",
|
||||
description="删除当前登录用户可见的草稿报销单。",
|
||||
@router.post(
|
||||
"/claims/{claim_id}/return",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="退回报销单",
|
||||
description="财务人员或高级管理人员可将可见报销单退回到待补充状态。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
@@ -427,7 +428,40 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "仅草稿状态允许删除。",
|
||||
"description": "当前用户或单据状态不允许退回。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def return_expense_claim(
|
||||
claim_id: str,
|
||||
payload: ExpenseClaimReturnPayload,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseClaimRead:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
claim = service.return_claim(claim_id, current_user, reason=payload.reason)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
return claim
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
summary="删除报销单",
|
||||
description="普通用户仅可删除草稿或待补充报销单;财务人员和高级管理人员可删除可见报销单。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "报销单不存在。",
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "当前用户或单据状态不允许删除。",
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -442,7 +476,7 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
|
||||
return ExpenseClaimActionResponse(
|
||||
message=f"{claim.claim_no} 草稿已删除。",
|
||||
message=f"{claim.claim_no} 报销单已删除。",
|
||||
claim_id=claim.id,
|
||||
status="deleted",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
@@ -16,6 +18,12 @@ class AuthUserRead(BaseModel):
|
||||
departmentName: str = ""
|
||||
position: str = ""
|
||||
grade: str = ""
|
||||
employeeNo: str = ""
|
||||
managerName: str = ""
|
||||
location: str = ""
|
||||
costCenter: str = ""
|
||||
financeOwnerName: str = ""
|
||||
riskProfile: dict[str, Any] = Field(default_factory=dict)
|
||||
roleCodes: list[str] = Field(default_factory=list)
|
||||
email: EmailStr | str
|
||||
avatar: str
|
||||
|
||||
@@ -50,6 +50,7 @@ class EmployeeMetaRead(BaseModel):
|
||||
totalEmployees: int
|
||||
statusSummary: list[EmployeeStatusSummaryRead]
|
||||
roleOptions: list[EmployeeRoleOptionRead]
|
||||
organizationOptions: list[EmployeeOrganizationRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmployeeRead(BaseModel):
|
||||
@@ -63,6 +64,7 @@ class EmployeeRead(BaseModel):
|
||||
position: str
|
||||
grade: str
|
||||
manager: str
|
||||
managerEmployeeNo: str | None = None
|
||||
financeOwner: str
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
roleCodes: list[str] = Field(default_factory=list)
|
||||
@@ -112,6 +114,28 @@ class EmployeeCreate(BaseModel):
|
||||
return _parse_optional_date(self.join_date, "入职日期")
|
||||
|
||||
|
||||
class EmployeeImportErrorRead(BaseModel):
|
||||
row: int
|
||||
column: str
|
||||
employeeNo: str = ""
|
||||
message: str
|
||||
|
||||
|
||||
class EmployeeImportSummaryRead(BaseModel):
|
||||
totalRows: int = 0
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
errorCount: int = 0
|
||||
|
||||
|
||||
class EmployeeImportResultRead(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
summary: EmployeeImportSummaryRead
|
||||
errors: list[EmployeeImportErrorRead] = Field(default_factory=list)
|
||||
importedAt: str | None = 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)
|
||||
@@ -124,6 +148,8 @@ class EmployeeUpdate(BaseModel):
|
||||
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)
|
||||
organization_unit_code: str | None = Field(default=None, max_length=50)
|
||||
manager_employee_no: str | None = Field(default=None, max_length=50)
|
||||
role_codes: list[str] | None = None
|
||||
password: str | None = Field(default=None, min_length=5, max_length=128)
|
||||
|
||||
|
||||
@@ -148,6 +148,10 @@ class ExpenseClaimActionResponse(BaseModel):
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class ExpenseClaimReturnPayload(BaseModel):
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ExpenseClaimAttachmentActionResponse(BaseModel):
|
||||
message: str
|
||||
claim_id: str
|
||||
|
||||
@@ -85,6 +85,8 @@ class UserAgentReviewRiskBrief(BaseModel):
|
||||
title: str = Field(description="风险或注意事项标题。")
|
||||
level: str = Field(default="info", description="级别,例如 info / warning / high。")
|
||||
content: str = Field(description="面向用户展示的摘要说明。")
|
||||
detail: str = Field(default="", description="点击风险项后展示的详细解释。")
|
||||
suggestion: str = Field(default="", description="面向用户的处理建议。")
|
||||
|
||||
|
||||
class UserAgentReviewSlotCard(BaseModel):
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
from app.core.security import verify_password
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
|
||||
from app.services.employee import EmployeeService
|
||||
from app.services.employee_seed import ROLE_DISPLAY_ORDER
|
||||
@@ -34,6 +37,12 @@ class AuthenticatedUser:
|
||||
department: str
|
||||
position: str
|
||||
grade: str
|
||||
employee_no: str
|
||||
manager_name: str
|
||||
location: str
|
||||
cost_center: str
|
||||
finance_owner_name: str
|
||||
risk_profile: dict[str, Any]
|
||||
role_codes: list[str]
|
||||
email: str
|
||||
avatar: str
|
||||
@@ -82,6 +91,12 @@ class AuthService:
|
||||
department="",
|
||||
position="系统管理员",
|
||||
grade="",
|
||||
employee_no="",
|
||||
manager_name="",
|
||||
location="",
|
||||
cost_center="",
|
||||
finance_owner_name="",
|
||||
risk_profile={},
|
||||
role_codes=["manager"],
|
||||
email=admin_email or f"{admin_username}@local",
|
||||
avatar=display_name[:1].upper(),
|
||||
@@ -96,7 +111,11 @@ class AuthService:
|
||||
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
|
||||
.options(
|
||||
selectinload(Employee.organization_unit),
|
||||
selectinload(Employee.manager),
|
||||
selectinload(Employee.roles),
|
||||
)
|
||||
.where(func.lower(Employee.email) == identifier.lower())
|
||||
)
|
||||
employee = self.db.execute(stmt).scalars().first()
|
||||
@@ -117,20 +136,75 @@ class AuthService:
|
||||
)
|
||||
role_codes = [role.role_code for role in sorted_roles]
|
||||
primary_role_code = role_codes[0] if role_codes else "user"
|
||||
department = employee.organization_unit.name if employee.organization_unit is not None else ""
|
||||
manager_name = self._resolve_manager_name(employee)
|
||||
|
||||
return AuthenticatedUser(
|
||||
username=employee.email,
|
||||
name=employee.name,
|
||||
role=ROLE_LABELS.get(primary_role_code, "使用者"),
|
||||
department=employee.organization_unit.name if employee.organization_unit is not None else "",
|
||||
department=department,
|
||||
position=employee.position,
|
||||
grade=employee.grade,
|
||||
employee_no=employee.employee_no,
|
||||
manager_name=manager_name,
|
||||
location=employee.location or "",
|
||||
cost_center=employee.cost_center or "",
|
||||
finance_owner_name=employee.finance_owner_name or "",
|
||||
risk_profile=self._build_risk_profile(employee),
|
||||
role_codes=role_codes or ["user"],
|
||||
email=employee.email,
|
||||
avatar=(employee.name or "?")[:1].upper(),
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_manager_name(employee: Employee) -> str:
|
||||
if employee.manager is not None and employee.manager.name:
|
||||
return str(employee.manager.name).strip()
|
||||
if employee.organization_unit is not None and employee.organization_unit.manager_name:
|
||||
return str(employee.organization_unit.manager_name).strip()
|
||||
return ""
|
||||
|
||||
def _build_risk_profile(self, employee: Employee) -> dict[str, Any]:
|
||||
since = datetime.now(UTC) - timedelta(days=90)
|
||||
identity_values = [
|
||||
str(employee.name or "").strip(),
|
||||
str(employee.email or "").strip(),
|
||||
str(employee.employee_no or "").strip(),
|
||||
]
|
||||
name_candidates = [item for item in dict.fromkeys(identity_values) if item]
|
||||
conditions = [ExpenseClaim.employee_id == employee.id]
|
||||
if name_candidates:
|
||||
conditions.append(ExpenseClaim.employee_name.in_(name_candidates))
|
||||
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.where(or_(*conditions), ExpenseClaim.occurred_at >= since)
|
||||
.order_by(ExpenseClaim.occurred_at.desc())
|
||||
.limit(30)
|
||||
)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
recent_risk_flags: list[str] = []
|
||||
for claim in claims:
|
||||
for flag in claim.risk_flags_json or []:
|
||||
normalized = str(flag or "").strip()
|
||||
if normalized and normalized not in recent_risk_flags:
|
||||
recent_risk_flags.append(normalized)
|
||||
if len(recent_risk_flags) >= 6:
|
||||
break
|
||||
if len(recent_risk_flags) >= 6:
|
||||
break
|
||||
|
||||
return {
|
||||
"windowDays": 90,
|
||||
"totalClaimCount": len(claims),
|
||||
"riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
|
||||
"draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
|
||||
"recentRiskFlags": recent_risk_flags,
|
||||
"lastClaimAt": claims[0].occurred_at.isoformat() if claims and claims[0].occurred_at else "",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_user(user: AuthenticatedUser) -> AuthUserRead:
|
||||
return AuthUserRead(
|
||||
@@ -141,6 +215,12 @@ class AuthService:
|
||||
departmentName=user.department,
|
||||
position=user.position,
|
||||
grade=user.grade,
|
||||
employeeNo=user.employee_no,
|
||||
managerName=user.manager_name,
|
||||
location=user.location,
|
||||
costCenter=user.cost_center,
|
||||
financeOwnerName=user.finance_owner_name,
|
||||
riskProfile=user.risk_profile,
|
||||
roleCodes=user.role_codes,
|
||||
email=user.email,
|
||||
avatar=user.avatar,
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections import Counter
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -20,6 +20,9 @@ from app.repositories.employee import EmployeeRepository
|
||||
from app.schemas.employee import (
|
||||
EmployeeCreate,
|
||||
EmployeeHistoryRead,
|
||||
EmployeeImportErrorRead,
|
||||
EmployeeImportResultRead,
|
||||
EmployeeImportSummaryRead,
|
||||
EmployeeMetaRead,
|
||||
EmployeeOrganizationRead,
|
||||
EmployeeRead,
|
||||
@@ -27,8 +30,16 @@ from app.schemas.employee import (
|
||||
EmployeeStatusSummaryRead,
|
||||
EmployeeUpdate,
|
||||
)
|
||||
from app.services.employee_spreadsheet import (
|
||||
EmployeeImportRow,
|
||||
EmployeeSpreadsheetError,
|
||||
build_export_workbook_bytes,
|
||||
build_import_template_bytes,
|
||||
parse_employee_workbook,
|
||||
)
|
||||
from app.services.employee_seed import (
|
||||
EMPLOYEE_DEFINITIONS,
|
||||
EMPLOYEE_PROFILE_REPAIRS,
|
||||
ORGANIZATION_DEFINITIONS,
|
||||
ROLE_DEFINITIONS,
|
||||
ROLE_DISPLAY_ORDER,
|
||||
@@ -37,6 +48,7 @@ from app.services.employee_seed import (
|
||||
|
||||
logger = get_logger("app.services.employee")
|
||||
DEFAULT_EMPLOYEE_PASSWORD = "123456"
|
||||
MAX_EMPLOYEE_CHANGE_LOGS = 5
|
||||
|
||||
STATUS_TONE_MAP = {
|
||||
"在职": "success",
|
||||
@@ -57,7 +69,9 @@ def prepare_employee_directory() -> None:
|
||||
|
||||
session_factory = get_session_factory()
|
||||
with session_factory() as db:
|
||||
EmployeeService(db).ensure_directory_ready()
|
||||
service = EmployeeService(db)
|
||||
service.ensure_directory_ready()
|
||||
service.apply_profile_repairs()
|
||||
|
||||
|
||||
class EmployeeService:
|
||||
@@ -120,10 +134,27 @@ class EmployeeService:
|
||||
for role in self._sorted_roles(self.repository.list_roles())
|
||||
]
|
||||
|
||||
organization_options = [
|
||||
EmployeeOrganizationRead(
|
||||
id=unit.id,
|
||||
code=unit.unit_code,
|
||||
name=unit.name,
|
||||
unitType=unit.unit_type,
|
||||
costCenter=unit.cost_center,
|
||||
location=unit.location,
|
||||
managerName=unit.manager_name,
|
||||
)
|
||||
for unit in sorted(
|
||||
self.repository.list_organization_units(),
|
||||
key=lambda item: item.name,
|
||||
)
|
||||
]
|
||||
|
||||
return EmployeeMetaRead(
|
||||
totalEmployees=len(employees),
|
||||
statusSummary=status_summary,
|
||||
roleOptions=role_options,
|
||||
organizationOptions=organization_options,
|
||||
)
|
||||
|
||||
def create_employee(self, payload: EmployeeCreate) -> EmployeeRead:
|
||||
@@ -261,6 +292,43 @@ class EmployeeService:
|
||||
employee.finance_owner_name = finance_owner_name
|
||||
changed_fields.append("财务归口")
|
||||
|
||||
if "organization_unit_code" in payload.model_fields_set:
|
||||
organization_code = self._normalize_optional_text(payload.organization_unit_code)
|
||||
current_code = (
|
||||
employee.organization_unit.unit_code if employee.organization_unit else None
|
||||
)
|
||||
if organization_code != current_code:
|
||||
if organization_code:
|
||||
organization = self.repository.get_organization_by_code(organization_code)
|
||||
if organization is None:
|
||||
raise ValueError(f"部门编码 {organization_code} 不存在")
|
||||
employee.organization_unit = organization
|
||||
else:
|
||||
employee.organization_unit = None
|
||||
changed_fields.append("所属部门")
|
||||
|
||||
if "manager_employee_no" in payload.model_fields_set:
|
||||
manager_employee_no = self._normalize_optional_text(payload.manager_employee_no)
|
||||
current_manager_no = employee.manager.employee_no if employee.manager else None
|
||||
|
||||
if manager_employee_no:
|
||||
if manager_employee_no == employee.employee_no:
|
||||
raise ValueError("直属上级不能是员工本人")
|
||||
|
||||
manager = self.repository.get_by_employee_no(manager_employee_no)
|
||||
if manager is None:
|
||||
raise ValueError(f"直属上级工号 {manager_employee_no} 不存在")
|
||||
|
||||
if manager_employee_no != current_manager_no:
|
||||
employee.manager = manager
|
||||
changed_fields.append("直属上级")
|
||||
elif current_manager_no is not None:
|
||||
employee.manager = None
|
||||
changed_fields.append("直属上级")
|
||||
|
||||
role_changed = False
|
||||
sorted_roles: list[Role] = []
|
||||
|
||||
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] = []
|
||||
@@ -280,7 +348,7 @@ class EmployeeService:
|
||||
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("系统角色")
|
||||
role_changed = True
|
||||
|
||||
if "password" in payload.model_fields_set and payload.password:
|
||||
password = payload.password.strip()
|
||||
@@ -289,7 +357,7 @@ class EmployeeService:
|
||||
employee.password_hash = hash_password(password)
|
||||
password_changed = True
|
||||
|
||||
if not changed_fields and not password_changed:
|
||||
if not changed_fields and not password_changed and not role_changed:
|
||||
return self._serialize_employee(employee)
|
||||
|
||||
now = datetime.now()
|
||||
@@ -303,13 +371,25 @@ class EmployeeService:
|
||||
occurred_at=now,
|
||||
)
|
||||
|
||||
if role_changed:
|
||||
role_labels = "、".join(role.name for role in sorted_roles)
|
||||
self._append_change_log(
|
||||
employee,
|
||||
action=f"更新系统角色({role_labels})",
|
||||
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)
|
||||
hydrated = self._save_employee_and_reload(employee)
|
||||
logger.info(
|
||||
"Updated employee id=%s fields=%s role_changed=%s",
|
||||
employee.id,
|
||||
",".join(changed_fields),
|
||||
role_changed,
|
||||
)
|
||||
return self._serialize_employee(hydrated)
|
||||
|
||||
def disable_employee(self, employee_id: str) -> EmployeeRead:
|
||||
self.ensure_directory_ready()
|
||||
@@ -328,10 +408,9 @@ class EmployeeService:
|
||||
employee.spotlight = False
|
||||
self._append_change_log(employee, action="停用员工账号", occurred_at=now)
|
||||
|
||||
saved = self.repository.save(employee)
|
||||
hydrated = self.repository.get(saved.id)
|
||||
hydrated = self._save_employee_and_reload(employee)
|
||||
logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no)
|
||||
return self._serialize_employee(hydrated or saved)
|
||||
return self._serialize_employee(hydrated)
|
||||
|
||||
def enable_employee(self, employee_id: str) -> EmployeeRead:
|
||||
self.ensure_directory_ready()
|
||||
@@ -349,10 +428,299 @@ class EmployeeService:
|
||||
employee.last_sync_at = now
|
||||
self._append_change_log(employee, action="启用员工账号", occurred_at=now)
|
||||
|
||||
saved = self.repository.save(employee)
|
||||
hydrated = self.repository.get(saved.id)
|
||||
hydrated = self._save_employee_and_reload(employee)
|
||||
logger.info("Enabled employee id=%s no=%s", employee.id, employee.employee_no)
|
||||
return self._serialize_employee(hydrated or saved)
|
||||
return self._serialize_employee(hydrated)
|
||||
|
||||
def build_import_template(self) -> bytes:
|
||||
self.ensure_directory_ready()
|
||||
return build_import_template_bytes()
|
||||
|
||||
def export_employees(self, status: str | None = None, keyword: str | None = None) -> bytes:
|
||||
self.ensure_directory_ready()
|
||||
employees = self.repository.list(status=status, keyword=keyword)
|
||||
rows: list[list[str]] = []
|
||||
|
||||
for employee in employees:
|
||||
organization = employee.organization_unit
|
||||
role_codes = ",".join(role.role_code for role in self._sorted_roles(list(employee.roles)))
|
||||
rows.append(
|
||||
[
|
||||
employee.employee_no,
|
||||
employee.name,
|
||||
employee.email,
|
||||
employee.gender or "",
|
||||
self._format_date(employee.birth_date) or "",
|
||||
employee.phone or "",
|
||||
self._format_date(employee.join_date) or "",
|
||||
employee.location or "",
|
||||
employee.position,
|
||||
employee.grade,
|
||||
organization.unit_code if organization else "",
|
||||
employee.manager.employee_no if employee.manager else "",
|
||||
employee.finance_owner_name or "",
|
||||
employee.cost_center or "",
|
||||
employee.employment_status,
|
||||
role_codes,
|
||||
]
|
||||
)
|
||||
|
||||
return build_export_workbook_bytes(rows)
|
||||
|
||||
def import_employees(self, content: bytes, actor: str = "系统管理员") -> EmployeeImportResultRead:
|
||||
self.ensure_directory_ready()
|
||||
parsed_rows, parse_errors = parse_employee_workbook(content)
|
||||
if parse_errors:
|
||||
return self._build_import_failure(parse_errors, total_rows=len(parsed_rows))
|
||||
|
||||
validation_errors = self._validate_import_rows(parsed_rows)
|
||||
if validation_errors:
|
||||
return self._build_import_failure(validation_errors, total_rows=len(parsed_rows))
|
||||
|
||||
try:
|
||||
summary = self._apply_import_rows(parsed_rows, actor=actor)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
logger.exception("Employee import failed during database write")
|
||||
raise
|
||||
|
||||
imported_at = self._format_datetime(datetime.now()) or ""
|
||||
message = f"导入成功:新增 {summary['created']} 人,更新 {summary['updated']} 人。"
|
||||
logger.info(
|
||||
"Imported employees created=%d updated=%d total=%d",
|
||||
summary["created"],
|
||||
summary["updated"],
|
||||
len(parsed_rows),
|
||||
)
|
||||
return EmployeeImportResultRead(
|
||||
success=True,
|
||||
message=message,
|
||||
summary=EmployeeImportSummaryRead(
|
||||
totalRows=len(parsed_rows),
|
||||
created=summary["created"],
|
||||
updated=summary["updated"],
|
||||
errorCount=0,
|
||||
),
|
||||
errors=[],
|
||||
importedAt=imported_at,
|
||||
)
|
||||
|
||||
def _validate_import_rows(
|
||||
self, rows: list[EmployeeImportRow]
|
||||
) -> list[EmployeeSpreadsheetError]:
|
||||
errors: list[EmployeeSpreadsheetError] = []
|
||||
employee_nos_in_file: dict[str, int] = {}
|
||||
emails_in_file: dict[str, int] = {}
|
||||
|
||||
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||
organizations_by_code = {
|
||||
unit.unit_code: unit for unit in self.repository.list_organization_units()
|
||||
}
|
||||
employees_by_no = {
|
||||
employee.employee_no: employee for employee in self.repository.list()
|
||||
}
|
||||
import_employee_nos = {row.employee_no for row in rows}
|
||||
|
||||
for row in rows:
|
||||
if row.employee_no in employee_nos_in_file:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="员工编号*",
|
||||
employee_no=row.employee_no,
|
||||
message=f"员工编号 {row.employee_no} 在文件中重复。",
|
||||
)
|
||||
)
|
||||
else:
|
||||
employee_nos_in_file[row.employee_no] = row.row_number
|
||||
|
||||
if row.email in emails_in_file:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="邮箱*",
|
||||
employee_no=row.employee_no,
|
||||
message=f"邮箱 {row.email} 在文件中重复。",
|
||||
)
|
||||
)
|
||||
else:
|
||||
emails_in_file[row.email] = row.row_number
|
||||
|
||||
existing_by_email = self.repository.get_by_email(row.email)
|
||||
if existing_by_email is not None and existing_by_email.employee_no != row.employee_no:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="邮箱*",
|
||||
employee_no=row.employee_no,
|
||||
message=(
|
||||
f"邮箱 {row.email} 已被员工 "
|
||||
f"{existing_by_email.employee_no} 使用。"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if row.organization_unit_code and row.organization_unit_code not in organizations_by_code:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="部门编码",
|
||||
employee_no=row.employee_no,
|
||||
message=f"部门编码 {row.organization_unit_code} 不存在。",
|
||||
)
|
||||
)
|
||||
|
||||
if row.manager_employee_no:
|
||||
manager_exists = (
|
||||
row.manager_employee_no in employees_by_no
|
||||
or row.manager_employee_no in import_employee_nos
|
||||
)
|
||||
if not manager_exists:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="直属上级工号",
|
||||
employee_no=row.employee_no,
|
||||
message=f"直属上级工号 {row.manager_employee_no} 不存在。",
|
||||
)
|
||||
)
|
||||
if row.manager_employee_no == row.employee_no:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="直属上级工号",
|
||||
employee_no=row.employee_no,
|
||||
message="直属上级不能是员工本人。",
|
||||
)
|
||||
)
|
||||
|
||||
invalid_role_codes = [
|
||||
code for code in row.role_codes if code not in roles_by_code
|
||||
]
|
||||
if invalid_role_codes:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row.row_number,
|
||||
column="角色编码",
|
||||
employee_no=row.employee_no,
|
||||
message=f"角色不存在:{'、'.join(invalid_role_codes)}。",
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
def _apply_import_rows(
|
||||
self,
|
||||
rows: list[EmployeeImportRow],
|
||||
*,
|
||||
actor: str,
|
||||
) -> dict[str, int]:
|
||||
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||
organizations_by_code = {
|
||||
unit.unit_code: unit for unit in self.repository.list_organization_units()
|
||||
}
|
||||
employees_by_no = {
|
||||
employee.employee_no: employee for employee in self.repository.list()
|
||||
}
|
||||
created = 0
|
||||
updated = 0
|
||||
now = datetime.now()
|
||||
|
||||
try:
|
||||
for row in rows:
|
||||
employee = employees_by_no.get(row.employee_no)
|
||||
is_new = employee is None
|
||||
|
||||
if is_new:
|
||||
employee = Employee(
|
||||
employee_no=row.employee_no,
|
||||
name=row.name,
|
||||
email=row.email,
|
||||
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
|
||||
)
|
||||
self.db.add(employee)
|
||||
employees_by_no[row.employee_no] = employee
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
employee.name = row.name
|
||||
employee.email = row.email
|
||||
employee.gender = row.gender
|
||||
employee.birth_date = row.birth_date
|
||||
employee.phone = row.phone
|
||||
employee.join_date = row.join_date
|
||||
employee.location = row.location
|
||||
employee.position = row.position
|
||||
employee.grade = row.grade
|
||||
employee.finance_owner_name = row.finance_owner_name
|
||||
employee.cost_center = row.cost_center
|
||||
employee.employment_status = row.employment_status
|
||||
employee.sync_state = "已同步"
|
||||
employee.last_sync_at = now
|
||||
|
||||
if row.organization_unit_code:
|
||||
employee.organization_unit = organizations_by_code[row.organization_unit_code]
|
||||
else:
|
||||
employee.organization_unit = None
|
||||
|
||||
employee.roles = self._sorted_roles(
|
||||
[roles_by_code[code] for code in row.role_codes if code in roles_by_code]
|
||||
)
|
||||
|
||||
action = (
|
||||
"通过 Excel 导入新建员工档案"
|
||||
if is_new
|
||||
else "通过 Excel 导入更新员工档案"
|
||||
)
|
||||
self._append_change_log(employee, action=action, owner=actor, occurred_at=now)
|
||||
|
||||
self.db.flush()
|
||||
|
||||
for row in rows:
|
||||
employee = employees_by_no[row.employee_no]
|
||||
if row.manager_employee_no:
|
||||
employee.manager = employees_by_no.get(row.manager_employee_no)
|
||||
else:
|
||||
employee.manager = None
|
||||
|
||||
self.db.commit()
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
return {"created": created, "updated": updated}
|
||||
|
||||
def _build_import_failure(
|
||||
self,
|
||||
errors: list[EmployeeSpreadsheetError],
|
||||
*,
|
||||
total_rows: int,
|
||||
) -> EmployeeImportResultRead:
|
||||
error_reads = [
|
||||
EmployeeImportErrorRead(
|
||||
row=item.row,
|
||||
column=item.column,
|
||||
employeeNo=item.employee_no,
|
||||
message=item.message,
|
||||
)
|
||||
for item in errors
|
||||
]
|
||||
return EmployeeImportResultRead(
|
||||
success=False,
|
||||
message=(
|
||||
f"导入未执行:共发现 {len(error_reads)} 处错误,请修正后重新导入。"
|
||||
"原有员工数据未变更。"
|
||||
),
|
||||
summary=EmployeeImportSummaryRead(
|
||||
totalRows=total_rows,
|
||||
created=0,
|
||||
updated=0,
|
||||
errorCount=len(error_reads),
|
||||
),
|
||||
errors=error_reads,
|
||||
importedAt=None,
|
||||
)
|
||||
|
||||
def _seed_roles(self) -> None:
|
||||
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||
@@ -471,6 +839,69 @@ class EmployeeService:
|
||||
|
||||
self.db.flush()
|
||||
|
||||
def apply_profile_repairs(self) -> None:
|
||||
"""Apply one-off demo profile repairs. Intended for startup/bootstrap only."""
|
||||
try:
|
||||
self._repair_employee_profiles()
|
||||
self._trim_all_employee_change_logs()
|
||||
self.db.commit()
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
logger.exception("Failed to apply employee profile repairs")
|
||||
raise
|
||||
|
||||
def _repair_employee_profiles(self) -> None:
|
||||
if not EMPLOYEE_PROFILE_REPAIRS:
|
||||
return
|
||||
|
||||
employees = self.repository.list()
|
||||
employees_by_email = {employee.email.lower(): employee for employee in employees if employee.email}
|
||||
employees_by_no = {employee.employee_no: employee for employee in employees if employee.employee_no}
|
||||
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||
organizations_by_code = {
|
||||
unit.unit_code: unit for unit in self.repository.list_organization_units()
|
||||
}
|
||||
|
||||
for definition in EMPLOYEE_PROFILE_REPAIRS:
|
||||
email = str(definition.get("email") or "").strip().lower()
|
||||
employee_no = str(definition.get("employee_no") or "").strip()
|
||||
employee = employees_by_email.get(email) or employees_by_no.get(employee_no)
|
||||
if employee is None:
|
||||
continue
|
||||
|
||||
for field_name in (
|
||||
"position",
|
||||
"grade",
|
||||
"location",
|
||||
"cost_center",
|
||||
"finance_owner_name",
|
||||
"employment_status",
|
||||
"sync_state",
|
||||
):
|
||||
value = definition.get(field_name)
|
||||
if value:
|
||||
setattr(employee, field_name, value)
|
||||
|
||||
organization_code = definition.get("organization_unit_code")
|
||||
if organization_code:
|
||||
employee.organization_unit = organizations_by_code.get(organization_code)
|
||||
|
||||
manager_employee_no = definition.get("manager_employee_no")
|
||||
if manager_employee_no:
|
||||
employee.manager = employees_by_no.get(manager_employee_no)
|
||||
|
||||
if not employee.password_hash:
|
||||
employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD)
|
||||
|
||||
role_codes = [item for item in definition.get("role_codes", []) if item in roles_by_code]
|
||||
if role_codes:
|
||||
merged_roles = {role.role_code: role for role in employee.roles}
|
||||
for role_code in role_codes:
|
||||
merged_roles[role_code] = roles_by_code[role_code]
|
||||
employee.roles = self._sorted_roles(list(merged_roles.values()))
|
||||
|
||||
self.db.flush()
|
||||
|
||||
def _prune_extra_seed_employees(self) -> None:
|
||||
if not EXTRA_SEED_EMPLOYEE_NOS:
|
||||
return
|
||||
@@ -530,6 +961,12 @@ class EmployeeService:
|
||||
)
|
||||
existing_keys.add(identity)
|
||||
|
||||
def _save_employee_and_reload(self, employee: Employee) -> Employee:
|
||||
saved = self.repository.save(employee)
|
||||
self._trim_employee_change_logs(saved.id)
|
||||
self.db.commit()
|
||||
return self.repository.get(saved.id) or saved
|
||||
|
||||
def _append_change_log(
|
||||
self,
|
||||
employee: Employee,
|
||||
@@ -546,6 +983,26 @@ class EmployeeService:
|
||||
)
|
||||
)
|
||||
|
||||
def _trim_all_employee_change_logs(self) -> None:
|
||||
for employee in self.repository.list():
|
||||
self._trim_employee_change_logs(employee.id)
|
||||
|
||||
def _sorted_change_logs(self, employee: Employee) -> list[EmployeeChangeLog]:
|
||||
return sorted(employee.change_logs, key=lambda item: item.occurred_at, reverse=True)
|
||||
|
||||
def _trim_employee_change_logs(self, employee_id: str) -> None:
|
||||
stmt = (
|
||||
select(EmployeeChangeLog)
|
||||
.where(EmployeeChangeLog.employee_id == employee_id)
|
||||
.order_by(EmployeeChangeLog.occurred_at.desc())
|
||||
)
|
||||
logs = list(self.db.execute(stmt).scalars().all())
|
||||
if len(logs) <= MAX_EMPLOYEE_CHANGE_LOGS:
|
||||
return
|
||||
|
||||
for stale in logs[MAX_EMPLOYEE_CHANGE_LOGS:]:
|
||||
self.db.delete(stale)
|
||||
|
||||
def _serialize_employee(self, employee: Employee) -> EmployeeRead:
|
||||
organization = employee.organization_unit
|
||||
roles = self._sorted_roles(list(employee.roles))
|
||||
@@ -556,10 +1013,10 @@ class EmployeeService:
|
||||
EmployeeHistoryRead(
|
||||
action=item.action,
|
||||
owner=item.owner,
|
||||
time=self._format_datetime(item.occurred_at) or "",
|
||||
occurredAt=self._format_datetime(item.occurred_at) or "",
|
||||
time=self._format_history_datetime(item.occurred_at),
|
||||
occurredAt=self._format_history_datetime(item.occurred_at),
|
||||
)
|
||||
for item in employee.change_logs
|
||||
for item in self._sorted_change_logs(employee)[:MAX_EMPLOYEE_CHANGE_LOGS]
|
||||
]
|
||||
|
||||
return EmployeeRead(
|
||||
@@ -571,6 +1028,7 @@ class EmployeeService:
|
||||
position=employee.position,
|
||||
grade=employee.grade,
|
||||
manager=employee.manager.name if employee.manager else "CEO",
|
||||
managerEmployeeNo=employee.manager.employee_no if employee.manager else None,
|
||||
financeOwner=employee.finance_owner_name or "",
|
||||
roles=role_labels,
|
||||
roleCodes=role_codes,
|
||||
@@ -654,6 +1112,15 @@ class EmployeeService:
|
||||
return None
|
||||
return value.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
@staticmethod
|
||||
def _format_history_datetime(value: datetime | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return (
|
||||
f"{value.year}年{value.month}月{value.day}日"
|
||||
f"{value.hour}时{value.minute}分{value.second}秒"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _calculate_age(birth_date: date | None) -> int | None:
|
||||
if birth_date is None:
|
||||
|
||||
@@ -144,6 +144,24 @@ ORGANIZATION_DEFINITIONS = [
|
||||
},
|
||||
]
|
||||
|
||||
EMPLOYEE_PROFILE_REPAIRS = [
|
||||
{
|
||||
"employee_no": "E90919",
|
||||
"name": "曹笑竹",
|
||||
"email": "caoxiaozhu@xf.com",
|
||||
"location": "武汉",
|
||||
"position": "财务智能化产品经理",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6112",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
]
|
||||
|
||||
EMPLOYEE_DEFINITIONS = [
|
||||
{
|
||||
"employee_no": "E10018",
|
||||
|
||||
368
server/src/app/services/employee_spreadsheet.py
Normal file
368
server/src/app/services/employee_spreadsheet.py
Normal file
@@ -0,0 +1,368 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from email.utils import parseaddr
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
EMPLOYEE_SHEET_NAME = "员工目录"
|
||||
INSTRUCTION_SHEET_NAME = "填表说明"
|
||||
|
||||
EMPLOYEE_HEADERS: tuple[str, ...] = (
|
||||
"员工编号*",
|
||||
"姓名*",
|
||||
"邮箱*",
|
||||
"性别",
|
||||
"出生日期",
|
||||
"手机号",
|
||||
"入职日期",
|
||||
"办公地点",
|
||||
"岗位*",
|
||||
"职级*",
|
||||
"部门编码",
|
||||
"直属上级工号",
|
||||
"财务归口",
|
||||
"成本中心",
|
||||
"在职状态*",
|
||||
"角色编码",
|
||||
)
|
||||
|
||||
HEADER_TO_FIELD: dict[str, str] = {
|
||||
"员工编号*": "employee_no",
|
||||
"姓名*": "name",
|
||||
"邮箱*": "email",
|
||||
"性别": "gender",
|
||||
"出生日期": "birth_date",
|
||||
"手机号": "phone",
|
||||
"入职日期": "join_date",
|
||||
"办公地点": "location",
|
||||
"岗位*": "position",
|
||||
"职级*": "grade",
|
||||
"部门编码": "organization_unit_code",
|
||||
"直属上级工号": "manager_employee_no",
|
||||
"财务归口": "finance_owner_name",
|
||||
"成本中心": "cost_center",
|
||||
"在职状态*": "employment_status",
|
||||
"角色编码": "role_codes",
|
||||
}
|
||||
|
||||
VALID_EMPLOYMENT_STATUSES = {"在职", "试用中", "停用"}
|
||||
DEFAULT_ROLE_CODES = ("user",)
|
||||
MAX_IMPORT_ROWS = 2000
|
||||
MAX_IMPORT_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmployeeImportRow:
|
||||
row_number: int
|
||||
employee_no: str
|
||||
name: str
|
||||
email: str
|
||||
gender: str | None
|
||||
birth_date: date | None
|
||||
phone: str | None
|
||||
join_date: date | None
|
||||
location: str | None
|
||||
position: str
|
||||
grade: str
|
||||
organization_unit_code: str | None
|
||||
manager_employee_no: str | None
|
||||
finance_owner_name: str | None
|
||||
cost_center: str | None
|
||||
employment_status: str
|
||||
role_codes: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmployeeSpreadsheetError:
|
||||
row: int
|
||||
column: str
|
||||
employee_no: str
|
||||
message: str
|
||||
|
||||
|
||||
def build_import_template_bytes() -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = EMPLOYEE_SHEET_NAME
|
||||
sheet.append(list(EMPLOYEE_HEADERS))
|
||||
|
||||
instructions = workbook.create_sheet(INSTRUCTION_SHEET_NAME)
|
||||
instructions.append(["字段", "说明"])
|
||||
instruction_rows = [
|
||||
("员工编号*", "必填,全局唯一,导入时用于判断新建或覆盖。"),
|
||||
("姓名*", "必填。"),
|
||||
("邮箱*", "必填,全局唯一。"),
|
||||
("性别", "可选:男、女,留空表示不填写。"),
|
||||
("出生日期", "可选,格式 YYYY-MM-DD。"),
|
||||
("手机号", "可选。"),
|
||||
("入职日期", "可选,格式 YYYY-MM-DD。"),
|
||||
("办公地点", "可选。"),
|
||||
("岗位*", "必填。"),
|
||||
("职级*", "必填,例如 P3、P5。"),
|
||||
("部门编码", "可选,须与系统组织编码一致,例如 FIN-SSC。"),
|
||||
("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"),
|
||||
("财务归口", "可选。"),
|
||||
("成本中心", "可选。"),
|
||||
("在职状态*", "必填:在职、试用中、停用。"),
|
||||
("角色编码", "可选,多个角色用英文逗号分隔,例如 user,finance;留空默认为 user。"),
|
||||
("导入规则", "全部校验通过后才写入数据库;任一行有错则整批不导入,原有数据保持不变。"),
|
||||
]
|
||||
for row in instruction_rows:
|
||||
instructions.append(list(row))
|
||||
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def build_export_workbook_bytes(rows: list[list[Any]]) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = EMPLOYEE_SHEET_NAME
|
||||
sheet.append(list(EMPLOYEE_HEADERS))
|
||||
for row in rows:
|
||||
sheet.append(row)
|
||||
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def parse_employee_workbook(content: bytes) -> tuple[list[EmployeeImportRow], list[EmployeeSpreadsheetError]]:
|
||||
errors: list[EmployeeSpreadsheetError] = []
|
||||
|
||||
if not content:
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message="上传文件不能为空。",
|
||||
)
|
||||
]
|
||||
|
||||
if len(content) > MAX_IMPORT_BYTES:
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message=f"文件大小不能超过 {MAX_IMPORT_BYTES // (1024 * 1024)}MB。",
|
||||
)
|
||||
]
|
||||
|
||||
try:
|
||||
workbook = load_workbook(filename=BytesIO(content), read_only=True, data_only=True)
|
||||
except Exception:
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message="无法解析 Excel 文件,请使用系统提供的 .xlsx 模板。",
|
||||
)
|
||||
]
|
||||
|
||||
if EMPLOYEE_SHEET_NAME not in workbook.sheetnames:
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="工作表",
|
||||
employee_no="",
|
||||
message=f"缺少工作表“{EMPLOYEE_SHEET_NAME}”。",
|
||||
)
|
||||
]
|
||||
|
||||
worksheet = workbook[EMPLOYEE_SHEET_NAME]
|
||||
raw_rows = list(worksheet.iter_rows(values_only=True))
|
||||
if not raw_rows:
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message="Excel 中没有可导入的数据行。",
|
||||
)
|
||||
]
|
||||
|
||||
header_row = [_normalize_cell(value) for value in raw_rows[0]]
|
||||
if list(header_row) != list(EMPLOYEE_HEADERS):
|
||||
return [], [
|
||||
EmployeeSpreadsheetError(
|
||||
row=1,
|
||||
column="表头",
|
||||
employee_no="",
|
||||
message="表头与员工导入模板不一致,请下载最新模板后重试。",
|
||||
)
|
||||
]
|
||||
|
||||
parsed_rows: list[EmployeeImportRow] = []
|
||||
for index, raw_row in enumerate(raw_rows[1:], start=2):
|
||||
if index - 1 > MAX_IMPORT_ROWS:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=index,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message=f"单次最多导入 {MAX_IMPORT_ROWS} 行数据。",
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
if _is_empty_data_row(raw_row):
|
||||
continue
|
||||
|
||||
row_errors, parsed = _parse_data_row(index, raw_row)
|
||||
errors.extend(row_errors)
|
||||
if parsed is not None:
|
||||
parsed_rows.append(parsed)
|
||||
|
||||
if not parsed_rows and not errors:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=0,
|
||||
column="文件",
|
||||
employee_no="",
|
||||
message="Excel 中没有可导入的数据行。",
|
||||
)
|
||||
)
|
||||
|
||||
return parsed_rows, errors
|
||||
|
||||
|
||||
def _parse_data_row(
|
||||
row_number: int,
|
||||
raw_row: tuple[Any, ...],
|
||||
) -> tuple[list[EmployeeSpreadsheetError], EmployeeImportRow | None]:
|
||||
errors: list[EmployeeSpreadsheetError] = []
|
||||
values = {
|
||||
HEADER_TO_FIELD[header]: _normalize_cell(raw_row[index] if index < len(raw_row) else "")
|
||||
for index, header in enumerate(EMPLOYEE_HEADERS)
|
||||
}
|
||||
employee_no = values["employee_no"]
|
||||
|
||||
def add_error(column: str, message: str) -> None:
|
||||
errors.append(
|
||||
EmployeeSpreadsheetError(
|
||||
row=row_number,
|
||||
column=column,
|
||||
employee_no=employee_no,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
if not employee_no:
|
||||
add_error("员工编号*", "员工编号不能为空。")
|
||||
|
||||
name = values["name"]
|
||||
if not name:
|
||||
add_error("姓名*", "姓名不能为空。")
|
||||
|
||||
email = values["email"].lower() if values["email"] else ""
|
||||
if not email:
|
||||
add_error("邮箱*", "邮箱不能为空。")
|
||||
elif not _is_valid_email(email):
|
||||
add_error("邮箱*", "邮箱格式不正确。")
|
||||
|
||||
position = values["position"]
|
||||
if not position:
|
||||
add_error("岗位*", "岗位不能为空。")
|
||||
|
||||
grade = values["grade"]
|
||||
if not grade:
|
||||
add_error("职级*", "职级不能为空。")
|
||||
|
||||
employment_status = values["employment_status"]
|
||||
if not employment_status:
|
||||
add_error("在职状态*", "在职状态不能为空。")
|
||||
elif employment_status not in VALID_EMPLOYMENT_STATUSES:
|
||||
add_error("在职状态*", "在职状态必须为:在职、试用中、停用。")
|
||||
|
||||
gender = values["gender"] or None
|
||||
if gender and gender not in {"男", "女"}:
|
||||
add_error("性别", "性别只能填写:男、女,或留空。")
|
||||
|
||||
birth_date, birth_error = _parse_optional_date(values["birth_date"], "出生日期")
|
||||
if birth_error:
|
||||
add_error("出生日期", birth_error)
|
||||
|
||||
join_date, join_error = _parse_optional_date(values["join_date"], "入职日期")
|
||||
if join_error:
|
||||
add_error("入职日期", join_error)
|
||||
|
||||
role_codes = _parse_role_codes(values["role_codes"])
|
||||
if values["role_codes"] and not role_codes:
|
||||
add_error("角色编码", "角色编码不能为空片段,多个角色请用英文逗号分隔。")
|
||||
|
||||
if errors:
|
||||
return errors, None
|
||||
|
||||
return (
|
||||
[],
|
||||
EmployeeImportRow(
|
||||
row_number=row_number,
|
||||
employee_no=employee_no,
|
||||
name=name,
|
||||
email=email,
|
||||
gender=gender,
|
||||
birth_date=birth_date,
|
||||
phone=values["phone"] or None,
|
||||
join_date=join_date,
|
||||
location=values["location"] or None,
|
||||
position=position,
|
||||
grade=grade,
|
||||
organization_unit_code=values["organization_unit_code"] or None,
|
||||
manager_employee_no=values["manager_employee_no"] or None,
|
||||
finance_owner_name=values["finance_owner_name"] or None,
|
||||
cost_center=values["cost_center"] or None,
|
||||
employment_status=employment_status,
|
||||
role_codes=role_codes or list(DEFAULT_ROLE_CODES),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_role_codes(value: str) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
codes = [item.strip() for item in value.replace(",", ",").split(",")]
|
||||
return list(dict.fromkeys(code for code in codes if code))
|
||||
|
||||
|
||||
def _parse_optional_date(value: str, label: str) -> tuple[date | None, str | None]:
|
||||
if not value:
|
||||
return None, None
|
||||
|
||||
if isinstance(value, datetime):
|
||||
return value.date(), None
|
||||
|
||||
if isinstance(value, date):
|
||||
return value, None
|
||||
|
||||
text = str(value).strip()
|
||||
try:
|
||||
return datetime.strptime(text, "%Y-%m-%d").date(), None
|
||||
except ValueError:
|
||||
return None, f"{label}格式必须为 YYYY-MM-DD。"
|
||||
|
||||
|
||||
def _is_valid_email(value: str) -> bool:
|
||||
_, address = parseaddr(value)
|
||||
return bool(address) and "@" in address
|
||||
|
||||
|
||||
def _normalize_cell(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d")
|
||||
if isinstance(value, date):
|
||||
return value.strftime("%Y-%m-%d")
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _is_empty_data_row(raw_row: tuple[Any, ...]) -> bool:
|
||||
return not any(_normalize_cell(value) for value in raw_row)
|
||||
@@ -54,7 +54,7 @@ EXPENSE_TYPE_LABELS = {
|
||||
"welfare": "福利",
|
||||
}
|
||||
|
||||
PRIVILEGED_CLAIM_ROLE_CODES = {"finance"}
|
||||
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
|
||||
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
|
||||
MAX_DRAFT_CLAIMS_PER_USER = 3
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES = {
|
||||
@@ -814,17 +814,19 @@ class ExpenseClaimService:
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
self._ensure_draft_claim(claim)
|
||||
before_json = self._serialize_claim(claim)
|
||||
resource_id = claim.id
|
||||
|
||||
self._delete_claim_attachment_root(claim.id)
|
||||
self.db.delete(claim)
|
||||
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if not self._has_privileged_claim_access(current_user):
|
||||
self._ensure_draft_claim(claim)
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
resource_id = claim.id
|
||||
|
||||
self._delete_claim_attachment_root(claim.id)
|
||||
self.db.delete(claim)
|
||||
self.db.commit()
|
||||
|
||||
self.audit_service.log_action(
|
||||
@@ -835,10 +837,60 @@ class ExpenseClaimService:
|
||||
before_json=before_json,
|
||||
after_json=None,
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
def upsert_draft_from_ontology(
|
||||
|
||||
return claim
|
||||
|
||||
def return_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
reason: str | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if not self._has_privileged_claim_access(current_user):
|
||||
raise ValueError("只有财务人员或高级管理人员可以退回报销单。")
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status == "draft":
|
||||
raise ValueError("草稿状态无需退回。")
|
||||
if normalized_status in {"approved", "completed", "paid"}:
|
||||
raise ValueError("已完成单据不允许退回。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = current_user.name or current_user.username
|
||||
return_reason = str(reason or "").strip()
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"severity": "medium",
|
||||
"label": "人工退回",
|
||||
"message": return_reason or f"{operator} 已退回该报销单,请申请人补充后重新提交。",
|
||||
"operator": operator,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
claim.status = "returned"
|
||||
claim.approval_stage = "待补充"
|
||||
claim.risk_flags_json = [*list(claim.risk_flags_json or []), return_flag]
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.return",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
def upsert_draft_from_ontology(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
|
||||
@@ -530,8 +530,16 @@ class UserAgentService:
|
||||
"entry_source": payload.context_json.get("entry_source"),
|
||||
"user_name": payload.context_json.get("name"),
|
||||
"user_role": payload.context_json.get("role"),
|
||||
"user_department": payload.context_json.get("department_name")
|
||||
or payload.context_json.get("department"),
|
||||
"user_position": payload.context_json.get("position"),
|
||||
"user_grade": payload.context_json.get("grade"),
|
||||
"employee_no": payload.context_json.get("employee_no"),
|
||||
"manager_name": payload.context_json.get("manager_name"),
|
||||
"employee_location": payload.context_json.get("employee_location"),
|
||||
"cost_center": payload.context_json.get("cost_center"),
|
||||
"finance_owner_name": payload.context_json.get("finance_owner_name"),
|
||||
"employee_risk_profile": payload.context_json.get("employee_risk_profile", {}),
|
||||
"user_role_codes": payload.context_json.get("role_codes", []),
|
||||
"is_admin": bool(payload.context_json.get("is_admin")),
|
||||
"request_context": payload.context_json.get("request_context"),
|
||||
@@ -2178,18 +2186,36 @@ class UserAgentService:
|
||||
title="AI预审未通过",
|
||||
level="high",
|
||||
content=reason,
|
||||
detail=(
|
||||
"该项属于提交审批前的阻断条件。系统会先要求补齐基础字段、附件或业务说明,"
|
||||
"否则审批人无法判断成本归属、业务真实性或票据有效性。"
|
||||
),
|
||||
suggestion="按提示补齐对应信息;如果业务场景本身合理,请补充说明或佐证附件后再提交。",
|
||||
)
|
||||
)
|
||||
|
||||
employee_name = self._collect_entity_values(payload).get("employee_name") or str(
|
||||
payload.context_json.get("name") or ""
|
||||
).strip()
|
||||
employee = self._resolve_employee_profile(payload)
|
||||
employee_name = (
|
||||
str(employee.name).strip()
|
||||
if employee is not None and employee.name
|
||||
else self._collect_entity_values(payload).get("employee_name")
|
||||
or str(payload.context_json.get("name") or "").strip()
|
||||
)
|
||||
if employee_name:
|
||||
since = datetime.now(UTC) - timedelta(days=90)
|
||||
stmt = select(ExpenseClaim).where(
|
||||
ExpenseClaim.employee_name == employee_name,
|
||||
ExpenseClaim.occurred_at >= since,
|
||||
)
|
||||
claim_identity_conditions = [ExpenseClaim.employee_name == employee_name]
|
||||
if employee is not None:
|
||||
employee_identifiers = {
|
||||
str(employee.name or "").strip(),
|
||||
str(employee.email or "").strip(),
|
||||
str(employee.employee_no or "").strip(),
|
||||
}
|
||||
employee_identifiers.discard("")
|
||||
claim_identity_conditions = [
|
||||
ExpenseClaim.employee_id == employee.id,
|
||||
ExpenseClaim.employee_name.in_(list(employee_identifiers)),
|
||||
]
|
||||
stmt = select(ExpenseClaim).where(or_(*claim_identity_conditions), ExpenseClaim.occurred_at >= since)
|
||||
recent_claims = list(self.db.scalars(stmt).all())
|
||||
if recent_claims:
|
||||
risky_count = sum(1 for item in recent_claims if item.risk_flags_json)
|
||||
@@ -2202,6 +2228,11 @@ class UserAgentService:
|
||||
f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销,"
|
||||
f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。"
|
||||
),
|
||||
detail=(
|
||||
"该画像来自员工近 90 天报销记录,用于辅助判断是否存在频繁草稿、"
|
||||
"历史风险或异常重复报销倾向,不会单独阻断审批。"
|
||||
),
|
||||
suggestion="如历史记录中存在风险标记,本次提交时建议主动补充业务背景和票据说明。",
|
||||
)
|
||||
)
|
||||
current_amount = self._resolve_amount_value(payload)
|
||||
@@ -2220,6 +2251,11 @@ class UserAgentService:
|
||||
f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录,"
|
||||
"提交前建议核对是否为重复报销或拆分不当。"
|
||||
),
|
||||
detail=(
|
||||
"系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规,"
|
||||
"但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。"
|
||||
),
|
||||
suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2229,6 +2265,8 @@ class UserAgentService:
|
||||
title="制度注意事项",
|
||||
level="info",
|
||||
content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。",
|
||||
detail=f"本条来自规则或知识库引用:{citations[0].title}。提交前应确认当前单据符合该条口径。",
|
||||
suggestion="如当前场景与制度口径存在差异,请补充审批说明或选择更准确的报销分类。",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2239,6 +2277,8 @@ class UserAgentService:
|
||||
title="票据识别提醒",
|
||||
level="warning",
|
||||
content=f"当前共有 {warning_count} 条票据识别提示,建议逐张确认 OCR 识别字段。",
|
||||
detail="票据 OCR 识别存在字段缺失、置信度偏低或类型判断不稳定时,会生成该提醒。",
|
||||
suggestion="打开票据明细逐张核对日期、金额、商户和票据类型,必要时更正后再提交。",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2248,6 +2288,8 @@ class UserAgentService:
|
||||
title="建议拆单",
|
||||
level="high",
|
||||
content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。",
|
||||
detail="同一批附件中包含多类费用场景时,混在一张报销单里会影响规则匹配、附件核验和审批归口。",
|
||||
suggestion="按费用场景拆成多张报销单,分别确认金额、事由和附件归属。",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2676,6 +2718,7 @@ class UserAgentService:
|
||||
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.organization_unit), selectinload(Employee.manager))
|
||||
.where(
|
||||
or_(
|
||||
Employee.name.in_(normalized),
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"file_name": "发票_3_京S98876.pdf",
|
||||
"storage_key": "1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 61170,
|
||||
"uploaded_at": "2026-05-20T02:21:35.637474+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "发票_3_京S98876.preview.png",
|
||||
"analysis": {
|
||||
"severity": "medium",
|
||||
"label": "中风险",
|
||||
"headline": "AI提示:附件存在明显待整改项",
|
||||
"summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。",
|
||||
"points": [
|
||||
"用途字段:当前费用项目为其他,但附件内容更像住宿、交通相关票据。"
|
||||
],
|
||||
"suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "vat_invoice",
|
||||
"document_type_label": "增值税发票",
|
||||
"scene_code": "other",
|
||||
"scene_label": "通用发票",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "121.54元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "日期",
|
||||
"value": "2026-03-04"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "信息"
|
||||
},
|
||||
{
|
||||
"key": "invoice_number",
|
||||
"label": "票据号码",
|
||||
"value": "26427004426998871533"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "other",
|
||||
"current_expense_type_label": "其他费用",
|
||||
"allowed_scene_labels": [
|
||||
"其他票据"
|
||||
],
|
||||
"allowed_document_type_labels": [
|
||||
"一般收据/凭证",
|
||||
"增值税发票"
|
||||
],
|
||||
"recognized_scene_code": "other",
|
||||
"recognized_scene_label": "通用发票",
|
||||
"recognized_document_type": "vat_invoice",
|
||||
"recognized_document_type_label": "增值税发票",
|
||||
"mismatch_severity": "medium",
|
||||
"rule_code": "rule.expense.scene_submission_standard",
|
||||
"rule_name": "报销场景提交与附件标准",
|
||||
"message": "当前费用项目为其他费用,已识别为增值税发票,符合当前其他费用场景的附件要求。"
|
||||
},
|
||||
"ocr_status": "recognized",
|
||||
"ocr_error": "",
|
||||
"ocr_text": "发票号码:26427004426998871533\n旅普发票)\n电子发票\n开票日期:2026年03月04日\n购买方信息\n名称:北京京能电力股份有限公司\n销售方信息\n名称:北京小桔科技有限公司\n统一社会信用代码/纳税人识别1:110000717734559Y\n统一社会信用代码/纳税人识别1:110108MA00293G5X\n项目名称\n单价\n数量\n金额\n税率/征收率\n税额\n*运输服务*客运服务费\n118.00\n1\n118.00\n3%\n3.54\n合\n计\n¥118.00\n¥3.54\n出行人\n有效身份证件号\n出行日期\n出发地\n到达地\n等级\n交通工具\n类\n2026-03-04\n小汤山酒店\n林萃花园南门\n网约车\n价税合计(大写)\n壹佰贰拾壹圆伍角肆分\n(小写)¥121.54\n购方开户银行:-;\n银行账号:-;\n备注\n销方开户银行:中国建设银行北京中关村支行;\n银行账号:11001006500059041897;\n开票人:系统自动开票",
|
||||
"ocr_summary": "发票号码:26427004426998871533;旅普发票);电子发票",
|
||||
"ocr_avg_score": 0.9825071743194093,
|
||||
"ocr_line_count": 47,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.74,
|
||||
"ocr_classification_evidence": [
|
||||
"发票号码",
|
||||
"价税合计",
|
||||
"电子发票"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
@@ -135,6 +135,112 @@ def test_enable_employee_restores_status_and_logs_change() -> None:
|
||||
assert any(item.action == "启用员工账号" for item in updated.history)
|
||||
|
||||
|
||||
def test_profile_repairs_do_not_run_on_every_list() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
employee = service.list_employees()[0]
|
||||
|
||||
updated = service.update_employee(
|
||||
employee.id,
|
||||
EmployeeUpdate(position="测试岗位-不会被回滚"),
|
||||
)
|
||||
|
||||
listed = next(item for item in service.list_employees() if item.id == employee.id)
|
||||
assert updated.position == "测试岗位-不会被回滚"
|
||||
assert listed.position == "测试岗位-不会被回滚"
|
||||
|
||||
|
||||
def test_role_update_appends_recent_history() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
employee = service.list_employees()[0]
|
||||
current_codes = list(employee.roleCodes)
|
||||
next_codes = ["finance", "user"] if "finance" not in current_codes else ["user"]
|
||||
|
||||
updated = service.update_employee(employee.id, EmployeeUpdate(role_codes=next_codes))
|
||||
|
||||
assert any("更新系统角色" in item.action for item in updated.history)
|
||||
|
||||
|
||||
def test_employee_change_logs_keep_only_latest_five() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
employee = service.list_employees()[0]
|
||||
persisted = db.get(Employee, employee.id)
|
||||
assert persisted is not None
|
||||
|
||||
for index in range(7):
|
||||
service._append_change_log(
|
||||
persisted,
|
||||
action=f"测试变更-{index}",
|
||||
owner="单元测试",
|
||||
)
|
||||
|
||||
db.commit()
|
||||
service._trim_employee_change_logs(persisted.id)
|
||||
db.commit()
|
||||
hydrated = db.get(Employee, employee.id)
|
||||
assert hydrated is not None
|
||||
assert len(hydrated.change_logs) == 5
|
||||
assert hydrated.change_logs[0].action == "测试变更-6"
|
||||
|
||||
|
||||
def test_employee_meta_includes_organization_options() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
meta = service.get_employee_meta()
|
||||
|
||||
assert meta.organizationOptions
|
||||
assert all(item.code and item.name for item in meta.organizationOptions)
|
||||
|
||||
|
||||
def test_update_employee_changes_organization() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
employee = service.list_employees()[0]
|
||||
organizations = service.repository.list_organization_units()
|
||||
current_code = employee.organization.code if employee.organization else None
|
||||
target = next(unit for unit in organizations if unit.unit_code != current_code)
|
||||
|
||||
updated = service.update_employee(
|
||||
employee.id,
|
||||
EmployeeUpdate(organization_unit_code=target.unit_code),
|
||||
)
|
||||
|
||||
assert updated.organization is not None
|
||||
assert updated.organization.code == target.unit_code
|
||||
assert updated.department == target.name
|
||||
assert any("更新员工信息" in item.action for item in updated.history)
|
||||
|
||||
|
||||
def test_update_employee_rejects_unknown_organization() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
employee = service.list_employees()[0]
|
||||
|
||||
with pytest.raises(ValueError, match="部门编码"):
|
||||
service.update_employee(
|
||||
employee.id,
|
||||
EmployeeUpdate(organization_unit_code="ORG-NOT-EXISTS"),
|
||||
)
|
||||
|
||||
|
||||
def test_update_employee_changes_manager() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
employees = service.list_employees()
|
||||
employee = employees[0]
|
||||
manager = next(item for item in employees if item.id != employee.id)
|
||||
|
||||
updated = service.update_employee(
|
||||
employee.id,
|
||||
EmployeeUpdate(manager_employee_no=manager.employeeNo),
|
||||
)
|
||||
|
||||
assert updated.managerEmployeeNo == manager.employeeNo
|
||||
assert updated.manager == manager.name
|
||||
|
||||
|
||||
def test_update_employee_rejects_invalid_date_format() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
|
||||
153
server/tests/test_employee_spreadsheet_import.py
Normal file
153
server/tests/test_employee_spreadsheet_import.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.services.employee import EmployeeService
|
||||
from app.services.employee_spreadsheet import EMPLOYEE_HEADERS, EMPLOYEE_SHEET_NAME
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def build_workbook_bytes(rows: list[list[object]]) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = EMPLOYEE_SHEET_NAME
|
||||
sheet.append(list(EMPLOYEE_HEADERS))
|
||||
for row in rows:
|
||||
sheet.append(row)
|
||||
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def test_import_employees_rejects_invalid_row_without_writing() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
first = service.list_employees()[0]
|
||||
|
||||
content = build_workbook_bytes(
|
||||
[
|
||||
[
|
||||
first.employeeNo,
|
||||
"",
|
||||
first.email,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
first.position,
|
||||
first.grade,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"在职",
|
||||
"user",
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
result = service.import_employees(content)
|
||||
|
||||
assert result.success is False
|
||||
assert result.summary.errorCount >= 1
|
||||
assert any("姓名" in item.message for item in result.errors)
|
||||
refreshed = service.get_employee(first.id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.name == first.name
|
||||
|
||||
|
||||
def test_import_employees_updates_existing_employee() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
employee = service.list_employees()[0]
|
||||
new_name = f"{employee.name}-导入"
|
||||
|
||||
content = build_workbook_bytes(
|
||||
[
|
||||
[
|
||||
employee.employeeNo,
|
||||
new_name,
|
||||
employee.email,
|
||||
"男",
|
||||
"",
|
||||
"13900000001",
|
||||
"",
|
||||
"上海",
|
||||
employee.position,
|
||||
employee.grade,
|
||||
"FIN-SSC",
|
||||
"",
|
||||
"华东财务组",
|
||||
"CC-TEST",
|
||||
"在职",
|
||||
"user",
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
result = service.import_employees(content, actor="测试管理员")
|
||||
|
||||
assert result.success is True
|
||||
assert result.summary.updated == 1
|
||||
updated = service.get_employee(employee.id)
|
||||
assert updated is not None
|
||||
assert updated.name == new_name
|
||||
assert updated.phone == "13900000001"
|
||||
|
||||
|
||||
def test_import_employees_creates_new_employee() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
service.list_employees()
|
||||
|
||||
content = build_workbook_bytes(
|
||||
[
|
||||
[
|
||||
"E90001",
|
||||
"导入新员工",
|
||||
"import.new.user@xfinance.com",
|
||||
"女",
|
||||
"",
|
||||
"13811112222",
|
||||
"2025-01-01",
|
||||
"上海",
|
||||
"业务专员",
|
||||
"P3",
|
||||
"FIN-SSC",
|
||||
"E10234",
|
||||
"华东财务组",
|
||||
"CC-9001",
|
||||
"在职",
|
||||
"user",
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
result = service.import_employees(content)
|
||||
|
||||
assert result.success is True
|
||||
assert result.summary.created == 1
|
||||
imported = db.execute(
|
||||
select(Employee).where(Employee.employee_no == "E90001")
|
||||
).scalar_one()
|
||||
assert imported.name == "导入新员工"
|
||||
assert imported.email == "import.new.user@xfinance.com"
|
||||
@@ -1222,6 +1222,111 @@ def test_list_claims_allows_finance_to_view_all_records() -> None:
|
||||
assert {claim.claim_no for claim in claims} == {"EXP-FIN-101", "EXP-FIN-102"}
|
||||
|
||||
|
||||
def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive@example.com",
|
||||
name="高管",
|
||||
role_codes=["executive"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-EXE-101",
|
||||
employee_name="甲",
|
||||
department_name="A部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="A 报销",
|
||||
location="上海",
|
||||
amount=Decimal("120.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-EXE-102",
|
||||
employee_name="乙",
|
||||
department_name="B部",
|
||||
project_code="PRJ-B",
|
||||
expense_type="meal",
|
||||
reason="B 报销",
|
||||
location="杭州",
|
||||
amount=Decimal("300.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="completed",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_claims(current_user)
|
||||
|
||||
assert len(claims) == 2
|
||||
assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"}
|
||||
|
||||
|
||||
def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance@example.com",
|
||||
name="财务",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-RET-101",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="差旅报销",
|
||||
location="上海",
|
||||
amount=Decimal("120.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
returned = service.return_claim(claim_id, current_user, reason="资料不完整")
|
||||
|
||||
assert returned is not None
|
||||
assert returned.status == "returned"
|
||||
assert returned.approval_stage == "待补充"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_return"
|
||||
and flag.get("message") == "资料不完整"
|
||||
for flag in returned.risk_flags_json
|
||||
)
|
||||
|
||||
deleted = service.delete_claim(claim_id, current_user)
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted.claim_no == "EXP-RET-101"
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager@example.com",
|
||||
|
||||
@@ -103,3 +103,39 @@ h1 { margin-top: 4px; color: var(--ink); font-size: 24px; line-height: 1.25; fon
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
|
||||
.table-loading__spinner {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: #10b981;
|
||||
border-radius: 50%;
|
||||
animation: table-spinner-rotate .8s linear infinite !important;
|
||||
}
|
||||
|
||||
.table-loading.sky .table-loading__spinner {
|
||||
border-top-color: #0ea5e9;
|
||||
}
|
||||
|
||||
.table-loading.detail .table-loading__spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.table-loading.banner .table-loading__spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.table-loading__spinner i {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes table-spinner-rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +359,11 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table-state > .table-loading,
|
||||
.detail-inline-state > .table-loading {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-state i,
|
||||
.detail-inline-state i {
|
||||
font-size: 28px;
|
||||
@@ -455,11 +460,7 @@ tbody tr:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
tbody tr.spotlight {
|
||||
background: linear-gradient(90deg, rgba(16, 185, 129, 0.05), rgba(59, 130, 246, 0.03));
|
||||
}
|
||||
|
||||
.skill-name-cell {
|
||||
.skill-name-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
@@ -942,7 +943,7 @@ tbody tr.spotlight {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.spreadsheet-change-center {
|
||||
.spreadsheet-change-center {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
@@ -956,20 +957,20 @@ tbody tr.spotlight {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.change-center-head h3,
|
||||
.change-center-head p,
|
||||
.change-center-section header,
|
||||
.change-center-section p {
|
||||
.change-center-head h3,
|
||||
.change-center-head p,
|
||||
.change-center-section header,
|
||||
.change-center-section p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.change-center-head h3 {
|
||||
.change-center-head h3 {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.change-center-head p {
|
||||
.change-center-head p {
|
||||
margin-top: 3px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
@@ -1030,37 +1031,37 @@ tbody tr.spotlight {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.change-center-section {
|
||||
.change-center-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.change-history-section {
|
||||
.change-history-section {
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.change-center-section > header {
|
||||
.change-center-section > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.change-center-section > header strong {
|
||||
.change-center-section > header strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.change-center-section > header small,
|
||||
.change-center-section > header button {
|
||||
.change-center-section > header small,
|
||||
.change-center-section > header button {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.change-center-section > header button {
|
||||
.change-center-section > header button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -1068,7 +1069,7 @@ tbody tr.spotlight {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.change-center-list {
|
||||
.change-center-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
@@ -1077,7 +1078,7 @@ tbody tr.spotlight {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.change-center-item {
|
||||
.change-center-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
@@ -1086,12 +1087,12 @@ tbody tr.spotlight {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.change-center-item.active {
|
||||
.change-center-item.active {
|
||||
border-color: rgba(16, 185, 129, 0.35);
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.change-center-item > button {
|
||||
.change-center-item > button {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
@@ -1101,31 +1102,31 @@ tbody tr.spotlight {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.change-center-item > button:disabled {
|
||||
.change-center-item > button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.change-center-item > button div {
|
||||
.change-center-item > button div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.change-center-item > button strong {
|
||||
.change-center-item > button strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.change-center-item > button span,
|
||||
.change-center-item > button p,
|
||||
.change-center-item > button small {
|
||||
.change-center-item > button span,
|
||||
.change-center-item > button p,
|
||||
.change-center-item > button small {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.change-center-item > button p {
|
||||
.change-center-item > button p {
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -1250,7 +1251,7 @@ tbody tr.spotlight {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.change-flow-empty {
|
||||
.change-flow-empty {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -1746,11 +1747,19 @@ tbody tr.spotlight {
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
background: rgba(248, 250, 252, 0.94);
|
||||
background:
|
||||
radial-gradient(circle at 50% 38%, rgba(224, 242, 254, 0.72), rgba(248, 250, 252, 0) 58%),
|
||||
rgba(248, 250, 252, 0.94);
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.rule-spreadsheet-state > .table-loading {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rule-spreadsheet-state i {
|
||||
@@ -1857,6 +1866,14 @@ tbody tr.spotlight {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subtle-banner > .table-loading {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rule-drawer-state > .table-loading {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-foot {
|
||||
margin-top: 12px;
|
||||
color: #64748b;
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
|
||||
.picker-trigger,
|
||||
.ghost-filter-btn,
|
||||
.template-btn,
|
||||
.export-btn,
|
||||
.create-btn,
|
||||
.row-action {
|
||||
min-height: 38px;
|
||||
@@ -282,6 +284,24 @@
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.template-btn,
|
||||
.export-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #d7e0ea;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.template-btn:hover,
|
||||
.export-btn:hover {
|
||||
border-color: rgba(16, 185, 129, 0.34);
|
||||
background: #f6fffb;
|
||||
color: #0f9f78;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -293,6 +313,50 @@
|
||||
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
|
||||
}
|
||||
|
||||
.create-btn:disabled,
|
||||
.template-btn:disabled,
|
||||
.export-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.import-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.import-error-table-wrap {
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.import-error-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.import-error-table th,
|
||||
.import-error-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.import-error-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.import-error-table td:last-child {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -333,13 +397,14 @@
|
||||
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.table-wrap table {
|
||||
width: 100%;
|
||||
align-self: flex-start;
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
@@ -503,7 +568,7 @@
|
||||
}
|
||||
|
||||
table {
|
||||
height: 100%;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
min-width: 1180px;
|
||||
border-collapse: collapse;
|
||||
@@ -659,9 +724,12 @@ tbody tr:last-child td {
|
||||
}
|
||||
|
||||
.role-stack {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.role-pill {
|
||||
@@ -770,6 +838,7 @@ tbody tr:last-child td {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.82fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.detail-main,
|
||||
@@ -777,6 +846,7 @@ tbody tr:last-child td {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.detail-card,
|
||||
@@ -821,6 +891,7 @@ tbody tr:last-child td {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -850,6 +921,118 @@ tbody tr:last-child td {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.manager-picker,
|
||||
.department-picker {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.manager-picker.open,
|
||||
.department-picker.open {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.manager-picker-trigger {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.manager-picker.open .manager-picker-trigger,
|
||||
.manager-picker-trigger:hover {
|
||||
border-color: rgba(16, 185, 129, 0.34);
|
||||
background: #f6fffb;
|
||||
}
|
||||
|
||||
.manager-picker-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.manager-picker-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: min(420px, 100%);
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.manager-picker-panel input[type='search'] {
|
||||
width: 100%;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.manager-picker-panel input[type='search']:focus {
|
||||
outline: none;
|
||||
border-color: rgba(16, 185, 129, 0.6);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.manager-picker-options {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.manager-picker-option {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 10px;
|
||||
background: #fbfdff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.manager-picker-option strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.manager-picker-option span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.manager-picker-option:hover,
|
||||
.manager-picker-option.active {
|
||||
border-color: rgba(16, 185, 129, 0.32);
|
||||
background: linear-gradient(180deg, rgba(240, 253, 244, 0.85), #ffffff);
|
||||
}
|
||||
|
||||
.manager-picker-empty {
|
||||
margin: 0;
|
||||
padding: 8px 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.role-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -912,9 +1095,9 @@ tbody tr:last-child td {
|
||||
|
||||
.history-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #edf2f7;
|
||||
}
|
||||
@@ -925,19 +1108,47 @@ tbody tr:last-child td {
|
||||
}
|
||||
|
||||
.history-row strong {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.history-row span,
|
||||
.history-row small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
.history-row-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.history-row-owner,
|
||||
.history-row-time {
|
||||
display: inline-block;
|
||||
margin-top: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-row-owner {
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.history-row-time {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
td.cell-updated {
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.publish-card {
|
||||
|
||||
@@ -707,6 +707,15 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inline-empty.is-loading {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.inline-empty.is-loading > .table-loading {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.inspector-empty {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
|
||||
@@ -418,12 +418,20 @@ th {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
.empty-row {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-loading-row {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-loading-row > .table-loading {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
|
||||
@@ -2246,10 +2246,78 @@
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.review-side-risk-item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 66px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: #334155;
|
||||
text-align: left;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.review-side-risk-item:hover {
|
||||
border-color: rgba(249, 115, 22, 0.38);
|
||||
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.review-side-risk-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
color: #0284c7;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.review-side-risk-item.warning .review-side-risk-icon {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.review-side-risk-item.high .review-side-risk-icon {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.review-side-risk-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.review-side-risk-copy strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.review-side-risk-copy p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.review-side-risk-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.review-side-link {
|
||||
@@ -4133,6 +4201,93 @@
|
||||
flex: 1 1 168px;
|
||||
}
|
||||
|
||||
.review-risk-detail-modal {
|
||||
width: min(560px, calc(100vw - 40px));
|
||||
max-height: min(760px, calc(100vh - 48px));
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #e7eef6;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(245, 158, 11, 0.10), transparent 28%),
|
||||
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
|
||||
box-shadow:
|
||||
0 24px 80px rgba(15, 23, 42, 0.22),
|
||||
0 2px 12px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.review-risk-detail-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px 18px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.review-risk-detail-head h3 {
|
||||
margin: 12px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 21px;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.review-risk-detail-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.review-risk-detail-level {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
padding: 0 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
color: #0284c7;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.review-risk-detail-level.warning {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.review-risk-detail-level.high {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.review-risk-detail-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.92);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.review-risk-detail-section strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.review-risk-detail-section p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.review-edit-modal {
|
||||
max-height: min(860px, calc(100vh - 48px));
|
||||
display: grid;
|
||||
|
||||
@@ -1061,6 +1061,13 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.return-action {
|
||||
min-width: 98px;
|
||||
border: 1px solid #fed7aa;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.detail-action-hint {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
@@ -1583,6 +1590,7 @@
|
||||
.ai-preview-secondary:disabled,
|
||||
.ai-preview-primary:disabled,
|
||||
.approve-action:disabled,
|
||||
.return-action:disabled,
|
||||
.ai-send-btn:disabled {
|
||||
opacity: .45;
|
||||
cursor: not-allowed;
|
||||
|
||||
176
web/src/components/shared/TableLoadingState.vue
Normal file
176
web/src/components/shared/TableLoadingState.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div
|
||||
class="table-loading"
|
||||
:class="[variant, tone]"
|
||||
role="status"
|
||||
:aria-label="ariaLabel"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="table-loading__spinner" aria-hidden="true">
|
||||
<i :class="icon"></i>
|
||||
</span>
|
||||
|
||||
<div v-if="hasCopy" class="table-loading__copy">
|
||||
<strong v-if="title">{{ title }}</strong>
|
||||
<p v-if="message">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'panel',
|
||||
validator: (value) => ['panel', 'detail', 'overlay', 'drawer', 'banner'].includes(value)
|
||||
},
|
||||
tone: {
|
||||
type: String,
|
||||
default: 'emerald',
|
||||
validator: (value) => ['emerald', 'sky'].includes(value)
|
||||
},
|
||||
title: { type: String, default: '' },
|
||||
message: { type: String, default: '' },
|
||||
icon: { type: String, default: 'mdi mdi-loading' },
|
||||
showSkeleton: { type: Boolean, default: true },
|
||||
skeletonRows: { type: Number, default: 5 }
|
||||
})
|
||||
|
||||
const hasCopy = computed(() => Boolean(props.title || props.message))
|
||||
|
||||
const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).join(', ') || 'Loading')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-loading {
|
||||
--accent: #10b981;
|
||||
--accent-deep: #059669;
|
||||
width: 100%;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.table-loading.sky {
|
||||
--accent: #0ea5e9;
|
||||
--accent-deep: #0284c7;
|
||||
}
|
||||
|
||||
.table-loading.panel {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 12px;
|
||||
padding: 28px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-loading.detail {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 22px 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table-loading.overlay,
|
||||
.table-loading.drawer {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-loading.overlay {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.table-loading.drawer {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.table-loading.banner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.table-loading__spinner {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
color: var(--accent-deep);
|
||||
animation: table-spinner-rotate 0.8s linear infinite !important;
|
||||
}
|
||||
|
||||
.table-loading.detail .table-loading__spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.table-loading.banner .table-loading__spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.table-loading__spinner i {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-loading__copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-loading.panel .table-loading__copy,
|
||||
.table-loading.overlay .table-loading__copy,
|
||||
.table-loading.drawer .table-loading__copy {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.table-loading.detail .table-loading__copy {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-loading.banner .table-loading__copy {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.table-loading__copy strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.table-loading__copy p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.table-loading.banner .table-loading__copy strong {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-loading.banner .table-loading__copy p {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@keyframes table-spinner-rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -94,10 +94,16 @@ function buildAnonymousUser() {
|
||||
departmentName: '',
|
||||
position: '',
|
||||
grade: '',
|
||||
employeeNo: '',
|
||||
managerName: '',
|
||||
location: '',
|
||||
costCenter: '',
|
||||
financeOwnerName: '',
|
||||
riskProfile: {},
|
||||
roleCodes: [],
|
||||
email: '',
|
||||
avatar: '',
|
||||
isAdmin: false
|
||||
email: '',
|
||||
avatar: '',
|
||||
isAdmin: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +119,16 @@ function buildLegacyAdminUser(username = '') {
|
||||
departmentName: '',
|
||||
position: DEFAULT_USER_ROLE,
|
||||
grade: '',
|
||||
employeeNo: '',
|
||||
managerName: '',
|
||||
location: '',
|
||||
costCenter: '',
|
||||
financeOwnerName: '',
|
||||
riskProfile: {},
|
||||
roleCodes: ['manager'],
|
||||
email: '',
|
||||
avatar: name.slice(0, 1).toUpperCase(),
|
||||
isAdmin: true
|
||||
email: '',
|
||||
avatar: name.slice(0, 1).toUpperCase(),
|
||||
isAdmin: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,10 +155,16 @@ function readStoredUser() {
|
||||
departmentName: String(payload.departmentName || payload.department || ''),
|
||||
position: String(payload.position || ''),
|
||||
grade: String(payload.grade || ''),
|
||||
employeeNo: String(payload.employeeNo || payload.employee_no || ''),
|
||||
managerName: String(payload.managerName || payload.manager_name || ''),
|
||||
location: String(payload.location || ''),
|
||||
costCenter: String(payload.costCenter || payload.cost_center || ''),
|
||||
financeOwnerName: String(payload.financeOwnerName || payload.finance_owner_name || ''),
|
||||
riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {},
|
||||
roleCodes,
|
||||
email: String(payload.email || ''),
|
||||
avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),
|
||||
isAdmin: Boolean(payload.isAdmin)
|
||||
email: String(payload.email || ''),
|
||||
avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),
|
||||
isAdmin: Boolean(payload.isAdmin)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function fetchEmployees(params = {}) {
|
||||
function buildEmployeesQuery(params = {}) {
|
||||
const search = new URLSearchParams()
|
||||
|
||||
if (params.status && params.status !== '全部员工') {
|
||||
@@ -11,10 +11,54 @@ export function fetchEmployees(params = {}) {
|
||||
search.set('keyword', params.keyword)
|
||||
}
|
||||
|
||||
const query = search.toString()
|
||||
return search
|
||||
}
|
||||
|
||||
export function fetchEmployees(params = {}) {
|
||||
const query = buildEmployeesQuery(params).toString()
|
||||
return apiRequest(`/employees${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob, filename) {
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = objectUrl
|
||||
anchor.download = filename
|
||||
anchor.rel = 'noopener'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
export async function downloadEmployeeImportTemplate() {
|
||||
const blob = await apiRequest('/employees/import-template', {
|
||||
responseType: 'blob',
|
||||
contentType: null
|
||||
})
|
||||
triggerBlobDownload(blob, '员工导入模板.xlsx')
|
||||
}
|
||||
|
||||
export async function exportEmployees(params = {}) {
|
||||
const query = buildEmployeesQuery(params).toString()
|
||||
const blob = await apiRequest(`/employees/export${query ? `?${query}` : ''}`, {
|
||||
responseType: 'blob',
|
||||
contentType: null
|
||||
})
|
||||
triggerBlobDownload(blob, '员工目录导出.xlsx')
|
||||
}
|
||||
|
||||
export function importEmployees(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return apiRequest('/employees/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
contentType: null
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchEmployeeMeta() {
|
||||
return apiRequest('/employees/meta')
|
||||
}
|
||||
|
||||
@@ -87,6 +87,13 @@ export function submitExpenseClaim(claimId) {
|
||||
})
|
||||
}
|
||||
|
||||
export function returnExpenseClaim(claimId, payload = {}) {
|
||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/return`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteExpenseClaim(claimId) {
|
||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, {
|
||||
method: 'DELETE'
|
||||
|
||||
@@ -11,22 +11,25 @@ export const DEFAULT_APP_VIEW_ORDER = [
|
||||
]
|
||||
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
approval: ['approver'],
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
approval: ['approver', 'finance', 'executive'],
|
||||
audit: ['auditor', 'finance'],
|
||||
logs: ['manager'],
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
}
|
||||
const CLAIM_MANAGER_ROLE_CODES = new Set(['finance', 'executive'])
|
||||
|
||||
function normalizedRoleCodes(user) {
|
||||
if (!user) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : []
|
||||
}
|
||||
return Array.isArray(user.roleCodes)
|
||||
? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
export function isManagerUser(user) {
|
||||
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
||||
@@ -35,8 +38,20 @@ export function isManagerUser(user) {
|
||||
export function isFinanceUser(user) {
|
||||
return normalizedRoleCodes(user).includes('finance')
|
||||
}
|
||||
|
||||
export function canAccessAppView(user, viewId) {
|
||||
|
||||
export function isExecutiveUser(user) {
|
||||
return normalizedRoleCodes(user).includes('executive')
|
||||
}
|
||||
|
||||
export function canManageExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canAccessAppView(user, viewId) {
|
||||
if (!viewId || !user) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -391,9 +391,34 @@
|
||||
<span>退回列表</span>
|
||||
</button>
|
||||
<div class="approval-action-group" aria-label="审批操作">
|
||||
<button class="approve-action" type="button"><i class="mdi mdi-check-circle-outline"></i> 通过</button>
|
||||
<button class="reject-action" type="button"><i class="mdi mdi-close-circle-outline"></i> 驳回</button>
|
||||
<button class="supplement-action" type="button"><i class="mdi mdi-undo"></i> 补充</button>
|
||||
<button class="approve-action" type="button" :disabled="actionBusy">
|
||||
<i class="mdi mdi-check-circle-outline"></i> 通过
|
||||
</button>
|
||||
<button
|
||||
class="reject-action"
|
||||
type="button"
|
||||
:disabled="!canManageClaims || actionBusy"
|
||||
@click="handleReturnSelected"
|
||||
>
|
||||
<i class="mdi mdi-close-circle-outline"></i> 驳回
|
||||
</button>
|
||||
<button
|
||||
class="supplement-action"
|
||||
type="button"
|
||||
:disabled="!canManageClaims || actionBusy"
|
||||
@click="handleReturnSelected"
|
||||
>
|
||||
<i class="mdi mdi-undo"></i> 补充
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageClaims"
|
||||
class="reject-action"
|
||||
type="button"
|
||||
:disabled="actionBusy"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="mdi mdi-trash-can-outline"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -430,9 +455,11 @@
|
||||
|
||||
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
|
||||
<div v-if="loading" class="table-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<strong>正在加载审批待办</strong>
|
||||
<p>直属领导和财务节点下可处理的报销单据会直接展示在这里。</p>
|
||||
<TableLoadingState
|
||||
title="审批待办同步中"
|
||||
message="正在加载当前可见的待审报销单据"
|
||||
icon="mdi mdi-clipboard-check-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="table-state error">
|
||||
@@ -502,6 +529,38 @@
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="returnDialogOpen"
|
||||
badge="退回单据"
|
||||
badge-tone="warning"
|
||||
:title="`确认退回 ${selectedRow?.id || ''} 吗?`"
|
||||
description="退回后该单据会进入待补充状态,申请人需要补充后重新提交。"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认退回"
|
||||
busy-text="退回中..."
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-undo"
|
||||
:busy="actionBusy"
|
||||
@close="closeReturnDialog"
|
||||
@confirm="confirmReturnSelected"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="deleteDialogOpen"
|
||||
badge="删除单据"
|
||||
badge-tone="danger"
|
||||
:title="`确认删除 ${selectedRow?.id || ''} 吗?`"
|
||||
description="删除后该报销单及费用明细将不可恢复,请确认本次操作。"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认删除"
|
||||
busy-text="删除中..."
|
||||
confirm-tone="danger"
|
||||
confirm-icon="mdi mdi-trash-can-outline"
|
||||
:busy="actionBusy"
|
||||
@close="closeDeleteDialog"
|
||||
@confirm="confirmDeleteSelected"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -64,6 +64,40 @@
|
||||
<div class="hero-stat">
|
||||
<span>{{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }}</span>
|
||||
<strong>{{ selectedSkill.displayVersion || selectedSkill.version }}</strong>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span>最近更新</span>
|
||||
<strong>{{ selectedSkill.updatedAt }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="detailError" class="detail-inline-state panel error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<div>
|
||||
<strong>资产详情加载失败</strong>
|
||||
<p>{{ detailError }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TableLoadingState
|
||||
v-else-if="detailLoading && selectedSkill.loading"
|
||||
class="detail-inline-state panel"
|
||||
variant="detail"
|
||||
title="正在加载资产详情"
|
||||
message="列表数据已就绪,正在补充版本、审核和运行信息"
|
||||
icon="mdi mdi-file-document-outline"
|
||||
:show-skeleton="false"
|
||||
/>
|
||||
|
||||
<section
|
||||
v-else-if="selectedSkill.usesSpreadsheetRule"
|
||||
class="spreadsheet-editor-shell panel"
|
||||
>
|
||||
<header class="spreadsheet-editor-head">
|
||||
<div class="spreadsheet-editor-title">
|
||||
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
|
||||
<div>
|
||||
<h2>{{ selectedSkill.name }}</h2>
|
||||
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
|
||||
</div>
|
||||
@@ -71,7 +105,7 @@
|
||||
|
||||
<div class="spreadsheet-editor-actions">
|
||||
<span class="spreadsheet-mode-pill">
|
||||
{{ selectedSpreadsheetModeLabel }}
|
||||
{{ selectedSpreadsheetModeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -99,10 +133,15 @@
|
||||
class="rule-spreadsheet-host"
|
||||
:class="{ hidden: !spreadsheetOnlyOfficeReady && !spreadsheetOnlyOfficeError }"
|
||||
></div>
|
||||
<div v-if="spreadsheetOnlyOfficeLoading" class="rule-spreadsheet-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在加载 Excel 规则表...</span>
|
||||
</div>
|
||||
<TableLoadingState
|
||||
v-if="spreadsheetOnlyOfficeLoading"
|
||||
class="rule-spreadsheet-state"
|
||||
variant="overlay"
|
||||
tone="sky"
|
||||
message="正在加载 Excel 规则表"
|
||||
icon="mdi mdi-table-large"
|
||||
:show-skeleton="false"
|
||||
/>
|
||||
<div v-else-if="spreadsheetOnlyOfficeError" class="rule-spreadsheet-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ spreadsheetOnlyOfficeError }}</span>
|
||||
@@ -121,34 +160,34 @@
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<aside class="spreadsheet-change-center">
|
||||
<header class="change-center-head">
|
||||
<div>
|
||||
<h3>最近修改</h3>
|
||||
<p>展示最近 30 次保存后的具体改动。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="change-center-section change-history-section">
|
||||
<div v-if="selectedSpreadsheetChangeRecords.length" class="change-center-list">
|
||||
<button
|
||||
v-for="item in selectedSpreadsheetChangeRecords"
|
||||
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
|
||||
type="button"
|
||||
class="change-center-item change-record-item"
|
||||
@click="openSpreadsheetChangeDetail(item)"
|
||||
>
|
||||
<aside class="spreadsheet-change-center">
|
||||
<header class="change-center-head">
|
||||
<div>
|
||||
<h3>最近修改</h3>
|
||||
<p>展示最近 30 次保存后的具体改动。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="change-center-section change-history-section">
|
||||
<div v-if="selectedSpreadsheetChangeRecords.length" class="change-center-list">
|
||||
<button
|
||||
v-for="item in selectedSpreadsheetChangeRecords"
|
||||
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
|
||||
type="button"
|
||||
class="change-center-item change-record-item"
|
||||
@click="openSpreadsheetChangeDetail(item)"
|
||||
>
|
||||
<div class="change-record-head">
|
||||
<div>
|
||||
<strong>{{ item.actor }}</strong>
|
||||
<span>{{ item.time }}</span>
|
||||
</div>
|
||||
<b>{{ item.changeCountLabel }}</b>
|
||||
</div>
|
||||
<p>{{ item.summary }}</p>
|
||||
<small v-if="item.sheetPreview.length">
|
||||
涉及工作表:{{ item.sheetPreview.join('、') }}
|
||||
<template v-if="item.remainingSheetCount"> 等 {{ item.changedSheetNames.length }} 个</template>
|
||||
</div>
|
||||
<p>{{ item.summary }}</p>
|
||||
<small v-if="item.sheetPreview.length">
|
||||
涉及工作表:{{ item.sheetPreview.join('、') }}
|
||||
<template v-if="item.remainingSheetCount"> 等 {{ item.changedSheetNames.length }} 个</template>
|
||||
</small>
|
||||
<div v-if="item.previewChanges.length" class="change-record-preview">
|
||||
<span
|
||||
@@ -164,9 +203,9 @@
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="change-flow-empty">暂无修改记录</p>
|
||||
</section>
|
||||
</aside>
|
||||
<p v-else class="change-flow-empty">暂无修改记录</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -319,10 +358,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLoading" class="subtle-banner">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在刷新规则详情...</span>
|
||||
</div>
|
||||
<TableLoadingState
|
||||
v-if="detailLoading"
|
||||
class="subtle-banner"
|
||||
variant="banner"
|
||||
tone="sky"
|
||||
message="正在刷新规则详情"
|
||||
icon="mdi mdi-refresh"
|
||||
:show-skeleton="false"
|
||||
/>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ selectedSkill.code }}</span>
|
||||
@@ -799,11 +843,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showStatusFilter"
|
||||
class="picker-filter"
|
||||
:class="{ open: activeFilterPopover === 'status' }"
|
||||
>
|
||||
<div
|
||||
v-if="showStatusFilter"
|
||||
class="picker-filter"
|
||||
:class="{ open: activeFilterPopover === 'status' }"
|
||||
>
|
||||
|
||||
<button
|
||||
class="picker-trigger"
|
||||
@@ -866,8 +910,12 @@
|
||||
|
||||
<div class="table-wrap" :class="{ 'is-empty': !loading && !errorMessage && !visibleSkills.length }">
|
||||
<div v-if="loading" class="table-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<p>正在加载{{ activeTabLabel }}资产...</p>
|
||||
<TableLoadingState
|
||||
variant="panel"
|
||||
:title="`${activeTabLabel}资产同步中`"
|
||||
:message="`正在加载${activeTabLabel}资产`"
|
||||
icon="mdi mdi-view-list-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="table-state error">
|
||||
@@ -904,12 +952,11 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="skill in visibleSkills"
|
||||
:key="skill.id"
|
||||
:class="{ spotlight: skill.spotlight }"
|
||||
@click="openAssetDetail(skill)"
|
||||
>
|
||||
<tr
|
||||
v-for="skill in visibleSkills"
|
||||
:key="skill.id"
|
||||
@click="openAssetDetail(skill)"
|
||||
>
|
||||
<td>
|
||||
<div class="skill-name-cell">
|
||||
<span class="skill-avatar" :class="skill.badgeTone">{{ skill.short }}</span>
|
||||
@@ -1032,10 +1079,14 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="versionTimelineLoading" class="rule-drawer-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在加载操作记录...</span>
|
||||
</div>
|
||||
<TableLoadingState
|
||||
v-if="versionTimelineLoading"
|
||||
class="rule-drawer-state"
|
||||
variant="drawer"
|
||||
message="正在加载操作记录"
|
||||
icon="mdi mdi-history"
|
||||
:show-skeleton="false"
|
||||
/>
|
||||
<div v-else-if="versionTimelineError" class="rule-drawer-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ versionTimelineError }}</span>
|
||||
@@ -1053,9 +1104,9 @@
|
||||
<span>{{ item.timeLabel }}</span>
|
||||
</header>
|
||||
<p>{{ item.description || item.note || '暂无补充说明' }}</p>
|
||||
<small>
|
||||
操作人:{{ item.actor }}
|
||||
</small>
|
||||
<small>
|
||||
操作人:{{ item.actor }}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -1094,8 +1145,8 @@
|
||||
<span>修改时间</span>
|
||||
<strong>{{ selectedSpreadsheetChangeRecord.time }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>修改工作表</span>
|
||||
<article>
|
||||
<span>修改工作表</span>
|
||||
<strong>{{ selectedSpreadsheetChangeRecord.changed_sheet_count }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
|
||||
@@ -61,11 +61,23 @@
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>年龄</span>
|
||||
<input :value="detailAge" readonly />
|
||||
<input
|
||||
v-model="employeeForm.age"
|
||||
type="number"
|
||||
min="0"
|
||||
max="120"
|
||||
inputmode="numeric"
|
||||
placeholder="请输入年龄"
|
||||
@input="syncBirthDateFromAge"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>出生日期</span>
|
||||
<input v-model="employeeForm.birthDate" type="date" />
|
||||
<input
|
||||
v-model="employeeForm.birthDate"
|
||||
type="date"
|
||||
@change="syncAgeFromBirthDate"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>手机号</span>
|
||||
@@ -104,9 +116,50 @@
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<label
|
||||
class="field manager-picker department-picker"
|
||||
:class="{ open: departmentPickerOpen }"
|
||||
>
|
||||
<span>所属部门</span>
|
||||
<input v-model="employeeForm.department" readonly />
|
||||
<button
|
||||
class="manager-picker-trigger"
|
||||
type="button"
|
||||
@click.stop="toggleDepartmentPicker"
|
||||
>
|
||||
<span class="manager-picker-label">{{ departmentDisplayLabel }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="departmentPickerOpen"
|
||||
class="manager-picker-panel"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-model="departmentSearchKeyword"
|
||||
type="search"
|
||||
placeholder="输入部门名称或编码搜索"
|
||||
@keydown.enter.prevent="resolveDepartmentSelectionFromKeyword"
|
||||
/>
|
||||
<div class="manager-picker-options">
|
||||
<button
|
||||
v-for="option in filteredDepartmentOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="manager-picker-option"
|
||||
:class="{ active: employeeForm.organizationUnitCode === option.code }"
|
||||
@click="selectDepartment(option)"
|
||||
>
|
||||
<strong>{{ option.name }}({{ option.code }})</strong>
|
||||
<span v-if="option.unitType">{{ option.unitType }}</span>
|
||||
</button>
|
||||
<p
|
||||
v-if="!filteredDepartmentOptions.length"
|
||||
class="manager-picker-empty"
|
||||
>
|
||||
没有匹配的部门,请调整搜索关键词。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>岗位</span>
|
||||
@@ -116,9 +169,56 @@
|
||||
<span>职级</span>
|
||||
<input v-model="employeeForm.grade" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<label class="field manager-picker" :class="{ open: managerPickerOpen }">
|
||||
<span>直属上级</span>
|
||||
<input v-model="employeeForm.manager" readonly />
|
||||
<button
|
||||
class="manager-picker-trigger"
|
||||
type="button"
|
||||
@click.stop="toggleManagerPicker"
|
||||
>
|
||||
<span class="manager-picker-label">{{ managerDisplayLabel }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="managerPickerOpen"
|
||||
class="manager-picker-panel"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-model="managerSearchKeyword"
|
||||
type="search"
|
||||
placeholder="输入姓名、工号或部门搜索"
|
||||
@keydown.enter.prevent="resolveManagerSelectionFromKeyword"
|
||||
/>
|
||||
<div class="manager-picker-options">
|
||||
<button
|
||||
type="button"
|
||||
class="manager-picker-option"
|
||||
:class="{ active: !hasManagerAssignment }"
|
||||
@click="selectManager(null)"
|
||||
>
|
||||
<strong>无直属上级</strong>
|
||||
<span>清空当前设置</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="option in filteredManagerOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="manager-picker-option"
|
||||
:class="{ active: employeeForm.managerEmployeeNo === option.employeeNo }"
|
||||
@click="selectManager(option)"
|
||||
>
|
||||
<strong>{{ option.name }}({{ option.employeeNo }})</strong>
|
||||
<span>{{ option.department }} / {{ option.position }}</span>
|
||||
</button>
|
||||
<p
|
||||
v-if="!filteredManagerOptions.length"
|
||||
class="manager-picker-empty"
|
||||
>
|
||||
没有匹配的员工,请调整搜索关键词。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>财务归口</span>
|
||||
@@ -177,15 +277,26 @@
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>最近变更</h3>
|
||||
<p>查看角色与档案调整记录</p>
|
||||
<p>仅保留最近 5 次角色与档案调整记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-list">
|
||||
<div v-for="item in selectedEmployee.history" :key="item.time" class="history-row">
|
||||
<div
|
||||
v-for="item in recentEmployeeHistory"
|
||||
:key="`${item.occurredAt || item.time}-${item.action}`"
|
||||
class="history-row"
|
||||
>
|
||||
<strong>{{ item.action }}</strong>
|
||||
<span>{{ item.owner }}</span>
|
||||
<small>{{ item.time }}</small>
|
||||
<div class="history-row-meta">
|
||||
<span class="history-row-owner">{{ item.owner }}</span>
|
||||
<small class="history-row-time">{{
|
||||
formatEmployeeHistoryTime(item.time || item.occurredAt)
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!recentEmployeeHistory.length" class="manager-picker-empty">
|
||||
暂无变更记录
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -392,16 +503,34 @@
|
||||
<span>清空筛选</span>
|
||||
</button>
|
||||
|
||||
<button class="create-btn" type="button">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>新增员工</span>
|
||||
<button class="template-btn" type="button" :disabled="importExportBusy" @click="handleDownloadTemplate">
|
||||
<i class="mdi mdi-file-download-outline"></i>
|
||||
<span>下载模板</span>
|
||||
</button>
|
||||
|
||||
<button class="export-btn" type="button" :disabled="importExportBusy" @click="handleExportEmployees">
|
||||
<i class="mdi mdi-export"></i>
|
||||
<span>{{ actionState === 'export' ? '导出中...' : '导出员工' }}</span>
|
||||
</button>
|
||||
|
||||
<button class="create-btn" type="button" :disabled="importExportBusy" @click="openImportFilePicker">
|
||||
<i class="mdi mdi-file-upload-outline"></i>
|
||||
<span>{{ actionState === 'import' ? '导入中...' : '导入员工' }}</span>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref="importFileInput"
|
||||
class="import-file-input"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
@change="handleImportFileChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
点击任意员工行可进入基础信息与角色权限编辑界面
|
||||
点击任意员工行可进入基础信息与角色权限编辑界面;导入将按员工编号覆盖已有档案,任一行校验失败则整批不写入。
|
||||
</p>
|
||||
|
||||
<div v-if="activeFilterTokens.length" class="active-filter-strip">
|
||||
@@ -412,8 +541,11 @@
|
||||
|
||||
<div class="table-wrap">
|
||||
<div v-if="loading" class="table-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<p>正在加载员工数据...</p>
|
||||
<TableLoadingState
|
||||
title="员工数据同步中"
|
||||
message="正在加载员工档案与角色权限"
|
||||
icon="mdi mdi-account-group-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="table-state error">
|
||||
@@ -496,7 +628,7 @@
|
||||
<td>
|
||||
<span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span>
|
||||
</td>
|
||||
<td>{{ employee.updatedAt }}</td>
|
||||
<td class="cell-updated">{{ employee.updatedAt }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -572,9 +704,62 @@
|
||||
@close="closeDisableDialog"
|
||||
@confirm="confirmDisableEmployeeAccount"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="importConfirmDialogOpen"
|
||||
badge="导入确认"
|
||||
badge-tone="warning"
|
||||
title="确认导入员工 Excel?"
|
||||
description="系统将先校验全部数据,全部通过后才写入数据库。若存在错误,将不会修改任何现有员工信息。"
|
||||
cancel-text="取消"
|
||||
confirm-text="开始导入"
|
||||
busy-text="导入中..."
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-file-upload-outline"
|
||||
:busy="actionState === 'import'"
|
||||
@close="closeImportConfirmDialog"
|
||||
@confirm="confirmImportEmployees"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="importErrorDialogOpen"
|
||||
badge="导入失败"
|
||||
badge-tone="danger"
|
||||
title="导入未执行"
|
||||
:description="importResultMessage"
|
||||
cancel-text="关闭"
|
||||
confirm-text="下载模板"
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-file-download-outline"
|
||||
@close="closeImportErrorDialog"
|
||||
@confirm="handleDownloadTemplate"
|
||||
>
|
||||
<div class="import-error-table-wrap">
|
||||
<table class="import-error-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>行号</th>
|
||||
<th>字段</th>
|
||||
<th>工号</th>
|
||||
<th>原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in importErrors" :key="`${item.row}-${item.column}-${index}`">
|
||||
<td>{{ item.row || '-' }}</td>
|
||||
<td>{{ item.column }}</td>
|
||||
<td>{{ item.employeeNo || '-' }}</td>
|
||||
<td>{{ item.message }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script src="./scripts/EmployeeManagementView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/employee-management-view.css"></style>
|
||||
<style scoped>
|
||||
@import "../assets/styles/views/employee-management-view.css";
|
||||
</style>
|
||||
|
||||
@@ -128,8 +128,18 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!filteredHermesRuns.length" class="inline-empty">
|
||||
{{ hermesLoading ? '正在加载 Hermes 运行日志...' : '当前筛选条件下没有 Hermes 记录。' }}
|
||||
<div
|
||||
v-if="!filteredHermesRuns.length"
|
||||
class="inline-empty"
|
||||
:class="{ 'is-loading': hermesLoading }"
|
||||
>
|
||||
<TableLoadingState
|
||||
v-if="hermesLoading"
|
||||
title="Hermes 日志同步中"
|
||||
message="正在加载运行日志记录"
|
||||
icon="mdi mdi-text-box-search-outline"
|
||||
/>
|
||||
<span v-else>当前筛选条件下没有 Hermes 记录。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,8 +184,18 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!filteredSystemLogEntries.length" class="inline-empty">
|
||||
{{ systemLogLoading ? '正在加载系统日志...' : '当前筛选条件下没有系统日志记录。' }}
|
||||
<div
|
||||
v-if="!filteredSystemLogEntries.length"
|
||||
class="inline-empty"
|
||||
:class="{ 'is-loading': systemLogLoading }"
|
||||
>
|
||||
<TableLoadingState
|
||||
v-if="systemLogLoading"
|
||||
title="系统日志同步中"
|
||||
message="正在加载系统日志记录"
|
||||
icon="mdi mdi-text-box-search-outline"
|
||||
/>
|
||||
<span v-else>当前筛选条件下没有系统日志记录。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -129,11 +129,20 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!visibleDocuments.length">
|
||||
<td colspan="7" class="empty-row">
|
||||
{{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="loading && !visibleDocuments.length">
|
||||
<td colspan="7" class="empty-row table-loading-row">
|
||||
<TableLoadingState
|
||||
title="知识库文件同步中"
|
||||
message="正在加载当前文件夹的知识库文件"
|
||||
icon="mdi mdi-folder-table-outline"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="!visibleDocuments.length">
|
||||
<td colspan="7" class="empty-row">
|
||||
当前文件夹暂无文件
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -62,9 +62,11 @@
|
||||
|
||||
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
|
||||
<div v-if="loading" class="table-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<strong>正在加载真实报销数据</strong>
|
||||
<p>列表将直接展示后端返回的个人报销单据。</p>
|
||||
<TableLoadingState
|
||||
title="真实报销数据同步中"
|
||||
message="正在加载后端返回的个人报销单据"
|
||||
icon="mdi mdi-file-document-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="table-state error">
|
||||
|
||||
@@ -511,18 +511,23 @@
|
||||
<div v-if="composerDateMode === 'single'" class="composer-date-fields">
|
||||
<label class="composer-date-field">
|
||||
<span>日期</span>
|
||||
<input v-model="composerSingleDate" type="date" />
|
||||
<input v-model="composerSingleDate" type="date" @change="handleComposerDateInputChange" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="composer-date-fields composer-date-fields-range">
|
||||
<label class="composer-date-field">
|
||||
<span>开始</span>
|
||||
<input v-model="composerRangeStartDate" type="date" />
|
||||
<input v-model="composerRangeStartDate" type="date" @change="handleComposerDateInputChange" />
|
||||
</label>
|
||||
<span class="composer-date-range-sep">至</span>
|
||||
<label class="composer-date-field">
|
||||
<span>结束</span>
|
||||
<input v-model="composerRangeEndDate" type="date" :min="composerRangeStartDate" />
|
||||
<input
|
||||
v-model="composerRangeEndDate"
|
||||
type="date"
|
||||
:min="composerRangeStartDate"
|
||||
@change="handleComposerDateInputChange"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="composerDateMode === 'range' && !composerCanApplyDateSelection" class="composer-date-hint">
|
||||
@@ -1078,9 +1083,28 @@
|
||||
</span>
|
||||
</div>
|
||||
<p class="review-side-risk-summary">{{ reviewRiskSummary }}</p>
|
||||
<ul v-if="reviewRiskItems.length" class="review-side-risk-list">
|
||||
<li v-for="item in reviewRiskItems" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
<div v-if="reviewRiskItems.length" class="review-side-risk-list">
|
||||
<button
|
||||
v-for="item in reviewRiskItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="review-side-risk-item"
|
||||
:class="item.level"
|
||||
@click="openReviewRiskDetail(item)"
|
||||
>
|
||||
<span class="review-side-risk-icon">
|
||||
<i :class="item.icon"></i>
|
||||
</span>
|
||||
<span class="review-side-risk-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<p>{{ item.summary }}</p>
|
||||
</span>
|
||||
<span class="review-side-risk-meta">
|
||||
{{ item.levelLabel }}
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="reviewRiskEmpty" class="review-side-empty">
|
||||
<span class="review-side-empty-icon">
|
||||
<i class="mdi mdi-shield-check-outline"></i>
|
||||
@@ -1088,16 +1112,6 @@
|
||||
<strong>暂无风险评分</strong>
|
||||
<p>当前版本还没有返回结构化风险评分结果,这里先不展示虚拟分数。</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="reviewRiskActionAvailable"
|
||||
type="button"
|
||||
class="review-side-link"
|
||||
:disabled="submitting || reviewActionBusy"
|
||||
@click="explainCurrentReviewRisk"
|
||||
>
|
||||
查看全部风险项
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1192,6 +1206,41 @@
|
||||
@confirm="confirmCancelReview"
|
||||
/>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="reviewRiskDetailDialog.open" class="assistant-overlay review-overlay">
|
||||
<section class="review-risk-detail-modal">
|
||||
<header class="review-risk-detail-head">
|
||||
<div>
|
||||
<span class="assistant-badge warning">{{ reviewRiskDetailDialog.item?.sourceLabel || 'AI预审' }}</span>
|
||||
<h3>{{ reviewRiskDetailDialog.item?.title || '风险提示' }}</h3>
|
||||
</div>
|
||||
<button class="close-btn" type="button" aria-label="关闭风险说明" @click="closeReviewRiskDetail">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="review-risk-detail-body">
|
||||
<div class="review-risk-detail-level" :class="reviewRiskDetailDialog.item?.level">
|
||||
<i :class="reviewRiskDetailDialog.item?.icon || 'mdi mdi-information-outline'"></i>
|
||||
<span>{{ reviewRiskDetailDialog.item?.levelLabel || '提示' }}</span>
|
||||
</div>
|
||||
<article class="review-risk-detail-section">
|
||||
<strong>提示情况</strong>
|
||||
<p>{{ reviewRiskDetailDialog.item?.summary }}</p>
|
||||
</article>
|
||||
<article class="review-risk-detail-section">
|
||||
<strong>详细解释</strong>
|
||||
<p>{{ reviewRiskDetailDialog.item?.detail }}</p>
|
||||
</article>
|
||||
<article class="review-risk-detail-section">
|
||||
<strong>处理建议</strong>
|
||||
<p>{{ reviewRiskDetailDialog.item?.suggestion }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
|
||||
<section class="review-confirm-modal review-upload-decision-modal">
|
||||
|
||||
@@ -394,7 +394,7 @@
|
||||
<span>返回报销列表</span>
|
||||
</button>
|
||||
<div v-if="isDraftRequest" class="approval-action-group" aria-label="申请操作">
|
||||
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteDraft">
|
||||
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
{{ deleteBusy ? '删除中' : '删除草稿' }}
|
||||
</button>
|
||||
@@ -403,6 +403,22 @@
|
||||
{{ submitBusy ? '提交中' : '提交审批' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
|
||||
<button
|
||||
v-if="canReturnRequest"
|
||||
class="return-action"
|
||||
type="button"
|
||||
:disabled="actionBusy"
|
||||
@click="handleReturnRequest"
|
||||
>
|
||||
<i class="mdi mdi-undo"></i>
|
||||
{{ returnBusy ? '退回中' : '退回单据' }}
|
||||
</button>
|
||||
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
{{ deleteBusy ? '删除中' : '删除单据' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="detail-action-hint">当前单据已进入流程,详情页仅展示状态与费用明细。</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -465,10 +481,10 @@
|
||||
|
||||
<ConfirmDialog
|
||||
:open="deleteDialogOpen"
|
||||
badge="删除草稿"
|
||||
:badge="deleteActionLabel"
|
||||
badge-tone="danger"
|
||||
:title="`确认删除草稿 ${request.id} 吗?`"
|
||||
description="删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。"
|
||||
:title="deleteDialogTitle"
|
||||
:description="deleteDialogDescription"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认删除"
|
||||
busy-text="删除中..."
|
||||
@@ -476,7 +492,23 @@
|
||||
confirm-icon="mdi mdi-trash-can-outline"
|
||||
:busy="deleteBusy"
|
||||
@close="closeDeleteDialog"
|
||||
@confirm="confirmDeleteDraft"
|
||||
@confirm="confirmDeleteRequest"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="returnDialogOpen"
|
||||
badge="退回单据"
|
||||
badge-tone="warning"
|
||||
:title="`确认退回 ${request.id} 吗?`"
|
||||
description="退回后该单据会进入待补充状态,申请人需要补充后重新提交。"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认退回"
|
||||
busy-text="退回中..."
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-undo"
|
||||
:busy="returnBusy"
|
||||
@close="closeReturnDialog"
|
||||
@confirm="confirmReturnRequest"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { deleteExpenseClaim, fetchExpenseClaims, returnExpenseClaim } from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims } from '../../utils/accessControl.js'
|
||||
|
||||
const DEFAULT_SLA_HOURS = 24
|
||||
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
|
||||
@@ -195,7 +199,6 @@ function buildFlowItems(request) {
|
||||
|
||||
function canCurrentUserProcessRequest(request, currentUser) {
|
||||
const node = String(request?.workflowNode || '').trim()
|
||||
const roleCodes = Array.isArray(currentUser?.roleCodes) ? currentUser.roleCodes.filter(Boolean) : []
|
||||
const currentName = String(currentUser?.name || '').trim()
|
||||
const applicantName = String(request?.person || request?.employeeName || '').trim()
|
||||
|
||||
@@ -203,8 +206,8 @@ function canCurrentUserProcessRequest(request, currentUser) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentUser?.isAdmin || roleCodes.includes('finance')) {
|
||||
return node.includes('财务')
|
||||
if (canManageExpenseClaims(currentUser)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -251,10 +254,13 @@ function buildApprovalRow(request) {
|
||||
export default {
|
||||
name: 'ApprovalCenterView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
setup() {
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const activeTab = ref('全部待审')
|
||||
const selectedClaimId = ref('')
|
||||
const expandedExpenseId = ref(null)
|
||||
@@ -262,6 +268,9 @@ export default {
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const actionBusy = ref(false)
|
||||
const returnDialogOpen = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
|
||||
const selectedRow = computed({
|
||||
get() {
|
||||
@@ -303,6 +312,7 @@ export default {
|
||||
})
|
||||
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
|
||||
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
|
||||
const canManageClaims = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const approvalEmptyState = computed(() => {
|
||||
if (!rows.value.length) {
|
||||
return {
|
||||
@@ -381,6 +391,76 @@ export default {
|
||||
activeTab.value = '全部待审'
|
||||
}
|
||||
|
||||
function handleReturnSelected() {
|
||||
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
returnDialogOpen.value = true
|
||||
}
|
||||
|
||||
function handleDeleteSelected() {
|
||||
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeReturnDialog() {
|
||||
if (!actionBusy.value) {
|
||||
returnDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (!actionBusy.value) {
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReturnSelected() {
|
||||
const row = selectedRow.value
|
||||
if (!row?.claimId || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionBusy.value = true
|
||||
try {
|
||||
await returnExpenseClaim(row.claimId, {
|
||||
reason: '审批中心退回,请申请人补充后重新提交。'
|
||||
})
|
||||
toast(`${row.id} 已退回待补充。`)
|
||||
returnDialogOpen.value = false
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
} catch (nextError) {
|
||||
toast(nextError?.message || '退回单据失败,请稍后重试。')
|
||||
} finally {
|
||||
actionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteSelected() {
|
||||
const row = selectedRow.value
|
||||
if (!row?.claimId || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionBusy.value = true
|
||||
try {
|
||||
const payload = await deleteExpenseClaim(row.claimId)
|
||||
toast(payload?.message || `${row.id} 报销单已删除。`)
|
||||
deleteDialogOpen.value = false
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
} catch (nextError) {
|
||||
toast(nextError?.message || '删除单据失败,请稍后重试。')
|
||||
} finally {
|
||||
actionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -420,8 +500,15 @@ export default {
|
||||
visibleRows,
|
||||
showTable,
|
||||
showEmpty,
|
||||
actionBusy,
|
||||
approvalEmptyState,
|
||||
approvalSteps,
|
||||
canManageClaims,
|
||||
closeDeleteDialog,
|
||||
closeReturnDialog,
|
||||
confirmDeleteSelected,
|
||||
confirmReturnSelected,
|
||||
deleteDialogOpen,
|
||||
summaryItems,
|
||||
heroSummaryItems,
|
||||
currentProgressRingMotion,
|
||||
@@ -434,8 +521,11 @@ export default {
|
||||
riskItems,
|
||||
flowItems,
|
||||
handleEmptyAction,
|
||||
handleDeleteSelected,
|
||||
handleReturnSelected,
|
||||
loading,
|
||||
error,
|
||||
returnDialogOpen,
|
||||
reload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { fetchEmployees } from '../../services/employees.js'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
@@ -1130,7 +1131,6 @@ function buildListItem(asset) {
|
||||
changeCount,
|
||||
updatedAt: formatDateTime(asset.updated_at),
|
||||
badgeTone: tabMeta.badgeTone,
|
||||
spotlight: asset.status === 'active',
|
||||
domainValue: asset.domain
|
||||
}
|
||||
}
|
||||
@@ -1653,6 +1653,7 @@ export default {
|
||||
name: 'AuditView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
emits: ['detail-open-change'],
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
disableEmployee,
|
||||
downloadEmployeeImportTemplate,
|
||||
enableEmployee,
|
||||
exportEmployees,
|
||||
fetchEmployeeDetail,
|
||||
fetchEmployeeMeta,
|
||||
fetchEmployees,
|
||||
importEmployees,
|
||||
updateEmployee
|
||||
} from '../../services/employees.js'
|
||||
|
||||
@@ -56,6 +61,7 @@ function createEmployeeForm() {
|
||||
name: '',
|
||||
employeeNo: '',
|
||||
gender: '',
|
||||
age: '',
|
||||
birthDate: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
@@ -64,7 +70,9 @@ function createEmployeeForm() {
|
||||
position: '',
|
||||
grade: '',
|
||||
department: '',
|
||||
organizationUnitCode: '',
|
||||
manager: '',
|
||||
managerEmployeeNo: '',
|
||||
financeOwner: '',
|
||||
costCenter: '',
|
||||
roleCodes: [],
|
||||
@@ -72,24 +80,120 @@ function createEmployeeForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmployeeForm(employee) {
|
||||
function isPlaceholderManagerName(name) {
|
||||
const normalized = normalizeText(name)
|
||||
return !normalized || normalized === 'CEO' || normalized === '无'
|
||||
}
|
||||
|
||||
function resolveManagerEmployeeNo(employee, roster = []) {
|
||||
const fromApi = normalizeText(employee?.managerEmployeeNo)
|
||||
if (fromApi) {
|
||||
return fromApi
|
||||
}
|
||||
|
||||
const managerName = normalizeText(employee?.manager)
|
||||
if (isPlaceholderManagerName(managerName)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const matches = roster.filter((item) => normalizeText(item.name) === managerName)
|
||||
if (matches.length === 1) {
|
||||
return matches[0].employeeNo
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function enrichEmployeeRecord(employee, roster = []) {
|
||||
if (!employee) {
|
||||
return employee
|
||||
}
|
||||
|
||||
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
|
||||
if (!managerEmployeeNo || managerEmployeeNo === employee.managerEmployeeNo) {
|
||||
return employee
|
||||
}
|
||||
|
||||
return {
|
||||
...employee,
|
||||
managerEmployeeNo
|
||||
}
|
||||
}
|
||||
|
||||
function mergeEmployeeRecords(listItem, detailItem, roster = []) {
|
||||
if (!listItem && !detailItem) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!listItem) {
|
||||
return enrichEmployeeRecord(detailItem, roster)
|
||||
}
|
||||
|
||||
if (!detailItem) {
|
||||
return enrichEmployeeRecord(listItem, roster)
|
||||
}
|
||||
|
||||
const managerEmployeeNo =
|
||||
normalizeText(detailItem.managerEmployeeNo) ||
|
||||
normalizeText(listItem.managerEmployeeNo) ||
|
||||
resolveManagerEmployeeNo(detailItem, roster) ||
|
||||
resolveManagerEmployeeNo(listItem, roster)
|
||||
|
||||
const history =
|
||||
Array.isArray(detailItem.history) && detailItem.history.length
|
||||
? detailItem.history
|
||||
: listItem.history || []
|
||||
|
||||
const permissions =
|
||||
Array.isArray(detailItem.permissions) && detailItem.permissions.length
|
||||
? detailItem.permissions
|
||||
: listItem.permissions || []
|
||||
|
||||
return enrichEmployeeRecord(
|
||||
{
|
||||
...listItem,
|
||||
...detailItem,
|
||||
manager: detailItem.manager || listItem.manager,
|
||||
managerEmployeeNo: managerEmployeeNo || null,
|
||||
history,
|
||||
permissions,
|
||||
roleCodes: detailItem.roleCodes?.length ? detailItem.roleCodes : listItem.roleCodes,
|
||||
roles: detailItem.roles?.length ? detailItem.roles : listItem.roles,
|
||||
organization: detailItem.organization || listItem.organization,
|
||||
department: detailItem.department || listItem.department
|
||||
},
|
||||
roster
|
||||
)
|
||||
}
|
||||
|
||||
function buildEmployeeForm(employee, roster = []) {
|
||||
if (!employee) {
|
||||
return createEmployeeForm()
|
||||
}
|
||||
|
||||
const birthDate = employee.birthDate || ''
|
||||
const managerName = employee.manager || ''
|
||||
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
|
||||
|
||||
return {
|
||||
name: employee.name || '',
|
||||
employeeNo: employee.employeeNo || '',
|
||||
gender: employee.gender || '',
|
||||
birthDate: employee.birthDate || '',
|
||||
age:
|
||||
employee.age !== null && employee.age !== undefined && employee.age !== ''
|
||||
? String(employee.age)
|
||||
: calculateAgeFromDate(birthDate),
|
||||
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 || '',
|
||||
department: resolveOrganizationUnitName(employee),
|
||||
organizationUnitCode: resolveOrganizationUnitCode(employee),
|
||||
manager: managerName,
|
||||
managerEmployeeNo,
|
||||
financeOwner: employee.financeOwner || '',
|
||||
costCenter: employee.costCenter || '',
|
||||
roleCodes: [...(employee.roleCodes || [])],
|
||||
@@ -154,6 +258,60 @@ function sameValues(left, right) {
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function formatEmployeeHistoryTime(value) {
|
||||
const raw = normalizeText(value)
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const matched = raw.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/
|
||||
)
|
||||
if (!matched) {
|
||||
return raw
|
||||
}
|
||||
|
||||
const year = Number.parseInt(matched[1], 10)
|
||||
const month = Number.parseInt(matched[2], 10)
|
||||
const day = Number.parseInt(matched[3], 10)
|
||||
const hour = Number.parseInt(matched[4] || '0', 10)
|
||||
const minute = Number.parseInt(matched[5] || '0', 10)
|
||||
const second = Number.parseInt(matched[6] || '0', 10)
|
||||
|
||||
return `${year}年${month}月${day}日${hour}时${minute}分${second}秒`
|
||||
}
|
||||
|
||||
function resolveOrganizationUnitCode(employee) {
|
||||
return normalizeText(employee?.organization?.code)
|
||||
}
|
||||
|
||||
function resolveOrganizationUnitName(employee) {
|
||||
return normalizeText(employee?.department) || normalizeText(employee?.organization?.name)
|
||||
}
|
||||
|
||||
function captureEmployeeDetailSnapshot(form) {
|
||||
return {
|
||||
roleCodes: [...(form.roleCodes || [])].sort(),
|
||||
organizationUnitCode: normalizeText(form.organizationUnitCode) || ''
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOrganizationOptions(metaOrganizations) {
|
||||
if (!Array.isArray(metaOrganizations) || !metaOrganizations.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return metaOrganizations
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
unitType: item.unitType,
|
||||
label: `${item.name}(${item.code})`
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||
}
|
||||
|
||||
function calculateAgeFromDate(dateString) {
|
||||
if (!dateString) {
|
||||
return ''
|
||||
@@ -177,6 +335,33 @@ function calculateAgeFromDate(dateString) {
|
||||
return age >= 0 ? String(age) : ''
|
||||
}
|
||||
|
||||
function calculateBirthDateFromAge(ageValue, existingBirthDate = '') {
|
||||
const age = Number.parseInt(String(ageValue ?? '').trim(), 10)
|
||||
if (Number.isNaN(age) || age < 0 || age > 120) {
|
||||
return existingBirthDate || ''
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
let month = '01'
|
||||
let day = '01'
|
||||
|
||||
if (existingBirthDate && isValidIsoDate(existingBirthDate)) {
|
||||
const [, monthText, dayText] = existingBirthDate.split('-')
|
||||
month = monthText
|
||||
day = dayText
|
||||
}
|
||||
|
||||
let birthYear = today.getFullYear() - age
|
||||
let candidate = `${birthYear}-${month}-${day}`
|
||||
|
||||
if (Number(calculateAgeFromDate(candidate)) > age) {
|
||||
birthYear -= 1
|
||||
candidate = `${birthYear}-${month}-${day}`
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
function matchKeyword(employee, keyword) {
|
||||
if (!keyword) {
|
||||
return true
|
||||
@@ -249,6 +434,7 @@ export default {
|
||||
name: 'EmployeeManagementView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
emits: ['overview-change'],
|
||||
@@ -272,17 +458,37 @@ export default {
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const disableDialogOpen = ref(false)
|
||||
const importFileInput = ref(null)
|
||||
const pendingImportFile = ref(null)
|
||||
const importConfirmDialogOpen = ref(false)
|
||||
const importErrorDialogOpen = ref(false)
|
||||
const importErrors = ref([])
|
||||
const importResultMessage = ref('')
|
||||
const managerPickerOpen = ref(false)
|
||||
const managerSearchKeyword = ref('')
|
||||
const departmentPickerOpen = ref(false)
|
||||
const departmentSearchKeyword = ref('')
|
||||
const organizationUnitOptions = ref([])
|
||||
const employeeDetailSnapshot = ref(null)
|
||||
|
||||
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 actionBusy = computed(
|
||||
() =>
|
||||
actionState.value === 'save' ||
|
||||
actionState.value === 'disable' ||
|
||||
actionState.value === 'import' ||
|
||||
actionState.value === 'export'
|
||||
)
|
||||
const importExportBusy = computed(
|
||||
() => actionState.value === 'import' || actionState.value === 'export'
|
||||
)
|
||||
const disableActionDisabled = computed(() => actionBusy.value || !selectedEmployee.value)
|
||||
const selectedEmployeeDisabled = computed(() => selectedEmployee.value?.status === '停用')
|
||||
const statusActionCopy = computed(() => {
|
||||
@@ -333,6 +539,94 @@ export default {
|
||||
)
|
||||
)
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const currentId = selectedEmployee.value?.id
|
||||
return employees.value.filter((item) => item.id !== currentId)
|
||||
})
|
||||
|
||||
const filteredManagerOptions = computed(() => {
|
||||
const keyword = managerSearchKeyword.value.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return managerOptions.value.slice(0, 20)
|
||||
}
|
||||
|
||||
return managerOptions.value
|
||||
.filter((item) => {
|
||||
const haystack = [
|
||||
item.name,
|
||||
item.employeeNo,
|
||||
item.department,
|
||||
item.position,
|
||||
item.email
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(keyword)
|
||||
})
|
||||
.slice(0, 20)
|
||||
})
|
||||
|
||||
const managerDisplayLabel = computed(() => {
|
||||
const managerNo = normalizeText(employeeForm.value.managerEmployeeNo)
|
||||
const managerName = normalizeText(employeeForm.value.manager)
|
||||
|
||||
if (managerNo) {
|
||||
const matched =
|
||||
managerOptions.value.find((item) => item.employeeNo === managerNo) ||
|
||||
employees.value.find((item) => item.employeeNo === managerNo)
|
||||
|
||||
if (matched) {
|
||||
return `${matched.name}(${matched.employeeNo})`
|
||||
}
|
||||
|
||||
return managerName ? `${managerName}(${managerNo})` : managerNo
|
||||
}
|
||||
|
||||
if (!isPlaceholderManagerName(managerName)) {
|
||||
return managerName
|
||||
}
|
||||
|
||||
return '未设置直属上级'
|
||||
})
|
||||
|
||||
const filteredDepartmentOptions = computed(() => {
|
||||
const keyword = departmentSearchKeyword.value.trim().toLowerCase()
|
||||
const options = organizationUnitOptions.value
|
||||
|
||||
if (!keyword) {
|
||||
return options.slice(0, 20)
|
||||
}
|
||||
|
||||
return options
|
||||
.filter((item) => {
|
||||
const haystack = [item.name, item.code, item.unitType, item.label]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(keyword)
|
||||
})
|
||||
.slice(0, 20)
|
||||
})
|
||||
|
||||
const departmentDisplayLabel = computed(() => {
|
||||
const code = normalizeText(employeeForm.value.organizationUnitCode)
|
||||
const name = normalizeText(employeeForm.value.department)
|
||||
|
||||
if (code) {
|
||||
const matched = organizationUnitOptions.value.find((item) => item.code === code)
|
||||
if (matched) {
|
||||
return matched.label
|
||||
}
|
||||
|
||||
return name ? `${name}(${code})` : code
|
||||
}
|
||||
|
||||
return name || '请选择所属部门'
|
||||
})
|
||||
|
||||
const filteredEmployees = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
|
||||
@@ -431,14 +725,66 @@ export default {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function syncFormFromEmployee(employee) {
|
||||
if (!employee) {
|
||||
employeeForm.value = createEmployeeForm()
|
||||
employeeDetailSnapshot.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const preservedPassword = employeeForm.value.password
|
||||
employeeForm.value = buildEmployeeForm(employee, employees.value)
|
||||
employeeForm.value.password = preservedPassword
|
||||
employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedEmployee,
|
||||
(employee) => {
|
||||
employeeForm.value = buildEmployeeForm(employee)
|
||||
() => selectedEmployee.value?.id ?? null,
|
||||
(employeeId, previousId) => {
|
||||
if (!employeeId) {
|
||||
syncFormFromEmployee(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (employeeId === previousId) {
|
||||
return
|
||||
}
|
||||
|
||||
syncFormFromEmployee(selectedEmployee.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(employees, () => {
|
||||
if (!selectedEmployee.value?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const preserved = selectedEmployee.value
|
||||
const fromList = employees.value.find((item) => item.id === preserved.id)
|
||||
if (!fromList) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value)
|
||||
})
|
||||
|
||||
const hasManagerAssignment = computed(() => {
|
||||
return (
|
||||
Boolean(normalizeText(employeeForm.value.managerEmployeeNo)) ||
|
||||
!isPlaceholderManagerName(employeeForm.value.manager)
|
||||
)
|
||||
})
|
||||
|
||||
const recentEmployeeHistory = computed(() => {
|
||||
const history = selectedEmployee.value?.history
|
||||
if (!Array.isArray(history)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return history.slice(0, 5)
|
||||
})
|
||||
|
||||
watch(filteredEmployees, () => {
|
||||
currentPage.value = 1
|
||||
pageSizeOpen.value = false
|
||||
@@ -510,15 +856,105 @@ export default {
|
||||
closeFilterPopover()
|
||||
}
|
||||
|
||||
if (!target.closest('.manager-picker')) {
|
||||
closeManagerPicker()
|
||||
}
|
||||
|
||||
if (!target.closest('.department-picker')) {
|
||||
closeDepartmentPicker()
|
||||
}
|
||||
|
||||
if (!target.closest('.page-size-wrap')) {
|
||||
pageSizeOpen.value = false
|
||||
}
|
||||
|
||||
if (target.closest('.picker-filter') || target.closest('.page-size-wrap')) {
|
||||
if (
|
||||
target.closest('.picker-filter') ||
|
||||
target.closest('.page-size-wrap') ||
|
||||
target.closest('.manager-picker') ||
|
||||
target.closest('.department-picker')
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDepartmentPicker() {
|
||||
departmentPickerOpen.value = !departmentPickerOpen.value
|
||||
if (!departmentPickerOpen.value) {
|
||||
departmentSearchKeyword.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function closeDepartmentPicker() {
|
||||
departmentPickerOpen.value = false
|
||||
departmentSearchKeyword.value = ''
|
||||
}
|
||||
|
||||
function selectDepartment(option) {
|
||||
if (!option) {
|
||||
return
|
||||
}
|
||||
|
||||
employeeForm.value.organizationUnitCode = option.code
|
||||
employeeForm.value.department = option.name
|
||||
closeDepartmentPicker()
|
||||
}
|
||||
|
||||
function resolveDepartmentSelectionFromKeyword() {
|
||||
const keyword = normalizeText(departmentSearchKeyword.value)
|
||||
if (!keyword || normalizeText(employeeForm.value.organizationUnitCode)) {
|
||||
return
|
||||
}
|
||||
|
||||
const exactMatches = organizationUnitOptions.value.filter(
|
||||
(item) => item.code === keyword || item.name === keyword
|
||||
)
|
||||
|
||||
if (exactMatches.length === 1) {
|
||||
selectDepartment(exactMatches[0])
|
||||
}
|
||||
}
|
||||
|
||||
function toggleManagerPicker() {
|
||||
managerPickerOpen.value = !managerPickerOpen.value
|
||||
if (!managerPickerOpen.value) {
|
||||
managerSearchKeyword.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function closeManagerPicker() {
|
||||
managerPickerOpen.value = false
|
||||
managerSearchKeyword.value = ''
|
||||
}
|
||||
|
||||
function selectManager(option) {
|
||||
if (!option) {
|
||||
employeeForm.value.managerEmployeeNo = ''
|
||||
employeeForm.value.manager = ''
|
||||
closeManagerPicker()
|
||||
return
|
||||
}
|
||||
|
||||
employeeForm.value.managerEmployeeNo = option.employeeNo
|
||||
employeeForm.value.manager = option.name
|
||||
closeManagerPicker()
|
||||
}
|
||||
|
||||
function resolveManagerSelectionFromKeyword() {
|
||||
const keyword = normalizeText(managerSearchKeyword.value)
|
||||
if (!keyword || normalizeText(employeeForm.value.managerEmployeeNo)) {
|
||||
return
|
||||
}
|
||||
|
||||
const exactMatches = managerOptions.value.filter(
|
||||
(item) => item.employeeNo === keyword || item.name === keyword
|
||||
)
|
||||
|
||||
if (exactMatches.length === 1) {
|
||||
selectManager(exactMatches[0])
|
||||
}
|
||||
}
|
||||
|
||||
function openEmployeeDetail(employee) {
|
||||
selectedEmployee.value = employee
|
||||
}
|
||||
@@ -527,6 +963,24 @@ export default {
|
||||
selectedEmployee.value = null
|
||||
employeeForm.value = createEmployeeForm()
|
||||
actionState.value = ''
|
||||
closeManagerPicker()
|
||||
closeDepartmentPicker()
|
||||
}
|
||||
|
||||
function syncAgeFromBirthDate() {
|
||||
employeeForm.value.age = calculateAgeFromDate(employeeForm.value.birthDate)
|
||||
}
|
||||
|
||||
function syncBirthDateFromAge() {
|
||||
const ageText = normalizeText(employeeForm.value.age)
|
||||
if (!ageText) {
|
||||
return
|
||||
}
|
||||
|
||||
employeeForm.value.birthDate = calculateBirthDateFromAge(
|
||||
ageText,
|
||||
employeeForm.value.birthDate
|
||||
)
|
||||
}
|
||||
|
||||
function buildUpdatePayload() {
|
||||
@@ -583,6 +1037,15 @@ export default {
|
||||
payload.grade = nextGrade
|
||||
}
|
||||
|
||||
const nextOrganizationCode = normalizeText(form.organizationUnitCode)
|
||||
const currentOrganizationCode =
|
||||
normalizeText(employeeDetailSnapshot.value?.organizationUnitCode) ||
|
||||
resolveOrganizationUnitCode(current) ||
|
||||
''
|
||||
if (nextOrganizationCode !== currentOrganizationCode) {
|
||||
payload.organization_unit_code = nextOrganizationCode
|
||||
}
|
||||
|
||||
const nextFinanceOwner = normalizeNullableText(form.financeOwner)
|
||||
if (nextFinanceOwner !== (current.financeOwner || null)) {
|
||||
payload.finance_owner_name = nextFinanceOwner
|
||||
@@ -593,10 +1056,19 @@ export default {
|
||||
payload.cost_center = nextCostCenter
|
||||
}
|
||||
|
||||
const nextManagerEmployeeNo = normalizeNullableText(form.managerEmployeeNo)
|
||||
const currentManagerEmployeeNo =
|
||||
normalizeNullableText(current.managerEmployeeNo) ||
|
||||
resolveManagerEmployeeNo(current, employees.value) ||
|
||||
null
|
||||
if (nextManagerEmployeeNo !== currentManagerEmployeeNo) {
|
||||
payload.manager_employee_no = nextManagerEmployeeNo || ''
|
||||
}
|
||||
|
||||
const nextRoleCodes = [...form.roleCodes].sort()
|
||||
const currentRoleCodes = [...(current.roleCodes || [])].sort()
|
||||
const currentRoleCodes = [...(employeeDetailSnapshot.value?.roleCodes || current.roleCodes || [])].sort()
|
||||
if (!sameValues(nextRoleCodes, currentRoleCodes)) {
|
||||
payload.role_codes = form.roleCodes
|
||||
payload.role_codes = [...form.roleCodes]
|
||||
}
|
||||
|
||||
const nextPassword = normalizeText(form.password)
|
||||
@@ -637,6 +1109,16 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
const ageText = normalizeText(employeeForm.value.age)
|
||||
if (ageText) {
|
||||
const age = Number.parseInt(ageText, 10)
|
||||
if (Number.isNaN(age) || age < 0 || age > 120) {
|
||||
toast('年龄请输入 0 到 120 之间的整数。')
|
||||
return
|
||||
}
|
||||
syncBirthDateFromAge()
|
||||
}
|
||||
|
||||
const birthDate = normalizeNullableText(employeeForm.value.birthDate)
|
||||
if (birthDate && !isValidIsoDate(birthDate)) {
|
||||
toast('出生日期格式不正确,请使用 YYYY-MM-DD。')
|
||||
@@ -654,18 +1136,56 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
resolveManagerSelectionFromKeyword()
|
||||
resolveDepartmentSelectionFromKeyword()
|
||||
|
||||
if (!normalizeText(employeeForm.value.organizationUnitCode)) {
|
||||
toast('请选择所属部门。')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = buildUpdatePayload()
|
||||
if (!Object.keys(payload).length) {
|
||||
toast('未检测到需要保存的变更。')
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizeText(employeeForm.value.managerEmployeeNo) === selectedEmployee.value.employeeNo) {
|
||||
toast('直属上级不能设置为员工本人。')
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'save'
|
||||
|
||||
try {
|
||||
const updated = await updateEmployee(selectedEmployee.value.id, payload)
|
||||
const employeeId = selectedEmployee.value.id
|
||||
const updated = await updateEmployee(employeeId, payload)
|
||||
selectedEmployee.value = updated
|
||||
await loadEmployees()
|
||||
|
||||
let refreshed = updated
|
||||
try {
|
||||
refreshed = await fetchEmployeeDetail(employeeId)
|
||||
} catch {
|
||||
refreshed = updated
|
||||
}
|
||||
|
||||
const fromList = employees.value.find((item) => item.id === employeeId)
|
||||
const merged = mergeEmployeeRecords(fromList, refreshed, employees.value)
|
||||
selectedEmployee.value = merged
|
||||
|
||||
const listIndex = employees.value.findIndex((item) => item.id === employeeId)
|
||||
if (listIndex >= 0) {
|
||||
employees.value[listIndex] = {
|
||||
...employees.value[listIndex],
|
||||
...merged
|
||||
}
|
||||
}
|
||||
|
||||
closeManagerPicker()
|
||||
closeDepartmentPicker()
|
||||
syncFormFromEmployee(selectedEmployee.value)
|
||||
employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value)
|
||||
toast('员工信息已保存并生效。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '员工信息保存失败,请稍后重试。')
|
||||
@@ -723,6 +1243,95 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function openImportFilePicker() {
|
||||
importFileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleImportFileChange(event) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingImportFile.value = file
|
||||
importConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeImportConfirmDialog() {
|
||||
if (actionState.value === 'import') {
|
||||
return
|
||||
}
|
||||
|
||||
importConfirmDialogOpen.value = false
|
||||
pendingImportFile.value = null
|
||||
}
|
||||
|
||||
function closeImportErrorDialog() {
|
||||
importErrorDialogOpen.value = false
|
||||
importErrors.value = []
|
||||
importResultMessage.value = ''
|
||||
}
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
try {
|
||||
await downloadEmployeeImportTemplate()
|
||||
toast('员工导入模板已开始下载。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '模板下载失败,请稍后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportEmployees() {
|
||||
actionState.value = 'export'
|
||||
|
||||
try {
|
||||
await exportEmployees({
|
||||
status: activeTab.value,
|
||||
keyword: searchKeyword.value.trim()
|
||||
})
|
||||
toast('员工目录已开始导出。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '员工导出失败,请稍后重试。')
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmImportEmployees() {
|
||||
const file = pendingImportFile.value
|
||||
if (!file) {
|
||||
closeImportConfirmDialog()
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'import'
|
||||
|
||||
try {
|
||||
const result = await importEmployees(file)
|
||||
|
||||
if (!result?.success) {
|
||||
importErrors.value = Array.isArray(result?.errors) ? result.errors : []
|
||||
importResultMessage.value =
|
||||
result?.message || '导入未执行,请根据下方错误提示修正 Excel 后重试。'
|
||||
importConfirmDialogOpen.value = false
|
||||
importErrorDialogOpen.value = true
|
||||
pendingImportFile.value = null
|
||||
return
|
||||
}
|
||||
|
||||
importConfirmDialogOpen.value = false
|
||||
pendingImportFile.value = null
|
||||
await loadEmployees()
|
||||
toast(result.message || '员工导入成功。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '员工导入失败,请稍后重试。')
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
@@ -735,6 +1344,7 @@ export default {
|
||||
if (employeesResult.status !== 'fulfilled') {
|
||||
employees.value = []
|
||||
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
|
||||
organizationUnitOptions.value = []
|
||||
selectedEmployee.value = null
|
||||
errorMessage.value =
|
||||
employeesResult.reason?.message || '员工数据加载失败,请稍后重试。'
|
||||
@@ -742,12 +1352,17 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
employees.value = Array.isArray(employeesResult.value) ? employeesResult.value : []
|
||||
const roster = Array.isArray(employeesResult.value) ? employeesResult.value : []
|
||||
employees.value = roster.map((item) => enrichEmployeeRecord(item, roster))
|
||||
|
||||
if (metaResult.status === 'fulfilled') {
|
||||
roleOptions.value = resolveRoleOptions(metaResult.value?.roleOptions, employees.value)
|
||||
organizationUnitOptions.value = resolveOrganizationOptions(
|
||||
metaResult.value?.organizationOptions
|
||||
)
|
||||
} else {
|
||||
roleOptions.value = resolveRoleOptions([], employees.value)
|
||||
organizationUnitOptions.value = []
|
||||
}
|
||||
|
||||
if (!DEFAULT_STATUS_TABS.includes(activeTab.value)) {
|
||||
@@ -755,8 +1370,9 @@ export default {
|
||||
}
|
||||
|
||||
if (selectedEmployee.value) {
|
||||
selectedEmployee.value =
|
||||
employees.value.find((item) => item.id === selectedEmployee.value.id) || null
|
||||
const preserved = selectedEmployee.value
|
||||
const fromList = employees.value.find((item) => item.id === preserved.id) || null
|
||||
selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value)
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
@@ -767,6 +1383,7 @@ export default {
|
||||
loadEmployees().catch((error) => {
|
||||
employees.value = []
|
||||
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
|
||||
organizationUnitOptions.value = []
|
||||
selectedEmployee.value = null
|
||||
errorMessage.value = error?.message || '员工数据加载失败,请稍后重试。'
|
||||
loading.value = false
|
||||
@@ -781,13 +1398,27 @@ export default {
|
||||
tabs,
|
||||
activeTab,
|
||||
employeeForm,
|
||||
detailAge,
|
||||
roleCount,
|
||||
syncAgeFromBirthDate,
|
||||
syncBirthDateFromAge,
|
||||
selectedRoleLabels,
|
||||
selectedEmployeeDisabled,
|
||||
statusActionCopy,
|
||||
actionState,
|
||||
actionBusy,
|
||||
importExportBusy,
|
||||
importFileInput,
|
||||
importConfirmDialogOpen,
|
||||
importErrorDialogOpen,
|
||||
importErrors,
|
||||
importResultMessage,
|
||||
openImportFilePicker,
|
||||
handleImportFileChange,
|
||||
closeImportConfirmDialog,
|
||||
closeImportErrorDialog,
|
||||
handleDownloadTemplate,
|
||||
handleExportEmployees,
|
||||
confirmImportEmployees,
|
||||
disableActionDisabled,
|
||||
selectedEmployee,
|
||||
roleOptions,
|
||||
@@ -806,6 +1437,25 @@ export default {
|
||||
departmentOptions,
|
||||
gradeOptions,
|
||||
roleFilterOptions,
|
||||
managerPickerOpen,
|
||||
managerSearchKeyword,
|
||||
managerDisplayLabel,
|
||||
hasManagerAssignment,
|
||||
departmentPickerOpen,
|
||||
departmentSearchKeyword,
|
||||
departmentDisplayLabel,
|
||||
filteredDepartmentOptions,
|
||||
toggleDepartmentPicker,
|
||||
closeDepartmentPicker,
|
||||
selectDepartment,
|
||||
resolveDepartmentSelectionFromKeyword,
|
||||
recentEmployeeHistory,
|
||||
formatEmployeeHistoryTime,
|
||||
filteredManagerOptions,
|
||||
toggleManagerPicker,
|
||||
closeManagerPicker,
|
||||
selectManager,
|
||||
resolveManagerSelectionFromKeyword,
|
||||
activeFilterTokens,
|
||||
hasActiveFilters,
|
||||
hasEmployeeFilters,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import LogTrendChart from '../../components/charts/LogTrendChart.vue'
|
||||
import DonutChart from '../../components/charts/DonutChart.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import { fetchAgentRuns } from '../../services/agentAssets.js'
|
||||
import { fetchSystemLogEntries } from '../../services/systemLogs.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
@@ -217,10 +218,11 @@ function buildTrendSeries(runs) {
|
||||
|
||||
export default {
|
||||
name: 'LogsView',
|
||||
components: {
|
||||
LogTrendChart,
|
||||
DonutChart
|
||||
},
|
||||
components: {
|
||||
LogTrendChart,
|
||||
DonutChart,
|
||||
TableLoadingState
|
||||
},
|
||||
emits: ['summary-change'],
|
||||
setup(_, { emit }) {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
@@ -79,10 +80,11 @@ function setBodyScrollLocked(isLocked) {
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'PoliciesView',
|
||||
components: {
|
||||
ConfirmDialog
|
||||
},
|
||||
name: 'PoliciesView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState
|
||||
},
|
||||
emits: ['summary-change'],
|
||||
setup(_, { emit }) {
|
||||
const { currentUser } = useSystemState()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
|
||||
@@ -11,6 +12,7 @@ function extractRowDate(value) {
|
||||
export default {
|
||||
name: 'RequestsView',
|
||||
components: {
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
props: {
|
||||
|
||||
@@ -43,6 +43,24 @@ const INTENT_LABELS = {
|
||||
operate: '动作请求'
|
||||
}
|
||||
|
||||
const REVIEW_RISK_LEVEL_META = {
|
||||
high: {
|
||||
label: '高风险',
|
||||
icon: 'mdi mdi-alert-octagon-outline',
|
||||
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
|
||||
},
|
||||
warning: {
|
||||
label: '需关注',
|
||||
icon: 'mdi mdi-alert-circle-outline',
|
||||
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
|
||||
},
|
||||
info: {
|
||||
label: '提示',
|
||||
icon: 'mdi mdi-information-outline',
|
||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况继续核对。'
|
||||
}
|
||||
}
|
||||
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
travel_ticket: '行程单/机票/车票',
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
@@ -1503,7 +1521,7 @@ function buildDraftSavedPayload({
|
||||
secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态',
|
||||
secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据',
|
||||
secondaryStatusTone: documents.length ? 'warning' : 'neutral',
|
||||
riskSummary: riskItems[0] || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
|
||||
riskSummary: riskItems[0]?.summary || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
|
||||
attachmentSummary,
|
||||
expenseTableSummary: documents.length
|
||||
? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认`
|
||||
@@ -2451,16 +2469,43 @@ function buildReviewRiskSummary(reviewPayload) {
|
||||
return '当前版本暂未生成风险评分结果。'
|
||||
}
|
||||
|
||||
function normalizeReviewRiskLevel(level) {
|
||||
const normalized = String(level || '').trim().toLowerCase()
|
||||
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
|
||||
if (normalized === 'warn' || normalized === 'medium') return 'warning'
|
||||
if (normalized === 'high' || normalized === 'warning' || normalized === 'info') return normalized
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function buildReviewRiskItems(reviewPayload) {
|
||||
return resolveReviewRiskBriefs(reviewPayload)
|
||||
.map((brief) => {
|
||||
.map((brief, index) => {
|
||||
const title = String(brief?.title || '').trim()
|
||||
const content = String(brief?.content || '').trim()
|
||||
if (title && content) return `${title}:${content}`
|
||||
return content || title
|
||||
const detail = String(brief?.detail || '').trim()
|
||||
const suggestion = String(brief?.suggestion || '').trim()
|
||||
const level = normalizeReviewRiskLevel(brief?.level)
|
||||
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.info
|
||||
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
|
||||
const normalizedTitle = title || fallbackTitle
|
||||
const summary = content || normalizedTitle
|
||||
|
||||
if (!normalizedTitle && !summary) return null
|
||||
|
||||
return {
|
||||
key: `${level}-${normalizedTitle}-${index}`,
|
||||
title: normalizedTitle,
|
||||
summary,
|
||||
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
|
||||
level,
|
||||
levelLabel: meta.label,
|
||||
icon: meta.icon,
|
||||
sourceLabel: title === '历史报销画像' ? '历史记录' : 'AI预审',
|
||||
suggestion: suggestion || meta.suggestion
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.slice(0, 6)
|
||||
}
|
||||
|
||||
function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
|
||||
@@ -2904,6 +2949,7 @@ export default {
|
||||
const composerRangeStartDate = ref(formatDateInputValue())
|
||||
const composerRangeEndDate = ref(formatDateInputValue())
|
||||
const composerBusinessTimeTags = ref([])
|
||||
const composerBusinessTimeDraftTouched = ref(false)
|
||||
const attachedFiles = ref([])
|
||||
const composerFilesExpanded = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -2947,6 +2993,10 @@ export default {
|
||||
const activeReviewDocumentIndex = ref(0)
|
||||
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
|
||||
const insightPanelCollapsed = ref(false)
|
||||
const reviewRiskDetailDialog = ref({
|
||||
open: false,
|
||||
item: null
|
||||
})
|
||||
const documentPreviewDialog = ref({
|
||||
open: false,
|
||||
filename: '',
|
||||
@@ -3107,7 +3157,6 @@ export default {
|
||||
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
|
||||
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
|
||||
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
|
||||
const reviewRiskActionAvailable = computed(() => reviewRiskItems.value.length > 0)
|
||||
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
|
||||
const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value))
|
||||
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
|
||||
@@ -3932,6 +3981,91 @@ export default {
|
||||
return `业务发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}`
|
||||
}
|
||||
|
||||
function hasComposerBusinessTimeSelection() {
|
||||
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeContext() {
|
||||
if (!hasComposerBusinessTimeSelection()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
|
||||
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
|
||||
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
|
||||
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const displayValue = mode === 'range' && startDate !== endDate
|
||||
? `${startDate} 至 ${endDate}`
|
||||
: startDate
|
||||
return {
|
||||
mode,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
occurred_date: startDate,
|
||||
time_range: displayValue,
|
||||
business_time: displayValue,
|
||||
time_range_raw: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
|
||||
if (!businessTimeContext) {
|
||||
return extraContext
|
||||
}
|
||||
|
||||
const baseReviewFormValues =
|
||||
extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
|
||||
? extraContext.review_form_values
|
||||
: {}
|
||||
|
||||
return {
|
||||
...extraContext,
|
||||
occurred_date: businessTimeContext.occurred_date,
|
||||
business_time: businessTimeContext.business_time,
|
||||
business_time_context: {
|
||||
mode: businessTimeContext.mode,
|
||||
start_date: businessTimeContext.start_date,
|
||||
end_date: businessTimeContext.end_date,
|
||||
display_value: businessTimeContext.business_time
|
||||
},
|
||||
review_form_values: {
|
||||
...baseReviewFormValues,
|
||||
occurred_date: businessTimeContext.occurred_date,
|
||||
time_range: businessTimeContext.time_range,
|
||||
business_time: businessTimeContext.business_time,
|
||||
time_range_raw: businessTimeContext.time_range_raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncComposerBusinessTimeToReviewCard(businessTimeContext) {
|
||||
if (!businessTimeContext || !activeReviewPayload.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextInlineState = {
|
||||
...reviewInlineForm.value,
|
||||
occurred_date: businessTimeContext.occurred_date
|
||||
}
|
||||
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
|
||||
reviewInlineForm.value = nextInlineState
|
||||
if (latestReviewMessage.value) {
|
||||
latestReviewMessage.value.reviewPayload = nextReviewPayload
|
||||
}
|
||||
if (currentInsight.value?.agent) {
|
||||
currentInsight.value = {
|
||||
...currentInsight.value,
|
||||
agent: {
|
||||
...currentInsight.value.agent,
|
||||
reviewPayload: nextReviewPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveComposerSubmitText(explicitRawText) {
|
||||
const draftPart = String(explicitRawText ?? composerDraft.value).trim()
|
||||
const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join(',')
|
||||
@@ -3956,8 +4090,16 @@ export default {
|
||||
composerDateMode.value = mode === 'range' ? 'range' : 'single'
|
||||
}
|
||||
|
||||
function handleComposerDateInputChange() {
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
}
|
||||
|
||||
function removeComposerBusinessTimeTag(tagId) {
|
||||
composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId)
|
||||
if (!composerBusinessTimeTags.value.length) {
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleComposerDatePickerOutside(event) {
|
||||
@@ -3975,12 +4117,14 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
composerBusinessTimeTags.value = [
|
||||
{
|
||||
id: `biz-time-${Date.now()}`,
|
||||
label: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
]
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
composerDatePickerOpen.value = false
|
||||
await nextTick()
|
||||
adjustComposerTextareaHeight()
|
||||
@@ -4432,13 +4576,19 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
function explainCurrentReviewRisk() {
|
||||
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
|
||||
submitComposer({
|
||||
rawText: '请解释一下当前这笔报销的合规风险和待补充项。',
|
||||
userText: '查看全部风险项',
|
||||
systemGenerated: true
|
||||
})
|
||||
function openReviewRiskDetail(item) {
|
||||
if (!item) return
|
||||
reviewRiskDetailDialog.value = {
|
||||
open: true,
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
function closeReviewRiskDetail() {
|
||||
reviewRiskDetailDialog.value = {
|
||||
...reviewRiskDetailDialog.value,
|
||||
open: false
|
||||
}
|
||||
}
|
||||
|
||||
function goReviewDocument(direction) {
|
||||
@@ -4642,9 +4792,13 @@ export default {
|
||||
}
|
||||
if (!rawText && !files.length) return
|
||||
|
||||
const extraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
const initialExtraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
? { ...options.extraContext }
|
||||
: {}
|
||||
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
|
||||
const extraContext = isKnowledgeSession.value
|
||||
? initialExtraContext
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||||
const hasExistingDocumentEvent =
|
||||
@@ -4699,6 +4853,7 @@ export default {
|
||||
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
clearAttachedFiles()
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
@@ -4769,6 +4924,12 @@ export default {
|
||||
department_name: user.department || user.departmentName || '',
|
||||
position: user.position || '',
|
||||
grade: user.grade || '',
|
||||
employee_no: user.employeeNo || user.employee_no || '',
|
||||
manager_name: user.managerName || user.manager_name || '',
|
||||
employee_location: user.location || '',
|
||||
cost_center: user.costCenter || user.cost_center || '',
|
||||
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
|
||||
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
|
||||
...buildClientTimeContext(),
|
||||
session_type: activeSessionType.value,
|
||||
entry_source: props.entrySource,
|
||||
@@ -4802,16 +4963,6 @@ export default {
|
||||
? ''
|
||||
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
try {
|
||||
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已识别,但附件持久化失败,请重试上传。')
|
||||
}
|
||||
}
|
||||
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
|
||||
@@ -4832,6 +4983,14 @@ export default {
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
syncComposerFilesToDraft(resolvedDraftClaimId, files).catch((error) => {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
failCurrentFlowStep(error)
|
||||
@@ -5144,6 +5303,7 @@ export default {
|
||||
toggleComposerDatePicker,
|
||||
closeComposerDatePicker,
|
||||
setComposerDateMode,
|
||||
handleComposerDateInputChange,
|
||||
removeComposerBusinessTimeTag,
|
||||
flowSteps,
|
||||
flowRunId,
|
||||
@@ -5213,7 +5373,7 @@ export default {
|
||||
reviewRiskSummary,
|
||||
reviewRiskItems,
|
||||
reviewRiskEmpty,
|
||||
reviewRiskActionAvailable,
|
||||
reviewRiskDetailDialog,
|
||||
recognizedNarratives,
|
||||
reviewRecognitionNotes,
|
||||
reviewDocumentSummaries,
|
||||
@@ -5298,7 +5458,8 @@ export default {
|
||||
selectReviewCategory,
|
||||
selectReviewOtherCategory,
|
||||
queryDraftByClaimNo,
|
||||
explainCurrentReviewRisk,
|
||||
openReviewRiskDetail,
|
||||
closeReviewRiskDetail,
|
||||
goReviewDocument,
|
||||
openActiveReviewDocumentPreview,
|
||||
closeDocumentPreview,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import {
|
||||
@@ -9,10 +10,12 @@ import {
|
||||
deleteExpenseClaim,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
fetchExpenseClaimItemAttachmentPreview,
|
||||
returnExpenseClaim,
|
||||
submitExpenseClaim,
|
||||
uploadExpenseClaimItemAttachment,
|
||||
updateExpenseClaimItem
|
||||
} from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims } from '../../utils/accessControl.js'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
|
||||
const EXPENSE_TYPE_OPTIONS = [
|
||||
@@ -380,6 +383,7 @@ export default {
|
||||
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
||||
setup(props, { emit }) {
|
||||
const { toast } = useToast()
|
||||
const { currentUser } = useSystemState()
|
||||
const editingExpenseId = ref('')
|
||||
const savingExpenseId = ref('')
|
||||
const creatingExpense = ref(false)
|
||||
@@ -390,6 +394,8 @@ export default {
|
||||
const submitBusy = ref(false)
|
||||
const deleteBusy = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const returnBusy = ref(false)
|
||||
const returnDialogOpen = ref(false)
|
||||
const expenseUploadInput = ref(null)
|
||||
const expenseAttachmentMeta = reactive({})
|
||||
const attachmentPreviewOpen = ref(false)
|
||||
@@ -448,10 +454,25 @@ export default {
|
||||
|
||||
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
|
||||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const canDeleteRequest = computed(() => isDraftRequest.value || canManageCurrentClaim.value)
|
||||
const canReturnRequest = computed(() =>
|
||||
canManageCurrentClaim.value
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
||||
const deleteDialogDescription = computed(() =>
|
||||
isDraftRequest.value
|
||||
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
|
||||
: '删除后该报销单及费用明细将不可恢复,请确认本次操作。'
|
||||
)
|
||||
const actionBusy = computed(() =>
|
||||
Boolean(savingExpenseId.value)
|
||||
|| submitBusy.value
|
||||
|| deleteBusy.value
|
||||
|| returnBusy.value
|
||||
|| creatingExpense.value
|
||||
|| Boolean(uploadingExpenseId.value)
|
||||
|| Boolean(deletingAttachmentId.value)
|
||||
@@ -1105,9 +1126,14 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDraft() {
|
||||
async function handleDeleteRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法删除。')
|
||||
toast('当前单据缺少 claimId,暂时无法删除。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canDeleteRequest.value) {
|
||||
toast('当前单据已进入流程,只有财务人员或高级管理人员可以删除。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1122,9 +1148,9 @@ export default {
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmDeleteDraft() {
|
||||
async function confirmDeleteRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法删除。')
|
||||
toast('当前单据缺少 claimId,暂时无法删除。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1132,15 +1158,58 @@ export default {
|
||||
try {
|
||||
const payload = await deleteExpenseClaim(request.value.claimId)
|
||||
deleteDialogOpen.value = false
|
||||
toast(payload?.message || `${request.value.id} 草稿已删除。`)
|
||||
toast(payload?.message || `${request.value.id} 报销单已删除。`)
|
||||
emit('request-deleted', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '删除草稿失败,请稍后重试。')
|
||||
toast(error?.message || '删除单据失败,请稍后重试。')
|
||||
} finally {
|
||||
deleteBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReturnRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法退回。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canReturnRequest.value) {
|
||||
toast('当前状态不支持退回。')
|
||||
return
|
||||
}
|
||||
|
||||
returnDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeReturnDialog() {
|
||||
if (returnBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
returnDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmReturnRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法退回。')
|
||||
return
|
||||
}
|
||||
|
||||
returnBusy.value = true
|
||||
try {
|
||||
await returnExpenseClaim(request.value.claimId, {
|
||||
reason: '详情页退回,请申请人补充后重新提交。'
|
||||
})
|
||||
returnDialogOpen.value = false
|
||||
toast(`${request.value.id} 已退回待补充。`)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '退回单据失败,请稍后重试。')
|
||||
} finally {
|
||||
returnBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAiEntry() {
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
@@ -1164,14 +1233,22 @@ export default {
|
||||
attachmentPreviewName,
|
||||
attachmentPreviewOpen,
|
||||
attachmentPreviewUrl,
|
||||
canDeleteRequest,
|
||||
canManageCurrentClaim,
|
||||
canReturnRequest,
|
||||
canSubmit,
|
||||
canPreviewAttachment,
|
||||
closeDeleteDialog,
|
||||
closeAttachmentPreview,
|
||||
confirmDeleteDraft,
|
||||
closeReturnDialog,
|
||||
confirmDeleteRequest,
|
||||
confirmReturnRequest,
|
||||
currentProgressRingMotion,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
deleteDialogDescription,
|
||||
deleteDialogOpen,
|
||||
deleteDialogTitle,
|
||||
deletingAttachmentId,
|
||||
deletingExpenseId,
|
||||
detailNote,
|
||||
@@ -1186,8 +1263,9 @@ export default {
|
||||
expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
handleAddExpenseItem,
|
||||
handleDeleteDraft,
|
||||
handleDeleteRequest,
|
||||
handleExpenseFileChange,
|
||||
handleReturnRequest,
|
||||
handleSubmit,
|
||||
hasExpenseRiskColumn,
|
||||
heroFactItems,
|
||||
@@ -1205,6 +1283,8 @@ export default {
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
returnBusy,
|
||||
returnDialogOpen,
|
||||
savingExpenseId,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
|
||||
Reference in New Issue
Block a user