Compare commits

..

3 Commits

Author SHA1 Message Date
焦龙言
b70e29563a 更新前端样式 2026-06-29 15:00:17 +08:00
焦龙言
960d2aa028 更新前端样式 2026-06-29 14:59:49 +08:00
焦龙言
d6f858b363 修复后端时区BUG 2026-06-29 14:49:32 +08:00
63 changed files with 4927 additions and 885 deletions

4
.env
View File

@ -5,5 +5,5 @@
# If MySQL runs on another server, use that server's reachable IP address or domain name. # If MySQL runs on another server, use that server's reachable IP address or domain name.
DATABASE_URL=mysql+pymysql://root:Plo6lvzOPtMNzVIA@host.docker.internal:3306/financial_system?charset=utf8mb4 DATABASE_URL=mysql+pymysql://root:Plo6lvzOPtMNzVIA@host.docker.internal:3306/financial_system?charset=utf8mb4
BACKEND_PORT=8000 BACKEND_PORT=8005
FRONTEND_PORT=5173 FRONTEND_PORT=5179

View File

@ -1,4 +1,4 @@
FROM python:3.12-slim FROM docker.m.daocloud.io/library/python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1

View File

@ -137,6 +137,7 @@ config/app_settings.json
| --- | --- | | --- | --- |
| `server.host` / `server.port` | 后端监听地址和端口 | | `server.host` / `server.port` | 后端监听地址和端口 |
| `database.url` | 数据库连接 | | `database.url` | 数据库连接 |
| 系统时区 | 后端统一使用 `Asia/Shanghai`MySQL 会话启动时自动设置为 `+08:00` |
| `dingtalk.app_key` / `dingtalk.app_secret` | 钉钉开放平台凭证 | | `dingtalk.app_key` / `dingtalk.app_secret` | 钉钉开放平台凭证 |
| `storage.upload_dir` | 上传文件目录 | | `storage.upload_dir` | 上传文件目录 |
| `storage.output_dir` | 导出工资文件目录 | | `storage.output_dir` | 导出工资文件目录 |
@ -187,6 +188,12 @@ mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_syste
mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260618_monthly_payroll_center.sql mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260618_monthly_payroll_center.sql
``` ```
如果历史页面时间整体少 8 小时,说明旧数据曾按 UTC 写入,可确认后执行一次修正脚本:
```bash
mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260623_timezone_shanghai.sql
```
SQL 文件说明: SQL 文件说明:
| 文件 | 说明 | | 文件 | 说明 |
@ -196,6 +203,7 @@ SQL 文件说明:
| `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 | | `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 |
| `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 | | `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 |
| `upgrade_20260618_monthly_payroll_center.sql` | 实时考勤同步、薪资预估、月度核算批次和异常清单升级脚本 | | `upgrade_20260618_monthly_payroll_center.sql` | 实时考勤同步、薪资预估、月度核算批次和异常清单升级脚本 |
| `upgrade_20260623_timezone_shanghai.sql` | 一次性修正历史 UTC 时间为北京时间,确认整体少 8 小时后再执行 |
主要业务表: 主要业务表:

View File

@ -162,6 +162,7 @@ def get_payroll_service(
repository: PayrollRepository = Depends(get_payroll_repository), repository: PayrollRepository = Depends(get_payroll_repository),
salary_repository: SalaryProfileRepository = Depends(get_salary_profile_repository), salary_repository: SalaryProfileRepository = Depends(get_salary_profile_repository),
commission_repository: CommissionRepository = Depends(get_commission_repository), commission_repository: CommissionRepository = Depends(get_commission_repository),
employee_repository: EmployeeRepository = Depends(get_employee_repository),
config_repository: SalaryConfigRepository = Depends(get_salary_config_repository), config_repository: SalaryConfigRepository = Depends(get_salary_config_repository),
settings: AppSettings = Depends(get_app_settings), settings: AppSettings = Depends(get_app_settings),
) -> PayrollApplicationService: ) -> PayrollApplicationService:
@ -171,6 +172,7 @@ def get_payroll_service(
salary_repository=salary_repository, salary_repository=salary_repository,
commission_repository=commission_repository, commission_repository=commission_repository,
config_service=SalaryConfigService(config_repository), config_service=SalaryConfigService(config_repository),
employee_repository=employee_repository,
) )

View File

@ -40,6 +40,8 @@ def preview_next_employee_no(
def list_employees( def list_employees(
http_request: Request, http_request: Request,
keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"), keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"),
employee_keyword: str | None = Query(default=None, description="员工信息关键字编号、姓名、钉钉用户ID或手机号"),
organization_keyword: str | None = Query(default=None, description="组织信息关键字:部门或岗位"),
employment_status: str | None = Query(default=None, description="员工状态active或inactive"), employment_status: str | None = Query(default=None, description="员工状态active或inactive"),
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)), current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)),
service: EmployeeService = Depends(get_employee_service), service: EmployeeService = Depends(get_employee_service),
@ -47,7 +49,12 @@ def list_employees(
) -> list[EmployeeResponse]: ) -> list[EmployeeResponse]:
"""查询员工档案。""" """查询员工档案。"""
try: try:
employees = service.list_employees(keyword=keyword, employment_status=employment_status) employees = service.list_employees(
keyword=keyword,
employee_keyword=employee_keyword,
organization_keyword=organization_keyword,
employment_status=employment_status,
)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record( operation_logs.record(
@ -69,6 +76,8 @@ def list_employees_page(
page: int = Query(default=1, ge=1, description="页码从1开始"), page: int = Query(default=1, ge=1, description="页码从1开始"),
page_size: int = Query(default=10, ge=1, le=100, description="每页条数最大100"), page_size: int = Query(default=10, ge=1, le=100, description="每页条数最大100"),
keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"), keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"),
employee_keyword: str | None = Query(default=None, description="员工信息关键字编号、姓名、钉钉用户ID或手机号"),
organization_keyword: str | None = Query(default=None, description="组织信息关键字:部门或岗位"),
employment_status: str | None = Query(default=None, description="员工状态active或inactive"), employment_status: str | None = Query(default=None, description="员工状态active或inactive"),
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)), current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)),
service: EmployeeService = Depends(get_employee_service), service: EmployeeService = Depends(get_employee_service),
@ -80,6 +89,8 @@ def list_employees_page(
page=page, page=page,
page_size=page_size, page_size=page_size,
keyword=keyword, keyword=keyword,
employee_keyword=employee_keyword,
organization_keyword=organization_keyword,
employment_status=employment_status, employment_status=employment_status,
) )
except ValueError as exc: except ValueError as exc:

View File

@ -62,8 +62,6 @@ ROLE_MENU_CODES = {
ROLE_SUPERUSER: ( ROLE_SUPERUSER: (
"attendance_realtime", "attendance_realtime",
"monthly_payroll", "monthly_payroll",
"payroll",
"jobs",
"employees", "employees",
"organization", "organization",
"salary_profiles", "salary_profiles",
@ -76,8 +74,6 @@ ROLE_MENU_CODES = {
ROLE_MANAGER: ( ROLE_MANAGER: (
"attendance_realtime", "attendance_realtime",
"monthly_payroll", "monthly_payroll",
"payroll",
"jobs",
"employees", "employees",
"organization", "organization",
"salary_profiles", "salary_profiles",
@ -86,7 +82,7 @@ ROLE_MENU_CODES = {
"configs", "configs",
"operation_logs", "operation_logs",
), ),
ROLE_VIEWER: ("attendance_realtime", "jobs", "reports"), ROLE_VIEWER: ("attendance_realtime", "monthly_payroll", "reports"),
} }
ROLE_OPERATION_PERMISSIONS = { ROLE_OPERATION_PERMISSIONS = {

View File

@ -0,0 +1,12 @@
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
SYSTEM_TIMEZONE = "Asia/Shanghai"
def now_shanghai() -> datetime:
"""返回系统统一使用的北京时间,保存到 DATETIME 前去掉 tzinfo。"""
return datetime.now(ZoneInfo(SYSTEM_TIMEZONE)).replace(tzinfo=None)

View File

@ -5,6 +5,8 @@ from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from ..core.timezone import now_shanghai
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
@ -84,14 +86,14 @@ class UserORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -124,12 +126,12 @@ class DepartmentORM(Base):
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前") sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="部门备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="部门备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -165,12 +167,12 @@ class PositionORM(Base):
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前") sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="岗位备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="岗位备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -217,14 +219,14 @@ class EmployeeORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -306,14 +308,14 @@ class SalaryProfileORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -362,7 +364,7 @@ class AttendanceRecordORM(Base):
overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当日打卡推算加班工时") overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当日打卡推算加班工时")
raw_text: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="原始考勤单元格内容") raw_text: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="原始考勤单元格内容")
source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源excel或dingtalk") source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源excel或dingtalk")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
class LeaveRecordORM(Base): class LeaveRecordORM(Base):
@ -397,7 +399,7 @@ class LeaveRecordORM(Base):
source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源excel或dingtalk") source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源excel或dingtalk")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="approved", comment="状态approved已生效") status: Mapped[str] = mapped_column(String(32), nullable=False, default="approved", comment="状态approved已生效")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
class OvertimeRecordORM(Base): class OvertimeRecordORM(Base):
@ -438,7 +440,7 @@ class OvertimeRecordORM(Base):
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班费金额") amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班费金额")
source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源excel或dingtalk") source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源excel或dingtalk")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态calculated已计算") status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态calculated已计算")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
class AttendanceSyncJobORM(Base): class AttendanceSyncJobORM(Base):
@ -463,12 +465,12 @@ class AttendanceSyncJobORM(Base):
record_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="本次同步获得或解析的考勤记录数量") record_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="本次同步获得或解析的考勤记录数量")
error_message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="任务失败时的错误信息") error_message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="任务失败时的错误信息")
created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="创建人用户名快照") created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="创建人用户名快照")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -517,12 +519,12 @@ class SalaryPreviewORM(Base):
estimated_net_salary: Mapped[float | None] = mapped_column(Float, nullable=True, comment="当前预估实发工资") estimated_net_salary: Mapped[float | None] = mapped_column(Float, nullable=True, comment="当前预估实发工资")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="estimated", index=True, comment="预估状态estimated已预估、warning有异常") status: Mapped[str] = mapped_column(String(32), nullable=False, default="estimated", index=True, comment="预估状态estimated已预估、warning有异常")
note: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="预估备注或异常说明") note: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="预估备注或异常说明")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -553,12 +555,12 @@ class MonthlyPayrollRunORM(Base):
locked_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="锁定人用户名快照") locked_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="锁定人用户名快照")
locked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="锁定时间") locked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="锁定时间")
created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="创建人用户名快照") created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="创建人用户名快照")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -599,7 +601,7 @@ class PayrollExceptionORM(Base):
severity: Mapped[str] = mapped_column(String(32), nullable=False, default="warning", index=True, comment="严重级别info、warning、error") severity: Mapped[str] = mapped_column(String(32), nullable=False, default="warning", index=True, comment="严重级别info、warning、error")
message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="异常说明") message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="异常说明")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="open", index=True, comment="处理状态open待处理、resolved已处理、ignored已忽略") status: Mapped[str] = mapped_column(String(32), nullable=False, default="open", index=True, comment="处理状态open待处理、resolved已处理、ignored已忽略")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="处理完成时间") resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="处理完成时间")
run: Mapped[MonthlyPayrollRunORM | None] = relationship() run: Mapped[MonthlyPayrollRunORM | None] = relationship()
@ -630,12 +632,12 @@ class CommissionRecordORM(Base):
source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="manual", comment="来源manual手工、excel导入") source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="manual", comment="来源manual手工、excel导入")
business_ref: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="业务单号或来源标识") business_ref: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="业务单号或来源标识")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -718,7 +720,7 @@ class OperationLogORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
index=True, index=True,
comment="操作时间", comment="操作时间",
) )
@ -760,14 +762,14 @@ class PayrollJobORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -961,12 +963,12 @@ class SalaryRecordORM(Base):
net_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="实发工资") net_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="实发工资")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态calculated已计算") status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态calculated已计算")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -1004,7 +1006,7 @@ class SalaryDetailORM(Base):
quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="数量或工时") quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="数量或工时")
unit_price: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="单价") unit_price: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="单价")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
salary_record: Mapped[SalaryRecordORM] = relationship(back_populates="details") salary_record: Mapped[SalaryRecordORM] = relationship(back_populates="details")
@ -1027,11 +1029,11 @@ class SalaryConfigORM(Base):
value_type: Mapped[str] = mapped_column(String(32), nullable=False, default="string", comment="值类型string、number、integer、boolean、json") value_type: Mapped[str] = mapped_column(String(32), nullable=False, default="string", comment="值类型string、number、integer、boolean、json")
is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否启用") is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否启用")
remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="配置说明") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="配置说明")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import create_engine, inspect, text from sqlalchemy import create_engine, event, inspect, text
from sqlalchemy.engine import make_url from sqlalchemy.engine import make_url
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@ -13,6 +13,21 @@ logger = AppLogger.get_logger(__name__)
# SQLite 默认限制跨线程连接FastAPI 请求线程会复用连接池,需要显式放开。 # SQLite 默认限制跨线程连接FastAPI 请求线程会复用连接池,需要显式放开。
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args) engine = create_engine(settings.database_url, connect_args=connect_args)
@event.listens_for(engine, "connect")
def _set_mysql_session_timezone(dbapi_connection, connection_record) -> None:
"""MySQL 会话统一使用北京时间,避免 CURRENT_TIMESTAMP 少 8 小时。"""
if not settings.database_url.startswith("mysql"):
return
cursor = dbapi_connection.cursor()
try:
cursor.execute("SET time_zone = '+08:00'")
finally:
cursor.close()
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
USER_PROFILE_COLUMNS = { USER_PROFILE_COLUMNS = {

View File

@ -7,6 +7,7 @@ CREATE DATABASE IF NOT EXISTS `financial_system`
DEFAULT COLLATE utf8mb4_unicode_ci; DEFAULT COLLATE utf8mb4_unicode_ci;
USE `financial_system`; USE `financial_system`;
SET time_zone = '+08:00';
CREATE TABLE IF NOT EXISTS `users` ( CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID', `id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID',

View File

@ -6,6 +6,7 @@ CREATE DATABASE IF NOT EXISTS `financial_system`
DEFAULT COLLATE utf8mb4_unicode_ci; DEFAULT COLLATE utf8mb4_unicode_ci;
USE `financial_system`; USE `financial_system`;
SET time_zone = '+08:00';
CREATE TABLE IF NOT EXISTS `users` ( CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID', `id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID',

View File

@ -0,0 +1,115 @@
-- 修正历史时间:将此前按 UTC 写入的 DATETIME 字段统一调整为北京时间。
-- 重要:本脚本只能在确认历史数据整体少 8 小时后执行一次,不能重复执行。
-- 执行示例:
-- mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260623_timezone_shanghai.sql
SET time_zone = '+08:00';
CREATE TABLE IF NOT EXISTS `schema_migrations` (
`version` VARCHAR(64) NOT NULL COMMENT '迁移版本号',
`description` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '迁移说明',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '执行时间',
PRIMARY KEY (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='数据库迁移执行记录表';
SET @migration_version = _utf8mb4'20260623_timezone_shanghai' COLLATE utf8mb4_unicode_ci;
SET @should_run = (
SELECT IF(COUNT(*) = 0, 1, 0)
FROM `schema_migrations`
WHERE `version` = @migration_version
);
START TRANSACTION;
UPDATE `users`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `departments`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `positions`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `employees`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `salary_profiles`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `operation_logs`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `payroll_jobs`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `attendance_record`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `leave_record`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `overtime_record`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `attendance_sync_jobs`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `salary_preview`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `monthly_payroll_runs`
SET `locked_at` = CASE WHEN `locked_at` IS NULL THEN NULL ELSE DATE_ADD(`locked_at`, INTERVAL 8 HOUR) END,
`created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `payroll_exceptions`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`resolved_at` = CASE WHEN `resolved_at` IS NULL THEN NULL ELSE DATE_ADD(`resolved_at`, INTERVAL 8 HOUR) END
WHERE @should_run = 1;
UPDATE `commission_record`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `salary_record`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `salary_detail`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
UPDATE `salary_config`
SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR),
`updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR)
WHERE @should_run = 1;
INSERT INTO `schema_migrations` (`version`, `description`)
SELECT @migration_version, '修正历史 UTC 时间为北京时间'
WHERE @should_run = 1;
COMMIT;

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from openpyxl import Workbook from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
@ -61,6 +61,13 @@ DAILY_HEADERS = [
"请假/调休记录", "请假/调休记录",
] ]
SALARY_MODE_LABELS = {
"monthly": "包月",
"hourly": "计时",
"piecework": "计件",
"probation": "试用期",
}
def export_payroll_results(results: list[PayrollResult], config: AppConfig, output_path: str | Path) -> Path: def export_payroll_results(results: list[PayrollResult], config: AppConfig, output_path: str | Path) -> Path:
"""导出工资汇总、每日明细和规则说明三张表。""" """导出工资汇总、每日明细和规则说明三张表。"""
@ -84,6 +91,26 @@ def export_payroll_results(results: list[PayrollResult], config: AppConfig, outp
return output_path return output_path
def localize_payroll_export_salary_modes(source_path: str | Path, output_path: str | Path) -> Path:
"""复制工资表并把汇总页的薪资模式编码转换为中文标签。"""
source_path = Path(source_path)
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
workbook = load_workbook(source_path)
if "工资汇总" in workbook.sheetnames:
sheet = workbook["工资汇总"]
salary_mode_column = _find_header_column(sheet, "薪资模式")
if salary_mode_column is not None:
for row_idx in range(2, sheet.max_row + 1):
cell = sheet.cell(row=row_idx, column=salary_mode_column)
if cell.value is not None:
cell.value = _salary_mode_label(str(cell.value))
workbook.save(output_path)
return output_path
def _write_summary(sheet, results: list[PayrollResult]) -> None: def _write_summary(sheet, results: list[PayrollResult]) -> None:
sheet.append(SUMMARY_HEADERS) sheet.append(SUMMARY_HEADERS)
for result in results: for result in results:
@ -94,7 +121,7 @@ def _write_summary(sheet, results: list[PayrollResult]) -> None:
employee.position, employee.position,
employee.attendance_group, employee.attendance_group,
result.salary_month, result.salary_month,
result.salary_mode, _salary_mode_label(result.salary_mode),
result.attendance_days, result.attendance_days,
result.absence_days, result.absence_days,
result.actual_work_hours, result.actual_work_hours,
@ -192,3 +219,14 @@ def _format_time(value) -> str:
if value is None: if value is None:
return "" return ""
return value.strftime("%H:%M") return value.strftime("%H:%M")
def _find_header_column(sheet, header: str) -> int | None:
for cell in sheet[1]:
if cell.value == header:
return cell.column
return None
def _salary_mode_label(value: str) -> str:
return SALARY_MODE_LABELS.get(value, value)

View File

@ -90,9 +90,16 @@ class EmployeeRepository:
self, self,
*, *,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> list[EmployeeORM]: ) -> list[EmployeeORM]:
query = self._filtered_query(keyword=keyword, employment_status=employment_status) query = self._filtered_query(
keyword=keyword,
employee_keyword=employee_keyword,
organization_keyword=organization_keyword,
employment_status=employment_status,
)
return query.order_by(EmployeeORM.id.desc()).all() return query.order_by(EmployeeORM.id.desc()).all()
def list_employees_page( def list_employees_page(
@ -101,9 +108,16 @@ class EmployeeRepository:
page: int, page: int,
page_size: int, page_size: int,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> tuple[list[EmployeeORM], int]: ) -> tuple[list[EmployeeORM], int]:
query = self._filtered_query(keyword=keyword, employment_status=employment_status) query = self._filtered_query(
keyword=keyword,
employee_keyword=employee_keyword,
organization_keyword=organization_keyword,
employment_status=employment_status,
)
total = query.count() total = query.count()
items = ( items = (
query.order_by(EmployeeORM.id.desc()) query.order_by(EmployeeORM.id.desc())
@ -113,7 +127,14 @@ class EmployeeRepository:
) )
return items, total return items, total
def _filtered_query(self, *, keyword: str | None, employment_status: str | None): def _filtered_query(
self,
*,
keyword: str | None,
employee_keyword: str | None,
organization_keyword: str | None,
employment_status: str | None,
):
query = self.session.query(EmployeeORM) query = self.session.query(EmployeeORM)
if employment_status: if employment_status:
query = query.filter(EmployeeORM.employment_status == employment_status) query = query.filter(EmployeeORM.employment_status == employment_status)
@ -129,4 +150,22 @@ class EmployeeRepository:
EmployeeORM.phone.like(like_keyword), EmployeeORM.phone.like(like_keyword),
) )
) )
if employee_keyword:
like_employee_keyword = f"%{employee_keyword}%"
query = query.filter(
or_(
EmployeeORM.employee_no.like(like_employee_keyword),
EmployeeORM.name.like(like_employee_keyword),
EmployeeORM.dingtalk_user_id.like(like_employee_keyword),
EmployeeORM.phone.like(like_employee_keyword),
)
)
if organization_keyword:
like_organization_keyword = f"%{organization_keyword}%"
query = query.filter(
or_(
EmployeeORM.department.like(like_organization_keyword),
EmployeeORM.position.like(like_organization_keyword),
)
)
return query return query

View File

@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..core.timezone import now_shanghai
from ..database.orm import MonthlyPayrollRunORM, PayrollExceptionORM from ..database.orm import MonthlyPayrollRunORM, PayrollExceptionORM
@ -30,7 +29,7 @@ class MonthlyPayrollRepository:
existing.source_job_id = source_job_id existing.source_job_id = source_job_id
existing.status = status existing.status = status
existing.created_by = created_by existing.created_by = created_by
existing.updated_at = datetime.utcnow() existing.updated_at = now_shanghai()
self.session.commit() self.session.commit()
self.session.refresh(existing) self.session.refresh(existing)
return existing return existing
@ -90,7 +89,7 @@ class MonthlyPayrollRepository:
run.deduction_total = deduction_total run.deduction_total = deduction_total
run.overtime_total = overtime_total run.overtime_total = overtime_total
run.status = status run.status = status
run.updated_at = datetime.utcnow() run.updated_at = now_shanghai()
self.session.commit() self.session.commit()
self.session.refresh(run) self.session.refresh(run)
return run return run
@ -99,8 +98,8 @@ class MonthlyPayrollRepository:
run = self.require_run(run_id) run = self.require_run(run_id)
run.status = "locked" run.status = "locked"
run.locked_by = locked_by run.locked_by = locked_by
run.locked_at = datetime.utcnow() run.locked_at = now_shanghai()
run.updated_at = datetime.utcnow() run.updated_at = now_shanghai()
self.session.commit() self.session.commit()
self.session.refresh(run) self.session.refresh(run)
return run return run

View File

@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import date
from uuid import uuid4 from uuid import uuid4
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..core.timezone import now_shanghai
from ..database.orm import AttendanceRecordORM, AttendanceSyncJobORM, EmployeeORM, SalaryPreviewORM from ..database.orm import AttendanceRecordORM, AttendanceSyncJobORM, EmployeeORM, SalaryPreviewORM
@ -70,7 +71,7 @@ class RealtimeAttendanceRepository:
job.employee_count = employee_count job.employee_count = employee_count
job.record_count = record_count job.record_count = record_count
job.error_message = "" job.error_message = ""
job.updated_at = datetime.utcnow() job.updated_at = now_shanghai()
self.session.commit() self.session.commit()
self.session.refresh(job) self.session.refresh(job)
return job return job
@ -79,7 +80,7 @@ class RealtimeAttendanceRepository:
job = self.require_sync_job(job_id) job = self.require_sync_job(job_id)
job.status = "failed" job.status = "failed"
job.error_message = error_message job.error_message = error_message
job.updated_at = datetime.utcnow() job.updated_at = now_shanghai()
self.session.commit() self.session.commit()
self.session.refresh(job) self.session.refresh(job)
return job return job

View File

@ -108,6 +108,8 @@ class EmployeeService:
self, self,
*, *,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> list[EmployeeORM]: ) -> list[EmployeeORM]:
clean_status = _clean_optional(employment_status) clean_status = _clean_optional(employment_status)
@ -115,6 +117,8 @@ class EmployeeService:
clean_status = _valid_status(clean_status) clean_status = _valid_status(clean_status)
employees = self.repository.list_employees( employees = self.repository.list_employees(
keyword=_clean_optional(keyword), keyword=_clean_optional(keyword),
employee_keyword=_clean_optional(employee_keyword),
organization_keyword=_clean_optional(organization_keyword),
employment_status=clean_status, employment_status=clean_status,
) )
logger.info("查询员工列表成功 count=%s", len(employees)) logger.info("查询员工列表成功 count=%s", len(employees))
@ -126,6 +130,8 @@ class EmployeeService:
page: int, page: int,
page_size: int, page_size: int,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> EmployeePage: ) -> EmployeePage:
clean_status = _clean_optional(employment_status) clean_status = _clean_optional(employment_status)
@ -137,6 +143,8 @@ class EmployeeService:
page=safe_page, page=safe_page,
page_size=safe_page_size, page_size=safe_page_size,
keyword=_clean_optional(keyword), keyword=_clean_optional(keyword),
employee_keyword=_clean_optional(employee_keyword),
organization_keyword=_clean_optional(organization_keyword),
employment_status=clean_status, employment_status=clean_status,
) )
logger.info("分页查询员工列表成功 total=%s page=%s page_size=%s", total, safe_page, safe_page_size) logger.info("分页查询员工列表成功 total=%s page=%s page_size=%s", total, safe_page, safe_page_size)

View File

@ -4,6 +4,7 @@ from calendar import monthrange
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
import re
from typing import BinaryIO from typing import BinaryIO
from ..core.logger import AppLogger from ..core.logger import AppLogger
@ -13,6 +14,7 @@ from ..repositories import EmployeeRepository, MonthlyPayrollRepository
from .payroll_service import PayrollApplicationService, PayrollComputation from .payroll_service import PayrollApplicationService, PayrollComputation
logger = AppLogger.get_logger(__name__) logger = AppLogger.get_logger(__name__)
SALARY_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
@dataclass(frozen=True) @dataclass(frozen=True)
@ -62,8 +64,11 @@ class MonthlyPayrollService:
config_path=None, config_path=None,
export_excel=export_excel, export_excel=export_excel,
) )
content_month = self._month_from_computation(computation)
if content_month != salary_month:
raise ValueError(f"Excel 内容月份为 {content_month},与当前核算月份 {salary_month} 不一致")
run = self._save_calculation( run = self._save_calculation(
salary_month=salary_month or self._month_from_computation(computation), salary_month=salary_month,
source_type="excel", source_type="excel",
created_by=created_by, created_by=created_by,
computation=computation, computation=computation,
@ -111,8 +116,9 @@ class MonthlyPayrollService:
raise FileNotFoundError("该月度核算批次没有可导出的工资表") raise FileNotFoundError("该月度核算批次没有可导出的工资表")
path = Path(detail.job.output_file) path = Path(detail.job.output_file)
if path.exists(): if path.exists():
return path return self.payroll_service.localize_export_file(path, f"monthly_{detail.run.source_type}")
return self.payroll_service.resolve_output_file(path.name) resolved_path = self.payroll_service.resolve_output_file(path.name)
return self.payroll_service.localize_export_file(resolved_path, f"monthly_{detail.run.source_type}")
def _save_calculation( def _save_calculation(
self, self,
@ -169,6 +175,7 @@ class MonthlyPayrollService:
return rows return rows
def _ensure_month_can_calculate(self, salary_month: str) -> None: def _ensure_month_can_calculate(self, salary_month: str) -> None:
_validate_salary_month(salary_month)
existing = self.repository.get_run_by_month(salary_month) existing = self.repository.get_run_by_month(salary_month)
if existing and existing.status == "locked": if existing and existing.status == "locked":
raise ValueError("本月工资已锁定,不能重新计算") raise ValueError("本月工资已锁定,不能重新计算")
@ -181,7 +188,15 @@ class MonthlyPayrollService:
def _month_range(salary_month: str) -> tuple[date, date]: def _month_range(salary_month: str) -> tuple[date, date]:
_validate_salary_month(salary_month)
year_text, month_text = salary_month.split("-", 1) year_text, month_text = salary_month.split("-", 1)
year = int(year_text) year = int(year_text)
month = int(month_text) month = int(month_text)
return date(year, month, 1), date(year, month, monthrange(year, month)[1]) return date(year, month, 1), date(year, month, monthrange(year, month)[1])
def _validate_salary_month(salary_month: str) -> None:
if not salary_month:
raise ValueError("请选择核算月份")
if not SALARY_MONTH_RE.match(salary_month):
raise ValueError("核算月份格式错误,请选择正确的月份")

View File

@ -11,10 +11,10 @@ from ..core.settings import AppSettings
from ..domain.calculator import PayrollCalculator from ..domain.calculator import PayrollCalculator
from ..domain.models import EmployeeAttendance, EmployeePayConfig, PayrollResult from ..domain.models import EmployeeAttendance, EmployeePayConfig, PayrollResult
from ..integrations.dingtalk import DingTalkAttendanceAdapter, DingTalkClient, DingTalkSettings from ..integrations.dingtalk import DingTalkAttendanceAdapter, DingTalkClient, DingTalkSettings
from ..io.exporter import export_payroll_results from ..io.exporter import export_payroll_results, localize_payroll_export_salary_modes
from ..io.parser import MonthlySummaryParser from ..io.parser import MonthlySummaryParser
from ..io.storage import FileStorage from ..io.storage import FileStorage
from ..repositories import CommissionRepository, PayrollRepository, SalaryProfileRepository from ..repositories import CommissionRepository, EmployeeRepository, PayrollRepository, SalaryProfileRepository
from .salary_config_service import SalaryConfigService from .salary_config_service import SalaryConfigService
logger = AppLogger.get_logger(__name__) logger = AppLogger.get_logger(__name__)
@ -63,12 +63,14 @@ class PayrollApplicationService:
salary_repository: SalaryProfileRepository | None = None, salary_repository: SalaryProfileRepository | None = None,
commission_repository: CommissionRepository | None = None, commission_repository: CommissionRepository | None = None,
config_service: SalaryConfigService | None = None, config_service: SalaryConfigService | None = None,
employee_repository: EmployeeRepository | None = None,
): ):
self.repository = repository self.repository = repository
self.settings = settings self.settings = settings
self.salary_repository = salary_repository self.salary_repository = salary_repository
self.commission_repository = commission_repository self.commission_repository = commission_repository
self.config_service = config_service self.config_service = config_service
self.employee_repository = employee_repository
self.storage = FileStorage(settings) self.storage = FileStorage(settings)
def calculate_from_excel_upload( def calculate_from_excel_upload(
@ -85,6 +87,7 @@ class PayrollApplicationService:
try: try:
config = self._load_runtime_config(config_path) config = self._load_runtime_config(config_path)
employees = MonthlySummaryParser(config.attendance).parse(upload_path) employees = MonthlySummaryParser(config.attendance).parse(upload_path)
self._enrich_employees_from_maintenance(employees)
salary_month = self._salary_month_from_employees(employees) salary_month = self._salary_month_from_employees(employees)
config = self._load_runtime_config(config_path, salary_month=salary_month) config = self._load_runtime_config(config_path, salary_month=salary_month)
results = PayrollCalculator(config).calculate(employees) results = PayrollCalculator(config).calculate(employees)
@ -129,6 +132,7 @@ class PayrollApplicationService:
records = await client.list_attendance_records(user_ids, start_dt, end_dt) records = await client.list_attendance_records(user_ids, start_dt, end_dt)
adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone) adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone)
employees = adapter.to_employee_attendance(records) employees = adapter.to_employee_attendance(records)
self._enrich_employees_from_maintenance(employees)
results = PayrollCalculator(config).calculate(employees) results = PayrollCalculator(config).calculate(employees)
output_file = self._export_if_needed(config, results, "dingtalk", export_excel) output_file = self._export_if_needed(config, results, "dingtalk", export_excel)
self.repository.save_results(job.id, results) self.repository.save_results(job.id, results)
@ -155,6 +159,13 @@ class PayrollApplicationService:
logger.info("解析工资结果下载文件 filename=%s", filename) logger.info("解析工资结果下载文件 filename=%s", filename)
return self.storage.resolve_output_file(filename) return self.storage.resolve_output_file(filename)
def localize_export_file(self, source_path: str | Path, source_type: str) -> Path:
"""生成一份用户下载用工资表副本,修正历史文件中的薪资模式编码。"""
output_path = self.storage.payroll_output_path(source_type)
localize_payroll_export_salary_modes(source_path, output_path)
logger.info("工资表导出副本已中文化 source_path=%s output_path=%s", source_path, output_path)
return output_path
def load_runtime_config(self, config_path: str | None, salary_month: str | None = None) -> AppConfig: def load_runtime_config(self, config_path: str | None, salary_month: str | None = None) -> AppConfig:
"""对外提供当前数据库规则合并后的工资配置。""" """对外提供当前数据库规则合并后的工资配置。"""
return self._load_runtime_config(config_path, salary_month=salary_month) return self._load_runtime_config(config_path, salary_month=salary_month)
@ -228,6 +239,48 @@ class PayrollApplicationService:
), ),
) )
def _enrich_employees_from_maintenance(self, employees: list[EmployeeAttendance]) -> None:
"""优先使用员工维护中的员工编号、部门和岗位,未维护时保留 Excel/钉钉原值。"""
if self.employee_repository is None or not employees:
return
maintained_employees = self.employee_repository.list_employees()
by_employee_no = {
_clean_key(employee.employee_no): employee
for employee in maintained_employees
if _clean_key(employee.employee_no)
}
by_dingtalk_user_id = {
_clean_key(employee.dingtalk_user_id): employee
for employee in maintained_employees
if _clean_key(employee.dingtalk_user_id)
}
by_name = {
_clean_key(employee.name): employee
for employee in maintained_employees
if _clean_key(employee.name)
}
enriched_count = 0
for employee in employees:
maintained = (
by_employee_no.get(_clean_key(employee.employee_no))
or by_dingtalk_user_id.get(_clean_key(employee.user_id))
or by_name.get(_clean_key(employee.name))
)
if maintained is None:
continue
employee.employee_no = maintained.employee_no or employee.employee_no
employee.name = maintained.name or employee.name
employee.user_id = maintained.dingtalk_user_id or employee.user_id
employee.department = maintained.department or employee.department
employee.position = maintained.position or employee.position
enriched_count += 1
if enriched_count:
logger.info("已使用员工维护数据补齐工资计算员工信息 count=%s", enriched_count)
def _salary_month_from_employees(self, employees: list[EmployeeAttendance]) -> str | None: def _salary_month_from_employees(self, employees: list[EmployeeAttendance]) -> str | None:
for employee in employees: for employee in employees:
if employee.daily_records: if employee.daily_records:
@ -244,3 +297,7 @@ class PayrollApplicationService:
base_url=self.settings.dingtalk_base_url, base_url=self.settings.dingtalk_base_url,
timezone=self.settings.dingtalk_timezone, timezone=self.settings.dingtalk_timezone,
) )
def _clean_key(value: object) -> str:
return str(value or "").strip()

View File

@ -1,4 +1,4 @@
FROM node:20-alpine AS build FROM docker.m.daocloud.io/library/node:20-alpine AS build
WORKDIR /app WORKDIR /app
@ -10,7 +10,7 @@ ARG VITE_API_BASE_URL=
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build RUN npm run build
FROM nginx:1.27-alpine FROM docker.m.daocloud.io/library/nginx:1.27-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html

53
frontend/nginx.conf Normal file
View File

@ -0,0 +1,53 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 50m;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://backend:8000/health;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /docs {
proxy_pass http://backend:8000/docs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /openapi.json {
proxy_pass http://backend:8000/openapi.json;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -4,9 +4,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1 --port 5173", "dev": "vite --host 0.0.0.0 --port 5173",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173" "preview": "vite preview --host 0.0.0.0 --port 4173"
}, },
"dependencies": { "dependencies": {
"@lucide/vue": "1.18.0", "@lucide/vue": "1.18.0",

View File

@ -1,7 +1,9 @@
import { apiFetch } from "./http"; import { apiFetch } from "./http";
import type { EmployeeListResponse, EmployeeNoResponse, EmployeeRecord, EmployeeRequest } from "./types"; import type { EmployeeListResponse, EmployeeNoResponse, EmployeeRecord, EmployeeRequest } from "./types";
export function listEmployees(params: { keyword?: string; employment_status?: string } = {}): Promise<EmployeeRecord[]> { export function listEmployees(
params: { keyword?: string; employee_keyword?: string; organization_keyword?: string; employment_status?: string } = {},
): Promise<EmployeeRecord[]> {
const search = new URLSearchParams(); const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== "") { if (value !== undefined && value !== null && String(value).trim() !== "") {
@ -13,7 +15,14 @@ export function listEmployees(params: { keyword?: string; employment_status?: st
} }
export function listEmployeePage( export function listEmployeePage(
params: { page?: number; page_size?: number; keyword?: string; employment_status?: string } = {}, params: {
page?: number;
page_size?: number;
keyword?: string;
employee_keyword?: string;
organization_keyword?: string;
employment_status?: string;
} = {},
): Promise<EmployeeListResponse> { ): Promise<EmployeeListResponse> {
const search = new URLSearchParams(); const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {

View File

@ -10,24 +10,31 @@ export function getMonthlyRun(runId: number): Promise<MonthlyPayrollDetailRespon
return apiFetch<MonthlyPayrollDetailResponse>(`/api/payroll/monthly/runs/${runId}`); return apiFetch<MonthlyPayrollDetailResponse>(`/api/payroll/monthly/runs/${runId}`);
} }
export function calculateMonthlyFromExcel(file: File, salaryMonth: string): Promise<MonthlyPayrollDetailResponse> { export function calculateMonthlyFromExcel(
file: File,
salaryMonth: string,
exportExcel = true,
): Promise<MonthlyPayrollDetailResponse> {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("salary_month", salaryMonth); formData.append("salary_month", salaryMonth);
formData.append("export_excel", "true"); formData.append("export_excel", String(exportExcel));
return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/excel", { return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/excel", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
} }
export function calculateMonthlyFromDingTalk(salaryMonth: string): Promise<MonthlyPayrollDetailResponse> { export function calculateMonthlyFromDingTalk(
salaryMonth: string,
exportExcel = true,
): Promise<MonthlyPayrollDetailResponse> {
return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/dingtalk", { return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/dingtalk", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
salary_month: salaryMonth, salary_month: salaryMonth,
source_type: "dingtalk", source_type: "dingtalk",
export_excel: true, export_excel: exportExcel,
}), }),
}); });
} }

View File

@ -1,7 +1,6 @@
import { ApiError, apiFetch, buildApiUrl, getStoredToken } from "./http"; import { ApiError, apiFetch, buildApiUrl, getStoredToken } from "./http";
import type { import type {
DingTalkPayrollRequest, DingTalkPayrollRequest,
PayrollJobResponse,
PayrollResponse, PayrollResponse,
RecentPayrollJob, RecentPayrollJob,
} from "./types"; } from "./types";
@ -33,10 +32,6 @@ export function calculateFromDingTalk(payload: DingTalkPayrollRequest): Promise<
}); });
} }
export function getPayrollJob(jobId: string): Promise<PayrollJobResponse> {
return apiFetch<PayrollJobResponse>(`/api/payroll/jobs/${encodeURIComponent(jobId)}`);
}
export function getDownloadUrl(downloadUrl: string | null, outputFile?: string | null): string { export function getDownloadUrl(downloadUrl: string | null, outputFile?: string | null): string {
if (downloadUrl) { if (downloadUrl) {
return buildApiUrl(downloadUrl); return buildApiUrl(downloadUrl);

View File

@ -106,19 +106,6 @@ export interface PayrollResponse {
results: PayrollResult[]; results: PayrollResult[];
} }
export interface PayrollJobResponse {
job_id: string;
source_type: string;
status: string;
input_file: string | null;
output_file: string | null;
employee_count: number;
error_message: string | null;
created_at: string;
updated_at: string;
results: PayrollResult[];
}
export interface DingTalkPayrollRequest { export interface DingTalkPayrollRequest {
user_ids: string[]; user_ids: string[];
start_date: string; start_date: string;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,313 @@
<script setup lang="ts">
import { CalendarDays, ChevronLeft, ChevronRight } from "@lucide/vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
const props = withDefaults(
defineProps<{
modelValue: string;
mode: "month" | "date";
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
}>(),
{
placeholder: "请选择日期",
disabled: false,
clearable: true,
},
);
const emit = defineEmits<{
(event: "update:modelValue", value: string): void;
(event: "change", value: string): void;
}>();
type CalendarCell = {
dateValue: string;
day: number;
muted: boolean;
};
const PANEL_WIDTH = 292;
const PANEL_OFFSET = 8;
const VIEWPORT_GAP = 12;
const rootRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const panelStyle = ref<Record<string, string>>({});
const open = ref(false);
const panelYear = ref(new Date().getFullYear());
const panelMonth = ref(new Date().getMonth());
const monthNames = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
const weekNames = ["一", "二", "三", "四", "五", "六", "日"];
const displayValue = computed(() => {
if (!props.modelValue) {
return props.placeholder;
}
if (props.mode === "month") {
const parsed = parseMonth(props.modelValue);
return parsed ? `${parsed.year}${String(parsed.month + 1).padStart(2, "0")}` : props.modelValue;
}
const parsed = parseDate(props.modelValue);
return parsed
? `${parsed.year}${String(parsed.month + 1).padStart(2, "0")}${String(parsed.day).padStart(2, "0")}`
: props.modelValue;
});
const selectedMonthValue = computed(() => props.modelValue.slice(0, 7));
const todayDateValue = computed(() => formatDate(new Date()));
const currentMonthValue = computed(() => todayDateValue.value.slice(0, 7));
const calendarCells = computed<CalendarCell[]>(() => {
const cells: CalendarCell[] = [];
const firstDay = new Date(panelYear.value, panelMonth.value, 1);
const firstOffset = (firstDay.getDay() + 6) % 7;
const firstVisible = new Date(panelYear.value, panelMonth.value, 1 - firstOffset);
for (let index = 0; index < 42; index += 1) {
const date = new Date(firstVisible);
date.setDate(firstVisible.getDate() + index);
cells.push({
dateValue: formatDate(date),
day: date.getDate(),
muted: date.getMonth() !== panelMonth.value,
});
}
return cells;
});
watch(
() => props.modelValue,
async () => {
if (open.value) {
syncPanelFromValue();
await nextTick();
updatePanelPosition();
}
},
);
onMounted(() => {
document.addEventListener("click", handleOutsideClick);
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
});
onBeforeUnmount(() => {
document.removeEventListener("click", handleOutsideClick);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
});
async function togglePanel(): Promise<void> {
if (props.disabled) {
return;
}
if (open.value) {
closePanel();
return;
}
syncPanelFromValue();
open.value = true;
await nextTick();
updatePanelPosition();
}
function closePanel(): void {
open.value = false;
}
function handleOutsideClick(event: MouseEvent): void {
const target = event.target as Node;
if (rootRef.value?.contains(target) || panelRef.value?.contains(target)) {
return;
}
closePanel();
}
function updatePanelPosition(): void {
if (!open.value) {
return;
}
const trigger = rootRef.value?.querySelector<HTMLElement>(".date-picker-trigger");
if (!trigger) {
return;
}
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const width = Math.min(PANEL_WIDTH, Math.max(220, viewportWidth - VIEWPORT_GAP * 2));
const measuredHeight = panelRef.value?.offsetHeight;
const panelHeight = measuredHeight || (props.mode === "date" ? 380 : 242);
const minLeft = VIEWPORT_GAP;
const maxLeft = Math.max(minLeft, viewportWidth - width - VIEWPORT_GAP);
const left = clamp(rect.left, minLeft, maxLeft);
const belowTop = rect.bottom + PANEL_OFFSET;
const aboveTop = rect.top - panelHeight - PANEL_OFFSET;
const fitsBelow = belowTop + panelHeight <= viewportHeight - VIEWPORT_GAP;
const fitsAbove = aboveTop >= VIEWPORT_GAP;
const top = fitsBelow
? belowTop
: fitsAbove
? aboveTop
: Math.max(VIEWPORT_GAP, viewportHeight - panelHeight - VIEWPORT_GAP);
panelStyle.value = {
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
width: `${Math.round(width)}px`,
};
}
function syncPanelFromValue(): void {
const parsed = props.mode === "month" ? parseMonth(props.modelValue) : parseDate(props.modelValue);
const fallback = new Date();
panelYear.value = parsed?.year ?? fallback.getFullYear();
panelMonth.value = parsed?.month ?? fallback.getMonth();
}
function previousPanel(): void {
if (props.mode === "month") {
panelYear.value -= 1;
return;
}
const date = new Date(panelYear.value, panelMonth.value - 1, 1);
panelYear.value = date.getFullYear();
panelMonth.value = date.getMonth();
}
function nextPanel(): void {
if (props.mode === "month") {
panelYear.value += 1;
return;
}
const date = new Date(panelYear.value, panelMonth.value + 1, 1);
panelYear.value = date.getFullYear();
panelMonth.value = date.getMonth();
}
function selectMonth(monthIndex: number): void {
commitValue(`${panelYear.value}-${String(monthIndex + 1).padStart(2, "0")}`);
}
function selectDate(dateValue: string): void {
commitValue(dateValue);
}
function selectCurrent(): void {
commitValue(props.mode === "month" ? currentMonthValue.value : todayDateValue.value);
}
function clearValue(): void {
commitValue("");
}
function commitValue(value: string): void {
emit("update:modelValue", value);
emit("change", value);
closePanel();
}
function parseMonth(value: string): { year: number; month: number } | null {
const match = /^(\d{4})-(\d{2})$/.exec(value || "");
if (!match) {
return null;
}
const month = Number(match[2]) - 1;
if (month < 0 || month > 11) {
return null;
}
return { year: Number(match[1]), month };
}
function parseDate(value: string): { year: number; month: number; day: number } | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || "");
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]) - 1;
const day = Number(match[3]);
const date = new Date(year, month, day);
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
return null;
}
return { year, month, day };
}
function formatDate(value: Date): string {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
</script>
<template>
<div ref="rootRef" class="date-picker" :class="{ 'is-open': open, 'is-disabled': disabled }">
<button class="date-picker-trigger" type="button" :disabled="disabled" @click="togglePanel">
<span :class="{ muted: !modelValue }">{{ displayValue }}</span>
<CalendarDays :size="17" aria-hidden="true" />
</button>
<Teleport to="body">
<div v-if="open" ref="panelRef" class="date-picker-panel" :style="panelStyle" role="dialog" aria-label="日期选择">
<div class="date-picker-panel-head">
<button class="date-picker-nav" type="button" title="上一个" @click="previousPanel">
<ChevronLeft :size="17" aria-hidden="true" />
</button>
<strong>{{ panelYear }}{{ mode === "date" ? `${String(panelMonth + 1).padStart(2, "0")}` : "年" }}</strong>
<button class="date-picker-nav" type="button" title="下一个" @click="nextPanel">
<ChevronRight :size="17" aria-hidden="true" />
</button>
</div>
<div v-if="mode === 'month'" class="month-grid">
<button
v-for="(month, index) in monthNames"
:key="month"
class="date-picker-cell"
:class="{ active: `${panelYear}-${String(index + 1).padStart(2, '0')}` === selectedMonthValue }"
type="button"
@click="selectMonth(index)"
>
{{ month }}
</button>
</div>
<div v-else class="calendar-shell">
<div class="calendar-week-row">
<span v-for="week in weekNames" :key="week">{{ week }}</span>
</div>
<div class="calendar-grid">
<button
v-for="cell in calendarCells"
:key="cell.dateValue"
class="date-picker-cell calendar-day"
:class="{ active: cell.dateValue === modelValue, today: cell.dateValue === todayDateValue, muted: cell.muted }"
type="button"
@click="selectDate(cell.dateValue)"
>
{{ cell.day }}
</button>
</div>
</div>
<div class="date-picker-actions">
<button v-if="clearable" class="date-picker-text-button" type="button" @click="clearValue">清空</button>
<button class="date-picker-text-button primary" type="button" @click="selectCurrent">
{{ mode === "month" ? "本月" : "今天" }}
</button>
</div>
</div>
</Teleport>
</div>
</template>

View File

@ -1,9 +1,72 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PayrollResult } from "../api/types"; import { computed, ref, watch } from "vue";
defineProps<{ import type { PayrollException, PayrollResult } from "../api/types";
const props = withDefaults(defineProps<{
results: PayrollResult[]; results: PayrollResult[];
}>(); exceptions?: PayrollException[];
embedded?: boolean;
}>(), {
embedded: false,
exceptions: () => [],
});
const keyword = ref("");
const departmentFilter = ref("all");
const statusFilter = ref("all");
const anomalyFilter = ref("all");
const selectedResult = ref<PayrollResult | null>(null);
const exceptionMap = computed(() => {
const map = new Map<string, PayrollException[]>();
for (const item of props.exceptions) {
const keys = [item.employee_name, item.employee_no].filter(Boolean);
for (const key of keys) {
const list = map.get(key) || [];
list.push(item);
map.set(key, list);
}
}
return map;
});
const departmentOptions = computed(() =>
Array.from(new Set(props.results.map((item) => item.department).filter(Boolean))).sort((a, b) => a.localeCompare(b, "zh-CN")),
);
const filteredResults = computed(() => {
const query = keyword.value.trim().toLowerCase();
return props.results.filter((item) => {
const matchKeyword =
!query ||
[item.name, item.department, item.position, item.attendance_group, item.note]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query));
const matchDepartment = departmentFilter.value === "all" || item.department === departmentFilter.value;
const hasWarning = hasAnomaly(item);
const matchStatus =
statusFilter.value === "all" ||
(statusFilter.value === "normal" && !hasWarning) ||
(statusFilter.value === "warning" && hasWarning);
const matchAnomaly = anomalyFilter.value === "all" || anomalyTypes(item).includes(anomalyFilter.value);
return matchKeyword && matchDepartment && matchStatus && matchAnomaly;
});
});
watch(
filteredResults,
(items) => {
if (!items.length) {
selectedResult.value = null;
return;
}
if (!selectedResult.value || !items.includes(selectedResult.value)) {
selectedResult.value = items[0];
}
},
{ immediate: true },
);
function money(value: number | null): string { function money(value: number | null): string {
if (value === null || Number.isNaN(value)) { if (value === null || Number.isNaN(value)) {
@ -29,19 +92,235 @@ function modeLabel(value: string): string {
}; };
return labels[value] || value; return labels[value] || value;
} }
function exceptionItems(item: PayrollResult): PayrollException[] {
return [...(exceptionMap.value.get(item.name) || []), ...(exceptionMap.value.get(item.attendance_group) || [])];
}
function anomalyTypes(item: PayrollResult): string[] {
const types = new Set<string>();
for (const exception of exceptionItems(item)) {
if (exception.exception_type) {
types.add(exception.exception_type);
}
}
if (item.note?.includes("未配置薪资档案")) {
types.add("missing_salary_profile");
}
if (item.penalized_late_count > 0 || item.late_deduction > 0) {
types.add("late_penalty");
}
if (item.missing_card_count > 0 || item.missing_card_deduction > 0) {
types.add("missing_card");
}
if (item.remaining_overtime_hours < 0) {
types.add("negative_remaining_overtime");
}
if ((item.net_salary || 0) <= 0) {
types.add("salary_calculation_failed");
}
return Array.from(types);
}
function hasAnomaly(item: PayrollResult): boolean {
return anomalyTypes(item).length > 0;
}
function anomalyLabel(item: PayrollResult): string {
const types = anomalyTypes(item);
if (!types.length) {
return "正常";
}
const labels: Record<string, string> = {
missing_employee: "未匹配员工",
missing_salary_profile: "缺薪资档案",
negative_remaining_overtime: "加班不足",
late_penalty: "迟到扣款",
missing_card: "缺卡",
missing_attendance: "缺考勤",
salary_calculation_failed: "工资异常",
};
return labels[types[0]] || "异常";
}
function anomalyClass(item: PayrollResult): string {
return hasAnomaly(item) ? "warning" : "green";
}
function anomalySummary(item: PayrollResult): string {
const parts = [
item.penalized_late_count > 0 ? `迟到 ${item.penalized_late_count}` : "",
item.missing_card_count > 0 ? `缺卡 ${item.missing_card_count}` : "",
item.remaining_overtime_hours < 0 ? "剩余加班不足" : "",
item.note?.includes("未配置薪资档案") ? "缺薪资档案" : "",
].filter(Boolean);
return parts.join(" / ");
}
function selectResult(item: PayrollResult): void {
selectedResult.value = item;
}
function closeDetail(): void {
selectedResult.value = null;
}
</script> </script>
<template> <template>
<section class="panel table-panel"> <section :class="embedded ? 'salary-detail-table-shell' : 'panel table-panel'">
<div class="panel-header"> <div v-if="!embedded" class="panel-header">
<div> <div>
<h2>员工工资结果</h2> <h2>员工工资结果</h2>
<p> {{ results.length }} </p> <p> {{ results.length }} </p>
</div> </div>
</div> </div>
<div v-if="results.length" class="table-wrap"> <template v-if="embedded">
<table class="data-table"> <div class="salary-summary-toolbar">
<label class="field salary-summary-search">
<span>搜索</span>
<input v-model="keyword" type="search" placeholder="员工、部门、岗位" />
</label>
<label class="field">
<span>部门</span>
<select v-model="departmentFilter">
<option value="all">全部部门</option>
<option v-for="department in departmentOptions" :key="department" :value="department">{{ department }}</option>
</select>
</label>
<label class="field">
<span>状态</span>
<select v-model="statusFilter">
<option value="all">全部状态</option>
<option value="normal">正常</option>
<option value="warning">有异常</option>
</select>
</label>
<label class="field">
<span>异常</span>
<select v-model="anomalyFilter">
<option value="all">全部异常</option>
<option value="late_penalty">迟到扣款</option>
<option value="missing_card">缺卡</option>
<option value="negative_remaining_overtime">加班不足</option>
<option value="missing_salary_profile">缺薪资档案</option>
<option value="salary_calculation_failed">工资异常</option>
</select>
</label>
</div>
<div v-if="filteredResults.length" class="salary-summary-layout">
<div class="table-wrap">
<table class="data-table salary-summary-table">
<thead>
<tr>
<th>姓名</th>
<th>部门/岗位</th>
<th>应发工资</th>
<th>实发工资</th>
<th>异常标记</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in filteredResults"
:key="`${item.name}-${item.department}-${item.position}`"
:class="{ 'is-selected': selectedResult === item, 'has-warning': hasAnomaly(item) }"
@click="selectResult(item)"
>
<td>
<button class="link-button salary-name-button" type="button" @click.stop="selectResult(item)">
<strong>{{ item.name }}</strong>
</button>
<span>{{ item.attendance_group || "默认考勤组" }}</span>
</td>
<td>
<strong>{{ item.department || "-" }}</strong>
<span>{{ item.position || "-" }}</span>
</td>
<td class="mono">{{ money(item.gross_salary) }}</td>
<td class="mono strong">{{ money(item.net_salary) }}</td>
<td>
<span class="status-chip" :class="anomalyClass(item)">{{ anomalyLabel(item) }}</span>
<small v-if="anomalySummary(item)" class="salary-anomaly-note">{{ anomalySummary(item) }}</small>
</td>
<td>
<button class="link-button" type="button" @click.stop="selectResult(item)">查看构成</button>
</td>
</tr>
</tbody>
</table>
</div>
<aside v-if="selectedResult" class="salary-detail-drawer" aria-label="员工薪资构成">
<div class="salary-detail-drawer-header">
<div>
<span>薪资构成</span>
<h3>{{ selectedResult.name }}</h3>
<p>{{ selectedResult.department || "-" }} · {{ selectedResult.position || "-" }}</p>
</div>
<button class="link-button" type="button" @click="closeDetail">关闭</button>
</div>
<div>
<span>薪资模式</span>
<strong>{{ modeLabel(selectedResult.salary_mode) }}</strong>
</div>
<div>
<span>出勤/缺勤</span>
<strong>{{ selectedResult.attendance_days }} / {{ selectedResult.absence_days }}</strong>
</div>
<div>
<span>实际工时</span>
<strong>{{ hours(selectedResult.actual_work_hours) }}</strong>
</div>
<div>
<span>剩余加班</span>
<strong>{{ hours(selectedResult.remaining_overtime_hours) }}</strong>
</div>
<div>
<span>请假工时</span>
<strong>{{ hours(selectedResult.leave_hours) }}</strong>
</div>
<div>
<span>迟到扣款</span>
<strong>{{ money(selectedResult.late_deduction) }}</strong>
</div>
<div>
<span>缺卡扣款</span>
<strong>{{ money(selectedResult.missing_card_deduction) }}</strong>
</div>
<div>
<span>提成</span>
<strong>{{ money(selectedResult.commission_amount) }}</strong>
</div>
<div>
<span>基础工资</span>
<strong>{{ money(selectedResult.base_pay) }}</strong>
</div>
<div>
<span>加班费</span>
<strong>{{ money(selectedResult.overtime_pay) }}</strong>
</div>
<div>
<span>应发工资</span>
<strong>{{ money(selectedResult.gross_salary) }}</strong>
</div>
<div>
<span>实发工资</span>
<strong>{{ money(selectedResult.net_salary) }}</strong>
</div>
<p class="salary-detail-note">{{ selectedResult.note || anomalySummary(selectedResult) || "无备注" }}</p>
</aside>
</div>
<div v-else class="empty-state">
<p>没有匹配当前筛选条件的工资结果</p>
</div>
</template>
<div v-else-if="results.length" class="table-wrap">
<table class="data-table salary-detail-table">
<thead> <thead>
<tr> <tr>
<th>姓名</th> <th>姓名</th>

View File

@ -12,6 +12,7 @@ import {
X, X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, reactive, ref, watch } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ApiError, buildApiUrl } from "../api/http"; import { ApiError, buildApiUrl } from "../api/http";
import AppearancePanel from "./AppearancePanel.vue"; import AppearancePanel from "./AppearancePanel.vue";
@ -22,6 +23,7 @@ defineEmits<{
}>(); }>();
const auth = useAuthStore(); const auth = useAuthStore();
const route = useRoute();
const showThemePanel = ref(false); const showThemePanel = ref(false);
const showProfileModal = ref(false); const showProfileModal = ref(false);
const savingProfile = ref(false); const savingProfile = ref(false);
@ -50,6 +52,7 @@ const initials = computed(() => {
const source = auth.displayName || auth.user?.username || "U"; const source = auth.displayName || auth.user?.username || "U";
return source.slice(0, 2).toUpperCase(); return source.slice(0, 2).toUpperCase();
}); });
const showGlobalSearch = computed(() => route.path !== "/attendance/realtime");
const avatarSrc = computed(() => { const avatarSrc = computed(() => {
const avatarUrl = auth.user?.avatar_url || ""; const avatarUrl = auth.user?.avatar_url || "";
@ -145,7 +148,7 @@ async function handleAvatarChange(event: Event): Promise<void> {
<button class="icon-button mobile-menu" type="button" title="打开菜单" @click="$emit('toggle-sidebar')"> <button class="icon-button mobile-menu" type="button" title="打开菜单" @click="$emit('toggle-sidebar')">
<Menu :size="22" aria-hidden="true" /> <Menu :size="22" aria-hidden="true" />
</button> </button>
<div class="global-search"> <div v-if="showGlobalSearch" class="global-search">
<Search :size="20" aria-hidden="true" /> <Search :size="20" aria-hidden="true" />
<input type="search" placeholder="搜索员工、任务或部门..." /> <input type="search" placeholder="搜索员工、任务或部门..." />
</div> </div>

View File

@ -18,7 +18,6 @@ const DASHBOARD_MENU: SidebarMenuInput = {
const MENU_LABELS: Record<string, string> = { const MENU_LABELS: Record<string, string> = {
monthly_payroll: "工资核算", monthly_payroll: "工资核算",
jobs: "计算记录",
commissions: "提成录入", commissions: "提成录入",
salary_profiles: "薪资档案", salary_profiles: "薪资档案",
reports: "工资报表", reports: "工资报表",
@ -37,7 +36,7 @@ const SECTION_DEFINITIONS: Array<{
{ {
code: "monthly", code: "monthly",
title: "月度核算", title: "月度核算",
itemCodes: ["monthly_payroll", "jobs", "commissions"], itemCodes: ["monthly_payroll", "commissions"],
}, },
{ {
code: "employee", code: "employee",

View File

@ -6,12 +6,10 @@ import CommissionsView from "../views/CommissionsView.vue";
import ConfigCenterView from "../views/ConfigCenterView.vue"; import ConfigCenterView from "../views/ConfigCenterView.vue";
import DashboardView from "../views/DashboardView.vue"; import DashboardView from "../views/DashboardView.vue";
import EmployeesView from "../views/EmployeesView.vue"; import EmployeesView from "../views/EmployeesView.vue";
import JobsView from "../views/JobsView.vue";
import LoginView from "../views/LoginView.vue"; import LoginView from "../views/LoginView.vue";
import MonthlyPayrollView from "../views/MonthlyPayrollView.vue"; import MonthlyPayrollView from "../views/MonthlyPayrollView.vue";
import OperationLogsView from "../views/OperationLogsView.vue"; import OperationLogsView from "../views/OperationLogsView.vue";
import OrganizationView from "../views/OrganizationView.vue"; import OrganizationView from "../views/OrganizationView.vue";
import PayrollView from "../views/PayrollView.vue";
import RealtimeAttendanceView from "../views/RealtimeAttendanceView.vue"; import RealtimeAttendanceView from "../views/RealtimeAttendanceView.vue";
import ReportsView from "../views/ReportsView.vue"; import ReportsView from "../views/ReportsView.vue";
import SalaryProfilesView from "../views/SalaryProfilesView.vue"; import SalaryProfilesView from "../views/SalaryProfilesView.vue";
@ -61,13 +59,12 @@ const router = createRouter({
{ {
path: "payroll", path: "payroll",
name: "payroll", name: "payroll",
component: PayrollView, redirect: "/payroll/monthly",
meta: { permission: "payroll:excel:calculate" },
}, },
{ {
path: "payroll/jobs", path: "payroll/jobs",
name: "jobs", name: "jobs",
component: JobsView, redirect: "/payroll/monthly",
meta: { permission: "payroll:job:view" }, meta: { permission: "payroll:job:view" },
}, },
{ {
@ -131,7 +128,12 @@ router.beforeEach(async (to) => {
const auth = useAuthStore(); const auth = useAuthStore();
if (!to.meta.public && !auth.loaded) { if (!to.meta.public && !auth.loaded) {
try {
await auth.loadSession(); await auth.loadSession();
} catch {
auth.logout();
return { path: "/login", query: { redirect: to.fullPath } };
}
} }
if (to.meta.public) { if (to.meta.public) {
@ -157,11 +159,8 @@ function firstAllowedPath(permissions: string[]): string {
if (permissions.includes("monthly_payroll:view")) { if (permissions.includes("monthly_payroll:view")) {
return "/payroll/monthly"; return "/payroll/monthly";
} }
if (permissions.includes("payroll:excel:calculate")) {
return "/payroll";
}
if (permissions.includes("payroll:job:view")) { if (permissions.includes("payroll:job:view")) {
return "/payroll/jobs"; return "/payroll/monthly";
} }
if (permissions.includes("commission:view")) { if (permissions.includes("commission:view")) {
return "/payroll/commissions"; return "/payroll/commissions";

View File

@ -0,0 +1,54 @@
export interface ExcelMonthInfo {
month: string;
rangeLabel: string;
}
const SALARY_MONTH_RE = /^(\d{4})-(0[1-9]|1[0-2])$/;
export function isSalaryMonth(value: string): boolean {
return SALARY_MONTH_RE.test(value);
}
export function formatMonthRange(month: string): string {
if (!isSalaryMonth(month)) {
return "请选择核算月份";
}
const [yearText, monthText] = month.split("-");
const year = Number(yearText);
const monthIndex = Number(monthText) - 1;
const start = new Date(year, monthIndex, 1);
const end = new Date(year, monthIndex + 1, 0);
return `${formatDate(start)}${formatDate(end)}`;
}
export function extractMonthFromExcelFilename(filename: string): ExcelMonthInfo | null {
const compactDates = [...filename.matchAll(/((?:19|20)\d{2})(0[1-9]|1[0-2])([0-3]\d)/g)];
if (compactDates.length >= 2) {
const first = compactDates[0];
const last = compactDates[compactDates.length - 1];
const month = `${first[1]}-${first[2]}`;
return {
month,
rangeLabel: `${first[1]}/${first[2]}/${first[3]}${last[1]}/${last[2]}/${last[3]}`,
};
}
const dashedMonth = filename.match(/((?:19|20)\d{2})[-_/年](0[1-9]|1[0-2])/);
if (dashedMonth) {
const month = `${dashedMonth[1]}-${dashedMonth[2]}`;
return {
month,
rangeLabel: formatMonthRange(month),
};
}
return null;
}
function formatDate(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}/${month}/${day}`;
}

View File

@ -5,11 +5,11 @@ import {
Loader2, Loader2,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
Save, Save,
Search, Search,
Trash2, Trash2,
Upload, Upload,
X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
@ -23,6 +23,7 @@ import {
import { listEmployees } from "../api/employees"; import { listEmployees } from "../api/employees";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import type { CommissionRecord, CommissionRecordRequest, EmployeeRecord } from "../api/types"; import type { CommissionRecord, CommissionRecordRequest, EmployeeRecord } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
import StatCard from "../components/StatCard.vue"; import StatCard from "../components/StatCard.vue";
const employees = ref<EmployeeRecord[]>([]); const employees = ref<EmployeeRecord[]>([]);
@ -33,6 +34,7 @@ const importing = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const editingId = ref<number | null>(null); const editingId = ref<number | null>(null);
const showCommissionModal = ref(false);
const page = ref(1); const page = ref(1);
const pageSize = ref(10); const pageSize = ref(10);
const total = ref(0); const total = ref(0);
@ -97,8 +99,9 @@ async function submit(): Promise<void> {
await createCommission(normalizedForm()); await createCommission(normalizedForm());
successMessage.value = "提成记录已新增"; successMessage.value = "提成记录已新增";
} }
resetForm();
await loadData(); await loadData();
showCommissionModal.value = false;
resetForm();
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "提成记录保存失败"; errorMessage.value = error instanceof ApiError ? error.message : "提成记录保存失败";
} finally { } finally {
@ -141,6 +144,8 @@ async function handleImport(event: Event): Promise<void> {
} }
function editRecord(record: CommissionRecord): void { function editRecord(record: CommissionRecord): void {
errorMessage.value = "";
successMessage.value = "";
editingId.value = record.id; editingId.value = record.id;
Object.assign(form, { Object.assign(form, {
employee_id: record.employee_id, employee_id: record.employee_id,
@ -151,6 +156,7 @@ function editRecord(record: CommissionRecord): void {
business_ref: record.business_ref, business_ref: record.business_ref,
remark: record.remark, remark: record.remark,
}); });
showCommissionModal.value = true;
} }
function resetForm(): void { function resetForm(): void {
@ -166,6 +172,19 @@ function resetForm(): void {
}); });
} }
function openCreateModal(): void {
errorMessage.value = "";
successMessage.value = "";
resetForm();
showCommissionModal.value = true;
}
function closeCommissionModal(): void {
showCommissionModal.value = false;
errorMessage.value = "";
resetForm();
}
function searchRecords(): void { function searchRecords(): void {
page.value = 1; page.value = 1;
loadData(); loadData();
@ -199,6 +218,15 @@ function normalizedForm(): CommissionRecordRequest {
}; };
} }
function sourceTypeLabel(sourceType: string): string {
const labels: Record<string, string> = {
manual: "手工录入",
excel: "Excel导入",
dingtalk: "钉钉同步",
};
return labels[sourceType] || sourceType || "-";
}
function money(value: number): string { function money(value: number): string {
return new Intl.NumberFormat("zh-CN", { return new Intl.NumberFormat("zh-CN", {
style: "currency", style: "currency",
@ -209,7 +237,7 @@ function money(value: number): string {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack commissions-page">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>提成管理</h1> <h1>提成管理</h1>
@ -221,88 +249,39 @@ function money(value: number): string {
<span>{{ importing ? "导入中" : "导入Excel" }}</span> <span>{{ importing ? "导入中" : "导入Excel" }}</span>
<input accept=".xlsx,.xls" type="file" :disabled="importing" @change="handleImport" /> <input accept=".xlsx,.xls" type="file" :disabled="importing" @change="handleImport" />
</label> </label>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div> </div>
</header> </header>
<section class="summary-strip"> <section class="summary-strip commission-summary-strip">
<StatCard title="当前记录" :value="String(records.length)" :icon="FileSpreadsheet" tone="blue" meta="本页" /> <StatCard title="当前记录" :value="String(records.length)" :icon="FileSpreadsheet" tone="blue" meta="本页" />
<StatCard title="筛选总数" :value="String(total)" :icon="HandCoins" tone="neutral" meta="数据库" /> <StatCard title="筛选总数" :value="String(total)" :icon="HandCoins" tone="neutral" meta="数据库" />
<StatCard title="本页提成" :value="money(totalAmount)" :icon="HandCoins" tone="green" meta="当前页合计" /> <StatCard title="本页提成" :value="money(totalAmount)" :icon="HandCoins" tone="green" meta="当前页合计" />
</section> </section>
<section class="panel"> <p v-if="errorMessage && !showCommissionModal" class="form-error">{{ errorMessage }}</p>
<div class="panel-header">
<div>
<h2>{{ editingId ? "编辑提成" : "新增提成" }}</h2>
<p>按员工和月份维护提成金额</p>
</div>
<button class="secondary-button" type="button" @click="resetForm">清空</button>
</div>
<form class="form-grid" @submit.prevent="submit">
<label class="field">
<span>员工</span>
<select v-model.number="form.employee_id" required>
<option :value="0">请选择员工</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.name }} / {{ employee.employee_no }}
</option>
</select>
</label>
<label class="field">
<span>月份</span>
<input v-model="form.commission_month" type="month" required />
</label>
<label class="field">
<span>提成类型</span>
<input v-model="form.commission_type" type="text" placeholder="销售提成、项目奖金..." />
</label>
<label class="field">
<span>金额</span>
<input v-model.number="form.amount" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>业务单号</span>
<input v-model="form.business_ref" type="text" />
</label>
<label class="field span-row">
<span>备注</span>
<textarea v-model="form.remark" />
</label>
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : editingId ? "保存修改" : "新增提成" }}</span>
</button>
</div>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
</section>
<section class="panel table-panel"> <section class="panel table-panel commission-list-panel">
<div class="panel-header"> <div class="panel-header commission-list-header">
<div> <div>
<h2>提成列表</h2> <h2>提成列表</h2>
<p> {{ page }} / {{ totalPages }} {{ total }} </p> <p> {{ page }} / {{ totalPages }} {{ total }} </p>
</div> </div>
<button class="primary-button" type="button" @click="openCreateModal">
<Plus :size="18" aria-hidden="true" />
<span>新增提成</span>
</button>
</div> </div>
<form class="table-filter-bar" @submit.prevent="searchRecords"> <form class="table-filter-bar commission-filter-bar" @submit.prevent="searchRecords">
<label class="field"> <label class="field commission-keyword-field">
<span>关键字</span> <span>关键字</span>
<input v-model="filters.keyword" type="search" placeholder="员工、类型或业务单号" /> <input v-model="filters.keyword" type="search" placeholder="员工、类型或业务单号" />
</label> </label>
<label class="field"> <label class="field commission-month-field">
<span>月份</span> <span>月份</span>
<input v-model="filters.commission_month" type="month" /> <DatePicker v-model="filters.commission_month" mode="month" placeholder="请选择月份" />
</label> </label>
<button class="primary-button" type="submit" :disabled="loading"> <button class="primary-button commission-search-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" /> <Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span> <span>{{ loading ? "查询中" : "查询" }}</span>
@ -331,18 +310,18 @@ function money(value: number): string {
<td class="mono">{{ record.commission_month }}</td> <td class="mono">{{ record.commission_month }}</td>
<td>{{ record.commission_type }}</td> <td>{{ record.commission_type }}</td>
<td class="mono strong">{{ money(record.amount) }}</td> <td class="mono strong">{{ money(record.amount) }}</td>
<td><span class="status-chip neutral">{{ record.source_type }}</span></td> <td><span class="status-chip neutral">{{ sourceTypeLabel(record.source_type) }}</span></td>
<td>{{ record.business_ref || "-" }}</td> <td>{{ record.business_ref || "-" }}</td>
<td class="note-cell">{{ record.remark || "-" }}</td> <td class="note-cell">{{ record.remark || "-" }}</td>
<td> <td class="commission-action-cell">
<button class="link-button" type="button" @click="editRecord(record)"> <div class="row-action-group commission-row-actions">
<button class="icon-button table-action-icon" type="button" title="编辑提成" aria-label="编辑提成" @click="editRecord(record)">
<Pencil :size="15" aria-hidden="true" /> <Pencil :size="15" aria-hidden="true" />
<span>编辑</span>
</button> </button>
<button class="link-button danger" type="button" @click="removeRecord(record)"> <button class="icon-button table-action-icon danger" type="button" title="删除提成" aria-label="删除提成" @click="removeRecord(record)">
<Trash2 :size="15" aria-hidden="true" /> <Trash2 :size="15" aria-hidden="true" />
<span>删除</span>
</button> </button>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -356,5 +335,66 @@ function money(value: number): string {
<button class="secondary-button" type="button" :disabled="loading || page >= totalPages" @click="nextPage">下一页</button> <button class="secondary-button" type="button" :disabled="loading || page >= totalPages" @click="nextPage">下一页</button>
</div> </div>
</section> </section>
<Teleport to="body">
<div v-if="showCommissionModal" class="modal-backdrop commission-modal-backdrop" @click.self="closeCommissionModal">
<section class="profile-modal commission-modal" aria-label="提成维护">
<div class="modal-header">
<div>
<h2>{{ editingId ? "编辑提成" : "新增提成" }}</h2>
<p>按员工和月份维护提成金额</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="closeCommissionModal">
<X :size="21" aria-hidden="true" />
</button>
</div>
<form class="commission-form" @submit.prevent="submit">
<div class="commission-form-grid">
<label class="field">
<span>员工</span>
<select v-model.number="form.employee_id" required>
<option :value="0">请选择员工</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.name }} / {{ employee.employee_no }}
</option>
</select>
</label>
<label class="field">
<span>月份</span>
<DatePicker v-model="form.commission_month" mode="month" placeholder="请选择月份" />
</label>
<label class="field">
<span>提成类型</span>
<input v-model="form.commission_type" type="text" placeholder="销售提成、项目奖金..." />
</label>
<label class="field">
<span>金额</span>
<input v-model.number="form.amount" min="0" step="0.01" type="number" />
</label>
<label class="field commission-business-field">
<span>业务单号</span>
<input v-model="form.business_ref" type="text" />
</label>
<label class="field commission-remark-field">
<span>备注</span>
<textarea v-model="form.remark" rows="2" />
</label>
<p v-if="errorMessage" class="form-error commission-modal-message">{{ errorMessage }}</p>
<div class="form-actions align-right commission-form-actions">
<button class="secondary-button" type="button" @click="resetForm">清空</button>
<button class="secondary-button" type="button" @click="closeCommissionModal">取消</button>
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : editingId ? "保存修改" : "新增提成" }}</span>
</button>
</div>
</div>
</form>
</section>
</div>
</Teleport>
</div> </div>
</template> </template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Loader2, RefreshCw, Save, Settings2, SlidersHorizontal } from "@lucide/vue"; import { Loader2, Save, Settings2, SlidersHorizontal } from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ensureDefaultConfigs, listSalaryConfigs, updateSalaryConfig } from "../api/configs"; import { ensureDefaultConfigs, listSalaryConfigs, updateSalaryConfig } from "../api/configs";
@ -14,7 +14,102 @@ const savingKey = ref("");
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const groups = computed(() => Array.from(new Set(configs.value.map((item) => item.config_group)))); type RulePresentation = {
name: string;
purpose: string;
};
const groupLabels: Record<string, string> = {
attendance: "考勤规则",
payroll: "薪资规则",
overtime: "加班规则",
leave: "请假规则",
penalty: "扣款规则",
};
const groupOrder = ["attendance", "payroll", "overtime", "leave", "penalty"];
const weekdayOptions = [
{ value: 0, label: "周一" },
{ value: 1, label: "周二" },
{ value: 2, label: "周三" },
{ value: 3, label: "周四" },
{ value: 4, label: "周五" },
{ value: 5, label: "周六" },
{ value: 6, label: "周日" },
];
const rulePresentation: Record<string, RulePresentation> = {
"attendance.on_work_time": {
name: "上班时间",
purpose: "员工晚于这个时间打卡,系统会开始判断迟到。",
},
"attendance.off_work_time": {
name: "下班时间",
purpose: "用于判断下班后是否产生加班工时。",
},
"attendance.overtime_round_minutes": {
name: "加班取整方式",
purpose: "下班打卡超过下班时间后,加班时长按这个分钟数向下取整。",
},
"attendance.standard_daily_hours": {
name: "每天标准工时",
purpose: "计时工资和请假折算会按这个工时作为一天。",
},
"attendance.minor_late_minutes": {
name: "轻微迟到范围",
purpose: "迟到不超过这个分钟数,先按轻微迟到统计。",
},
"attendance.minor_late_free_times": {
name: "轻微迟到免扣次数",
purpose: "轻微迟到在这个次数以内不扣款,超过后按次扣款。",
},
"attendance.late_penalty": {
name: "迟到扣款金额",
purpose: "超过轻微迟到免扣次数,或迟到超过轻微范围时,每次扣这个金额。",
},
"attendance.missing_card_penalty": {
name: "缺卡扣款金额",
purpose: "员工上班或下班缺少打卡记录时,每次扣这个金额。",
},
"attendance.weekend_days": {
name: "周末加班日",
purpose: "用于识别哪些日期按周末加班规则计算。",
},
"attendance.legal_holidays": {
name: "法定节假日",
purpose: "用于识别哪些日期按节假日加班规则计算。",
},
"attendance.leave_keywords": {
name: "请假识别词",
purpose: "钉钉或 Excel 中出现这些文字时,系统会按请假或调休处理。",
},
"payroll.default_overtime_rate": {
name: "工作日加班单价",
purpose: "员工薪资档案没有维护加班单价时,默认按这个单价计算。",
},
"payroll.default_weekend_overtime_rate": {
name: "周末加班单价",
purpose: "员工薪资档案没有维护周末加班单价时,默认按这个单价计算。",
},
"payroll.default_holiday_overtime_rate": {
name: "节假日加班单价",
purpose: "员工薪资档案没有维护节假日加班单价时,默认按这个单价计算。",
},
};
const groupOptions = computed(() => {
const values = Array.from(new Set(configs.value.map((item) => item.config_group))).filter(Boolean);
return values
.sort((left, right) => {
const leftIndex = groupOrder.indexOf(left);
const rightIndex = groupOrder.indexOf(right);
if (leftIndex !== -1 || rightIndex !== -1) {
return (leftIndex === -1 ? 99 : leftIndex) - (rightIndex === -1 ? 99 : rightIndex);
}
return groupLabel(left).localeCompare(groupLabel(right), "zh-Hans-CN");
})
.map((value) => ({ value, label: groupLabel(value) }));
});
const visibleConfigs = computed(() => { const visibleConfigs = computed(() => {
if (!activeGroup.value) { if (!activeGroup.value) {
return configs.value; return configs.value;
@ -22,6 +117,7 @@ const visibleConfigs = computed(() => {
return configs.value.filter((item) => item.config_group === activeGroup.value); return configs.value.filter((item) => item.config_group === activeGroup.value);
}); });
const enabledCount = computed(() => configs.value.filter((item) => item.is_enabled).length); const enabledCount = computed(() => configs.value.filter((item) => item.is_enabled).length);
const activeGroupLabel = computed(() => (activeGroup.value ? groupLabel(activeGroup.value) : "全部规则"));
onMounted(loadData); onMounted(loadData);
@ -73,87 +169,253 @@ async function saveConfig(config: SalaryConfigRecord): Promise<void> {
savingKey.value = ""; savingKey.value = "";
} }
} }
function groupLabel(group: string): string {
return groupLabels[group] || "其他规则";
}
function configTitle(config: SalaryConfigRecord): string {
return rulePresentation[config.config_key]?.name || config.config_name || "未命名规则";
}
function configPurpose(config: SalaryConfigRecord): string {
return rulePresentation[config.config_key]?.purpose || config.remark || "影响工资核算时的自动判断规则。";
}
function isLongValue(config: SalaryConfigRecord): boolean {
return isTextListRule(config) || config.config_value.length > 18;
}
function formatConfigValue(config: SalaryConfigRecord): string {
const value = config.config_value.trim();
switch (config.config_key) {
case "attendance.on_work_time":
return value ? `${value} 后开始判断迟到` : "未设置上班时间";
case "attendance.off_work_time":
return value ? `${value} 后判断加班` : "未设置下班时间";
case "attendance.overtime_round_minutes":
return value ? `${value} 分钟向下取整` : "未设置取整方式";
case "attendance.standard_daily_hours":
return value ? `每天 ${value} 小时` : "未设置标准工时";
case "attendance.minor_late_minutes":
return value ? `${value} 分钟内算轻微迟到` : "未设置轻微迟到范围";
case "attendance.minor_late_free_times":
return value ? `每月前 ${value} 次不扣款` : "未设置免扣次数";
case "attendance.late_penalty":
return value ? `每次扣 ${formatMoney(value)}` : "未设置迟到扣款";
case "attendance.missing_card_penalty":
return value ? `每次扣 ${formatMoney(value)}` : "未设置缺卡扣款";
case "attendance.weekend_days":
return `${formatWeekdays(value)} 识别周末`;
case "attendance.legal_holidays":
return formatHolidaySummary(value);
case "attendance.leave_keywords":
return formatKeywordSummary(value);
case "payroll.default_overtime_rate":
case "payroll.default_weekend_overtime_rate":
case "payroll.default_holiday_overtime_rate":
return value ? `${formatMoney(value)} / 小时` : "未设置默认单价";
default:
return value || "未设置";
}
}
function formatMoney(value: string): string {
const amount = Number(value);
if (!Number.isFinite(amount)) {
return value;
}
return `¥${amount.toFixed(2).replace(/\.00$/, "")}`;
}
function formatWeekdays(value: string): string {
const weekdayNames = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const items = parseArray(value);
const labels = items
.map((item) => Number(item))
.filter((item) => Number.isInteger(item) && item >= 0 && item <= 6)
.map((item) => weekdayNames[item]);
return labels.length ? labels.join("、") : "未设置";
}
function formatHolidaySummary(value: string): string {
const items = parseArray(value);
if (!items.length) {
return "暂未设置节假日";
}
return `已设置 ${items.length} 天节假日`;
}
function formatKeywordSummary(value: string): string {
const items = parseArray(value).map((item) => String(item)).filter(Boolean);
if (!items.length) {
return "暂未设置请假识别词";
}
const visibleItems = items.slice(0, 5).join("、");
return items.length > 5 ? `${visibleItems}${items.length} 个词` : visibleItems;
}
function parseArray(value: string): unknown[] {
try {
const parsed = JSON.parse(value || "[]");
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function isWeekdayRule(config: SalaryConfigRecord): boolean {
return config.config_key === "attendance.weekend_days";
}
function isTextListRule(config: SalaryConfigRecord): boolean {
return ["attendance.legal_holidays", "attendance.leave_keywords"].includes(config.config_key);
}
function displayEditValue(config: SalaryConfigRecord): string {
if (config.config_key === "attendance.legal_holidays") {
return parseArray(config.config_value).map((item) => String(item)).filter(Boolean).join("\n");
}
if (config.config_key === "attendance.leave_keywords") {
return parseArray(config.config_value).map((item) => String(item)).filter(Boolean).join("、");
}
return config.config_value;
}
function updateDisplayValue(config: SalaryConfigRecord, event: Event): void {
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
const value = target.value;
if (config.config_key === "attendance.legal_holidays" || config.config_key === "attendance.leave_keywords") {
config.config_value = JSON.stringify(splitListValue(value), null, 0);
return;
}
config.config_value = value;
}
function splitListValue(value: string): string[] {
return value
.split(/[\n,,、;\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function textListPlaceholder(config: SalaryConfigRecord): string {
if (config.config_key === "attendance.legal_holidays") {
return "一行一个日期,例如 2026-10-01";
}
if (config.config_key === "attendance.leave_keywords") {
return "例如:调休、请假、事假、病假";
}
return "请输入当前设置";
}
function isWeekdaySelected(config: SalaryConfigRecord, weekday: number): boolean {
return parseArray(config.config_value).map((item) => Number(item)).includes(weekday);
}
function toggleWeekday(config: SalaryConfigRecord, weekday: number, event: Event): void {
const target = event.target as HTMLInputElement;
const selected = new Set(parseArray(config.config_value).map((item) => Number(item)).filter((item) => Number.isInteger(item)));
if (target.checked) {
selected.add(weekday);
} else {
selected.delete(weekday);
}
config.config_value = JSON.stringify(Array.from(selected).sort((left, right) => left - right));
}
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>规则配置</h1> <h1>薪资规则设</h1>
<p>维护考勤扣款加班取整加班单价和请假识别规则</p> <p>设置考勤加班迟到缺卡和请假等工资核算规则</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="secondary-button" type="button" :disabled="loading" @click="seedDefaults"> <button class="secondary-button" type="button" :disabled="loading" @click="seedDefaults">
<Settings2 :size="18" aria-hidden="true" /> <Settings2 :size="18" aria-hidden="true" />
<span>补齐默认项</span> <span>恢复默认规则</span>
</button>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button> </button>
</div> </div>
</header> </header>
<section class="summary-strip"> <section class="summary-strip">
<StatCard title="配置项" :value="String(configs.length)" :icon="SlidersHorizontal" tone="blue" meta="全部规则" /> <StatCard title="规则总数" :value="String(configs.length)" :icon="SlidersHorizontal" tone="blue" meta="可维护规则" />
<StatCard title="启用项" :value="String(enabledCount)" :icon="Settings2" tone="green" meta="参与计算" /> <StatCard title="使用中" :value="String(enabledCount)" :icon="Settings2" tone="green" meta="会参与核算" />
<StatCard title="当前分组" :value="activeGroup || '全部'" :icon="Settings2" tone="neutral" meta="筛选" /> <StatCard title="当前查看" :value="activeGroupLabel" :icon="Settings2" tone="neutral" meta="规则范围" />
</section> </section>
<section class="panel table-panel"> <section class="panel table-panel rules-panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>配置列表</h2> <h2>规则清单</h2>
<p>{{ visibleConfigs.length }} </p> <p> {{ visibleConfigs.length }} 修改后点击保存生效</p>
</div> </div>
<select v-model="activeGroup" class="compact-select"> <select v-model="activeGroup" class="compact-select">
<option value="">全部分组</option> <option value="">全部规则</option>
<option v-for="group in groups" :key="group" :value="group">{{ group }}</option> <option v-for="group in groupOptions" :key="group.value" :value="group.value">{{ group.label }}</option>
</select> </select>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table rules-table">
<thead> <thead>
<tr> <tr>
<th>配置</th> <th>规则</th>
<th>分组</th> <th>当前设置</th>
<th>类型</th> <th>是否使用</th>
<th></th> <th>用途说明</th>
<th>启用</th>
<th>说明</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="config in visibleConfigs" :key="config.config_key"> <tr v-for="config in visibleConfigs" :key="config.config_key">
<td> <td class="rule-name-cell">
<strong>{{ config.config_name }}</strong> <strong>{{ configTitle(config) }}</strong>
<span>{{ config.config_key }}</span> <span class="rule-group-chip">{{ groupLabel(config.config_group) }}</span>
</td> </td>
<td> <td class="rule-value-cell">
<input v-model="config.config_group" class="table-input" type="text" /> <span class="rule-value-preview">{{ formatConfigValue(config) }}</span>
</td> <div v-if="isWeekdayRule(config)" class="weekday-toggle-group" :aria-label="`${configTitle(config)}当前设置`">
<td> <label
<select v-model="config.value_type" class="table-input"> v-for="weekday in weekdayOptions"
<option value="string">string</option> :key="weekday.value"
<option value="integer">integer</option> class="weekday-toggle"
<option value="number">number</option> :class="{ 'is-selected': isWeekdaySelected(config, weekday.value) }"
<option value="boolean">boolean</option> >
<option value="json">json</option> <input
</select> type="checkbox"
</td> :checked="isWeekdaySelected(config, weekday.value)"
<td> @change="toggleWeekday(config, weekday.value, $event)"
<textarea v-model="config.config_value" class="table-textarea" /> />
<span>{{ weekday.label }}</span>
</label>
</div>
<textarea
v-else-if="isLongValue(config)"
:value="displayEditValue(config)"
class="table-textarea rule-value-input"
:placeholder="textListPlaceholder(config)"
:aria-label="`${configTitle(config)}当前设置`"
@input="updateDisplayValue(config, $event)"
/>
<input
v-else
:value="displayEditValue(config)"
class="table-input rule-value-input"
type="text"
:aria-label="`${configTitle(config)}当前设置`"
@input="updateDisplayValue(config, $event)"
/>
</td> </td>
<td> <td>
<label class="checkbox-line"> <label class="checkbox-line">
<input v-model="config.is_enabled" type="checkbox" /> <input v-model="config.is_enabled" type="checkbox" />
<span>{{ config.is_enabled ? "启用" : "停用" }}</span> <span>{{ config.is_enabled ? "使用" : "不用" }}</span>
</label> </label>
</td> </td>
<td> <td class="rule-purpose">
<textarea v-model="config.remark" class="table-textarea" /> <p>{{ configPurpose(config) }}</p>
</td> </td>
<td> <td>
<button class="link-button" type="button" :disabled="savingKey === config.config_key" @click="saveConfig(config)"> <button class="link-button" type="button" :disabled="savingKey === config.config_key" @click="saveConfig(config)">

View File

@ -6,22 +6,75 @@ import {
ShieldCheck, ShieldCheck,
UsersRound, UsersRound,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { downloadPayrollFile, readRecentJobs } from "../api/payroll"; import { exportMonthlyRun, listMonthlyRuns } from "../api/monthlyPayroll";
import type { RecentPayrollJob } from "../api/types"; import type { MonthlyPayrollRun } from "../api/types";
import StatCard from "../components/StatCard.vue"; import StatCard from "../components/StatCard.vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
const auth = useAuthStore(); const auth = useAuthStore();
const recentJobs = ref<RecentPayrollJob[]>(readRecentJobs()); const runs = ref<MonthlyPayrollRun[]>([]);
const loading = ref(false);
const errorMessage = ref("");
const latestJob = computed(() => recentJobs.value[0]); const latestRun = computed(() => runs.value[0]);
const totalEmployees = computed(() => recentJobs.value.reduce((total, job) => total + job.employee_count, 0)); const recentRuns = computed(() => runs.value.slice(0, 5));
const totalEmployees = computed(() => runs.value.reduce((total, run) => total + run.employee_count, 0));
const chartBars = [32, 48, 58, 76, 68, 88]; const chartBars = [32, 48, 58, 76, 68, 88];
async function download(job: RecentPayrollJob): Promise<void> { onMounted(loadRuns);
await downloadPayrollFile(job.download_url, job.output_file);
async function loadRuns(): Promise<void> {
if (!auth.hasPermission("monthly_payroll:view")) {
return;
}
loading.value = true;
errorMessage.value = "";
try {
runs.value = await listMonthlyRuns();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "核算历史加载失败";
} finally {
loading.value = false;
}
}
async function download(run: MonthlyPayrollRun): Promise<void> {
await exportMonthlyRun(run.id);
}
function sourceText(sourceType: string | undefined): string {
const map: Record<string, string> = {
excel: "Excel导入",
dingtalk: "钉钉同步",
manual: "手动重算",
};
return sourceType ? map[sourceType] || sourceType : "暂无";
}
function statusText(status: string | undefined): string {
const map: Record<string, string> = {
draft: "草稿",
calculated: "已计算",
reviewed: "已复核",
locked: "已锁定",
failed: "失败",
};
return status ? map[status] || status : "-";
}
function dateTime(value: string | null | undefined): string {
return value ? new Date(value).toLocaleString() : "-";
}
function money(value: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 0,
}).format(value || 0);
} }
</script> </script>
@ -33,38 +86,38 @@ async function download(job: RecentPayrollJob): Promise<void> {
<p>实时财务状况与薪资指标</p> <p>实时财务状况与薪资指标</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<RouterLink v-if="auth.hasPermission('payroll:excel:calculate')" class="primary-button" to="/payroll"> <RouterLink v-if="auth.hasPermission('monthly_payroll:view')" class="primary-button" to="/payroll/monthly">
<Banknote :size="19" aria-hidden="true" /> <Banknote :size="19" aria-hidden="true" />
<span>新建计</span> <span>进入工资核</span>
</RouterLink> </RouterLink>
<RouterLink v-if="auth.hasPermission('payroll:job:view')" class="secondary-button" to="/payroll/jobs"> <RouterLink v-if="auth.hasPermission('monthly_payroll:view')" class="secondary-button" to="/payroll/monthly">
<ClipboardList :size="19" aria-hidden="true" /> <ClipboardList :size="19" aria-hidden="true" />
<span>计算记录</span> <span>核算历史</span>
</RouterLink> </RouterLink>
</div> </div>
</header> </header>
<section class="summary-strip"> <section class="summary-strip">
<StatCard <StatCard
title="最近计算员工" title="最近核算人数"
:value="String(latestJob?.employee_count || 0)" :value="String(latestRun?.employee_count || 0)"
:icon="UsersRound" :icon="UsersRound"
tone="blue" tone="blue"
meta="当前浏览器" :meta="latestRun?.salary_month || '暂无月份'"
/> />
<StatCard <StatCard
title="任务记录" title="核算记录"
:value="String(recentJobs.length)" :value="String(runs.length)"
:icon="ClipboardList" :icon="ClipboardList"
tone="neutral" tone="neutral"
meta="最近 12 条" meta="月度历史"
/> />
<StatCard <StatCard
title="累计员工次数" title="累计核算人数"
:value="String(totalEmployees)" :value="String(totalEmployees)"
:icon="Gauge" :icon="Gauge"
tone="green" tone="green"
meta="本地记录" meta="全部历史"
/> />
<StatCard <StatCard
title="当前权限" title="当前权限"
@ -86,15 +139,15 @@ async function download(job: RecentPayrollJob): Promise<void> {
</div> </div>
<dl class="compact-list"> <dl class="compact-list">
<div> <div>
<dt>最近任务</dt> <dt>最近来源</dt>
<dd>{{ latestJob?.job_id || "暂无" }}</dd> <dd>{{ sourceText(latestRun?.source_type) }}</dd>
</div> </div>
<div> <div>
<dt>任务来源</dt> <dt>最近生成</dt>
<dd>{{ latestJob?.source_type || "-" }}</dd> <dd>{{ dateTime(latestRun?.created_at) }}</dd>
</div> </div>
</dl> </dl>
<RouterLink v-if="auth.hasPermission('payroll:dingtalk:calculate')" class="primary-button full" to="/payroll"> <RouterLink v-if="auth.hasPermission('monthly_payroll:calculate')" class="primary-button full" to="/payroll/monthly">
<span>立即同步</span> <span>立即同步</span>
</RouterLink> </RouterLink>
</div> </div>
@ -123,37 +176,42 @@ async function download(job: RecentPayrollJob): Promise<void> {
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>最近算记录</h2> <h2>最近算记录</h2>
<p>{{ recentJobs.length }} </p> <p>{{ runs.length }} </p>
</div> </div>
<RouterLink class="link-button" to="/payroll/jobs">查看全部</RouterLink> <RouterLink class="link-button" to="/payroll/monthly">查看全部</RouterLink>
</div> </div>
<div class="table-wrap"> <div v-if="errorMessage" class="inline-alert">{{ errorMessage }}</div>
<div v-if="recentRuns.length" class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>任务编号</th> <th>工资月份</th>
<th>来源</th> <th>来源</th>
<th>状态</th>
<th>员工数</th> <th>员工数</th>
<th>实发合计</th>
<th>创建时间</th> <th>创建时间</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="job in recentJobs.slice(0, 5)" :key="job.job_id"> <tr v-for="run in recentRuns" :key="run.id">
<td class="mono strong">{{ job.job_id }}</td> <td><strong>{{ run.salary_month }}</strong></td>
<td><span class="status-chip neutral">{{ job.source_type }}</span></td> <td><span class="status-chip neutral">{{ sourceText(run.source_type) }}</span></td>
<td>{{ job.employee_count }}</td> <td><span class="status-chip" :class="run.status === 'locked' ? 'green' : 'neutral'">{{ statusText(run.status) }}</span></td>
<td>{{ new Date(job.created_at).toLocaleString() }}</td> <td>{{ run.employee_count }}</td>
<td class="mono strong">{{ money(run.net_total) }}</td>
<td>{{ dateTime(run.created_at) }}</td>
<td> <td>
<button class="link-button" type="button" @click="download(job)">下载</button> <button class="link-button" type="button" @click="download(run)">导出</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="!recentJobs.length" class="empty-state"> <div v-else class="empty-state">
<p>暂无计算记录</p> <p>{{ loading ? "正在加载核算历史..." : "暂无核算历史" }}</p>
</div> </div>
</section> </section>
</div> </div>

View File

@ -3,7 +3,6 @@ import {
Loader2, Loader2,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
Search, Search,
UsersRound, UsersRound,
X, X,
@ -31,7 +30,8 @@ const pageSize = ref(10);
const total = ref(0); const total = ref(0);
const activeEmployeeTotal = ref(0); const activeEmployeeTotal = ref(0);
const filters = reactive({ const filters = reactive({
keyword: "", employee_info_keyword: "",
organization_keyword: "",
employment_status: "", employment_status: "",
}); });
@ -133,7 +133,8 @@ async function loadEmployees(): Promise<void> {
const response = await listEmployeePage({ const response = await listEmployeePage({
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
keyword: filters.keyword.trim(), employee_keyword: filters.employee_info_keyword.trim(),
organization_keyword: filters.organization_keyword.trim(),
employment_status: filters.employment_status, employment_status: filters.employment_status,
}); });
employees.value = response.items; employees.value = response.items;
@ -284,28 +285,11 @@ function nextPage(): void {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack employees-page">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>员工维护</h1> <h1>员工维护</h1>
<p>维护员工档案钉钉用户 ID 与在职状态</p> <p>管理员工资料工资核算会优先使用这里的信息</p>
</div>
<div class="employee-header-actions">
<div class="employee-inline-metric" aria-label="在职员工">
<span class="employee-inline-icon">
<UsersRound :size="21" aria-hidden="true" />
</span>
<div>
<span>在职员工</span>
<strong>{{ activeEmployeeTotal }}</strong>
</div>
<em>可参与薪资计算</em>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="refreshEmployees">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div> </div>
</header> </header>
@ -313,9 +297,15 @@ function nextPage(): void {
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel table-panel"> <section class="panel table-panel">
<div class="panel-header"> <div class="panel-header employee-list-header">
<div> <div class="employee-list-title">
<h2>员工列表</h2> <h2>员工列表</h2>
<span class="employee-inline-metric" aria-label="在职员工">
<UsersRound :size="15" aria-hidden="true" />
<span>在职员工</span>
<strong>{{ activeEmployeeTotal }}</strong>
<em></em>
</span>
</div> </div>
<button class="primary-button" type="button" @click="openCreateModal"> <button class="primary-button" type="button" @click="openCreateModal">
<Plus :size="18" aria-hidden="true" /> <Plus :size="18" aria-hidden="true" />
@ -323,11 +313,15 @@ function nextPage(): void {
</button> </button>
</div> </div>
<form class="table-filter-bar employee-filter-bar" @submit.prevent="searchEmployees"> <form class="table-filter-bar employee-filter-bar" @submit.prevent="searchEmployees">
<label class="field"> <label class="field employee-filter-employee">
<span>关键字</span> <span>找员工</span>
<input v-model="filters.keyword" type="search" placeholder="编号、姓名、钉钉ID、部门或岗位" /> <input v-model="filters.employee_info_keyword" type="search" placeholder="输入姓名、编号或手机号" />
</label> </label>
<label class="field"> <label class="field employee-filter-organization">
<span>找部门/岗位</span>
<input v-model="filters.organization_keyword" type="search" placeholder="输入部门或岗位名称" />
</label>
<label class="field employee-filter-status">
<span>状态</span> <span>状态</span>
<select v-model="filters.employment_status"> <select v-model="filters.employment_status">
<option value="">全部状态</option> <option value="">全部状态</option>
@ -335,7 +329,7 @@ function nextPage(): void {
<option value="inactive">离职</option> <option value="inactive">离职</option>
</select> </select>
</label> </label>
<button class="primary-button" type="submit" :disabled="loading"> <button class="primary-button employee-search-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" /> <Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span> <span>{{ loading ? "查询中" : "查询" }}</span>
@ -346,7 +340,7 @@ function nextPage(): void {
<thead> <thead>
<tr> <tr>
<th>员工</th> <th>员工</th>
<th>钉钉用户ID</th> <th>钉钉账号</th>
<th>部门/岗位</th> <th>部门/岗位</th>
<th>手机号</th> <th>手机号</th>
<th>状态</th> <th>状态</th>
@ -361,14 +355,15 @@ function nextPage(): void {
<strong>{{ employee.name }}</strong> <strong>{{ employee.name }}</strong>
<span>{{ employee.employee_no }}</span> <span>{{ employee.employee_no }}</span>
</td> </td>
<td class="mono">{{ employee.dingtalk_user_id || "-" }}</td> <td class="mono">{{ employee.dingtalk_user_id || "未绑定" }}</td>
<td> <td>
<strong>{{ employee.department || "-" }}</strong> <strong>{{ employee.department || "-" }}</strong>
<span>{{ employee.position || "-" }}</span> <span>{{ employee.position || "-" }}</span>
</td> </td>
<td>{{ employee.phone || "-" }}</td> <td>{{ employee.phone || "-" }}</td>
<td> <td>
<span class="status-chip" :class="employee.employment_status === 'active' ? 'green' : 'red'"> <span class="employee-status-pill" :class="employee.employment_status === 'active' ? 'is-active' : 'is-inactive'">
<i class="employee-status-dot" aria-hidden="true"></i>
{{ statusLabel(employee.employment_status) }} {{ statusLabel(employee.employment_status) }}
</span> </span>
</td> </td>
@ -424,8 +419,8 @@ function nextPage(): void {
<input v-model="form.name" required maxlength="128" type="text" /> <input v-model="form.name" required maxlength="128" type="text" />
</label> </label>
<label class="field"> <label class="field">
<span>钉钉用户ID</span> <span>钉钉账号</span>
<input v-model="form.dingtalk_user_id" maxlength="128" type="text" /> <input v-model="form.dingtalk_user_id" maxlength="128" placeholder="用于匹配钉钉考勤,可不填" type="text" />
</label> </label>
<label class="field"> <label class="field">
<span>状态</span> <span>状态</span>

View File

@ -1,176 +0,0 @@
<script setup lang="ts">
import {
Download,
FileClock,
Loader2,
Search,
} from "@lucide/vue";
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ApiError } from "../api/http";
import {
downloadPayrollFile,
getPayrollJob,
readRecentJobs,
} from "../api/payroll";
import type { PayrollJobResponse, RecentPayrollJob } from "../api/types";
import ResultsTable from "../components/ResultsTable.vue";
const route = useRoute();
const router = useRouter();
const queryJobId = ref("");
const loading = ref(false);
const errorMessage = ref("");
const selectedJob = ref<PayrollJobResponse | null>(null);
const recentJobs = ref<RecentPayrollJob[]>(readRecentJobs());
onMounted(() => {
const jobId = typeof route.query.job_id === "string" ? route.query.job_id : "";
if (jobId) {
queryJobId.value = jobId;
searchJob();
}
});
async function searchJob(): Promise<void> {
const jobId = queryJobId.value.trim();
if (!jobId) {
errorMessage.value = "请输入任务编号";
return;
}
loading.value = true;
errorMessage.value = "";
try {
selectedJob.value = await getPayrollJob(jobId);
router.replace({ path: "/payroll/jobs", query: { job_id: jobId } });
} catch (error) {
selectedJob.value = null;
errorMessage.value = error instanceof ApiError ? error.message : "任务查询失败";
} finally {
loading.value = false;
}
}
function chooseRecent(job: RecentPayrollJob): void {
queryJobId.value = job.job_id;
searchJob();
}
async function downloadCurrent(): Promise<void> {
if (!selectedJob.value) {
return;
}
await downloadPayrollFile(null, selectedJob.value.output_file);
}
async function downloadRecent(job: RecentPayrollJob): Promise<void> {
await downloadPayrollFile(job.download_url, job.output_file);
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>计算记录</h1>
<p>按任务编号查询已落库的工资计算结果</p>
</div>
<button class="primary-button" type="button" :disabled="!selectedJob?.output_file" @click="downloadCurrent">
<Download :size="18" aria-hidden="true" />
<span>下载当前结果</span>
</button>
</header>
<section class="panel">
<div class="panel-header">
<div>
<h2>任务查询</h2>
<p>工资计算任务 ID</p>
</div>
</div>
<form class="search-form" @submit.prevent="searchJob">
<label class="field">
<span>任务编号</span>
<input v-model="queryJobId" type="text" placeholder="例如 8c21f..." />
</label>
<button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
</section>
<section v-if="selectedJob" class="panel">
<div class="panel-header">
<div>
<h2>任务详情</h2>
<p>{{ selectedJob.job_id }}</p>
</div>
<span class="status-chip" :class="selectedJob.status === 'completed' ? 'green' : 'neutral'">
{{ selectedJob.status }}
</span>
</div>
<dl class="detail-grid">
<div>
<dt>任务来源</dt>
<dd>{{ selectedJob.source_type }}</dd>
</div>
<div>
<dt>员工数量</dt>
<dd>{{ selectedJob.employee_count }}</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ new Date(selectedJob.created_at).toLocaleString() }}</dd>
</div>
<div>
<dt>更新时间</dt>
<dd>{{ new Date(selectedJob.updated_at).toLocaleString() }}</dd>
</div>
</dl>
<p v-if="selectedJob.error_message" class="form-error">{{ selectedJob.error_message }}</p>
</section>
<ResultsTable v-if="selectedJob" :results="selectedJob.results" />
<section class="panel">
<div class="panel-header">
<div>
<h2>最近任务</h2>
<p>当前浏览器记录</p>
</div>
</div>
<div class="recent-list">
<button v-for="job in recentJobs" :key="job.job_id" class="recent-item" type="button" @click="chooseRecent(job)">
<FileClock :size="20" aria-hidden="true" />
<span>
<strong>{{ job.job_id }}</strong>
<em>{{ job.source_type }} · {{ job.employee_count }} </em>
</span>
<small>{{ new Date(job.created_at).toLocaleDateString() }}</small>
</button>
</div>
<div v-if="!recentJobs.length" class="empty-state">
<p>暂无最近任务</p>
</div>
<div v-if="recentJobs.length" class="form-actions align-right">
<button
v-for="job in recentJobs.slice(0, 3)"
:key="`download-${job.job_id}`"
class="secondary-button"
type="button"
@click="downloadRecent(job)"
>
<Download :size="17" aria-hidden="true" />
<span>{{ job.job_id.slice(0, 8) }}</span>
</button>
</div>
</section>
</div>
</template>

View File

@ -98,6 +98,7 @@ async function submit(): Promise<void> {
</section> </section>
<section class="login-visual"> <section class="login-visual">
<div class="login-background-layer" aria-hidden="true"></div>
<div class="ledger-visual"> <div class="ledger-visual">
<span class="status-chip green">企业版</span> <span class="status-chip green">企业版</span>
<h2>智能 · 精准 · 值得信赖</h2> <h2>智能 · 精准 · 值得信赖</h2>

View File

@ -1,5 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Calculator, Download, FileSpreadsheet, Loader2, LockKeyhole, RefreshCw, Upload } from "@lucide/vue"; import {
Download,
FileSpreadsheet,
Loader2,
LockKeyhole,
Play,
Upload,
} from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
@ -12,11 +19,21 @@ import {
lockMonthlyRun, lockMonthlyRun,
} from "../api/monthlyPayroll"; } from "../api/monthlyPayroll";
import type { MonthlyPayrollDetailResponse, MonthlyPayrollRun } from "../api/types"; import type { MonthlyPayrollDetailResponse, MonthlyPayrollRun } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
import ResultsTable from "../components/ResultsTable.vue"; import ResultsTable from "../components/ResultsTable.vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
import {
extractMonthFromExcelFilename,
formatMonthRange,
isSalaryMonth,
type ExcelMonthInfo,
} from "../utils/payrollMonth";
const auth = useAuthStore(); const auth = useAuthStore();
const selectedMonth = ref(new Date().toISOString().slice(0, 7)); const selectedMonth = ref(defaultSalaryMonth());
const selectedFile = ref<File | null>(null);
const selectedFileMonth = ref<ExcelMonthInfo | null>(null);
const exportExcel = ref(true);
const runs = ref<MonthlyPayrollRun[]>([]); const runs = ref<MonthlyPayrollRun[]>([]);
const detail = ref<MonthlyPayrollDetailResponse | null>(null); const detail = ref<MonthlyPayrollDetailResponse | null>(null);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
@ -33,18 +50,47 @@ const canExport = computed(() => auth.hasPermission("monthly_payroll:export"));
const grossTotal = computed(() => money(activeRun.value?.gross_total || 0)); const grossTotal = computed(() => money(activeRun.value?.gross_total || 0));
const netTotal = computed(() => money(activeRun.value?.net_total || 0)); const netTotal = computed(() => money(activeRun.value?.net_total || 0));
const deductionTotal = computed(() => money(activeRun.value?.deduction_total || 0)); const deductionTotal = computed(() => money(activeRun.value?.deduction_total || 0));
const overtimeTotal = computed(() => `${(activeRun.value?.overtime_total || 0).toFixed(1)}h`);
const monthRange = computed(() => formatMonthRange(selectedMonth.value));
const hasValidMonth = computed(() => isSalaryMonth(selectedMonth.value));
const dingtalkScopeText = computed(() =>
hasValidMonth.value ? `将同步 ${monthRange.value} 的钉钉考勤` : "请选择核算月份后再同步钉钉",
);
const excelMonthMismatch = computed(
() => Boolean(selectedFile.value && selectedFileMonth.value && selectedFileMonth.value.month !== selectedMonth.value),
);
const excelMonthHint = computed(() => {
if (!selectedFile.value) {
return "请先选择 Excel 文件。";
}
if (!selectedFileMonth.value) {
return "未从文件名识别月份,请确认核算月份与 Excel 内容一致。";
}
if (excelMonthMismatch.value) {
return `Excel 文件月份为 ${selectedFileMonth.value.month},当前核算月份为 ${selectedMonth.value || "未选择"},请保持一致后再导入。`;
}
return `Excel 文件月份:${selectedFileMonth.value.month}${selectedFileMonth.value.rangeLabel}`;
});
onMounted(() => { onMounted(() => {
void loadRuns(); void loadRuns();
}); });
async function loadRuns(): Promise<void> { function defaultSalaryMonth(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
async function loadRuns(preferredRunId?: number): Promise<void> {
loading.value = true; loading.value = true;
errorMessage.value = ""; errorMessage.value = "";
try { try {
runs.value = await listMonthlyRuns(selectedMonth.value); runs.value = await listMonthlyRuns(selectedMonth.value);
if (runs.value.length) { if (runs.value.length) {
detail.value = await getMonthlyRun(runs.value[0].id); const selectedRun = runs.value.find((run) => run.id === preferredRunId) || runs.value[0];
detail.value = await getMonthlyRun(selectedRun.id);
} else { } else {
detail.value = null; detail.value = null;
} }
@ -57,6 +103,24 @@ async function loadRuns(): Promise<void> {
} }
} }
async function handleMonthChange(): Promise<void> {
errorMessage.value = "";
successMessage.value = "";
await loadRuns();
}
async function viewRun(run: MonthlyPayrollRun): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
detail.value = await getMonthlyRun(run.id);
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "核算记录加载失败";
} finally {
loading.value = false;
}
}
function chooseExcel(): void { function chooseExcel(): void {
fileInput.value?.click(); fileInput.value?.click();
} }
@ -68,18 +132,52 @@ async function handleExcelChange(event: Event): Promise<void> {
if (!file) { if (!file) {
return; return;
} }
await runAction("excel", async () => {
detail.value = await calculateMonthlyFromExcel(file, selectedMonth.value); selectedFile.value = file;
successMessage.value = "Excel 已导入并完成月度核算"; selectedFileMonth.value = extractMonthFromExcelFilename(file.name);
await loadRuns();
}); let detectedMonthMessage = "";
if (selectedFileMonth.value && selectedMonth.value !== selectedFileMonth.value.month) {
selectedMonth.value = selectedFileMonth.value.month;
detectedMonthMessage = `已根据文件识别核算月份:${selectedFileMonth.value.month}`;
}
await importSelectedExcel(detectedMonthMessage);
} }
async function calculateFromDingTalk(): Promise<void> { async function calculateFromDingTalk(): Promise<void> {
if (!hasValidMonth.value) {
errorMessage.value = "请选择核算月份";
return;
}
await runAction("dingtalk", async () => { await runAction("dingtalk", async () => {
detail.value = await calculateMonthlyFromDingTalk(selectedMonth.value); const calculated = await calculateMonthlyFromDingTalk(selectedMonth.value, exportExcel.value);
successMessage.value = "钉钉考勤已同步并完成月度核算"; detail.value = calculated;
await loadRuns(); successMessage.value = `钉钉考勤已同步并完成 ${calculated.run.salary_month} 工资核算`;
await loadRuns(calculated.run.id);
});
}
async function importSelectedExcel(prefixMessage = ""): Promise<void> {
if (!selectedFile.value) {
errorMessage.value = "请选择钉钉月度汇总 Excel 文件";
return;
}
if (!hasValidMonth.value) {
errorMessage.value = "请选择核算月份";
return;
}
if (excelMonthMismatch.value) {
errorMessage.value = excelMonthHint.value;
return;
}
await runAction("excel", async () => {
const calculated = await calculateMonthlyFromExcel(selectedFile.value!, selectedMonth.value, exportExcel.value);
detail.value = calculated;
successMessage.value = `${prefixMessage}Excel 已导入并完成 ${calculated.run.salary_month} 工资核算`;
await loadRuns(calculated.run.id);
}); });
} }
@ -87,11 +185,12 @@ async function lockRun(): Promise<void> {
if (!activeRun.value || !window.confirm("锁定后本月工资不能重新计算,只能导出。确认锁定?")) { if (!activeRun.value || !window.confirm("锁定后本月工资不能重新计算,只能导出。确认锁定?")) {
return; return;
} }
const runId = activeRun.value.id;
await runAction("lock", async () => { await runAction("lock", async () => {
await lockMonthlyRun(activeRun.value!.id); await lockMonthlyRun(runId);
detail.value = await getMonthlyRun(activeRun.value!.id); detail.value = await getMonthlyRun(runId);
successMessage.value = "本月工资已锁定"; successMessage.value = "本月工资已锁定";
await loadRuns(); await loadRuns(runId);
}); });
} }
@ -132,6 +231,50 @@ function statusText(status: string | undefined): string {
return status ? map[status] || status : "未开始"; return status ? map[status] || status : "未开始";
} }
function sourceText(sourceType: string | undefined): string {
const map: Record<string, string> = {
excel: "Excel导入",
dingtalk: "钉钉同步",
manual: "手动重算",
};
return sourceType ? map[sourceType] || sourceType : "-";
}
function exceptionTypeText(type: string | undefined): string {
const map: Record<string, string> = {
missing_employee: "未匹配员工",
missing_salary_profile: "缺少薪资档案",
negative_remaining_overtime: "剩余加班不足",
late_penalty: "迟到扣款",
missing_card: "缺卡",
missing_attendance: "缺少考勤",
salary_calculation_failed: "工资计算失败",
};
return type ? map[type] || type : "-";
}
function severityText(severity: string | undefined): string {
const map: Record<string, string> = {
info: "提醒",
warning: "预警",
error: "错误",
};
return severity ? map[severity] || severity : "-";
}
function exceptionStatusText(status: string | undefined): string {
const map: Record<string, string> = {
pending: "待处理",
resolved: "已处理",
ignored: "已忽略",
};
return status ? map[status] || status : "-";
}
function dateTime(value: string | null | undefined): string {
return value ? new Date(value).toLocaleString() : "-";
}
function money(value: number): string { function money(value: number): string {
return new Intl.NumberFormat("zh-CN", { return new Intl.NumberFormat("zh-CN", {
style: "currency", style: "currency",
@ -145,71 +288,60 @@ function money(value: number): string {
<div class="view-stack"> <div class="view-stack">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>月度核算</h1> <h1>工资核算</h1>
<p>按工资月份完成导入计算异常处理锁定和导出</p> <p>一个入口完成考勤取数工资计算异常核对锁定和导出</p>
</div>
<div class="header-actions">
<label class="field compact-field">
<span>工资月份</span>
<input v-model="selectedMonth" type="month" @change="loadRuns" />
</label>
<button class="secondary-button" type="button" :disabled="loading" @click="loadRuns">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div> </div>
</header> </header>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p> <p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel"> <section class="panel payroll-compact-toolbar" aria-label="工资核算工具栏">
<div class="panel-header"> <div class="payroll-toolbar-month">
<span>本月核算</span>
<label class="field">
<span>核算月份</span>
<DatePicker v-model="selectedMonth" mode="month" placeholder="请选择核算月份" @change="handleMonthChange" />
</label>
</div>
<div class="payroll-toolbar-summary">
<div> <div>
<h2>核算流程</h2>
<p>数据导入 -> 考勤转换 -> 异常处理 -> 工资计算 -> 复核锁定</p>
</div>
<span class="status-chip" :class="isLocked ? 'green' : 'neutral'">{{ statusText(activeRun?.status) }}</span>
</div>
<div class="workflow-steps">
<span>1 数据导入</span>
<span>2 考勤转换</span>
<span>3 异常处理</span>
<span>4 工资计算</span>
<span>5 复核锁定</span>
</div>
<div class="compact-stats-grid">
<div class="compact-stat">
<span>员工数</span>
<strong>{{ activeRun?.employee_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>应发合计</span>
<strong>{{ grossTotal }}</strong>
</div>
<div class="compact-stat">
<span>实发合计</span> <span>实发合计</span>
<strong>{{ netTotal }}</strong> <strong>{{ netTotal }}</strong>
<small>{{ sourceText(activeRun?.source_type) }} · {{ activeRun?.employee_count || 0 }} </small>
</div> </div>
<div class="compact-stat"> <div>
<span>应发合计</span>
<strong>{{ grossTotal }}</strong>
<small>{{ statusText(activeRun?.status) }}</small>
</div>
<div>
<span>扣款合计</span> <span>扣款合计</span>
<strong>{{ deductionTotal }}</strong> <strong>{{ deductionTotal }}</strong>
<small>加班 {{ overtimeTotal }}</small>
</div>
<div class="payroll-toolbar-range">
<span>同步范围</span>
<strong>{{ monthRange }}</strong>
<small>{{ dingtalkScopeText }}</small>
</div> </div>
</div> </div>
<div class="form-actions"> <div class="payroll-toolbar-actions">
<label class="payroll-toolbar-export">
<input v-model="exportExcel" type="checkbox" />
<span>生成 Excel 结果文件</span>
</label>
<button class="primary-button" type="button" :disabled="!canCalculate || !hasValidMonth || isLocked || actionLoading === 'dingtalk'" @click="calculateFromDingTalk">
<Loader2 v-if="actionLoading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<Play v-else :size="18" aria-hidden="true" />
<span>{{ actionLoading === "dingtalk" ? "同步中" : "同步钉钉核算" }}</span>
</button>
<button class="secondary-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'excel'" @click="chooseExcel"> <button class="secondary-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'excel'" @click="chooseExcel">
<Loader2 v-if="actionLoading === 'excel'" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="actionLoading === 'excel'" class="spin" :size="18" aria-hidden="true" />
<Upload v-else :size="18" aria-hidden="true" /> <Upload v-else :size="18" aria-hidden="true" />
<span>导入 Excel</span> <span>{{ selectedFile ? "更换 Excel 核算" : "导入 Excel 核算" }}</span>
</button>
<button class="primary-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'dingtalk'" @click="calculateFromDingTalk">
<Loader2 v-if="actionLoading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<Calculator v-else :size="18" aria-hidden="true" />
<span>同步钉钉并计算</span>
</button> </button>
<button class="secondary-button" type="button" :disabled="!activeRun || !canLock || isLocked || actionLoading === 'lock'" @click="lockRun"> <button class="secondary-button" type="button" :disabled="!activeRun || !canLock || isLocked || actionLoading === 'lock'" @click="lockRun">
<Loader2 v-if="actionLoading === 'lock'" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="actionLoading === 'lock'" class="spin" :size="18" aria-hidden="true" />
@ -219,13 +351,97 @@ function money(value: number): string {
<button class="secondary-button" type="button" :disabled="!activeRun || !canExport || actionLoading === 'export'" @click="exportRun"> <button class="secondary-button" type="button" :disabled="!activeRun || !canExport || actionLoading === 'export'" @click="exportRun">
<Loader2 v-if="actionLoading === 'export'" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="actionLoading === 'export'" class="spin" :size="18" aria-hidden="true" />
<Download v-else :size="18" aria-hidden="true" /> <Download v-else :size="18" aria-hidden="true" />
<span>导出</span> <span>导出工资表</span>
</button> </button>
<button v-if="selectedFile" class="link-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'excel' || excelMonthMismatch" @click="importSelectedExcel()">
重新导入
</button>
</div>
<input ref="fileInput" accept=".xlsx,.xls" hidden type="file" @change="handleExcelChange" /> <input ref="fileInput" accept=".xlsx,.xls" hidden type="file" @change="handleExcelChange" />
</section>
<section class="panel payroll-detail-panel">
<div class="payroll-detail-header">
<div>
<h2>工资明细</h2>
<p>锁定后本月工资不能重新计算只能导出</p>
</div>
<div class="payroll-detail-summary">
<div>
<span>员工数</span>
<strong>{{ activeRun?.employee_count || 0 }}</strong>
</div>
<div>
<span>实发合计</span>
<strong>{{ netTotal }}</strong>
</div>
<div>
<span>应发合计</span>
<strong>{{ grossTotal }}</strong>
</div>
<div>
<span>状态</span>
<strong>{{ statusText(activeRun?.status) }}</strong>
</div>
</div>
</div>
<ResultsTable v-if="detail?.results.length" :exceptions="detail.exceptions" :results="detail.results" embedded />
<div v-else class="empty-state">
<FileSpreadsheet :size="38" aria-hidden="true" />
<p>当前月份还没有核算结果请导入 Excel 或同步钉钉并计算</p>
</div> </div>
</section> </section>
<section v-if="detail?.exceptions.length" class="panel"> <div class="payroll-secondary-grid">
<section class="panel monthly-history-panel">
<div class="panel-header">
<div>
<h2>核算历史</h2>
<p>展示本月最近生成的工资版本可切换查看明细或导出</p>
</div>
<span class="status-chip neutral">{{ runs.length }} 条记录</span>
</div>
<div v-if="runs.length" class="table-wrap">
<table class="data-table monthly-history-table">
<thead>
<tr>
<th>工资月份</th>
<th>来源</th>
<th>状态</th>
<th>员工数</th>
<th>实发合计</th>
<th>计算时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="run in runs" :key="run.id" :class="{ 'is-selected': activeRun?.id === run.id }">
<td>
<strong>{{ run.salary_month }}</strong>
<span v-if="activeRun?.id === run.id">当前查看</span>
</td>
<td>{{ sourceText(run.source_type) }}</td>
<td><span class="status-chip" :class="run.status === 'locked' ? 'green' : 'neutral'">{{ statusText(run.status) }}</span></td>
<td>{{ run.employee_count }}</td>
<td class="mono strong">{{ money(run.net_total) }}</td>
<td>{{ dateTime(run.created_at) }}</td>
<td>
<button class="link-button" type="button" :disabled="loading || activeRun?.id === run.id" @click="viewRun(run)">
查看明细
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-state">
<FileSpreadsheet :size="38" aria-hidden="true" />
<p>当前月份还没有核算历史请导入 Excel 或同步钉钉并计算</p>
</div>
</section>
<section v-if="detail?.exceptions.length" class="panel payroll-exception-panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>异常清单</h2> <h2>异常清单</h2>
@ -249,29 +465,38 @@ function money(value: number): string {
<strong>{{ item.employee_name || "-" }}</strong> <strong>{{ item.employee_name || "-" }}</strong>
<span>{{ item.employee_no || "-" }}</span> <span>{{ item.employee_no || "-" }}</span>
</td> </td>
<td>{{ item.exception_type }}</td> <td>{{ exceptionTypeText(item.exception_type) }}</td>
<td>{{ item.severity }}</td> <td>{{ severityText(item.severity) }}</td>
<td>{{ item.message }}</td> <td>{{ item.message }}</td>
<td>{{ item.status }}</td> <td>{{ exceptionStatusText(item.status) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
</div>
<section class="panel"> <details v-if="activeRun" class="panel monthly-technical-panel">
<div class="panel-header"> <summary>技术信息</summary>
<p class="technical-note">用于系统排查和审计日常工资核对不需要使用这些字段</p>
<dl class="detail-grid">
<div> <div>
<h2>工资明细</h2> <dt>系统记录ID</dt>
<p>锁定后本月工资不能重新计算只能导出</p> <dd>{{ activeRun.id }}</dd>
</div> </div>
<FileSpreadsheet :size="22" aria-hidden="true" /> <div>
<dt>来源任务编号</dt>
<dd class="mono">{{ activeRun.source_job_id || "-" }}</dd>
</div> </div>
<ResultsTable v-if="detail?.results.length" :results="detail.results" /> <div>
<div v-else class="empty-state"> <dt>创建时间</dt>
<FileSpreadsheet :size="38" aria-hidden="true" /> <dd>{{ dateTime(activeRun.created_at) }}</dd>
<p>当前月份还没有核算结果请导入 Excel 或同步钉钉并计算</p>
</div> </div>
</section> <div>
<dt>更新时间</dt>
<dd>{{ dateTime(activeRun.updated_at) }}</dd>
</div>
</dl>
</details>
</div> </div>
</template> </template>

View File

@ -3,7 +3,6 @@ import {
CheckCircle2, CheckCircle2,
Clock3, Clock3,
Loader2, Loader2,
RefreshCw,
ScrollText, ScrollText,
Search, Search,
XCircle, XCircle,
@ -168,11 +167,6 @@ function targetLabel(log: OperationLog): string {
<h1>操作日志</h1> <h1>操作日志</h1>
<p>查看登录工资计算用户管理和下载等关键操作</p> <p>查看登录工资计算用户管理和下载等关键操作</p>
</div> </div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadLogs">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header> </header>
<section class="summary-strip"> <section class="summary-strip">

View File

@ -8,7 +8,6 @@ import {
GitBranch, GitBranch,
Loader2, Loader2,
Plus, Plus,
RefreshCw,
Save, Save,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
@ -500,11 +499,6 @@ function sortByOrderThenId<T extends { sort_order: number; id: number }>(left: T
<h1>组织架构</h1> <h1>组织架构</h1>
<p>用树状关系维护部门上下级和部门下岗位员工维护会按这里的关系联动</p> <p>用树状关系维护部门上下级和部门下岗位员工维护会按这里的关系联动</p>
</div> </div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header> </header>
<section class="organization-summary-strip"> <section class="organization-summary-strip">

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ArrowRight,
Banknote, Banknote,
CalendarDays,
CloudCog, CloudCog,
Download, Download,
FileSpreadsheet, FileSpreadsheet,
@ -9,115 +9,148 @@ import {
Play, Play,
Trash2, Trash2,
Upload, Upload,
UsersRound,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import { import {
calculateFromDingTalk, calculateMonthlyFromDingTalk,
calculateFromExcel, calculateMonthlyFromExcel,
downloadPayrollFile, exportMonthlyRun,
saveRecentJob, } from "../api/monthlyPayroll";
} from "../api/payroll"; import type { MonthlyPayrollDetailResponse } from "../api/types";
import type { PayrollResponse } from "../api/types"; import DatePicker from "../components/DatePicker.vue";
import ResultsTable from "../components/ResultsTable.vue"; import ResultsTable from "../components/ResultsTable.vue";
import StatCard from "../components/StatCard.vue"; import StatCard from "../components/StatCard.vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
import {
extractMonthFromExcelFilename,
formatMonthRange,
isSalaryMonth,
type ExcelMonthInfo,
} from "../utils/payrollMonth";
const auth = useAuthStore(); const auth = useAuthStore();
const selectedFile = ref<File | null>(null); const selectedFile = ref<File | null>(null);
const configPath = ref(""); const selectedFileMonth = ref<ExcelMonthInfo | null>(null);
const salaryMonth = ref(defaultSalaryMonth());
const exportExcel = ref(true); const exportExcel = ref(true);
const userIdsText = ref("001\n002"); const activeDetail = ref<MonthlyPayrollDetailResponse | null>(null);
const startDate = ref("2026-05-01");
const endDate = ref("2026-05-31");
const activeResult = ref<PayrollResponse | null>(null);
const loading = ref<"excel" | "dingtalk" | "">(""); const loading = ref<"excel" | "dingtalk" | "">("");
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const canCalculateExcel = computed(() => auth.hasPermission("payroll:excel:calculate")); const canCalculate = computed(() => auth.hasPermission("monthly_payroll:calculate"));
const canCalculateDingTalk = computed(() => auth.hasPermission("payroll:dingtalk:calculate")); const activeRun = computed(() => activeDetail.value?.run);
const monthRange = computed(() => formatMonthRange(salaryMonth.value));
const hasValidMonth = computed(() => isSalaryMonth(salaryMonth.value));
const excelMonthMismatch = computed(
() => Boolean(selectedFile.value && selectedFileMonth.value && selectedFileMonth.value.month !== salaryMonth.value),
);
const dingtalkScopeText = computed(() =>
hasValidMonth.value ? `将同步 ${monthRange.value} 的钉钉考勤` : "请选择核算月份后再同步钉钉",
);
const excelMonthHint = computed(() => {
if (!selectedFile.value) {
return "选择 Excel 后会自动识别文件月份。";
}
if (!selectedFileMonth.value) {
return "未从文件名识别月份,请确认核算月份与 Excel 内容一致。";
}
if (excelMonthMismatch.value) {
return `Excel 文件月份为 ${selectedFileMonth.value.month},当前核算月份为 ${salaryMonth.value || "未选择"},请保持一致后再导入。`;
}
return `Excel 文件月份:${selectedFileMonth.value.month}${selectedFileMonth.value.rangeLabel}`;
});
const totalOvertime = computed(() => (activeRun.value?.overtime_total || 0).toFixed(1));
const totalDeduction = computed(() => money(activeRun.value?.deduction_total || 0));
const totalNetSalary = computed(() => money(activeRun.value?.net_total || 0));
const totalOvertime = computed(() => sumResults("remaining_overtime_hours").toFixed(1)); function defaultSalaryMonth(): string {
const totalDeduction = computed(() => money(sumResults("total_deduction"))); const now = new Date();
const totalNetSalary = computed(() => money(sumResults("net_salary"))); const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const year = previousMonth.getFullYear();
const month = String(previousMonth.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
function handleFileChange(event: Event): void { function handleFileChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
selectedFile.value = input.files?.[0] || null; selectedFile.value = input.files?.[0] || null;
selectedFileMonth.value = selectedFile.value ? extractMonthFromExcelFilename(selectedFile.value.name) : null;
errorMessage.value = "";
successMessage.value = "";
if (selectedFileMonth.value && salaryMonth.value !== selectedFileMonth.value.month) {
salaryMonth.value = selectedFileMonth.value.month;
successMessage.value = `已根据文件识别核算月份:${selectedFileMonth.value.month}`;
}
} }
async function submitExcel(): Promise<void> { async function submitExcel(): Promise<void> {
if (!selectedFile.value) { if (!selectedFile.value) {
errorMessage.value = "请选择 Excel 文件"; errorMessage.value = "请选择钉钉月度汇总 Excel 文件";
return;
}
if (!hasValidMonth.value) {
errorMessage.value = "请选择核算月份";
return;
}
if (excelMonthMismatch.value) {
errorMessage.value = excelMonthHint.value;
return; return;
} }
await runCalculation("excel", async () => { await runCalculation("excel", () => calculateMonthlyFromExcel(selectedFile.value!, salaryMonth.value, exportExcel.value));
const response = await calculateFromExcel(selectedFile.value!, exportExcel.value, configPath.value.trim());
saveRecentJob("excel", response);
return response;
});
} }
async function submitDingTalk(): Promise<void> { async function submitDingTalk(): Promise<void> {
const user_ids = userIdsText.value if (!hasValidMonth.value) {
.split(/[\n,\s]+/) errorMessage.value = "请选择核算月份";
.map((item) => item.trim())
.filter(Boolean);
if (!user_ids.length) {
errorMessage.value = "请输入钉钉员工 userId";
return; return;
} }
await runCalculation("dingtalk", async () => { await runCalculation("dingtalk", () => calculateMonthlyFromDingTalk(salaryMonth.value, exportExcel.value));
const response = await calculateFromDingTalk({
user_ids,
start_date: startDate.value,
end_date: endDate.value,
config_path: configPath.value.trim() || null,
export_excel: exportExcel.value,
});
saveRecentJob("dingtalk", response);
return response;
});
} }
async function runCalculation( async function runCalculation(
source: "excel" | "dingtalk", source: "excel" | "dingtalk",
executor: () => Promise<PayrollResponse>, executor: () => Promise<MonthlyPayrollDetailResponse>,
): Promise<void> { ): Promise<void> {
loading.value = source; loading.value = source;
errorMessage.value = ""; errorMessage.value = "";
successMessage.value = ""; successMessage.value = "";
try { try {
activeResult.value = await executor(); activeDetail.value = await executor();
successMessage.value = `计算完成:${activeResult.value.employee_count} 名员工`; successMessage.value = `核算完成:${activeDetail.value.run.salary_month}${activeDetail.value.run.employee_count} 名员工`;
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "算失败,请检查数据后重试"; errorMessage.value = error instanceof ApiError ? error.message : "算失败,请检查数据后重试";
} finally { } finally {
loading.value = ""; loading.value = "";
} }
} }
async function downloadCurrent(): Promise<void> { async function downloadCurrent(): Promise<void> {
if (!activeResult.value) { if (!activeRun.value) {
return; return;
} }
await downloadPayrollFile(activeResult.value.download_url, activeResult.value.output_file); await exportMonthlyRun(activeRun.value.id);
} }
function clearResult(): void { function clearResult(): void {
activeResult.value = null; activeDetail.value = null;
successMessage.value = ""; successMessage.value = "";
errorMessage.value = ""; errorMessage.value = "";
} }
function sumResults(field: "remaining_overtime_hours" | "total_deduction" | "net_salary"): number { function sourceText(sourceType: string | undefined): string {
return (activeResult.value?.results || []).reduce((total, item) => total + (item[field] || 0), 0); const map: Record<string, string> = {
excel: "Excel导入",
dingtalk: "钉钉同步",
};
return sourceType ? map[sourceType] || sourceType : "未核算";
} }
function money(value: number): string { function money(value: number): string {
@ -130,109 +163,117 @@ function money(value: number): string {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack payroll-import-view">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>薪资计算与集成</h1> <h1>同步导入</h1>
<p>Excel 月度汇总与钉钉实时考勤统一计算</p> <p>按月份导入考勤并生成工资核算结果</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="secondary-button" type="button" :disabled="!activeResult" @click="clearResult"> <button class="secondary-button" type="button" :disabled="!activeDetail" @click="clearResult">
<Trash2 :size="18" aria-hidden="true" /> <Trash2 :size="18" aria-hidden="true" />
<span>清空数据</span> <span>清空结果</span>
</button> </button>
<button class="primary-button" type="button" :disabled="!activeResult?.download_url && !activeResult?.output_file" @click="downloadCurrent"> <button class="primary-button" type="button" :disabled="!activeRun" @click="downloadCurrent">
<Download :size="18" aria-hidden="true" /> <Download :size="18" aria-hidden="true" />
<span>下载结果</span> <span>下载结果</span>
</button> </button>
</div> </div>
</header> </header>
<section class="action-grid"> <section class="panel payroll-settings-panel">
<div class="action-card">
<div class="action-icon blue">
<CloudCog :size="29" aria-hidden="true" />
</div>
<h2>实时同步钉钉考勤</h2>
<p>通过后端钉钉接口获取打卡请假及加班数据</p>
<button class="primary-button" type="button" :disabled="!canCalculateDingTalk || loading === 'dingtalk'" @click="submitDingTalk">
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<ArrowRight v-else :size="18" aria-hidden="true" />
<span>{{ loading === "dingtalk" ? "同步中" : "连接并抓取" }}</span>
</button>
</div>
<div class="action-card">
<div class="action-icon green">
<FileSpreadsheet :size="29" aria-hidden="true" />
</div>
<h2>离线 Excel 导入</h2>
<p>上传钉钉月度汇总表按当前工资规则计算</p>
<label class="file-button">
<Upload :size="18" aria-hidden="true" />
<span>{{ selectedFile?.name || "上传文件" }}</span>
<input accept=".xlsx,.xls" type="file" @change="handleFileChange" />
</label>
</div>
</section>
<section class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>计算参数</h2> <h2>核算设置</h2>
<p>{{ exportExcel ? "生成 Excel 结果文件" : "仅返回接口结果" }}</p> <p>核算月份会同时决定钉钉同步范围和 Excel 入库月份</p>
</div> </div>
<span class="status-chip green">API 已连接</span> <span class="status-chip green">API 已连接</span>
</div> </div>
<div class="form-grid"> <div class="settings-grid">
<label class="field"> <label class="field">
<span>配置文件路径</span> <span>核算月份</span>
<input v-model="configPath" type="text" placeholder="默认规则配置" /> <DatePicker v-model="salaryMonth" mode="month" placeholder="请选择核算月份" />
</label>
<label class="field">
<span>开始日期</span>
<input v-model="startDate" type="date" />
</label>
<label class="field">
<span>结束日期</span>
<input v-model="endDate" type="date" />
</label> </label>
<div class="readonly-field">
<span>核算周期</span>
<strong>{{ monthRange }}</strong>
</div>
<div class="readonly-field">
<span>同步范围</span>
<strong>{{ dingtalkScopeText }}</strong>
</div>
<label class="checkbox-card"> <label class="checkbox-card">
<input v-model="exportExcel" type="checkbox" /> <input v-model="exportExcel" type="checkbox" />
<span>导出 Excel</span> <span>生成 Excel 结果文件</span>
</label> </label>
</div> </div>
</section>
<label class="field"> <section class="source-workflow" aria-label="数据来源">
<span>钉钉员工 userId</span> <div class="section-title-row">
<textarea v-model="userIdsText" rows="4" /> <div>
</label> <h2>选择导入方式</h2>
<p>两种方式都会生成所选月份的同一套月度核算结果</p>
</div>
</div>
<div class="form-actions"> <div class="source-strip">
<button class="secondary-button" type="button" :disabled="!canCalculateExcel || loading === 'excel'" @click="submitExcel"> <article class="source-option">
<Loader2 v-if="loading === 'excel'" class="spin" :size="18" aria-hidden="true" /> <div class="source-option-main">
<Play v-else :size="18" aria-hidden="true" /> <span class="action-icon blue">
<span>{{ loading === "excel" ? "计算中" : "开始计算 Excel" }}</span> <CloudCog :size="22" aria-hidden="true" />
</button> </span>
<button class="primary-button" type="button" :disabled="!canCalculateDingTalk || loading === 'dingtalk'" @click="submitDingTalk"> <div>
<h2>钉钉同步</h2>
<p>{{ dingtalkScopeText }}</p>
</div>
</div>
<button class="primary-button" type="button" :disabled="!canCalculate || !hasValidMonth || loading === 'dingtalk'" @click="submitDingTalk">
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<Play v-else :size="18" aria-hidden="true" /> <Play v-else :size="18" aria-hidden="true" />
<span>{{ loading === "dingtalk" ? "计算中" : "开始计算钉钉" }}</span> <span>{{ loading === "dingtalk" ? "同步中" : "开始同步" }}</span>
</button> </button>
</article>
<article class="source-option">
<div class="source-option-main">
<span class="action-icon green">
<FileSpreadsheet :size="22" aria-hidden="true" />
</span>
<div>
<h2>Excel 导入</h2>
<p>{{ selectedFile?.name || "上传钉钉月度汇总表后开始核算。" }}</p>
<small class="source-note" :class="{ warning: excelMonthMismatch }">{{ excelMonthHint }}</small>
</div>
</div>
<div class="source-actions">
<label class="file-button secondary-file-button">
<Upload :size="18" aria-hidden="true" />
<span>{{ selectedFile ? "更换文件" : "选择文件" }}</span>
<input accept=".xlsx,.xls" type="file" @change="handleFileChange" />
</label>
<button class="secondary-button" type="button" :disabled="!canCalculate || !hasValidMonth || loading === 'excel' || !selectedFile || excelMonthMismatch" @click="submitExcel">
<Loader2 v-if="loading === 'excel'" class="spin" :size="18" aria-hidden="true" />
<Play v-else :size="18" aria-hidden="true" />
<span>{{ loading === "excel" ? "核算中" : "开始导入" }}</span>
</button>
</div>
</article>
</div> </div>
</section> </section>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p> <p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section v-if="activeResult" class="summary-strip"> <section v-if="activeRun" class="summary-strip">
<StatCard title="员工数量" :value="String(activeResult.employee_count)" :icon="FileSpreadsheet" tone="blue" :meta="activeResult.job_id" /> <StatCard title="工资月份" :value="activeRun.salary_month" :icon="CalendarDays" tone="blue" :meta="sourceText(activeRun.source_type)" />
<StatCard title="员工数量" :value="String(activeRun.employee_count)" :icon="UsersRound" tone="neutral" meta="参与核算" />
<StatCard title="剩余加班合计" :value="`${totalOvertime}h`" :icon="CloudCog" tone="green" meta="加班-请假" /> <StatCard title="剩余加班合计" :value="`${totalOvertime}h`" :icon="CloudCog" tone="green" meta="加班-请假" />
<StatCard title="扣款合计" :value="totalDeduction" :icon="Trash2" tone="red" meta="迟到+缺卡" /> <StatCard title="扣款合计" :value="totalDeduction" :icon="Trash2" tone="red" meta="迟到+缺卡" />
<StatCard title="实发合计" :value="totalNetSalary" :icon="Banknote" tone="neutral" meta="已计算" /> <StatCard title="实发合计" :value="totalNetSalary" :icon="Banknote" tone="neutral" meta="已计算" />
</section> </section>
<ResultsTable :results="activeResult?.results || []" /> <ResultsTable :results="activeDetail?.results || []" />
</div> </div>
</template> </template>

View File

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { CalendarClock, CloudCog, Loader2, RefreshCw } from "@lucide/vue"; import { CalendarClock, CloudCog, Loader2 } from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import { getTodayAttendance, syncRealtimeAttendance } from "../api/realtimeAttendance"; import { getTodayAttendance, syncRealtimeAttendance } from "../api/realtimeAttendance";
import type { RealtimeAttendanceResponse, TodayAttendanceItem } from "../api/types"; import type { RealtimeAttendanceResponse, TodayAttendanceItem } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
const auth = useAuthStore(); const auth = useAuthStore();
@ -14,10 +15,37 @@ const loading = ref(false);
const syncing = ref(false); const syncing = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const employeeQuery = ref("");
const departmentFilter = ref("all");
const summary = computed(() => response.value?.summary); const summary = computed(() => response.value?.summary);
const rows = computed(() => response.value?.items || []); const rows = computed(() => response.value?.items || []);
const canSync = computed(() => auth.hasPermission("attendance:realtime:sync")); const canSync = computed(() => auth.hasPermission("attendance:realtime:sync"));
const departmentOptions = computed(() =>
Array.from(new Set(rows.value.map((row) => row.department).filter(Boolean))).sort((a, b) => a.localeCompare(b, "zh-CN")),
);
const filteredRows = computed(() => {
const query = employeeQuery.value.trim().toLowerCase();
return rows.value.filter((row) => {
const matchDepartment = departmentFilter.value === "all" || row.department === departmentFilter.value;
const matchKeyword =
!query ||
[row.employee_name, row.employee_no, row.department, row.position]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query));
return matchDepartment && matchKeyword;
});
});
const hasRealtimeFilters = computed(() => departmentFilter.value !== "all" || employeeQuery.value.trim() !== "");
const syncStatusText = computed(() => {
const syncedAt = response.value?.synced_at;
if (syncedAt) {
const time = formatSyncTime(syncedAt);
return time ? `${time} 同步` : "已同步";
}
return response.value?.sync_job_id ? "已同步" : "未同步";
});
const syncStatusClass = computed(() => (response.value?.synced_at || response.value?.sync_job_id ? "green" : "neutral"));
onMounted(() => { onMounted(() => {
void loadTodayAttendance(); void loadTodayAttendance();
@ -78,6 +106,29 @@ function numberText(value: number, unit = ""): string {
return `${Number(value || 0).toFixed(1)}${unit}`; return `${Number(value || 0).toFixed(1)}${unit}`;
} }
function formatSyncTime(value: string): string {
const text = String(value || "").trim();
const match = text.match(/(?:T|\s)(\d{2}):(\d{2})/);
if (match) {
return `${match[1]}:${match[2]}`;
}
const parsed = new Date(text.includes(" ") ? text.replace(" ", "T") : text);
if (Number.isNaN(parsed.getTime())) {
return "";
}
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(parsed);
}
function clearRealtimeFilters(): void {
departmentFilter.value = "all";
employeeQuery.value = "";
}
function rowKey(row: TodayAttendanceItem): string { function rowKey(row: TodayAttendanceItem): string {
return row.employee_id ? String(row.employee_id) : `${row.employee_no}-${row.employee_name}`; return row.employee_id ? String(row.employee_id) : `${row.employee_no}-${row.employee_name}`;
} }
@ -85,21 +136,16 @@ function rowKey(row: TodayAttendanceItem): string {
<template> <template>
<div class="view-stack"> <div class="view-stack">
<header class="page-header"> <header class="page-header realtime-page-header">
<div> <div>
<h1>实时考勤</h1> <h1>实时考勤</h1>
<p>查看今日打卡状态并预估本月薪资进度</p> <p>查看今日打卡状态并预估本月薪资进度</p>
</div> </div>
<div class="header-actions"> <div class="header-actions realtime-header-actions">
<label class="field compact-field"> <label class="field compact-field realtime-date-field">
<span>考勤日期</span> <span>考勤日期</span>
<input v-model="selectedDate" type="date" @change="loadTodayAttendance" /> <DatePicker v-model="selectedDate" mode="date" placeholder="请选择考勤日期" @change="loadTodayAttendance" />
</label> </label>
<button class="secondary-button" type="button" :disabled="loading || syncing" @click="loadTodayAttendance">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
<button class="primary-button" type="button" :disabled="!canSync || syncing" @click="syncDingTalk"> <button class="primary-button" type="button" :disabled="!canSync || syncing" @click="syncDingTalk">
<Loader2 v-if="syncing" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="syncing" class="spin" :size="18" aria-hidden="true" />
<CloudCog v-else :size="18" aria-hidden="true" /> <CloudCog v-else :size="18" aria-hidden="true" />
@ -111,18 +157,18 @@ function rowKey(row: TodayAttendanceItem): string {
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p> <p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel"> <section class="panel realtime-overview-panel">
<div class="panel-header"> <div class="panel-header realtime-overview-header">
<div> <div class="realtime-overview-title">
<h2>今日考勤与薪资预估</h2> <h2>今日考勤与薪资预估</h2>
<p>预估金额最终以月度核算锁定结果为准</p> <p>预估金额最终以月度核算锁定结果为准</p>
</div> </div>
<span class="status-chip" :class="response?.sync_job_id ? 'green' : 'neutral'"> <span class="status-chip realtime-sync-chip" :class="syncStatusClass">
{{ response?.sync_job_id ? "已同步" : "未同步" }} {{ syncStatusText }}
</span> </span>
</div> </div>
<div class="compact-stats-grid"> <div class="compact-stats-grid realtime-overview-stats">
<div class="compact-stat"> <div class="compact-stat">
<span>在职员工</span> <span>在职员工</span>
<strong>{{ summary?.employee_total || 0 }}</strong> <strong>{{ summary?.employee_total || 0 }}</strong>
@ -158,11 +204,39 @@ function rowKey(row: TodayAttendanceItem): string {
</div> </div>
</div> </div>
<div v-if="rows.length" class="realtime-table-filter-bar">
<label class="field">
<span>部门筛选</span>
<select v-model="departmentFilter">
<option value="all">全部部门</option>
<option v-for="department in departmentOptions" :key="department" :value="department">{{ department }}</option>
</select>
</label>
<label class="field">
<span>员工搜索</span>
<input v-model="employeeQuery" type="search" placeholder="姓名 / 编号 / 岗位" />
</label>
<button
class="secondary-button realtime-clear-filter"
type="button"
:disabled="!hasRealtimeFilters"
@click="clearRealtimeFilters"
>
清空
</button>
<span class="realtime-filter-result">显示 {{ filteredRows.length }} / {{ rows.length }} </span>
</div>
<div v-if="!rows.length && !loading" class="empty-state"> <div v-if="!rows.length && !loading" class="empty-state">
<CalendarClock :size="38" aria-hidden="true" /> <CalendarClock :size="38" aria-hidden="true" />
<p>当前日期暂无考勤数据可点击同步钉钉拉取在职员工打卡记录</p> <p>当前日期暂无考勤数据可点击同步钉钉拉取在职员工打卡记录</p>
</div> </div>
<div v-else-if="!filteredRows.length" class="empty-state">
<CalendarClock :size="38" aria-hidden="true" />
<p>没有匹配当前部门或员工搜索条件的考勤记录</p>
</div>
<div v-else class="table-wrap"> <div v-else class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead> <thead>
@ -181,7 +255,7 @@ function rowKey(row: TodayAttendanceItem): string {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in rows" :key="rowKey(row)"> <tr v-for="row in filteredRows" :key="rowKey(row)">
<td> <td>
<strong>{{ row.employee_name }}</strong> <strong>{{ row.employee_name }}</strong>
<span>{{ row.employee_no || "-" }}</span> <span>{{ row.employee_no || "-" }}</span>

View File

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Banknote, Building2, Clock3, Loader2, RefreshCw, Search, WalletCards } from "@lucide/vue"; import { Banknote, Building2, Clock3, Loader2, Search, WalletCards } from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import { getPayrollReport } from "../api/reports"; import { getPayrollReport } from "../api/reports";
import type { PayrollReportResponse } from "../api/types"; import type { PayrollReportResponse } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
const salaryMonth = ref(new Date().toISOString().slice(0, 7)); const salaryMonth = ref(new Date().toISOString().slice(0, 7));
const report = ref<PayrollReportResponse | null>(null); const report = ref<PayrollReportResponse | null>(null);
@ -57,17 +58,13 @@ function modeLabel(value: string): string {
<form class="header-actions" @submit.prevent="loadReport"> <form class="header-actions" @submit.prevent="loadReport">
<label class="field inline-field"> <label class="field inline-field">
<span>工资月份</span> <span>工资月份</span>
<input v-model="salaryMonth" type="month" /> <DatePicker v-model="salaryMonth" mode="month" placeholder="请选择工资月份" />
</label> </label>
<button class="primary-button" type="submit" :disabled="loading"> <button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" /> <Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span> <span>{{ loading ? "查询中" : "查询" }}</span>
</button> </button>
<button class="secondary-button" type="button" :disabled="loading" @click="loadReport">
<RefreshCw :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</form> </form>
</header> </header>

View File

@ -5,7 +5,6 @@ import {
Loader2, Loader2,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
Save, Save,
Search, Search,
TimerReset, TimerReset,
@ -18,6 +17,7 @@ import { listEmployees } from "../api/employees";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import { listSalaryProfiles, saveSalaryProfile } from "../api/salaryProfiles"; import { listSalaryProfiles, saveSalaryProfile } from "../api/salaryProfiles";
import type { EmployeeRecord, SalaryProfileRecord, SalaryProfileRequest } from "../api/types"; import type { EmployeeRecord, SalaryProfileRecord, SalaryProfileRequest } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
const employees = ref<EmployeeRecord[]>([]); const employees = ref<EmployeeRecord[]>([]);
const profiles = ref<SalaryProfileRecord[]>([]); const profiles = ref<SalaryProfileRecord[]>([]);
@ -210,11 +210,6 @@ function money(value: number): string {
<h1>薪资维护</h1> <h1>薪资维护</h1>
<p>关联员工维护包月计时计件试用期规则和独立加班费单价</p> <p>关联员工维护包月计时计件试用期规则和独立加班费单价</p>
</div> </div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header> </header>
<section class="salary-profile-summary-strip" aria-label="薪资档案指标"> <section class="salary-profile-summary-strip" aria-label="薪资档案指标">
@ -364,7 +359,7 @@ function money(value: number): string {
</label> </label>
<label class="field"> <label class="field">
<span>生效月份</span> <span>生效月份</span>
<input v-model="form.effective_month" type="month" /> <DatePicker v-model="form.effective_month" mode="month" placeholder="长期" />
</label> </label>
</div> </div>
</section> </section>

View File

@ -4,6 +4,7 @@ import {
Plus, Plus,
ShieldCheck, ShieldCheck,
UsersRound, UsersRound,
X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
@ -17,6 +18,7 @@ const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const showUserModal = ref(false);
const form = reactive({ const form = reactive({
username: "", username: "",
@ -64,6 +66,17 @@ async function submit(): Promise<void> {
role: form.role, role: form.role,
}); });
users.value = [user, ...users.value.filter((item) => item.id !== user.id)]; users.value = [user, ...users.value.filter((item) => item.id !== user.id)];
resetForm();
showUserModal.value = false;
successMessage.value = "用户已创建";
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "用户创建失败";
} finally {
saving.value = false;
}
}
function resetForm(): void {
Object.assign(form, { Object.assign(form, {
username: "", username: "",
display_name: "", display_name: "",
@ -74,12 +87,19 @@ async function submit(): Promise<void> {
password: "", password: "",
role: "viewer", role: "viewer",
}); });
successMessage.value = "用户已创建"; }
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "用户创建失败"; function openCreateModal(): void {
} finally { errorMessage.value = "";
saving.value = false; successMessage.value = "";
} resetForm();
showUserModal.value = true;
}
function closeUserModal(): void {
showUserModal.value = false;
errorMessage.value = "";
resetForm();
} }
function roleLabel(role: string): string { function roleLabel(role: string): string {
@ -93,17 +113,12 @@ function roleLabel(role: string): string {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack users-page">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>用户管理</h1> <h1>用户管理</h1>
<p>账号角色与菜单权限</p> <p>账号角色与菜单权限</p>
</div> </div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadUsers">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<UsersRound v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header> </header>
<section class="summary-strip"> <section class="summary-strip">
@ -112,68 +127,19 @@ function roleLabel(role: string): string {
<StatCard title="查看权限" :value="String(viewerCount)" :icon="UsersRound" tone="neutral" meta="只读权限" /> <StatCard title="查看权限" :value="String(viewerCount)" :icon="UsersRound" tone="neutral" meta="只读权限" />
</section> </section>
<section class="panel"> <p v-if="errorMessage && !showUserModal" class="form-error">{{ errorMessage }}</p>
<div class="panel-header">
<div>
<h2>新增用户</h2>
<p>角色决定可见菜单和操作权限</p>
</div>
</div>
<form class="form-grid user-form" @submit.prevent="submit">
<label class="field">
<span>用户名</span>
<input v-model="form.username" required minlength="2" type="text" />
</label>
<label class="field">
<span>显示名称</span>
<input v-model="form.display_name" type="text" />
</label>
<label class="field">
<span>初始密码</span>
<input v-model="form.password" required minlength="6" type="password" />
</label>
<label class="field">
<span>角色</span>
<select v-model="form.role">
<option value="viewer">查看权限</option>
<option value="manager">管理者</option>
<option value="superuser">超级用户</option>
</select>
</label>
<label class="field">
<span>邮箱</span>
<input v-model="form.email" type="email" />
</label>
<label class="field">
<span>手机号</span>
<input v-model="form.phone" type="tel" />
</label>
<label class="field">
<span>部门</span>
<input v-model="form.department" type="text" />
</label>
<label class="field">
<span>职位</span>
<input v-model="form.position_title" type="text" />
</label>
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "创建中" : "创建用户" }}</span>
</button>
</div>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
</section>
<section class="panel table-panel"> <section class="panel table-panel user-list-panel">
<div class="panel-header"> <div class="panel-header user-list-header">
<div> <div>
<h2>用户列表</h2> <h2>用户列表</h2>
<p>{{ users.length }} 个账号</p> <p>{{ users.length }} 个账号</p>
</div> </div>
<button class="primary-button" type="button" @click="openCreateModal">
<Plus :size="18" aria-hidden="true" />
<span>新增用户</span>
</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
@ -220,5 +186,72 @@ function roleLabel(role: string): string {
<p>暂无用户</p> <p>暂无用户</p>
</div> </div>
</section> </section>
<Teleport to="body">
<div v-if="showUserModal" class="modal-backdrop user-modal-backdrop" @click.self="closeUserModal">
<section class="profile-modal user-modal" aria-label="用户维护">
<div class="modal-header">
<div>
<h2>新增用户</h2>
<p>角色决定可见菜单和操作权限</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="closeUserModal">
<X :size="21" aria-hidden="true" />
</button>
</div>
<form class="user-modal-form" @submit.prevent="submit">
<div class="user-modal-fields">
<label class="field">
<span>用户名</span>
<input v-model="form.username" required minlength="2" type="text" />
</label>
<label class="field">
<span>显示名称</span>
<input v-model="form.display_name" type="text" />
</label>
<label class="field">
<span>初始密码</span>
<input v-model="form.password" required minlength="6" type="password" />
</label>
<label class="field">
<span>角色</span>
<select v-model="form.role">
<option value="viewer">查看权限</option>
<option value="manager">管理者</option>
<option value="superuser">超级用户</option>
</select>
</label>
<label class="field">
<span>邮箱</span>
<input v-model="form.email" type="email" />
</label>
<label class="field">
<span>手机号</span>
<input v-model="form.phone" type="tel" />
</label>
<label class="field">
<span>部门</span>
<input v-model="form.department" type="text" />
</label>
<label class="field">
<span>职位</span>
<input v-model="form.position_title" type="text" />
</label>
<p v-if="errorMessage" class="form-error user-modal-message">{{ errorMessage }}</p>
<div class="form-actions user-modal-actions">
<button class="secondary-button" type="button" @click="resetForm">清空</button>
<button class="secondary-button" type="button" @click="closeUserModal">取消</button>
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "创建中" : "创建用户" }}</span>
</button>
</div>
</div>
</form>
</section>
</div>
</Teleport>
</div> </div>
</template> </template>

View File

@ -0,0 +1,36 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const routerPath = join(process.cwd(), "src/router/index.ts");
const routerSource = readFileSync(routerPath, "utf-8");
assertIncludes(routerSource, "await auth.loadSession();");
assertIncludes(routerSource, "catch");
assertIncludes(routerSource, "auth.logout();");
assertIncludes(routerSource, 'return { path: "/login", query: { redirect: to.fullPath } };');
assertBefore(routerSource, "try", "await auth.loadSession();");
assertBefore(routerSource, "await auth.loadSession();", "auth.logout();");
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertBefore(content: string, first: string, second: string): void {
const firstIndex = content.indexOf(first);
const secondIndex = content.indexOf(second);
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
throw new Error(`期望 ${first} 出现在 ${second} 之前`);
}
}

View File

@ -30,7 +30,7 @@ assertIncludes(summaryStripBlock, "border: var(--card-border);");
assertIncludes(summaryStripBlock, "background: var(--card);"); assertIncludes(summaryStripBlock, "background: var(--card);");
assertIncludes(summaryStripBlock, "box-shadow: var(--card-shadow);"); assertIncludes(summaryStripBlock, "box-shadow: var(--card-shadow);");
const innerCardBlock = cssBlock(".summary-metric,\\s*\\n\\.compact-stat,\\s*\\n\\.salary-profile-summary-item,\\s*\\n\\.organization-summary-strip div,\\s*\\n\\.organization-node-summary div,\\s*\\n\\.employee-inline-metric,\\s*\\n\\.workflow-steps span,\\s*\\n\\.recent-item,\\s*\\n\\.salary-profile-modal-section,\\s*\\n\\.report-total-item,\\s*\\n\\.report-inline-metrics span,\\s*\\n\\.report-attendance-card"); const innerCardBlock = cssBlock(".summary-metric,\\s*\\n\\.compact-stat,\\s*\\n\\.salary-profile-summary-item,\\s*\\n\\.organization-summary-strip div,\\s*\\n\\.organization-node-summary div,\\s*\\n\\.workflow-steps span,\\s*\\n\\.recent-item,\\s*\\n\\.salary-profile-modal-section,\\s*\\n\\.report-total-item,\\s*\\n\\.report-inline-metrics span,\\s*\\n\\.report-attendance-card");
assertIncludes(innerCardBlock, "border: var(--card-inner-border);"); assertIncludes(innerCardBlock, "border: var(--card-inner-border);");
assertIncludes(innerCardBlock, "background: var(--card-inner-bg);"); assertIncludes(innerCardBlock, "background: var(--card-inner-bg);");
assertIncludes(innerCardBlock, "box-shadow: var(--card-inner-shadow);"); assertIncludes(innerCardBlock, "box-shadow: var(--card-inner-shadow);");

View File

@ -0,0 +1,134 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const view = readFileSync(join(root, "src/views/CommissionsView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
assertIncludes(view, "view-stack commissions-page");
assertIncludes(view, "summary-strip commission-summary-strip");
assertIncludes(view, "commission-list-panel");
assertIncludes(view, "commission-filter-bar");
assertIncludes(view, "commission-keyword-field");
assertIncludes(view, "commission-month-field");
assertIncludes(view, "commission-search-button");
assertIncludes(view, "row-action-group commission-row-actions");
assertIncludes(view, "table-action-icon");
assertIncludes(view, "title=\"编辑提成\"");
assertIncludes(view, "title=\"删除提成\"");
assertIncludes(view, "sourceTypeLabel");
assertIncludes(view, "manual: \"手工录入\"");
assertIncludes(view, "excel: \"Excel导入\"");
assertIncludes(view, "dingtalk: \"钉钉同步\"");
assertIncludes(view, "{{ sourceTypeLabel(record.source_type) }}");
assertNotIncludes(view, "{{ record.source_type }}");
assertIncludes(view, "openCreateModal");
assertIncludes(view, "showCommissionModal");
assertIncludes(view, "closeCommissionModal");
assertIncludes(view, "Teleport to=\"body\"");
assertIncludes(view, "modal-backdrop commission-modal-backdrop");
assertIncludes(view, "profile-modal commission-modal");
assertIncludes(view, "aria-label=\"提成维护\"");
assertIncludes(view, "commission-form");
assertIncludes(view, "commission-form-grid");
assertIncludes(view, "commission-business-field");
assertIncludes(view, "commission-remark-field");
assertIncludes(view, "rows=\"2\"");
assertIncludes(view, "commission-form-actions");
assertIncludes(view, "@click=\"openCreateModal\"");
assertIncludes(view, "@click=\"closeCommissionModal\"");
assertNotIncludes(view, "field span-row");
assertNotIncludes(view, "commission-entry-panel");
const pageBlock = cssBlock(".commissions-page");
assertIncludes(pageBlock, "gap: 14px;");
const summaryBlock = cssBlock(".commission-summary-strip");
assertIncludes(summaryBlock, "display: grid;");
assertIncludes(summaryBlock, "grid-template-columns: repeat(3, minmax(0, 1fr));");
assertIncludes(summaryBlock, "padding: 8px;");
const summaryMetricBlock = cssBlock(".commission-summary-strip .summary-metric");
assertIncludes(summaryMetricBlock, "min-height: 58px;");
assertIncludes(summaryMetricBlock, "padding: 8px 12px;");
const summaryStrongBlock = cssBlock(".commission-summary-strip .summary-copy strong");
assertIncludes(summaryStrongBlock, "font-size: 20px;");
const listPanelBlock = cssBlock(".commission-list-panel");
assertIncludes(listPanelBlock, "overflow: hidden;");
const listHeaderBlock = cssBlock(".commission-list-header");
assertIncludes(listHeaderBlock, "align-items: center;");
const filterBlock = cssBlock(".commission-filter-bar");
assertIncludes(filterBlock, "grid-template-columns: minmax(220px, 420px) 180px 104px minmax(0, 1fr);");
assertIncludes(filterBlock, "align-items: end;");
const searchButtonBlock = cssBlock(".commission-search-button");
assertIncludes(searchButtonBlock, "width: 104px;");
const rowActionsBlock = cssBlock(".commission-row-actions");
assertIncludes(rowActionsBlock, "display: inline-flex;");
assertIncludes(rowActionsBlock, "justify-content: flex-end;");
const actionIconBlock = cssBlock(".table-action-icon");
assertIncludes(actionIconBlock, "width: 34px;");
assertIncludes(actionIconBlock, "height: 34px;");
const modalBlock = cssBlock(".commission-modal");
assertIncludes(modalBlock, "width: min(900px, 100%);");
assertIncludes(modalBlock, "max-height: min(720px, calc(100vh - 48px));");
const formBlock = cssBlock(".commission-form");
assertIncludes(formBlock, "padding: 14px var(--panel-padding) var(--panel-padding);");
const formGridBlock = cssBlock(".commission-form-grid");
assertIncludes(formGridBlock, "grid-template-columns: minmax(220px, 1.2fr) minmax(160px, 0.75fr) minmax(190px, 1fr) minmax(150px, 0.7fr);");
assertIncludes(formGridBlock, "gap: 10px 14px;");
const remarkFieldBlock = cssBlock(".commission-remark-field textarea");
assertIncludes(remarkFieldBlock, "min-height: 44px;");
assertIncludes(remarkFieldBlock, "max-height: 76px;");
const formActionsBlock = cssBlock(".commission-form-actions");
assertIncludes(formActionsBlock, "align-self: end;");
assertIncludes(formActionsBlock, "justify-content: flex-end;");
assertIncludes(css, ".commission-business-field,\n .commission-remark-field,\n .commission-form-actions {\n grid-column: auto;");
assertIncludes(css, ".commission-summary-strip,\n .commission-form-grid,\n .commission-filter-bar {\n grid-template-columns: 1fr;");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern);
if (!match) {
throw new Error(`缺少样式块 ${selector}`);
}
return match[1];
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string): void {
if (content.includes(unexpected)) {
throw new Error(`不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@ -0,0 +1,72 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const view = readFileSync(join(root, "src/views/ConfigCenterView.vue"), "utf-8");
const template = extractTemplate(view);
assertIncludes(template, "薪资规则设置");
assertIncludes(template, "设置考勤、加班、迟到、缺卡和请假等工资核算规则。");
assertIncludes(template, "恢复默认规则");
assertIncludes(template, "规则清单");
assertIncludes(template, "全部规则");
assertIncludes(view, "attendance: \"考勤规则\"");
assertIncludes(view, "payroll: \"薪资规则\"");
for (const label of ["规则", "当前设置", "是否使用", "用途说明", "操作"]) {
assertIncludes(template, `<th>${label}</th>`, `规则表格应展示业务化表头:${label}`);
}
for (const technicalText of ["配置列表", "<th>配置</th>", "<th>分组</th>", "<th>类型</th>", "全部分组"]) {
assertNotIncludes(template, technicalText, `规则页不应展示技术化文案:${technicalText}`);
}
for (const technicalBinding of [
"{{ config.config_key }}",
"v-model=\"config.config_group\"",
"v-model=\"config.value_type\"",
"<option value=\"string\">string</option>",
"<option value=\"integer\">integer</option>",
"<option value=\"number\">number</option>",
"<option value=\"boolean\">boolean</option>",
"<option value=\"json\">json</option>",
]) {
assertNotIncludes(template, technicalBinding, `规则页不应暴露系统字段:${technicalBinding}`);
}
assertIncludes(view, "groupLabel(config.config_group)", "规则分组应转换成业务名称展示");
assertIncludes(view, "formatConfigValue(config)", "规则值应转换成用户能理解的展示方式");
assertIncludes(template, "weekday-toggle-group", "周末规则应使用星期选择,不应展示原始数字数组");
assertIncludes(view, "displayEditValue(config)", "复杂规则编辑值应转换成业务可读格式");
assertIncludes(view, "updateDisplayValue(config", "用户输入应再转换回系统保存值");
assertNotIncludes(template, "v-model=\"config.config_value\"", "不应直接把系统保存值暴露给用户编辑");
function extractTemplate(content: string): string {
const match = content.match(/<template>([\s\S]*)<\/template>/);
if (!match) {
throw new Error("缺少 template");
}
return match[1];
}
function assertIncludes(content: string, expected: string, message?: string): void {
if (!content.includes(expected)) {
throw new Error(message || `期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string, message?: string): void {
if (content.includes(unexpected)) {
throw new Error(message || `不应包含 ${unexpected}`);
}
}

View File

@ -0,0 +1,88 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const component = readFileSync(join(root, "src/components/DatePicker.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
const datePages = [
"CommissionsView.vue",
"MonthlyPayrollView.vue",
"PayrollView.vue",
"RealtimeAttendanceView.vue",
"ReportsView.vue",
"SalaryProfilesView.vue",
];
for (const page of datePages) {
const content = readFileSync(join(root, "src/views", page), "utf-8");
assertIncludes(content, "DatePicker", `${page} 应使用统一 DatePicker 组件`);
assertNotIncludes(content, "type=\"month\"", `${page} 不应再使用浏览器原生月份选择器`);
assertNotIncludes(content, "type=\"date\"", `${page} 不应再使用浏览器原生日期选择器`);
}
assertIncludes(component, 'mode: "month" | "date"');
assertIncludes(component, "date-picker-panel");
assertIncludes(component, "<Teleport to=\"body\">", "日期面板应挂到 body避免被父级面板裁剪");
assertIncludes(component, "panelRef", "日期面板应独立追踪自身节点,保证弹层内部点击不会被当成外部点击");
assertIncludes(component, "panelStyle", "日期面板应使用运行时坐标定位到触发按钮附近");
assertIncludes(component, "updatePanelPosition", "日期面板打开、滚动或窗口变化时应重新定位");
assertIncludes(component, "month-grid");
assertIncludes(component, "calendar-grid");
assertIncludes(component, "emit(\"change\"");
assertIncludes(component, "本月");
assertIncludes(component, "今天");
const pickerBlock = cssBlock(".date-picker");
assertIncludes(pickerBlock, "position: relative;");
const triggerBlock = cssBlock(".date-picker-trigger");
assertIncludes(triggerBlock, "min-height: var(--control-height);");
assertIncludes(triggerBlock, "border: 1px solid var(--line);");
const panelBlock = cssBlock(".date-picker-panel");
assertIncludes(panelBlock, "position: fixed;");
assertIncludes(panelBlock, "max-height: min(420px, calc(100vh - 24px));");
assertIncludes(panelBlock, "overflow: auto;");
assertIncludes(panelBlock, "box-shadow: var(--shadow);");
const monthGridBlock = cssBlock(".month-grid");
assertIncludes(monthGridBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
const calendarGridBlock = cssBlock(".calendar-grid");
assertIncludes(calendarGridBlock, "grid-template-columns: repeat(7, minmax(0, 1fr));");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern);
if (!match) {
throw new Error(`缺少样式块 ${selector}`);
}
return match[1];
}
function assertIncludes(content: string, expected: string, message?: string): void {
if (!content.includes(expected)) {
throw new Error(message || `期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string, message?: string): void {
if (content.includes(unexpected)) {
throw new Error(message || `不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@ -0,0 +1,103 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const view = readFileSync(join(root, "src/views/EmployeesView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
assertIncludes(view, "view-stack employees-page");
assertIncludes(view, "employee-list-header");
assertIncludes(view, "employee-list-title");
assertIncludes(view, "employee-inline-metric");
assertNotIncludes(view, "employee-header-actions");
assertIncludes(view, "employee_info_keyword");
assertIncludes(view, "organization_keyword");
assertIncludes(view, "employee-filter-employee");
assertIncludes(view, "employee-filter-organization");
assertIncludes(view, "employee-filter-status");
assertIncludes(view, "employee-search-button");
assertIncludes(view, "employee-status-pill");
assertIncludes(view, "employee-status-dot");
assertNotIncludes(view, "placeholder=\"编号、姓名、钉钉ID、部门或岗位\"");
assertNotIncludes(view, "class=\"status-chip\"");
assertIncludes(view, "管理员工资料,工资核算会优先使用这里的信息。");
assertIncludes(view, "在职员工");
assertIncludes(view, "<em>人</em>");
assertIncludes(view, "<span>找员工</span>");
assertIncludes(view, "placeholder=\"输入姓名、编号或手机号\"");
assertIncludes(view, "<span>找部门/岗位</span>");
assertIncludes(view, "placeholder=\"输入部门或岗位名称\"");
assertIncludes(view, "<th>钉钉账号</th>");
assertIncludes(view, "employee.dingtalk_user_id || \"未绑定\"");
assertIncludes(view, "<span>钉钉账号</span>");
assertIncludes(view, "placeholder=\"用于匹配钉钉考勤,可不填\"");
assertNotIncludes(view, "钉钉用户ID");
assertNotIncludes(view, "<span>组织</span>");
assertNotIncludes(view, "参与薪资计算");
const pageBlock = cssBlock(".employees-page");
assertIncludes(pageBlock, "gap: 14px;");
const headerBlock = cssBlock(".employee-list-header");
assertIncludes(headerBlock, "display: grid;");
assertIncludes(headerBlock, "grid-template-columns: minmax(0, 1fr) auto;");
const titleBlock = cssBlock(".employee-list-title");
assertIncludes(titleBlock, "display: flex;");
assertIncludes(titleBlock, "align-items: center;");
const metricBlock = cssBlock(".employee-inline-metric");
assertIncludes(metricBlock, "min-height: 30px;");
assertIncludes(metricBlock, "box-shadow: none;");
const filterBlock = cssBlock(".employee-filter-bar");
assertIncludes(filterBlock, "grid-template-columns: minmax(180px, 260px) minmax(180px, 260px) minmax(130px, 160px) 104px minmax(0, 1fr);");
const scopedFilterBlock = cssBlock(".table-filter-bar.employee-filter-bar");
assertIncludes(scopedFilterBlock, "grid-template-columns: minmax(180px, 260px) minmax(180px, 260px) minmax(130px, 160px) 104px minmax(0, 1fr);");
const statusBlock = cssBlock(".employee-status-pill");
assertIncludes(statusBlock, "display: inline-flex;");
assertIncludes(statusBlock, "min-width: 0;");
assertIncludes(statusBlock, "background: transparent;");
const dotBlock = cssBlock(".employee-status-dot");
assertIncludes(dotBlock, "width: 7px;");
assertIncludes(dotBlock, "height: 7px;");
assertIncludes(css, ".table-filter-bar.employee-filter-bar {\n grid-template-columns: 1fr;");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern);
if (!match) {
throw new Error(`缺少样式块 ${selector}`);
}
return match[1];
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string): void {
if (content.includes(unexpected)) {
throw new Error(`不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@ -15,6 +15,10 @@ const root = process.cwd();
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8"); const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
const themeStore = readFileSync(join(root, "src/stores/theme.ts"), "utf-8"); const themeStore = readFileSync(join(root, "src/stores/theme.ts"), "utf-8");
const appRootBlock = cssBlock("html,\nbody,\n#app");
assertIncludes(appRootBlock, "max-width: 100%;");
assertIncludes(appRootBlock, "overflow-x: hidden;");
const rootBlock = cssBlock(":root"); const rootBlock = cssBlock(":root");
assertIncludes(rootBlock, "--base-font-size: 14px;"); assertIncludes(rootBlock, "--base-font-size: 14px;");
assertIncludes(rootBlock, "--page-padding: 20px;"); assertIncludes(rootBlock, "--page-padding: 20px;");
@ -25,6 +29,9 @@ const pageHeaderBlock = cssBlock(".page-header");
assertIncludes(pageHeaderBlock, "min-height: 52px;"); assertIncludes(pageHeaderBlock, "min-height: 52px;");
assertIncludes(pageHeaderBlock, "padding-bottom: 2px;"); assertIncludes(pageHeaderBlock, "padding-bottom: 2px;");
const bodyBlock = cssBlock("body");
assertIncludes(bodyBlock, "overflow-x: hidden;");
const pageTitleBlock = cssBlock(".page-header h1"); const pageTitleBlock = cssBlock(".page-header h1");
assertIncludes(pageTitleBlock, "font-size: 26px;"); assertIncludes(pageTitleBlock, "font-size: 26px;");
@ -47,6 +54,12 @@ const tableFilterBlock = cssBlock(".table-filter-bar");
assertIncludes(tableFilterBlock, "padding: 10px var(--panel-padding);"); assertIncludes(tableFilterBlock, "padding: 10px var(--panel-padding);");
assertIncludes(tableFilterBlock, "background: var(--surface);"); assertIncludes(tableFilterBlock, "background: var(--surface);");
const tableWrapBlock = cssBlock(".table-wrap");
assertIncludes(tableWrapBlock, "max-width: 100%;");
assertIncludes(tableWrapBlock, "min-width: 0;");
assertIncludes(tableWrapBlock, "contain: inline-size;");
assertIncludes(tableWrapBlock, "overflow-x: auto;");
const tableHeaderBlock = cssBlock(".data-table th"); const tableHeaderBlock = cssBlock(".data-table th");
assertIncludes(tableHeaderBlock, "position: sticky;"); assertIncludes(tableHeaderBlock, "position: sticky;");
assertIncludes(tableHeaderBlock, "top: 0;"); assertIncludes(tableHeaderBlock, "top: 0;");
@ -55,8 +68,8 @@ const modalBackdropBlock = cssBlock(".modal-backdrop");
assertIncludes(modalBackdropBlock, "backdrop-filter: blur(8px);"); assertIncludes(modalBackdropBlock, "backdrop-filter: blur(8px);");
const employeeMetricBlock = cssBlock(".employee-inline-metric"); const employeeMetricBlock = cssBlock(".employee-inline-metric");
assertIncludes(employeeMetricBlock, "min-height: 42px;"); assertIncludes(employeeMetricBlock, "min-height: 30px;");
assertIncludes(employeeMetricBlock, "padding: 6px 10px;"); assertIncludes(employeeMetricBlock, "box-shadow: none;");
const userFormBlock = cssBlock(".user-form"); const userFormBlock = cssBlock(".user-form");
assertIncludes(userFormBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));"); assertIncludes(userFormBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");

View File

@ -0,0 +1,34 @@
declare const process: { cwd(): string };
declare function require(name: string): {
existsSync?: (path: string) => boolean;
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { existsSync, readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!existsSync || !readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const loginView = readFileSync(join(root, "src/views/LoginView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
const backgroundPath = join(root, "src/assets/login-background.png");
assertIncludes(loginView, "login-background-layer");
assertIncludes(css, "url(\"./login-background.png\")");
assertIncludes(css, ".login-background-layer");
assertIncludes(css, ".login-visual::before");
assertIncludes(css, ".login-visual::after");
if (!existsSync(backgroundPath)) {
throw new Error("登录页背景图资产不存在");
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}

View File

@ -0,0 +1,59 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
const loginScreen = cssBlock(".login-screen");
assertIncludes(loginScreen, "grid-template-columns: minmax(340px, 34vw) minmax(0, 1fr);");
const loginFormSide = cssBlock(".login-form-side");
assertIncludes(loginFormSide, "background: linear-gradient(180deg, #f8fafc 0%, #f3f6fa 100%);");
assertIncludes(loginFormSide, "box-shadow: inset -1px 0 0 rgba(var(--primary-rgb), 0.08);");
const loginCard = cssBlock(".login-card");
assertIncludes(loginCard, "width: min(360px, 100%);");
const loginVisual = cssBlock(".login-visual");
assertIncludes(loginVisual, "justify-items: start;");
assertNotIncludes(loginVisual, "border-left: 1px solid var(--line);");
const ledgerVisual = cssBlock(".ledger-visual");
assertIncludes(ledgerVisual, "width: min(360px, 100%);");
assertIncludes(ledgerVisual, "background: rgba(255, 255, 255, 0.68);");
assertIncludes(ledgerVisual, "backdrop-filter: blur(10px);");
assertIncludes(ledgerVisual, "padding: 22px;");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern);
if (!match) {
throw new Error(`缺少样式块 ${selector}`);
}
return match[1];
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string): void {
if (content.includes(unexpected)) {
throw new Error(`不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@ -0,0 +1,88 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const pages = [
"RealtimeAttendanceView.vue",
"ConfigCenterView.vue",
"ReportsView.vue",
"OperationLogsView.vue",
"UsersView.vue",
"CommissionsView.vue",
"MonthlyPayrollView.vue",
"EmployeesView.vue",
"SalaryProfilesView.vue",
"OrganizationView.vue",
];
for (const page of pages) {
const content = readFileSync(join(root, "src/views", page), "utf-8");
assertNotIncludes(content, "<span>刷新</span>", `${page} 不应展示无业务含义的刷新按钮`);
}
const realtimeView = readView("RealtimeAttendanceView.vue");
assertIncludes(realtimeView, "同步钉钉");
assertIncludes(realtimeView, "@change=\"loadTodayAttendance\"");
assertNotIncludes(realtimeView, "RefreshCw");
const reportsView = readView("ReportsView.vue");
assertIncludes(reportsView, "查询");
assertIncludes(reportsView, "@submit.prevent=\"loadReport\"");
assertNotIncludes(reportsView, "RefreshCw");
const configView = readView("ConfigCenterView.vue");
assertIncludes(configView, "恢复默认规则");
assertIncludes(configView, "保存");
assertNotIncludes(configView, "RefreshCw");
const commissionView = readView("CommissionsView.vue");
assertIncludes(commissionView, "导入Excel");
assertIncludes(commissionView, "新增提成");
assertNotIncludes(commissionView, "RefreshCw");
const monthlyView = readView("MonthlyPayrollView.vue");
assertIncludes(monthlyView, "同步钉钉核算");
assertIncludes(monthlyView, "导入 Excel 核算");
assertNotIncludes(monthlyView, "RefreshCw");
const employeesView = readView("EmployeesView.vue");
assertIncludes(employeesView, "新增员工");
assertIncludes(employeesView, "在职员工");
assertNotIncludes(employeesView, "RefreshCw");
const salaryProfilesView = readView("SalaryProfilesView.vue");
assertIncludes(salaryProfilesView, "薪资档案");
assertIncludes(salaryProfilesView, "编辑");
assertNotIncludes(salaryProfilesView, "RefreshCw");
const organizationView = readView("OrganizationView.vue");
assertIncludes(organizationView, "新增部门");
assertIncludes(organizationView, "新增岗位");
assertNotIncludes(organizationView, "RefreshCw");
function readView(filename: string): string {
return readFileSync(join(root, "src/views", filename), "utf-8");
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string, message?: string): void {
if (content.includes(unexpected)) {
throw new Error(message || `不应包含 ${unexpected}`);
}
}

View File

@ -17,9 +17,15 @@ const realtimeApiPath = join(root, "src/api/realtimeAttendance.ts");
const monthlyApiPath = join(root, "src/api/monthlyPayroll.ts"); const monthlyApiPath = join(root, "src/api/monthlyPayroll.ts");
const realtimeViewPath = join(root, "src/views/RealtimeAttendanceView.vue"); const realtimeViewPath = join(root, "src/views/RealtimeAttendanceView.vue");
const monthlyViewPath = join(root, "src/views/MonthlyPayrollView.vue"); const monthlyViewPath = join(root, "src/views/MonthlyPayrollView.vue");
const resultsTablePath = join(root, "src/components/ResultsTable.vue");
const topBarPath = join(root, "src/components/TopBar.vue");
const dashboardViewPath = join(root, "src/views/DashboardView.vue");
const routerPath = join(root, "src/router/index.ts");
assertTruthy(existsSync(realtimeApiPath), "缺少实时考勤 API 客户端"); assertTruthy(existsSync(realtimeApiPath), "缺少实时考勤 API 客户端");
assertTruthy(existsSync(monthlyApiPath), "缺少月度核算 API 客户端"); assertTruthy(existsSync(monthlyApiPath), "缺少月度核算 API 客户端");
assertTruthy(existsSync(resultsTablePath), "缺少工资明细表组件");
assertTruthy(existsSync(topBarPath), "缺少顶部导航组件");
const realtimeApi = readFileSync(realtimeApiPath, "utf-8"); const realtimeApi = readFileSync(realtimeApiPath, "utf-8");
assertIncludes(realtimeApi, "/api/attendance/realtime/today"); assertIncludes(realtimeApi, "/api/attendance/realtime/today");
@ -27,15 +33,146 @@ assertIncludes(realtimeApi, "/api/attendance/realtime/sync");
const monthlyApi = readFileSync(monthlyApiPath, "utf-8"); const monthlyApi = readFileSync(monthlyApiPath, "utf-8");
assertIncludes(monthlyApi, "/api/payroll/monthly/runs"); assertIncludes(monthlyApi, "/api/payroll/monthly/runs");
assertIncludes(monthlyApi, "/api/payroll/monthly/dingtalk");
assertIncludes(monthlyApi, "/api/payroll/monthly/excel");
const realtimeView = readFileSync(realtimeViewPath, "utf-8"); const realtimeView = readFileSync(realtimeViewPath, "utf-8");
assertNotIncludes(realtimeView, "待接入菜单"); assertNotIncludes(realtimeView, "待接入菜单");
assertNotIncludes(realtimeView, "待接入钉钉同步"); assertNotIncludes(realtimeView, "待接入钉钉同步");
assertIncludes(realtimeView, "预估金额,最终以月度核算锁定结果为准"); assertIncludes(realtimeView, "预估金额,最终以月度核算锁定结果为准");
assertIncludes(realtimeView, "realtime-page-header");
assertIncludes(realtimeView, "realtime-header-actions");
assertIncludes(realtimeView, "realtime-date-field");
assertIncludes(realtimeView, "realtime-overview-panel");
assertIncludes(realtimeView, "realtime-overview-header");
assertIncludes(realtimeView, "realtime-overview-title");
assertIncludes(realtimeView, "realtime-overview-stats");
assertIncludes(realtimeView, "syncStatusText");
assertIncludes(realtimeView, "formatSyncTime");
assertIncludes(realtimeView, "realtime-sync-chip");
assertNotIncludes(realtimeView, 'response?.sync_job_id ? "已同步" : "未同步"');
assertIncludes(realtimeView, "employeeQuery");
assertIncludes(realtimeView, "departmentFilter");
assertIncludes(realtimeView, "departmentOptions");
assertIncludes(realtimeView, "filteredRows");
assertIncludes(realtimeView, "hasRealtimeFilters");
assertIncludes(realtimeView, "clearRealtimeFilters");
assertIncludes(realtimeView, "realtime-table-filter-bar");
assertIncludes(realtimeView, "部门筛选");
assertIncludes(realtimeView, "员工搜索");
assertIncludes(realtimeView, "realtime-clear-filter");
assertIncludes(realtimeView, ":disabled=\"!hasRealtimeFilters\"");
assertIncludes(realtimeView, "@click=\"clearRealtimeFilters\"");
assertIncludes(realtimeView, "清空");
assertIncludes(realtimeView, "显示 {{ filteredRows.length }} / {{ rows.length }} 人");
assertIncludes(realtimeView, "v-for=\"row in filteredRows\"");
const topBar = readFileSync(topBarPath, "utf-8");
assertIncludes(topBar, "useRoute");
assertIncludes(topBar, "showGlobalSearch");
assertIncludes(topBar, 'route.path !== "/attendance/realtime"');
assertIncludes(topBar, 'v-if="showGlobalSearch"');
const monthlyView = readFileSync(monthlyViewPath, "utf-8"); const monthlyView = readFileSync(monthlyViewPath, "utf-8");
assertNotIncludes(monthlyView, "下一阶段接入"); assertNotIncludes(monthlyView, "下一阶段接入");
assertNotIncludes(monthlyView, "核算流程");
assertNotIncludes(monthlyView, "计算编号仅用于系统排查");
assertIncludes(monthlyView, "本月核算");
assertNotIncludes(monthlyView, "工资核算工作台");
assertIncludes(monthlyView, "同步钉钉核算");
assertIncludes(monthlyView, "导入 Excel 核算");
assertNotIncludes(monthlyView, "payroll-workbench");
assertNotIncludes(monthlyView, "payroll-flow-strip");
assertIncludes(monthlyView, "payroll-compact-toolbar");
assertIncludes(monthlyView, "payroll-toolbar-month");
assertIncludes(monthlyView, "payroll-toolbar-summary");
assertIncludes(monthlyView, "payroll-toolbar-actions");
assertIncludes(monthlyView, "payroll-toolbar-export");
assertIncludes(monthlyView, "payroll-toolbar-range");
assertIncludes(monthlyView, "payroll-detail-panel");
assertIncludes(monthlyView, "payroll-detail-summary");
assertIncludes(monthlyView, "payroll-secondary-grid");
assertIncludes(monthlyView, ":exceptions=\"detail.exceptions\"");
assertNotIncludes(monthlyView, "payroll-command-copy");
assertNotIncludes(monthlyView, "payroll-command-card");
assertNotIncludes(monthlyView, "payroll-command-step");
assertNotIncludes(monthlyView, "payroll-step-index");
assertNotIncludes(monthlyView, "payroll-period-row");
assertNotIncludes(monthlyView, "payroll-source-row");
assertNotIncludes(monthlyView, "payroll-source-copy");
assertNotIncludes(monthlyView, "payroll-summary-band");
assertNotIncludes(monthlyView, "核算操作");
assertNotIncludes(monthlyView, "1 选择月份");
assertNotIncludes(monthlyView, "2 取数核算");
assertNotIncludes(monthlyView, "3 核对工资");
assertNotIncludes(monthlyView, "4 锁定导出");
assertNotIncludes(monthlyView, "payroll-action-card");
assertNotIncludes(monthlyView, "payroll-result-panel");
assertIncludes(monthlyView, "锁定后本月工资不能重新计算"); assertIncludes(monthlyView, "锁定后本月工资不能重新计算");
assertIncludes(monthlyView, "核算历史");
assertIncludes(monthlyView, "查看明细");
assertIncludes(monthlyView, "技术信息");
assertIncludes(monthlyView, "<summary>技术信息</summary>");
assertNotIncludes(monthlyView, "请输入任务编号");
assertIncludes(monthlyView, "calculateMonthlyFromDingTalk");
assertIncludes(monthlyView, "calculateMonthlyFromExcel");
assertIncludes(monthlyView, "monthly_payroll:calculate");
assertNotIncludes(monthlyView, "monthly-source-strip");
assertIncludes(monthlyView, "核算月份");
assertIncludes(monthlyView, "同步范围");
assertIncludes(monthlyView, "Excel 文件月份");
assertIncludes(monthlyView, "请选择核算月份后再同步钉钉");
assertIncludes(monthlyView, "已根据文件识别核算月份");
assertIncludes(monthlyView, "生成 Excel 结果文件");
assertNotIncludes(monthlyView, "优先同步钉钉,也可导入钉钉月度汇总 Excel。");
assertNotIncludes(monthlyView, "选择 Excel 后会自动识别文件月份。");
assertNotIncludes(monthlyView, ">01<");
assertNotIncludes(monthlyView, ">02<");
assertBefore(monthlyView, "核算月份", "同步钉钉核算");
assertBefore(monthlyView, "工资明细", "核算历史");
assertBefore(monthlyView, "工资明细", "异常清单");
assertBefore(monthlyView, "payroll-compact-toolbar", "payroll-detail-panel");
assertBefore(monthlyView, "payroll-detail-panel", "payroll-secondary-grid");
const resultsTable = readFileSync(resultsTablePath, "utf-8");
assertIncludes(resultsTable, "embedded");
assertIncludes(resultsTable, "salary-detail-table");
assertIncludes(resultsTable, "salary-summary-toolbar");
assertIncludes(resultsTable, "salary-summary-table");
assertIncludes(resultsTable, "salary-detail-drawer");
assertIncludes(resultsTable, "filteredResults");
assertIncludes(resultsTable, "anomalyLabel");
assertIncludes(resultsTable, "查看构成");
assertIncludes(resultsTable, "部门");
assertIncludes(resultsTable, "异常");
assertIncludes(resultsTable, "实发工资");
assertBefore(resultsTable, "salary-summary-table", "salary-detail-drawer");
const dashboardView = readFileSync(dashboardViewPath, "utf-8");
assertNotIncludes(dashboardView, "/payroll/jobs");
assertNotIncludes(dashboardView, 'to="/payroll"');
assertNotIncludes(dashboardView, "readRecentJobs");
assertNotIncludes(dashboardView, "RecentPayrollJob");
assertIncludes(dashboardView, "listMonthlyRuns");
assertIncludes(dashboardView, "exportMonthlyRun");
assertNotIncludes(dashboardView, "payroll:dingtalk:calculate");
assertIncludes(dashboardView, "monthly_payroll:calculate");
assertNotIncludes(dashboardView, "任务编号");
assertNotIncludes(dashboardView, "任务记录");
assertNotIncludes(dashboardView, "最近任务");
assertNotIncludes(dashboardView, "任务来源");
assertNotIncludes(dashboardView, "暂无计算记录");
assertIncludes(dashboardView, "核算历史");
assertIncludes(dashboardView, "最近核算人数");
assertIncludes(dashboardView, "工资月份");
assertIncludes(dashboardView, "实发合计");
const router = readFileSync(routerPath, "utf-8");
assertIncludes(router, 'path: "payroll"');
assertIncludes(router, 'redirect: "/payroll/monthly"');
assertNotIncludes(router, "../views/PayrollView.vue");
assertNotIncludes(router, "component: PayrollView");
assertNotIncludes(router, 'meta: { permission: "payroll:excel:calculate" }');
function assertIncludes(content: string, expected: string): void { function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) { if (!content.includes(expected)) {
@ -54,3 +191,11 @@ function assertTruthy(value: boolean, message: string): void {
throw new Error(message); throw new Error(message);
} }
} }
function assertBefore(content: string, first: string, second: string): void {
const firstIndex = content.indexOf(first);
const secondIndex = content.indexOf(second);
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
throw new Error(`期望 ${first} 出现在 ${second} 之前`);
}
}

View File

@ -0,0 +1,24 @@
import {
extractMonthFromExcelFilename,
formatMonthRange,
isSalaryMonth,
} from "../src/utils/payrollMonth";
const detected = extractMonthFromExcelFilename("宁波兆安流体科技有限公司_月度汇总_20260501-20260531.xlsx");
assertEqual(detected?.month || "", "2026-05");
assertEqual(detected?.rangeLabel || "", "2026/05/01 至 2026/05/31");
const dashed = extractMonthFromExcelFilename("考勤汇总_2026-06.xlsx");
assertEqual(dashed?.month || "", "2026-06");
assertEqual(formatMonthRange("2026-05"), "2026/05/01 至 2026/05/31");
assertEqual(formatMonthRange(""), "请选择核算月份");
assertEqual(String(isSalaryMonth("2026-05")), "true");
assertEqual(String(isSalaryMonth("")), "false");
assertEqual(String(isSalaryMonth("2026-13")), "false");
function assertEqual(actual: string, expected: string): void {
if (actual !== expected) {
throw new Error(`期望 ${expected},实际 ${actual}`);
}
}

View File

@ -26,10 +26,13 @@ assertEqual(
); );
assertEqual(sectionCodes("核心工作"), "dashboard,attendance_realtime"); assertEqual(sectionCodes("核心工作"), "dashboard,attendance_realtime");
assertEqual(sectionCodes("月度核算"), "monthly_payroll,jobs,commissions"); assertEqual(sectionCodes("月度核算"), "monthly_payroll,commissions");
assertEqual(sectionNames("月度核算"), "工资核算,提成录入");
assertEqual(sectionCodes("员工薪资"), "employees,salary_profiles,organization"); assertEqual(sectionCodes("员工薪资"), "employees,salary_profiles,organization");
assertEqual(sectionCodes("报表分析"), "reports"); assertEqual(sectionCodes("报表分析"), "reports");
assertEqual(sectionCodes("系统管理"), "configs,users,operation_logs"); assertEqual(sectionCodes("系统管理"), "configs,users,operation_logs");
assertEqual(hasMenu("payroll"), "false");
assertEqual(hasMenu("jobs"), "false");
function sectionCodes(title: string): string { function sectionCodes(title: string): string {
const section = sections.find((item) => item.title === title); const section = sections.find((item) => item.title === title);
@ -39,8 +42,20 @@ function sectionCodes(title: string): string {
return section.items.map((item) => item.code).join(","); return section.items.map((item) => item.code).join(",");
} }
function sectionNames(title: string): string {
const section = sections.find((item) => item.title === title);
if (!section) {
throw new Error(`找不到菜单分组:${title}`);
}
return section.items.map((item) => item.name).join(",");
}
function assertEqual(actual: string, expected: string): void { function assertEqual(actual: string, expected: string): void {
if (actual !== expected) { if (actual !== expected) {
throw new Error(`期望 ${expected},实际 ${actual}`); throw new Error(`期望 ${expected},实际 ${actual}`);
} }
} }
function hasMenu(code: string): string {
return String(sections.some((section) => section.items.some((item) => item.code === code)));
}

View File

@ -0,0 +1,84 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const view = readFileSync(join(root, "src/views/UsersView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
assertIncludes(view, "view-stack users-page");
assertIncludes(view, "showUserModal");
assertIncludes(view, "openCreateModal");
assertIncludes(view, "closeUserModal");
assertIncludes(view, "user-list-panel");
assertIncludes(view, "user-list-header");
assertIncludes(view, "@click=\"openCreateModal\"");
assertIncludes(view, "Teleport to=\"body\"");
assertIncludes(view, "modal-backdrop user-modal-backdrop");
assertIncludes(view, "profile-modal user-modal");
assertIncludes(view, "aria-label=\"用户维护\"");
assertIncludes(view, "user-modal-form");
assertIncludes(view, "user-modal-fields");
assertIncludes(view, "user-modal-actions");
assertIncludes(view, "@click=\"closeUserModal\"");
assertNotIncludes(view, "<section class=\"panel\">\n <div class=\"panel-header\">\n <div>\n <h2>新增用户</h2>");
const pageBlock = cssBlock(".users-page");
assertIncludes(pageBlock, "gap: 14px;");
const listPanelBlock = cssBlock(".user-list-panel");
assertIncludes(listPanelBlock, "overflow: hidden;");
const listHeaderBlock = cssBlock(".user-list-header");
assertIncludes(listHeaderBlock, "align-items: center;");
const modalBlock = cssBlock(".user-modal");
assertIncludes(modalBlock, "width: min(920px, 100%);");
assertIncludes(modalBlock, "max-height: min(760px, calc(100vh - 48px));");
const modalFormBlock = cssBlock(".user-modal-form");
assertIncludes(modalFormBlock, "padding: 14px var(--panel-padding) var(--panel-padding);");
const modalFieldsBlock = cssBlock(".user-modal-fields");
assertIncludes(modalFieldsBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
const modalActionsBlock = cssBlock(".user-modal-actions");
assertIncludes(modalActionsBlock, "grid-column: 1 / -1;");
assertIncludes(modalActionsBlock, "justify-content: flex-end;");
assertIncludes(css, ".user-modal-fields {\n grid-template-columns: 1fr;");
assertIncludes(css, ".user-modal-actions {\n justify-content: stretch;");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern);
if (!match) {
throw new Error(`缺少样式块 ${selector}`);
}
return match[1];
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string): void {
if (content.includes(unexpected)) {
throw new Error(`不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@ -20,7 +20,7 @@ assertNotIncludes(loginVisual, "rgba(var(--primary-rgb), 0.94)");
const ledgerVisual = cssBlock(".ledger-visual"); const ledgerVisual = cssBlock(".ledger-visual");
assertIncludes(ledgerVisual, "background: var(--card);"); assertIncludes(ledgerVisual, "background: var(--card);");
assertIncludes(ledgerVisual, "padding: 32px;"); assertIncludes(ledgerVisual, "padding: 22px;");
const appearancePanel = cssBlock(".appearance-panel"); const appearancePanel = cssBlock(".appearance-panel");
assertIncludes(appearancePanel, "width: min(480px, calc(100vw - 20px));"); assertIncludes(appearancePanel, "width: min(480px, calc(100vw - 20px));");
@ -32,6 +32,131 @@ assertIncludes(primaryFocus, "box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16
const tableRows = cssBlock(".data-table tbody tr"); const tableRows = cssBlock(".data-table tbody tr");
assertIncludes(tableRows, "transition: background var(--motion-duration-fast) ease;"); assertIncludes(tableRows, "transition: background var(--motion-duration-fast) ease;");
const realtimeHeaderActions = cssBlock(".realtime-header-actions");
assertIncludes(realtimeHeaderActions, "display: grid;");
assertIncludes(realtimeHeaderActions, "grid-template-columns: minmax(210px, 242px) 140px;");
assertIncludes(realtimeHeaderActions, "align-items: end;");
const realtimeDateField = cssBlock(".realtime-date-field");
assertIncludes(realtimeDateField, "min-width: 0;");
const realtimeOverviewPanel = cssBlock(".realtime-overview-panel");
assertIncludes(realtimeOverviewPanel, "overflow: hidden;");
const realtimeOverviewHeader = cssBlock(".realtime-overview-header");
assertIncludes(realtimeOverviewHeader, "align-items: center;");
assertIncludes(realtimeOverviewHeader, "padding: 12px var(--panel-padding) 10px;");
const realtimeOverviewTitle = cssBlock(".realtime-overview-title");
assertIncludes(realtimeOverviewTitle, "display: flex;");
assertIncludes(realtimeOverviewTitle, "align-items: baseline;");
assertIncludes(realtimeOverviewTitle, "gap: 10px;");
const realtimeOverviewTitleText = cssBlock(".realtime-overview-title h2,\n.realtime-overview-title p");
assertIncludes(realtimeOverviewTitleText, "margin: 0;");
const realtimeOverviewStats = cssBlock(".realtime-overview-stats");
assertIncludes(realtimeOverviewStats, "padding-top: 0;");
const realtimeOverviewNumbers = cssBlock(".realtime-overview-stats .compact-stat strong");
assertIncludes(realtimeOverviewNumbers, "font-size: 24px;");
const realtimeSyncChip = cssBlock(".realtime-sync-chip");
assertIncludes(realtimeSyncChip, "min-width: 96px;");
assertIncludes(realtimeSyncChip, "justify-content: center;");
const realtimeTableFilterBar = cssBlock(".realtime-table-filter-bar");
assertIncludes(realtimeTableFilterBar, "display: grid;");
assertIncludes(realtimeTableFilterBar, "grid-template-columns: minmax(180px, 220px) minmax(240px, 1fr) 78px auto;");
assertIncludes(realtimeTableFilterBar, "align-items: end;");
assertIncludes(realtimeTableFilterBar, "padding: 10px var(--panel-padding);");
const realtimeClearFilter = cssBlock(".realtime-clear-filter");
assertIncludes(realtimeClearFilter, "min-height: var(--control-height);");
assertIncludes(realtimeClearFilter, "width: 78px;");
assertIncludes(realtimeClearFilter, "justify-self: start;");
assertIncludes(realtimeClearFilter, "padding: 0 12px;");
assertIncludes(realtimeClearFilter, "white-space: nowrap;");
const realtimeFilterResult = cssBlock(".realtime-filter-result");
assertIncludes(realtimeFilterResult, "justify-self: end;");
const payrollCompactToolbar = cssBlock(".payroll-compact-toolbar");
assertIncludes(payrollCompactToolbar, "grid-template-columns: minmax(190px, 240px) minmax(0, 1fr) auto;");
assertIncludes(payrollCompactToolbar, "padding: 12px var(--panel-padding);");
const payrollToolbarSummary = cssBlock(".payroll-toolbar-summary");
assertIncludes(payrollToolbarSummary, "grid-template-columns: minmax(110px, 0.7fr) minmax(110px, 0.7fr) minmax(110px, 0.7fr) minmax(270px, 1.45fr);");
assertIncludes(payrollToolbarSummary, "border-inline: 1px solid var(--line-soft);");
const payrollToolbarRangeText = cssBlock(".payroll-toolbar-range strong,\n.payroll-toolbar-range small");
assertIncludes(payrollToolbarRangeText, "overflow: visible;");
assertIncludes(payrollToolbarRangeText, "white-space: normal;");
assertIncludes(payrollToolbarRangeText, "text-overflow: clip;");
const payrollToolbarActions = cssBlock(".payroll-toolbar-actions");
assertIncludes(payrollToolbarActions, "grid-template-columns: repeat(2, 138px);");
assertIncludes(payrollToolbarActions, "justify-content: end;");
const payrollToolbarActionButtons = cssBlock(".payroll-toolbar-actions .primary-button,\n.payroll-toolbar-actions .secondary-button");
assertIncludes(payrollToolbarActionButtons, "min-height: 34px;");
assertIncludes(payrollToolbarActionButtons, "padding: 0 10px;");
assertIncludes(payrollToolbarActionButtons, "font-size: 13px;");
const payrollDetailPanel = cssBlock(".payroll-detail-panel");
assertIncludes(payrollDetailPanel, "border-color: rgba(var(--accent-rgb), 0.2);");
assertIncludes(payrollDetailPanel, "overflow: hidden;");
const payrollDetailHeader = cssBlock(".payroll-detail-header");
assertIncludes(payrollDetailHeader, "grid-template-columns: minmax(0, 1fr) auto;");
const payrollDetailSummary = cssBlock(".payroll-detail-summary");
assertIncludes(payrollDetailSummary, "grid-template-columns: repeat(4, minmax(110px, auto));");
const salaryDetailTable = cssBlock(".salary-detail-table");
assertIncludes(salaryDetailTable, "font-size: 13px;");
assertIncludes(salaryDetailTable, "min-width: 1560px;");
const salarySummaryToolbar = cssBlock(".salary-summary-toolbar");
assertIncludes(salarySummaryToolbar, "grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(140px, 180px));");
const salarySummaryTable = cssBlock(".salary-summary-table");
assertIncludes(salarySummaryTable, "min-width: 860px;");
const salarySummaryTableCells = cssBlock(".salary-summary-table th,\n.salary-summary-table td");
assertIncludes(salarySummaryTableCells, "padding: 12px 14px;");
const salaryDetailDrawer = cssBlock(".salary-detail-drawer");
assertIncludes(salaryDetailDrawer, "grid-template-columns: repeat(4, minmax(0, 1fr));");
const salaryDetailDrawerHeader = cssBlock(".salary-detail-drawer-header");
assertIncludes(salaryDetailDrawerHeader, "grid-column: 1 / -1;");
const salaryDetailCells = cssBlock(".salary-detail-table th,\n.salary-detail-table td");
assertIncludes(salaryDetailCells, "padding: 10px 12px;");
const payrollSecondaryGrid = cssBlock(".payroll-secondary-grid");
assertIncludes(payrollSecondaryGrid, "grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);");
assertNotIncludes(css, ".payroll-workbench {");
assertNotIncludes(css, ".payroll-flow-strip");
assertNotIncludes(css, ".payroll-summary-band");
assertIncludes(css, "@media (max-width: 1320px)");
assertIncludes(css, ".payroll-compact-toolbar {\n grid-template-columns: 1fr;\n }");
assertIncludes(css, ".payroll-toolbar-summary {\n grid-template-columns: repeat(2, minmax(0, 1fr));");
assertIncludes(css, ".payroll-secondary-grid {\n grid-template-columns: 1fr;\n }");
assertIncludes(css, ".payroll-toolbar-summary,\n .payroll-toolbar-actions,\n .payroll-detail-summary {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }");
assertIncludes(css, ".realtime-header-actions {\n grid-template-columns: 1fr;\n }");
assertIncludes(css, ".realtime-overview-title {\n align-items: flex-start;\n flex-direction: column;");
assertIncludes(css, ".realtime-table-filter-bar {\n grid-template-columns: 1fr;");
assertNotIncludes(css, ".payroll-command-step");
assertNotIncludes(css, ".payroll-step-index");
assertNotIncludes(css, ".payroll-command-copy");
assertNotIncludes(css, ".payroll-period-row");
assertNotIncludes(css, ".payroll-source-row");
assertNotIncludes(css, ".payroll-source-copy");
function cssBlock(selector: string): string { function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m"); const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern); const match = css.match(pattern);