更新部分功能实现逻辑

This commit is contained in:
焦龙言 2026-06-22 13:23:04 +08:00
parent 1e6230d412
commit faa09101a1
31 changed files with 4859 additions and 307 deletions

View File

@ -17,7 +17,8 @@
## 当前能力 ## 当前能力
- 支持导入钉钉月度汇总 Excel 计算工资。 - 支持导入钉钉月度汇总 Excel 计算工资。
- 支持预留钉钉实时打卡接口计算入口。 - 支持实时考勤看板:按员工档案中的钉钉 userId 自动同步本月至今打卡,不需要人工输入 userId。
- 支持月度核算中心:按月份导入 Excel 或同步钉钉计算,生成异常清单、核算批次、锁定状态和导出入口。
- 自动统计出勤天数、缺勤天数、请假工时、缺卡、迟到扣款。 - 自动统计出勤天数、缺勤天数、请假工时、缺卡、迟到扣款。
- 按下班打卡时间自动计算加班17:00 下班18:00 计 1 小时20:30 计 3 小时。 - 按下班打卡时间自动计算加班17:00 下班18:00 计 1 小时20:30 计 3 小时。
- 自动计算:`剩余加班工时 = 打卡推算加班工时 - 请假工时`。 - 自动计算:`剩余加班工时 = 打卡推算加班工时 - 请假工时`。
@ -28,6 +29,7 @@
- 加班费支持:工作日、周末、法定节假日独立单价。 - 加班费支持:工作日、周末、法定节假日独立单价。
- 提成支持手工维护和 Excel 导入,并按工资月份自动汇总进工资计算。 - 提成支持手工维护和 Excel 导入,并按工资月份自动汇总进工资计算。
- 工资计算结果会落库为工资汇总、工资明细、考勤记录、请假记录和加班记录。 - 工资计算结果会落库为工资汇总、工资明细、考勤记录、请假记录和加班记录。
- 实时薪资预估只用于过程查看,最终工资以月度核算锁定结果为准。
## 项目结构 ## 项目结构
@ -144,6 +146,7 @@ mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/in
```bash ```bash
mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260617_payroll_modules.sql mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260617_payroll_modules.sql
mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260618_monthly_payroll_center.sql
``` ```
SQL 文件说明: SQL 文件说明:
@ -154,6 +157,7 @@ SQL 文件说明:
| `seed.sql` | 初始化默认超级管理员 | | `seed.sql` | 初始化默认超级管理员 |
| `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 | | `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 |
| `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 | | `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 |
| `upgrade_20260618_monthly_payroll_center.sql` | 实时考勤同步、薪资预估、月度核算批次和异常清单升级脚本 |
主要业务表: 主要业务表:
@ -170,6 +174,10 @@ SQL 文件说明:
| `commission_record` | 提成/奖金记录 | | `commission_record` | 提成/奖金记录 |
| `payroll_jobs` | 工资计算任务 | | `payroll_jobs` | 工资计算任务 |
| `payroll_results` | 员工工资计算结果 | | `payroll_results` | 员工工资计算结果 |
| `attendance_sync_jobs` | 钉钉/Excel 考勤同步批次 |
| `salary_preview` | 本月进行中的薪资预估 |
| `monthly_payroll_runs` | 月度正式核算批次 |
| `payroll_exceptions` | 月度核算异常清单 |
| `salary_record` | 月度工资汇总 | | `salary_record` | 月度工资汇总 |
| `salary_detail` | 工资明细项 | | `salary_detail` | 工资明细项 |
| `salary_config` | 规则配置中心 | | `salary_config` | 规则配置中心 |
@ -227,6 +235,8 @@ config/salary_rules.example.json
| --- | --- | --- | | --- | --- | --- |
| 登录 | `/login` | 用户登录 | | 登录 | `/login` | 用户登录 |
| 工作台 | `/dashboard` | 系统概览 | | 工作台 | `/dashboard` | 系统概览 |
| 实时考勤 | `/attendance/realtime` | 查看今日打卡、迟到、缺卡、请假、加班和本月薪资预估 |
| 月度核算 | `/payroll/monthly` | 按月导入/同步、计算、异常处理、锁定和导出 |
| 工资计算 | `/payroll` | Excel 导入和钉钉实时计算 | | 工资计算 | `/payroll` | Excel 导入和钉钉实时计算 |
| 计算记录 | `/payroll/jobs` | 查看历史计算任务 | | 计算记录 | `/payroll/jobs` | 查看历史计算任务 |
| 提成管理 | `/payroll/commissions` | 手工维护和 Excel 导入提成 | | 提成管理 | `/payroll/commissions` | 手工维护和 Excel 导入提成 |
@ -277,7 +287,32 @@ curl -X POST "http://127.0.0.1:8000/api/payroll/excel" \
-F "export_excel=true" -F "export_excel=true"
``` ```
钉钉实时计算: 实时考勤看板同步钉钉:
```bash
curl -X POST "http://127.0.0.1:8000/api/attendance/realtime/sync" \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"attendance_date":"2026-06-22","include_salary_preview":true}'
```
查看实时考勤看板:
```bash
curl "http://127.0.0.1:8000/api/attendance/realtime/today?date=2026-06-22" \
-H "Authorization: Bearer <access_token>"
```
月度核算同步钉钉并计算:
```bash
curl -X POST "http://127.0.0.1:8000/api/payroll/monthly/dingtalk" \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"salary_month":"2026-06","source_type":"dingtalk","export_excel":true}'
```
旧版钉钉实时计算接口仍可使用,但需要手动传 user_ids
```bash ```bash
curl -X POST "http://127.0.0.1:8000/api/payroll/dingtalk/realtime" \ curl -X POST "http://127.0.0.1:8000/api/payroll/dingtalk/realtime" \

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,20 @@ from ..core.settings import PROJECT_ROOT, get_settings
from ..database import SessionLocal, init_database from ..database import SessionLocal, init_database
from ..repositories import OrganizationRepository, SalaryConfigRepository, UserRepository from ..repositories import OrganizationRepository, SalaryConfigRepository, UserRepository
from ..services import AuthService, OrganizationService, SalaryConfigService from ..services import AuthService, OrganizationService, SalaryConfigService
from .routers import auth, commissions, configs, employees, health, operation_logs, organization, payroll, reports, salary_profiles from .routers import (
auth,
commissions,
configs,
employees,
health,
monthly_payroll,
operation_logs,
organization,
payroll,
realtime_attendance,
reports,
salary_profiles,
)
logger = AppLogger.get_logger(__name__) logger = AppLogger.get_logger(__name__)
@ -56,9 +69,11 @@ def create_app() -> FastAPI:
app.include_router(configs.router) app.include_router(configs.router)
app.include_router(employees.router) app.include_router(employees.router)
app.include_router(health.router) app.include_router(health.router)
app.include_router(monthly_payroll.router)
app.include_router(operation_logs.router) app.include_router(operation_logs.router)
app.include_router(organization.router) app.include_router(organization.router)
app.include_router(payroll.router) app.include_router(payroll.router)
app.include_router(realtime_attendance.router)
app.include_router(reports.router) app.include_router(reports.router)
app.include_router(salary_profiles.router) app.include_router(salary_profiles.router)
return app return app

View File

@ -16,9 +16,11 @@ from ..database.orm import UserORM
from ..repositories import ( from ..repositories import (
CommissionRepository, CommissionRepository,
EmployeeRepository, EmployeeRepository,
MonthlyPayrollRepository,
OperationLogRepository, OperationLogRepository,
OrganizationRepository, OrganizationRepository,
PayrollRepository, PayrollRepository,
RealtimeAttendanceRepository,
ReportRepository, ReportRepository,
SalaryConfigRepository, SalaryConfigRepository,
SalaryProfileRepository, SalaryProfileRepository,
@ -28,9 +30,11 @@ from ..services import (
AuthService, AuthService,
CommissionService, CommissionService,
EmployeeService, EmployeeService,
MonthlyPayrollService,
OperationLogService, OperationLogService,
OrganizationService, OrganizationService,
PayrollApplicationService, PayrollApplicationService,
RealtimeAttendanceService,
ReportService, ReportService,
SalaryConfigService, SalaryConfigService,
SalaryProfileService, SalaryProfileService,
@ -58,6 +62,14 @@ def get_payroll_repository(session: Session = Depends(get_db_session)) -> Payrol
return PayrollRepository(session) return PayrollRepository(session)
def get_monthly_payroll_repository(session: Session = Depends(get_db_session)) -> MonthlyPayrollRepository:
return MonthlyPayrollRepository(session)
def get_realtime_attendance_repository(session: Session = Depends(get_db_session)) -> RealtimeAttendanceRepository:
return RealtimeAttendanceRepository(session)
def get_commission_repository(session: Session = Depends(get_db_session)) -> CommissionRepository: def get_commission_repository(session: Session = Depends(get_db_session)) -> CommissionRepository:
return CommissionRepository(session) return CommissionRepository(session)
@ -162,6 +174,25 @@ def get_payroll_service(
) )
def get_realtime_attendance_service(
repository: RealtimeAttendanceRepository = Depends(get_realtime_attendance_repository),
payroll_service: PayrollApplicationService = Depends(get_payroll_service),
) -> RealtimeAttendanceService:
return RealtimeAttendanceService(repository=repository, payroll_service=payroll_service)
def get_monthly_payroll_service(
repository: MonthlyPayrollRepository = Depends(get_monthly_payroll_repository),
payroll_service: PayrollApplicationService = Depends(get_payroll_service),
employee_repository: EmployeeRepository = Depends(get_employee_repository),
) -> MonthlyPayrollService:
return MonthlyPayrollService(
repository=repository,
payroll_service=payroll_service,
employee_repository=employee_repository,
)
def get_current_user( def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
repository: UserRepository = Depends(get_user_repository), repository: UserRepository = Depends(get_user_repository),

View File

@ -0,0 +1,285 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from ...core.logger import AppLogger
from ...core.permissions import (
PERMISSION_MONTHLY_PAYROLL_CALCULATE,
PERMISSION_MONTHLY_PAYROLL_EXPORT,
PERMISSION_MONTHLY_PAYROLL_LOCK,
PERMISSION_MONTHLY_PAYROLL_VIEW,
)
from ...database.orm import MonthlyPayrollRunORM, PayrollExceptionORM, UserORM
from ...integrations.dingtalk import DingTalkError
from ...services import MonthlyPayrollDetailData, MonthlyPayrollService, OperationLogService
from ..dependencies import get_monthly_payroll_service, get_operation_log_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.monthly_payroll import (
MonthlyPayrollCreateRequest,
MonthlyPayrollDetailResponse,
MonthlyPayrollRunResponse,
PayrollExceptionItem,
)
from ..schemas.payroll import PayrollResultDTO
router = APIRouter(prefix="/api/payroll/monthly", tags=["monthly-payroll"])
logger = AppLogger.get_logger(__name__)
@router.get("/runs", response_model=list[MonthlyPayrollRunResponse])
def list_monthly_runs(
month: str | None = None,
_: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_VIEW)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
) -> list[MonthlyPayrollRunResponse]:
"""按月份查看月度核算批次。"""
return [_run_response(run) for run in service.list_runs(month)]
@router.get("/runs/{run_id}", response_model=MonthlyPayrollDetailResponse)
def get_monthly_run(
http_request: Request,
run_id: int,
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_VIEW)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> MonthlyPayrollDetailResponse:
"""查看月度核算批次明细、工资结果和异常清单。"""
try:
detail = service.get_detail(run_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.view",
target_type="monthly_payroll_run",
target_id=str(run_id),
status="success",
detail=f"查看月度核算 run_id={run_id}",
**operation_request_meta(http_request),
)
return _detail_response(detail)
@router.post("/excel", response_model=MonthlyPayrollDetailResponse)
def calculate_monthly_from_excel(
http_request: Request,
file: UploadFile = File(...),
salary_month: str = Form(...),
export_excel: bool = Form(default=True),
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_CALCULATE)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> MonthlyPayrollDetailResponse:
"""导入 Excel 并生成月度正式核算批次。"""
try:
detail = service.calculate_from_excel(
salary_month=salary_month,
filename=file.filename or "attendance.xlsx",
stream=file.file,
created_by=current_user.username,
export_excel=export_excel,
)
except ValueError as exc:
_record_failed(operation_logs, current_user, http_request, "monthly_payroll.excel.import", str(exc))
raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.excel.import",
target_type="monthly_payroll_run",
target_id=str(detail.run.id),
status="success",
detail=f"Excel月度核算成功 salary_month={salary_month} employee_count={detail.run.employee_count}",
**operation_request_meta(http_request),
)
return _detail_response(detail)
@router.post("/dingtalk", response_model=MonthlyPayrollDetailResponse)
async def calculate_monthly_from_dingtalk(
http_request: Request,
request: MonthlyPayrollCreateRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_CALCULATE)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> MonthlyPayrollDetailResponse:
"""按员工档案中的钉钉 userId 自动同步整月考勤并生成核算批次。"""
try:
detail = await service.calculate_from_dingtalk(
salary_month=request.salary_month,
created_by=current_user.username,
export_excel=request.export_excel,
)
except ValueError as exc:
_record_failed(operation_logs, current_user, http_request, "monthly_payroll.dingtalk.calculate", str(exc))
raise HTTPException(status_code=400, detail=str(exc)) from exc
except DingTalkError as exc:
_record_failed(operation_logs, current_user, http_request, "monthly_payroll.dingtalk.calculate", str(exc))
raise HTTPException(status_code=502, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.dingtalk.calculate",
target_type="monthly_payroll_run",
target_id=str(detail.run.id),
status="success",
detail=f"钉钉月度核算成功 salary_month={request.salary_month} employee_count={detail.run.employee_count}",
**operation_request_meta(http_request),
)
return _detail_response(detail)
@router.post("/runs/{run_id}/recalculate", response_model=MonthlyPayrollDetailResponse)
async def recalculate_monthly_run(
http_request: Request,
run_id: int,
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_CALCULATE)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> MonthlyPayrollDetailResponse:
"""重新计算钉钉来源的月度核算批次。"""
detail = service.get_detail(run_id)
if detail.run.status == "locked":
raise HTTPException(status_code=400, detail="本月工资已锁定,不能重新计算")
if detail.run.source_type != "dingtalk":
raise HTTPException(status_code=400, detail="Excel 来源请重新导入文件后计算")
detail = await service.calculate_from_dingtalk(
salary_month=detail.run.salary_month,
created_by=current_user.username,
export_excel=True,
)
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.recalculate",
target_type="monthly_payroll_run",
target_id=str(run_id),
status="success",
detail=f"重新计算月度核算 run_id={run_id}",
**operation_request_meta(http_request),
)
return _detail_response(detail)
@router.post("/runs/{run_id}/lock", response_model=MonthlyPayrollRunResponse)
def lock_monthly_run(
http_request: Request,
run_id: int,
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_LOCK)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> MonthlyPayrollRunResponse:
"""锁定月度工资,锁定后不允许重新计算或覆盖。"""
try:
run = service.lock_run(run_id, current_user.username)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.lock",
target_type="monthly_payroll_run",
target_id=str(run_id),
status="success",
detail=f"锁定月度核算 run_id={run_id}",
**operation_request_meta(http_request),
)
return _run_response(run)
@router.get("/runs/{run_id}/export")
def export_monthly_run(
http_request: Request,
run_id: int,
current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_EXPORT)),
service: MonthlyPayrollService = Depends(get_monthly_payroll_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> FileResponse:
"""导出月度工资表。"""
try:
path = service.export_path(run_id)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action="monthly_payroll.export",
target_type="monthly_payroll_run",
target_id=str(run_id),
status="success",
detail=f"导出月度核算 run_id={run_id} path={path}",
**operation_request_meta(http_request),
)
return FileResponse(
path,
filename=path.name,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
def _detail_response(detail: MonthlyPayrollDetailData) -> MonthlyPayrollDetailResponse:
return MonthlyPayrollDetailResponse(
run=_run_response(detail.run),
results=[PayrollResultDTO.from_orm_model(result) for result in detail.job.results] if detail.job else [],
exceptions=[_exception_response(item) for item in detail.exceptions],
)
def _run_response(run: MonthlyPayrollRunORM) -> MonthlyPayrollRunResponse:
return MonthlyPayrollRunResponse(
id=run.id,
salary_month=run.salary_month,
source_type=run.source_type,
source_job_id=run.source_job_id,
status=run.status,
employee_count=run.employee_count,
gross_total=run.gross_total,
net_total=run.net_total,
deduction_total=run.deduction_total,
overtime_total=run.overtime_total,
locked_by=run.locked_by,
locked_at=run.locked_at,
created_by=run.created_by,
created_at=run.created_at,
updated_at=run.updated_at,
)
def _exception_response(item: PayrollExceptionORM) -> PayrollExceptionItem:
return PayrollExceptionItem(
id=item.id,
run_id=item.run_id,
sync_job_id=item.sync_job_id,
employee_id=item.employee_id,
employee_no=item.employee_no,
employee_name=item.employee_name,
salary_month=item.salary_month,
exception_type=item.exception_type,
severity=item.severity,
message=item.message,
status=item.status,
created_at=item.created_at,
resolved_at=item.resolved_at,
)
def _record_failed(
operation_logs: OperationLogService,
current_user: UserORM,
http_request: Request,
action: str,
reason: str,
) -> None:
operation_logs.record(
actor=current_user,
module="monthly_payroll",
action=action,
target_type="monthly_payroll_run",
target_id="",
status="failed",
detail=reason,
**operation_request_meta(http_request),
)

View File

@ -0,0 +1,95 @@
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ...core.logger import AppLogger
from ...core.permissions import PERMISSION_ATTENDANCE_REALTIME_SYNC, PERMISSION_ATTENDANCE_REALTIME_VIEW
from ...database.orm import UserORM
from ...integrations.dingtalk import DingTalkError
from ...services import OperationLogService, RealtimeAttendanceService
from ..dependencies import get_operation_log_service, get_realtime_attendance_service, require_permission
from ..request_meta import operation_request_meta
from ..schemas.realtime_attendance import RealtimeAttendanceResponse, RealtimeAttendanceSyncRequest
router = APIRouter(prefix="/api/attendance/realtime", tags=["realtime-attendance"])
logger = AppLogger.get_logger(__name__)
@router.get("/today", response_model=RealtimeAttendanceResponse)
def get_today_attendance(
http_request: Request,
attendance_date: date = Query(alias="date"),
current_user: UserORM = Depends(require_permission(PERMISSION_ATTENDANCE_REALTIME_VIEW)),
service: RealtimeAttendanceService = Depends(get_realtime_attendance_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> RealtimeAttendanceResponse:
"""查看某一天的实时考勤看板,通常用于今日打卡状态。"""
response = service.get_today(attendance_date)
operation_logs.record(
actor=current_user,
module="attendance",
action="attendance.realtime.view",
target_type="attendance_date",
target_id=attendance_date.isoformat(),
status="success",
detail=f"查看实时考勤 attendance_date={attendance_date} employee_count={response.summary.employee_total}",
**operation_request_meta(http_request),
)
return RealtimeAttendanceResponse(**response.to_dict())
@router.post("/sync", response_model=RealtimeAttendanceResponse)
async def sync_realtime_attendance(
http_request: Request,
request: RealtimeAttendanceSyncRequest,
current_user: UserORM = Depends(require_permission(PERMISSION_ATTENDANCE_REALTIME_SYNC)),
service: RealtimeAttendanceService = Depends(get_realtime_attendance_service),
operation_logs: OperationLogService = Depends(get_operation_log_service),
) -> RealtimeAttendanceResponse:
"""按员工档案中的钉钉 userId 自动同步本月至今打卡,并刷新今日看板。"""
try:
response = await service.sync_today(
attendance_date=request.attendance_date,
created_by=current_user.username,
include_salary_preview=request.include_salary_preview,
)
except ValueError as exc:
operation_logs.record(
actor=current_user,
module="attendance",
action="attendance.realtime.sync",
target_type="attendance_sync_job",
target_id="",
status="failed",
detail=f"实时考勤同步失败 attendance_date={request.attendance_date} reason={exc}",
**operation_request_meta(http_request),
)
logger.error("实时考勤同步参数错误 attendance_date=%s reason=%s", request.attendance_date, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except DingTalkError as exc:
operation_logs.record(
actor=current_user,
module="attendance",
action="attendance.realtime.sync",
target_type="attendance_sync_job",
target_id="",
status="failed",
detail=f"实时考勤钉钉调用失败 attendance_date={request.attendance_date} reason={exc}",
**operation_request_meta(http_request),
)
logger.error("实时考勤钉钉调用失败 attendance_date=%s reason=%s", request.attendance_date, exc)
raise HTTPException(status_code=502, detail=str(exc)) from exc
operation_logs.record(
actor=current_user,
module="attendance",
action="attendance.realtime.sync",
target_type="attendance_sync_job",
target_id=response.sync_job_id or "",
status="success",
detail=f"实时考勤同步成功 attendance_date={request.attendance_date} employee_count={response.summary.employee_total}",
**operation_request_meta(http_request),
)
return RealtimeAttendanceResponse(**response.to_dict())

View File

@ -14,6 +14,18 @@ from .payroll import (
PayrollJobResponse, PayrollJobResponse,
PayrollResultDTO, PayrollResultDTO,
) )
from .monthly_payroll import (
MonthlyPayrollCreateRequest,
MonthlyPayrollDetailResponse,
MonthlyPayrollRunResponse,
PayrollExceptionItem,
)
from .realtime_attendance import (
RealtimeAttendanceResponse,
RealtimeAttendanceSummary,
RealtimeAttendanceSyncRequest,
TodayAttendanceItem,
)
from .operation_log import OperationLogDTO, OperationLogListResponse from .operation_log import OperationLogDTO, OperationLogListResponse
from .employee import EmployeeListResponse, EmployeeNoResponse, EmployeeRequest, EmployeeResponse, EmployeeUpdateRequest from .employee import EmployeeListResponse, EmployeeNoResponse, EmployeeRequest, EmployeeResponse, EmployeeUpdateRequest
from .salary_profile import SalaryProfileRequest, SalaryProfileResponse from .salary_profile import SalaryProfileRequest, SalaryProfileResponse
@ -32,12 +44,20 @@ __all__ = [
"ExcelPayrollResponse", "ExcelPayrollResponse",
"LoginRequest", "LoginRequest",
"LoginResponse", "LoginResponse",
"MonthlyPayrollCreateRequest",
"MonthlyPayrollDetailResponse",
"MonthlyPayrollRunResponse",
"OperationLogDTO", "OperationLogDTO",
"OperationLogListResponse", "OperationLogListResponse",
"PayrollJobResponse", "PayrollJobResponse",
"PayrollExceptionItem",
"PayrollResultDTO", "PayrollResultDTO",
"RealtimeAttendanceResponse",
"RealtimeAttendanceSummary",
"RealtimeAttendanceSyncRequest",
"SalaryProfileRequest", "SalaryProfileRequest",
"SalaryProfileResponse", "SalaryProfileResponse",
"TodayAttendanceItem",
"UserProfileDTO", "UserProfileDTO",
"UserResponse", "UserResponse",
] ]

View File

@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from .payroll import PayrollResultDTO
class MonthlyPayrollCreateRequest(BaseModel):
"""月度核算创建请求,用于钉钉计算。"""
salary_month: str
source_type: str = "dingtalk"
export_excel: bool = True
class MonthlyPayrollRunResponse(BaseModel):
"""月度核算批次响应。"""
id: int
salary_month: str
source_type: str
source_job_id: str
status: str
employee_count: int
gross_total: float
net_total: float
deduction_total: float
overtime_total: float
locked_by: str
locked_at: datetime | None
created_by: str
created_at: datetime
updated_at: datetime
class PayrollExceptionItem(BaseModel):
"""月度核算异常响应。"""
id: int
run_id: int | None
sync_job_id: str | None
employee_id: int | None
employee_no: str
employee_name: str
salary_month: str
exception_type: str
severity: str
message: str
status: str
created_at: datetime
resolved_at: datetime | None
class MonthlyPayrollDetailResponse(BaseModel):
"""月度核算批次详情。"""
run: MonthlyPayrollRunResponse
results: list[PayrollResultDTO]
exceptions: list[PayrollExceptionItem]

View File

@ -0,0 +1,55 @@
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel
class RealtimeAttendanceSyncRequest(BaseModel):
"""实时考勤同步请求。"""
attendance_date: date
include_salary_preview: bool = True
class TodayAttendanceItem(BaseModel):
"""实时考勤看板中的员工行。"""
employee_id: int | None
employee_no: str
employee_name: str
department: str
position: str
first_punch: str
last_punch: str
attendance_status: str
late_minutes: int
missing_card_count: int
leave_hours: float
today_overtime_hours: float
month_remaining_overtime_hours: float
estimated_net_salary: float | None
note: str
class RealtimeAttendanceSummary(BaseModel):
"""实时考勤看板摘要。"""
attendance_date: date
employee_total: int
checked_in_count: int
unchecked_count: int
late_count: int
leave_count: int
missing_card_count: int
estimated_overtime_count: int
estimated_net_total: float
class RealtimeAttendanceResponse(BaseModel):
"""实时考勤看板响应。"""
sync_job_id: str | None
synced_at: datetime | None
summary: RealtimeAttendanceSummary
items: list[TodayAttendanceItem]

View File

@ -0,0 +1,77 @@
from __future__ import annotations
from dataclasses import dataclass
from .models import PayrollResult
@dataclass(frozen=True)
class PayrollException:
"""工资核算异常,供月度核算服务转换为数据库记录。"""
employee_id: int | None
employee_no: str
employee_name: str
salary_month: str
exception_type: str
severity: str
message: str
def detect_payroll_exceptions(results: list[PayrollResult]) -> list[PayrollException]:
"""根据工资计算结果识别需要人事或财务复核的异常。"""
exceptions: list[PayrollException] = []
for result in results:
employee = result.employee
base = {
"employee_id": None,
"employee_no": employee.employee_no,
"employee_name": employee.name,
"salary_month": result.salary_month,
}
if result.base_pay is None:
exceptions.append(
PayrollException(
**base,
exception_type="missing_salary_profile",
severity="error",
message="员工未配置薪资档案,无法计算应发和实发工资",
)
)
if result.missing_card_count > 0:
exceptions.append(
PayrollException(
**base,
exception_type="missing_card",
severity="warning",
message=f"缺卡 {result.missing_card_count} 次,已产生扣款 {result.missing_card_deduction:.2f}",
)
)
if result.penalized_late_count > 0:
exceptions.append(
PayrollException(
**base,
exception_type="late_penalty",
severity="warning",
message=f"迟到扣款 {result.penalized_late_count} 次,扣款 {result.late_deduction:.2f}",
)
)
if result.remaining_overtime_hours < 0:
exceptions.append(
PayrollException(
**base,
exception_type="negative_remaining_overtime",
severity="warning",
message=f"请假工时超过加班工时,剩余加班 {result.remaining_overtime_hours:.1f} 小时",
)
)
if result.attendance_days == 0:
exceptions.append(
PayrollException(
**base,
exception_type="empty_attendance",
severity="error",
message="本月出勤天数为 0请检查考勤数据或员工状态",
)
)
return exceptions

View File

@ -2,7 +2,9 @@ from .commission_repository import CommissionRepository
from .employee_repository import EmployeeRepository from .employee_repository import EmployeeRepository
from .operation_log_repository import OperationLogRepository from .operation_log_repository import OperationLogRepository
from .organization_repository import OrganizationRepository from .organization_repository import OrganizationRepository
from .monthly_payroll_repository import MonthlyPayrollRepository
from .payroll_repository import PayrollRepository from .payroll_repository import PayrollRepository
from .realtime_attendance_repository import RealtimeAttendanceRepository
from .report_repository import ReportRepository from .report_repository import ReportRepository
from .salary_profile_repository import SalaryProfileRepository from .salary_profile_repository import SalaryProfileRepository
from .salary_config_repository import SalaryConfigRepository from .salary_config_repository import SalaryConfigRepository
@ -13,7 +15,9 @@ __all__ = [
"EmployeeRepository", "EmployeeRepository",
"OperationLogRepository", "OperationLogRepository",
"OrganizationRepository", "OrganizationRepository",
"MonthlyPayrollRepository",
"PayrollRepository", "PayrollRepository",
"RealtimeAttendanceRepository",
"ReportRepository", "ReportRepository",
"SalaryConfigRepository", "SalaryConfigRepository",
"SalaryProfileRepository", "SalaryProfileRepository",

View File

@ -0,0 +1,122 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy.orm import Session
from ..database.orm import MonthlyPayrollRunORM, PayrollExceptionORM
class MonthlyPayrollRepository:
"""月度核算仓库:维护核算批次和异常清单。"""
def __init__(self, session: Session):
self.session = session
def create_run(
self,
*,
salary_month: str,
source_type: str,
source_job_id: str,
created_by: str,
status: str = "calculated",
) -> MonthlyPayrollRunORM:
existing = self.get_run_by_month(salary_month)
if existing and existing.status == "locked":
raise ValueError("本月工资已锁定,不能重新计算")
if existing is not None:
existing.source_type = source_type
existing.source_job_id = source_job_id
existing.status = status
existing.created_by = created_by
existing.updated_at = datetime.utcnow()
self.session.commit()
self.session.refresh(existing)
return existing
run = MonthlyPayrollRunORM(
salary_month=salary_month,
source_type=source_type,
source_job_id=source_job_id,
status=status,
created_by=created_by,
)
self.session.add(run)
self.session.commit()
self.session.refresh(run)
return run
def get_run(self, run_id: int) -> MonthlyPayrollRunORM | None:
return self.session.query(MonthlyPayrollRunORM).filter(MonthlyPayrollRunORM.id == run_id).one_or_none()
def require_run(self, run_id: int) -> MonthlyPayrollRunORM:
run = self.get_run(run_id)
if run is None:
raise ValueError("月度核算批次不存在")
return run
def get_run_by_month(self, salary_month: str) -> MonthlyPayrollRunORM | None:
return (
self.session.query(MonthlyPayrollRunORM)
.filter(MonthlyPayrollRunORM.salary_month == salary_month)
.order_by(MonthlyPayrollRunORM.updated_at.desc(), MonthlyPayrollRunORM.id.desc())
.first()
)
def list_runs(self, salary_month: str | None = None) -> list[MonthlyPayrollRunORM]:
query = self.session.query(MonthlyPayrollRunORM)
if salary_month:
query = query.filter(MonthlyPayrollRunORM.salary_month == salary_month)
return query.order_by(MonthlyPayrollRunORM.salary_month.desc(), MonthlyPayrollRunORM.id.desc()).all()
def update_run_totals(
self,
run_id: int,
*,
employee_count: int,
gross_total: float,
net_total: float,
deduction_total: float,
overtime_total: float,
status: str = "calculated",
) -> MonthlyPayrollRunORM:
run = self.require_run(run_id)
if run.status == "locked":
raise ValueError("本月工资已锁定,不能重新计算")
run.employee_count = employee_count
run.gross_total = gross_total
run.net_total = net_total
run.deduction_total = deduction_total
run.overtime_total = overtime_total
run.status = status
run.updated_at = datetime.utcnow()
self.session.commit()
self.session.refresh(run)
return run
def lock_run(self, run_id: int, locked_by: str) -> MonthlyPayrollRunORM:
run = self.require_run(run_id)
run.status = "locked"
run.locked_by = locked_by
run.locked_at = datetime.utcnow()
run.updated_at = datetime.utcnow()
self.session.commit()
self.session.refresh(run)
return run
def replace_exceptions(self, run_id: int, rows: list[PayrollExceptionORM]) -> None:
self.session.query(PayrollExceptionORM).filter(PayrollExceptionORM.run_id == run_id).delete(
synchronize_session=False
)
if rows:
self.session.add_all(rows)
self.session.commit()
def list_exceptions(self, run_id: int) -> list[PayrollExceptionORM]:
return (
self.session.query(PayrollExceptionORM)
.filter(PayrollExceptionORM.run_id == run_id)
.order_by(PayrollExceptionORM.severity.desc(), PayrollExceptionORM.id.asc())
.all()
)

View File

@ -0,0 +1,147 @@
from __future__ import annotations
from datetime import date, datetime
from uuid import uuid4
from sqlalchemy.orm import Session
from ..database.orm import AttendanceRecordORM, AttendanceSyncJobORM, EmployeeORM, SalaryPreviewORM
class RealtimeAttendanceRepository:
"""实时考勤仓库:封装同步批次、考勤记录和薪资预估的数据库读写。"""
def __init__(self, session: Session):
self.session = session
def list_active_employees(self) -> list[EmployeeORM]:
return (
self.session.query(EmployeeORM)
.filter(EmployeeORM.employment_status == "active")
.order_by(EmployeeORM.id.asc())
.all()
)
def list_active_employees_with_dingtalk(self) -> list[EmployeeORM]:
return (
self.session.query(EmployeeORM)
.filter(EmployeeORM.employment_status == "active")
.filter(EmployeeORM.dingtalk_user_id != "")
.order_by(EmployeeORM.id.asc())
.all()
)
def create_sync_job(
self,
*,
source_type: str,
sync_mode: str,
attendance_date: date,
salary_month: str,
start_date: date,
end_date: date,
created_by: str,
) -> AttendanceSyncJobORM:
job = AttendanceSyncJobORM(
id=uuid4().hex,
source_type=source_type,
sync_mode=sync_mode,
attendance_date=attendance_date,
salary_month=salary_month,
start_date=start_date,
end_date=end_date,
status="running",
created_by=created_by,
)
self.session.add(job)
self.session.commit()
self.session.refresh(job)
return job
def mark_sync_completed(
self,
job_id: str,
*,
employee_count: int,
record_count: int,
) -> AttendanceSyncJobORM:
job = self.require_sync_job(job_id)
job.status = "completed"
job.employee_count = employee_count
job.record_count = record_count
job.error_message = ""
job.updated_at = datetime.utcnow()
self.session.commit()
self.session.refresh(job)
return job
def mark_sync_failed(self, job_id: str, error_message: str) -> AttendanceSyncJobORM:
job = self.require_sync_job(job_id)
job.status = "failed"
job.error_message = error_message
job.updated_at = datetime.utcnow()
self.session.commit()
self.session.refresh(job)
return job
def require_sync_job(self, job_id: str) -> AttendanceSyncJobORM:
job = self.session.query(AttendanceSyncJobORM).filter(AttendanceSyncJobORM.id == job_id).one_or_none()
if job is None:
raise ValueError("考勤同步任务不存在")
return job
def latest_completed_job(self, attendance_date: date) -> AttendanceSyncJobORM | None:
return (
self.session.query(AttendanceSyncJobORM)
.filter(AttendanceSyncJobORM.attendance_date == attendance_date)
.filter(AttendanceSyncJobORM.status == "completed")
.order_by(AttendanceSyncJobORM.updated_at.desc(), AttendanceSyncJobORM.created_at.desc())
.first()
)
def replace_attendance_records(
self,
*,
source_type: str,
start_date: date,
end_date: date,
employee_ids: list[int],
rows: list[AttendanceRecordORM],
) -> None:
if employee_ids:
(
self.session.query(AttendanceRecordORM)
.filter(AttendanceRecordORM.source_type == source_type)
.filter(AttendanceRecordORM.employee_id.in_(employee_ids))
.filter(AttendanceRecordORM.work_date >= start_date)
.filter(AttendanceRecordORM.work_date <= end_date)
.delete(synchronize_session=False)
)
if rows:
self.session.add_all(rows)
self.session.commit()
def replace_salary_previews(self, sync_job_id: str, rows: list[SalaryPreviewORM]) -> None:
self.session.query(SalaryPreviewORM).filter(SalaryPreviewORM.sync_job_id == sync_job_id).delete(
synchronize_session=False
)
if rows:
self.session.add_all(rows)
self.session.commit()
def list_today_records(self, attendance_date: date) -> list[AttendanceRecordORM]:
return (
self.session.query(AttendanceRecordORM)
.filter(AttendanceRecordORM.work_date == attendance_date)
.filter(AttendanceRecordORM.source_type == "dingtalk")
.order_by(AttendanceRecordORM.employee_name.asc(), AttendanceRecordORM.id.asc())
.all()
)
def list_salary_previews(self, salary_month: str) -> list[SalaryPreviewORM]:
return (
self.session.query(SalaryPreviewORM)
.filter(SalaryPreviewORM.salary_month == salary_month)
.order_by(SalaryPreviewORM.updated_at.desc(), SalaryPreviewORM.id.desc())
.all()
)

View File

@ -1,9 +1,11 @@
from .auth_service import AuthService, AuthSession from .auth_service import AuthService, AuthSession
from .commission_service import CommissionPage, CommissionService from .commission_service import CommissionPage, CommissionService
from .employee_service import EmployeePage, EmployeeService from .employee_service import EmployeePage, EmployeeService
from .monthly_payroll_service import MonthlyPayrollDetailData, MonthlyPayrollService
from .operation_log_service import OperationLogPage, OperationLogService from .operation_log_service import OperationLogPage, OperationLogService
from .organization_service import OrganizationService from .organization_service import OrganizationService
from .payroll_service import PayrollApplicationService, PayrollComputation, PayrollService from .payroll_service import PayrollApplicationService, PayrollComputation, PayrollService
from .realtime_attendance_service import RealtimeAttendanceService
from .report_service import PayrollReport, ReportService from .report_service import PayrollReport, ReportService
from .salary_config_service import SalaryConfigService from .salary_config_service import SalaryConfigService
from .salary_profile_service import SalaryProfileService from .salary_profile_service import SalaryProfileService
@ -15,6 +17,8 @@ __all__ = [
"CommissionService", "CommissionService",
"EmployeePage", "EmployeePage",
"EmployeeService", "EmployeeService",
"MonthlyPayrollDetailData",
"MonthlyPayrollService",
"OperationLogPage", "OperationLogPage",
"OperationLogService", "OperationLogService",
"OrganizationService", "OrganizationService",
@ -22,6 +26,7 @@ __all__ = [
"PayrollApplicationService", "PayrollApplicationService",
"PayrollComputation", "PayrollComputation",
"PayrollService", "PayrollService",
"RealtimeAttendanceService",
"ReportService", "ReportService",
"SalaryConfigService", "SalaryConfigService",
"SalaryProfileService", "SalaryProfileService",

View File

@ -0,0 +1,187 @@
from __future__ import annotations
from calendar import monthrange
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import BinaryIO
from ..core.logger import AppLogger
from ..database.orm import MonthlyPayrollRunORM, PayrollExceptionORM, PayrollJobORM
from ..domain.payroll_exceptions import PayrollException, detect_payroll_exceptions
from ..repositories import EmployeeRepository, MonthlyPayrollRepository
from .payroll_service import PayrollApplicationService, PayrollComputation
logger = AppLogger.get_logger(__name__)
@dataclass(frozen=True)
class MonthlyPayrollDetailData:
run: MonthlyPayrollRunORM
job: PayrollJobORM | None
exceptions: list[PayrollExceptionORM]
class MonthlyPayrollService:
"""月度核算应用服务:编排正式工资计算、异常识别、锁定和导出。"""
def __init__(
self,
repository: MonthlyPayrollRepository,
payroll_service: PayrollApplicationService,
employee_repository: EmployeeRepository,
):
self.repository = repository
self.payroll_service = payroll_service
self.employee_repository = employee_repository
def list_runs(self, salary_month: str | None = None) -> list[MonthlyPayrollRunORM]:
logger.info("查询月度核算批次 salary_month=%s", salary_month)
return self.repository.list_runs(salary_month)
def get_detail(self, run_id: int) -> MonthlyPayrollDetailData:
run = self.repository.require_run(run_id)
job = self.payroll_service.get_job(run.source_job_id) if run.source_job_id else None
exceptions = self.repository.list_exceptions(run.id)
logger.info("查询月度核算详情 run_id=%s job_found=%s exception_count=%s", run_id, job is not None, len(exceptions))
return MonthlyPayrollDetailData(run=run, job=job, exceptions=exceptions)
def calculate_from_excel(
self,
*,
salary_month: str,
filename: str,
stream: BinaryIO,
created_by: str,
export_excel: bool,
) -> MonthlyPayrollDetailData:
self._ensure_month_can_calculate(salary_month)
computation = self.payroll_service.calculate_from_excel_upload(
filename=filename,
stream=stream,
config_path=None,
export_excel=export_excel,
)
run = self._save_calculation(
salary_month=salary_month or self._month_from_computation(computation),
source_type="excel",
created_by=created_by,
computation=computation,
)
return self.get_detail(run.id)
async def calculate_from_dingtalk(
self,
*,
salary_month: str,
created_by: str,
export_excel: bool,
) -> MonthlyPayrollDetailData:
self._ensure_month_can_calculate(salary_month)
employees = [
employee
for employee in self.employee_repository.list_employees(employment_status="active")
if employee.dingtalk_user_id
]
if not employees:
raise ValueError("没有绑定钉钉 userId 的在职员工请先在员工维护中补齐钉钉用户ID")
start_date, end_date = _month_range(salary_month)
computation = await self.payroll_service.calculate_from_dingtalk_realtime(
user_ids=[employee.dingtalk_user_id for employee in employees],
start_date=start_date,
end_date=end_date,
config_path=None,
export_excel=export_excel,
)
run = self._save_calculation(
salary_month=salary_month,
source_type="dingtalk",
created_by=created_by,
computation=computation,
)
return self.get_detail(run.id)
def lock_run(self, run_id: int, locked_by: str) -> MonthlyPayrollRunORM:
logger.info("锁定月度核算 run_id=%s locked_by=%s", run_id, locked_by)
return self.repository.lock_run(run_id, locked_by)
def export_path(self, run_id: int) -> Path:
detail = self.get_detail(run_id)
if detail.job is None or not detail.job.output_file:
raise FileNotFoundError("该月度核算批次没有可导出的工资表")
path = Path(detail.job.output_file)
if path.exists():
return path
return self.payroll_service.resolve_output_file(path.name)
def _save_calculation(
self,
*,
salary_month: str,
source_type: str,
created_by: str,
computation: PayrollComputation,
) -> MonthlyPayrollRunORM:
run = self.repository.create_run(
salary_month=salary_month,
source_type=source_type,
source_job_id=computation.job_id,
created_by=created_by,
status="calculated",
)
self.repository.update_run_totals(
run.id,
employee_count=computation.employee_count,
gross_total=round(sum(result.gross_salary or 0 for result in computation.results), 2),
net_total=round(sum(result.net_salary or 0 for result in computation.results), 2),
deduction_total=round(sum(result.total_deduction for result in computation.results), 2),
overtime_total=round(sum(result.remaining_overtime_hours for result in computation.results), 2),
status="calculated",
)
self.repository.replace_exceptions(
run.id,
self._exception_rows(run.id, detect_payroll_exceptions(computation.results)),
)
logger.info("月度核算保存完成 run_id=%s job_id=%s", run.id, computation.job_id)
return self.repository.require_run(run.id)
def _exception_rows(
self,
run_id: int,
exceptions: list[PayrollException],
) -> list[PayrollExceptionORM]:
rows: list[PayrollExceptionORM] = []
for item in exceptions:
employee = self.employee_repository.get_by_employee_no(item.employee_no) if item.employee_no else None
rows.append(
PayrollExceptionORM(
run_id=run_id,
employee_id=employee.id if employee else None,
employee_no=item.employee_no,
employee_name=item.employee_name,
salary_month=item.salary_month,
exception_type=item.exception_type,
severity=item.severity,
message=item.message,
status="open",
)
)
return rows
def _ensure_month_can_calculate(self, salary_month: str) -> None:
existing = self.repository.get_run_by_month(salary_month)
if existing and existing.status == "locked":
raise ValueError("本月工资已锁定,不能重新计算")
def _month_from_computation(self, computation: PayrollComputation) -> str:
for result in computation.results:
if result.salary_month:
return result.salary_month
raise ValueError("无法从工资结果识别工资月份")
def _month_range(salary_month: str) -> tuple[date, date]:
year_text, month_text = salary_month.split("-", 1)
year = int(year_text)
month = int(month_text)
return date(year, month, 1), date(year, month, monthrange(year, month)[1])

View File

@ -155,6 +155,14 @@ 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 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)
def dingtalk_settings(self) -> DingTalkSettings:
"""对外提供钉钉配置读取,统一错误提示。"""
return self._dingtalk_settings()
def _export_if_needed( def _export_if_needed(
self, self,
config: AppConfig, config: AppConfig,

View File

@ -0,0 +1,395 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import date, datetime, time, timedelta
from ..core.logger import AppLogger
from ..database.orm import AttendanceRecordORM, EmployeeORM, SalaryPreviewORM
from ..domain.calculator import PayrollCalculator
from ..domain.models import DailyAttendance, EmployeeAttendance, PayrollResult
from ..integrations.dingtalk import DingTalkAttendanceAdapter, DingTalkClient
from ..repositories import RealtimeAttendanceRepository
from .payroll_service import PayrollApplicationService
logger = AppLogger.get_logger(__name__)
@dataclass(frozen=True)
class TodayAttendanceItemData:
employee_id: int | None
employee_no: str
employee_name: str
department: str
position: str
first_punch: str
last_punch: str
attendance_status: str
late_minutes: int
missing_card_count: int
leave_hours: float
today_overtime_hours: float
month_remaining_overtime_hours: float
estimated_net_salary: float | None
note: str
@dataclass(frozen=True)
class RealtimeAttendanceSummaryData:
attendance_date: date
employee_total: int
checked_in_count: int
unchecked_count: int
late_count: int
leave_count: int
missing_card_count: int
estimated_overtime_count: int
estimated_net_total: float
@dataclass(frozen=True)
class RealtimeAttendanceResponseData:
sync_job_id: str | None
synced_at: datetime | None
summary: RealtimeAttendanceSummaryData
items: list[TodayAttendanceItemData]
def to_dict(self) -> dict:
return asdict(self)
class RealtimeAttendanceService:
"""实时考勤应用服务:同步钉钉、落库标准考勤,并生成薪资预估看板。"""
def __init__(
self,
repository: RealtimeAttendanceRepository,
payroll_service: PayrollApplicationService,
):
self.repository = repository
self.payroll_service = payroll_service
def get_today(self, attendance_date: date) -> RealtimeAttendanceResponseData:
logger.info("实时考勤看板查询开始 attendance_date=%s", attendance_date)
employees = self.repository.list_active_employees()
records = self.repository.list_today_records(attendance_date)
previews = self.repository.list_salary_previews(attendance_date.strftime("%Y-%m"))
latest_job = self.repository.latest_completed_job(attendance_date)
items = self._build_today_items(attendance_date, employees, records, previews)
logger.info("实时考勤看板查询完成 attendance_date=%s employee_count=%s", attendance_date, len(items))
return RealtimeAttendanceResponseData(
sync_job_id=latest_job.id if latest_job else None,
synced_at=latest_job.updated_at if latest_job else None,
summary=build_realtime_summary(attendance_date, items),
items=items,
)
async def sync_today(
self,
*,
attendance_date: date,
created_by: str,
include_salary_preview: bool = True,
) -> RealtimeAttendanceResponseData:
employees = self.repository.list_active_employees_with_dingtalk()
if not employees:
logger.error("实时考勤同步失败 reason=no_dingtalk_user_id")
raise ValueError("没有绑定钉钉 userId 的在职员工请先在员工维护中补齐钉钉用户ID")
salary_month = attendance_date.strftime("%Y-%m")
start_date = attendance_date.replace(day=1)
job = self.repository.create_sync_job(
source_type="dingtalk",
sync_mode="month_to_date",
attendance_date=attendance_date,
salary_month=salary_month,
start_date=start_date,
end_date=attendance_date,
created_by=created_by,
)
logger.info(
"实时考勤同步任务创建 job_id=%s attendance_date=%s employee_count=%s",
job.id,
attendance_date,
len(employees),
)
try:
settings = self.payroll_service.dingtalk_settings()
config = self.payroll_service.load_runtime_config(None, salary_month=salary_month)
client = DingTalkClient(settings)
raw_records = await client.list_attendance_records(
[employee.dingtalk_user_id for employee in employees],
datetime.combine(start_date, time.min),
datetime.combine(attendance_date, time(23, 59, 59)),
)
adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone)
adapted_employees = adapter.to_employee_attendance(raw_records)
attendance = self._merge_employee_directory(
employees=employees,
adapted_employees=adapted_employees,
start_date=start_date,
end_date=attendance_date,
)
results = PayrollCalculator(config).calculate(attendance)
employee_by_no = {employee.employee_no: employee for employee in employees}
employee_ids = [employee.id for employee in employees]
self.repository.replace_attendance_records(
source_type="dingtalk",
start_date=start_date,
end_date=attendance_date,
employee_ids=employee_ids,
rows=self._attendance_rows(results, employee_by_no),
)
if include_salary_preview:
self.repository.replace_salary_previews(
job.id,
self._salary_preview_rows(job.id, results, employee_by_no),
)
self.repository.mark_sync_completed(
job.id,
employee_count=len(employees),
record_count=len(raw_records),
)
logger.info(
"实时考勤同步任务完成 job_id=%s raw_record_count=%s employee_count=%s",
job.id,
len(raw_records),
len(employees),
)
except Exception as exc:
self.repository.mark_sync_failed(job.id, str(exc))
logger.exception("实时考勤同步任务失败 job_id=%s", job.id)
raise
return self.get_today(attendance_date)
def _merge_employee_directory(
self,
*,
employees: list[EmployeeORM],
adapted_employees: list[EmployeeAttendance],
start_date: date,
end_date: date,
) -> list[EmployeeAttendance]:
adapted_by_user_id = {employee.user_id: employee for employee in adapted_employees}
days = list(_date_range(start_date, end_date))
merged: list[EmployeeAttendance] = []
for employee in employees:
adapted = adapted_by_user_id.get(employee.dingtalk_user_id)
daily_by_date = {
record.work_date: record
for record in adapted.daily_records
} if adapted else {}
merged.append(
EmployeeAttendance(
name=employee.name,
attendance_group="钉钉实时考勤",
department=employee.department,
employee_no=employee.employee_no,
position=employee.position,
user_id=employee.dingtalk_user_id,
daily_records=[daily_by_date.get(day) or DailyAttendance(work_date=day) for day in days],
)
)
return merged
def _build_today_items(
self,
attendance_date: date,
employees: list[EmployeeORM],
records: list[AttendanceRecordORM],
previews: list[SalaryPreviewORM],
) -> list[TodayAttendanceItemData]:
records_by_employee_id = {
record.employee_id: record
for record in records
if record.employee_id is not None
}
records_by_employee_no = {record.employee_no: record for record in records if record.employee_no}
previews_by_employee_id: dict[int, SalaryPreviewORM] = {}
previews_by_employee_no: dict[str, SalaryPreviewORM] = {}
for preview in previews:
if preview.employee_id is not None and preview.employee_id not in previews_by_employee_id:
previews_by_employee_id[preview.employee_id] = preview
if preview.employee_no and preview.employee_no not in previews_by_employee_no:
previews_by_employee_no[preview.employee_no] = preview
items = [
self._today_item_from_employee(
attendance_date,
employee,
records_by_employee_id.get(employee.id) or records_by_employee_no.get(employee.employee_no),
previews_by_employee_id.get(employee.id) or previews_by_employee_no.get(employee.employee_no),
)
for employee in employees
]
known_employee_ids = {employee.id for employee in employees}
for record in records:
if record.employee_id in known_employee_ids:
continue
items.append(self._today_item_from_record(record, previews_by_employee_no.get(record.employee_no)))
return items
def _today_item_from_employee(
self,
attendance_date: date,
employee: EmployeeORM,
record: AttendanceRecordORM | None,
preview: SalaryPreviewORM | None,
) -> TodayAttendanceItemData:
if record is None:
note = "未绑定钉钉 userId" if not employee.dingtalk_user_id else ""
return TodayAttendanceItemData(
employee_id=employee.id,
employee_no=employee.employee_no,
employee_name=employee.name,
department=employee.department,
position=employee.position,
first_punch="",
last_punch="",
attendance_status="unchecked",
late_minutes=0,
missing_card_count=0,
leave_hours=0,
today_overtime_hours=0,
month_remaining_overtime_hours=preview.remaining_overtime_hours if preview else 0,
estimated_net_salary=preview.estimated_net_salary if preview else None,
note=note,
)
return self._today_item_from_record(record, preview, fallback_position=employee.position)
def _today_item_from_record(
self,
record: AttendanceRecordORM,
preview: SalaryPreviewORM | None,
fallback_position: str = "",
) -> TodayAttendanceItemData:
return TodayAttendanceItemData(
employee_id=record.employee_id,
employee_no=record.employee_no,
employee_name=record.employee_name,
department=record.department,
position=preview.position if preview and preview.position else fallback_position,
first_punch=record.first_punch,
last_punch=record.last_punch,
attendance_status=_attendance_status(record),
late_minutes=record.late_minutes,
missing_card_count=record.missing_card_count,
leave_hours=record.leave_hours,
today_overtime_hours=record.overtime_hours,
month_remaining_overtime_hours=preview.remaining_overtime_hours if preview else 0,
estimated_net_salary=preview.estimated_net_salary if preview else None,
note=preview.note if preview else "",
)
def _attendance_rows(
self,
results: list[PayrollResult],
employee_by_no: dict[str, EmployeeORM],
) -> list[AttendanceRecordORM]:
rows: list[AttendanceRecordORM] = []
for result in results:
employee = employee_by_no.get(result.employee.employee_no)
for record in result.employee.daily_records:
rows.append(
AttendanceRecordORM(
employee_id=employee.id if employee else None,
employee_no=result.employee.employee_no,
employee_name=result.employee.name,
department=result.employee.department,
work_date=record.work_date,
first_punch=_time_text(record.first_punch),
last_punch=_time_text(record.last_punch),
attendance_status=_daily_status(record),
late_minutes=sum(record.late_minutes),
missing_card_count=record.missing_card_count,
leave_hours=record.leave_hours_in_cell,
overtime_hours=record.punch_overtime_hours,
raw_text=record.raw_text,
source_type="dingtalk",
)
)
return rows
def _salary_preview_rows(
self,
sync_job_id: str,
results: list[PayrollResult],
employee_by_no: dict[str, EmployeeORM],
) -> list[SalaryPreviewORM]:
rows: list[SalaryPreviewORM] = []
for result in results:
employee = employee_by_no.get(result.employee.employee_no)
rows.append(
SalaryPreviewORM(
sync_job_id=sync_job_id,
employee_id=employee.id if employee else None,
employee_no=result.employee.employee_no,
employee_name=result.employee.name,
department=result.employee.department,
position=result.employee.position,
salary_month=result.salary_month,
attendance_days=result.attendance_days,
actual_work_hours=result.actual_work_hours,
punch_overtime_hours=result.punch_overtime_hours,
leave_hours=result.leave_hours,
remaining_overtime_hours=result.remaining_overtime_hours,
late_deduction=result.late_deduction,
missing_card_deduction=result.missing_card_deduction,
commission_amount=result.commission_amount,
overtime_pay=result.overtime_pay,
estimated_gross_salary=result.gross_salary,
estimated_net_salary=result.net_salary,
status="warning" if result.note or result.remaining_overtime_hours < 0 else "estimated",
note=result.note,
)
)
return rows
def build_realtime_summary(
attendance_date: date,
items: list[TodayAttendanceItemData],
) -> RealtimeAttendanceSummaryData:
"""按页面行聚合实时考勤摘要,保持统计口径集中可测。"""
checked_in_count = sum(1 for item in items if item.first_punch or item.last_punch)
estimated_net_total = round(
sum(item.estimated_net_salary or 0 for item in items),
2,
)
return RealtimeAttendanceSummaryData(
attendance_date=attendance_date,
employee_total=len(items),
checked_in_count=checked_in_count,
unchecked_count=max(0, len(items) - checked_in_count),
late_count=sum(1 for item in items if item.late_minutes > 0),
leave_count=sum(1 for item in items if item.leave_hours > 0),
missing_card_count=sum(item.missing_card_count for item in items),
estimated_overtime_count=sum(1 for item in items if item.today_overtime_hours > 0),
estimated_net_total=estimated_net_total,
)
def _date_range(start_date: date, end_date: date):
current = start_date
while current <= end_date:
yield current
current += timedelta(days=1)
def _time_text(value) -> str:
return value.strftime("%H:%M") if value else ""
def _daily_status(record: DailyAttendance) -> str:
if record.first_punch or record.last_punch:
return "missing" if record.missing_card_count else "present"
if record.leave_hours_in_cell:
return "leave"
return "unchecked"
def _attendance_status(record: AttendanceRecordORM) -> str:
if record.attendance_status == "absent":
return "unchecked"
return record.attendance_status or "unchecked"

View File

@ -19,7 +19,7 @@
| `src/stores/` | Pinia 状态管理,目前主要是登录态、菜单、权限和主题色。 | | `src/stores/` | Pinia 状态管理,目前主要是登录态、菜单、权限和主题色。 |
| `src/router/` | 路由配置和登录/权限守卫。 | | `src/router/` | 路由配置和登录/权限守卫。 |
| `src/components/` | 通用布局、侧边栏、顶部栏、统计卡片和工资结果表格。 | | `src/components/` | 通用布局、侧边栏、顶部栏、统计卡片和工资结果表格。 |
| `src/views/` | 登录、工作台、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 | | `src/views/` | 登录、工作台、实时考勤、月度核算、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 |
| `src/assets/` | 全局样式和 UI 设计变量。 | | `src/assets/` | 全局样式和 UI 设计变量。 |
## 本地启动 ## 本地启动
@ -55,7 +55,7 @@ http://127.0.0.1:5173/
/static -> http://127.0.0.1:8000 /static -> http://127.0.0.1:8000
``` ```
因此前端代码中可以直接请求 `/api/auth/login`、`/api/payroll/excel` 等路径,头像静态资源也可以直接访问 `/static/uploads/avatars/...` 因此前端代码中可以直接请求 `/api/auth/login`、`/api/attendance/realtime/today`、`/api/payroll/monthly/runs`、`/api/payroll/excel` 等路径,头像静态资源也可以直接访问 `/static/uploads/avatars/...`
如果部署时前端和后端不在同一个域名,可以复制 `.env.example``.env`,并设置: 如果部署时前端和后端不在同一个域名,可以复制 `.env.example``.env`,并设置:
@ -90,6 +90,8 @@ VITE_DEV_PROXY_TARGET=http://127.0.0.1:8001
| --- | --- | --- | | --- | --- | --- |
| 登录 | `/login` | 用户登录入口。 | | 登录 | `/login` | 用户登录入口。 |
| 工作台 | `/dashboard` | 展示最近计算任务、统计卡片和趋势面板。 | | 工作台 | `/dashboard` | 展示最近计算任务、统计卡片和趋势面板。 |
| 实时考勤 | `/attendance/realtime` | 查看今日打卡、迟到、缺卡、请假、加班和本月薪资预估,可同步钉钉刷新。 |
| 月度核算 | `/payroll/monthly` | 按工资月份完成 Excel 导入、钉钉同步计算、异常处理、锁定和导出。 |
| 工资计算 | `/payroll` | 支持 Excel 导入和钉钉实时计算。 | | 工资计算 | `/payroll` | 支持 Excel 导入和钉钉实时计算。 |
| 计算记录 | `/payroll/jobs` | 支持按任务编号查询已落库工资结果。 | | 计算记录 | `/payroll/jobs` | 支持按任务编号查询已落库工资结果。 |
| 提成管理 | `/payroll/commissions` | 支持手工维护提成和 Excel 批量导入。 | | 提成管理 | `/payroll/commissions` | 支持手工维护提成和 Excel 批量导入。 |
@ -105,6 +107,8 @@ VITE_DEV_PROXY_TARGET=http://127.0.0.1:8001
## 个性化与个人资料 ## 个性化与个人资料
实时考勤中的薪资金额是过程预估,最终工资以月度核算锁定结果为准。月度核算锁定后,本月工资不能重新计算,只保留导出。
顶部栏提供两个常用入口: 顶部栏提供两个常用入口:
- 调色盘按钮:切换主题色,支持深海蓝、专业蓝、翡翠绿、勃艮第,选择结果保存在浏览器本地。 - 调色盘按钮:切换主题色,支持深海蓝、专业蓝、翡翠绿、勃艮第,选择结果保存在浏览器本地。
@ -121,8 +125,8 @@ static/uploads/avatars
| 角色 | 可见菜单和操作 | | 角色 | 可见菜单和操作 |
| --- | --- | | --- | --- |
| `superuser` | 拥有全部菜单和全部操作权限。 | | `superuser` | 拥有全部菜单和全部操作权限。 |
| `manager` | 可进行工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 | | `manager` | 可进行实时考勤同步、月度核算、工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 |
| `viewer` | 可查看计算记录、下载结果和统计报表。 | | `viewer` | 可查看实时考勤、计算记录、下载结果和统计报表。 |
## 构建 ## 构建

View File

@ -0,0 +1,68 @@
import { apiFetch, buildApiUrl, getStoredToken } from "./http";
import type { MonthlyPayrollDetailResponse, MonthlyPayrollRun } from "./types";
export function listMonthlyRuns(month?: string): Promise<MonthlyPayrollRun[]> {
const query = month ? `?month=${encodeURIComponent(month)}` : "";
return apiFetch<MonthlyPayrollRun[]>(`/api/payroll/monthly/runs${query}`);
}
export function getMonthlyRun(runId: number): Promise<MonthlyPayrollDetailResponse> {
return apiFetch<MonthlyPayrollDetailResponse>(`/api/payroll/monthly/runs/${runId}`);
}
export function calculateMonthlyFromExcel(file: File, salaryMonth: string): Promise<MonthlyPayrollDetailResponse> {
const formData = new FormData();
formData.append("file", file);
formData.append("salary_month", salaryMonth);
formData.append("export_excel", "true");
return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/excel", {
method: "POST",
body: formData,
});
}
export function calculateMonthlyFromDingTalk(salaryMonth: string): Promise<MonthlyPayrollDetailResponse> {
return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/dingtalk", {
method: "POST",
body: JSON.stringify({
salary_month: salaryMonth,
source_type: "dingtalk",
export_excel: true,
}),
});
}
export function recalculateMonthlyRun(runId: number): Promise<MonthlyPayrollDetailResponse> {
return apiFetch<MonthlyPayrollDetailResponse>(`/api/payroll/monthly/runs/${runId}/recalculate`, {
method: "POST",
});
}
export function lockMonthlyRun(runId: number): Promise<MonthlyPayrollRun> {
return apiFetch<MonthlyPayrollRun>(`/api/payroll/monthly/runs/${runId}/lock`, {
method: "POST",
});
}
export async function exportMonthlyRun(runId: number): Promise<void> {
const headers = new Headers();
const token = getStoredToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(buildApiUrl(`/api/payroll/monthly/runs/${runId}/export`), { headers });
if (!response.ok) {
throw new Error("月度工资表导出失败");
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `月度工资核算-${runId}.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}

View File

@ -0,0 +1,16 @@
import { apiFetch } from "./http";
import type { RealtimeAttendanceResponse } from "./types";
export function getTodayAttendance(date: string): Promise<RealtimeAttendanceResponse> {
return apiFetch<RealtimeAttendanceResponse>(`/api/attendance/realtime/today?date=${encodeURIComponent(date)}`);
}
export function syncRealtimeAttendance(date: string): Promise<RealtimeAttendanceResponse> {
return apiFetch<RealtimeAttendanceResponse>("/api/attendance/realtime/sync", {
method: "POST",
body: JSON.stringify({
attendance_date: date,
include_salary_preview: true,
}),
});
}

View File

@ -376,3 +376,80 @@ export interface PayrollReportResponse {
created_at: string; created_at: string;
}>; }>;
} }
export interface RealtimeAttendanceSummary {
attendance_date: string;
employee_total: number;
checked_in_count: number;
unchecked_count: number;
late_count: number;
leave_count: number;
missing_card_count: number;
estimated_overtime_count: number;
estimated_net_total: number;
}
export interface TodayAttendanceItem {
employee_id: number | null;
employee_no: string;
employee_name: string;
department: string;
position: string;
first_punch: string;
last_punch: string;
attendance_status: string;
late_minutes: number;
missing_card_count: number;
leave_hours: number;
today_overtime_hours: number;
month_remaining_overtime_hours: number;
estimated_net_salary: number | null;
note: string;
}
export interface RealtimeAttendanceResponse {
sync_job_id: string | null;
synced_at: string | null;
summary: RealtimeAttendanceSummary;
items: TodayAttendanceItem[];
}
export interface MonthlyPayrollRun {
id: number;
salary_month: string;
source_type: string;
source_job_id: string;
status: "draft" | "calculated" | "reviewed" | "locked" | "failed" | string;
employee_count: number;
gross_total: number;
net_total: number;
deduction_total: number;
overtime_total: number;
locked_by: string;
locked_at: string | null;
created_by: string;
created_at: string;
updated_at: string;
}
export interface PayrollException {
id: number;
run_id: number | null;
sync_job_id: string | null;
employee_id: number | null;
employee_no: string;
employee_name: string;
salary_month: string;
exception_type: string;
severity: string;
message: string;
status: string;
created_at: string;
resolved_at: string | null;
}
export interface MonthlyPayrollDetailResponse {
run: MonthlyPayrollRun;
results: PayrollResult[];
exceptions: PayrollException[];
}

View File

@ -1,50 +1,50 @@
:root { :root {
--surface: #f7f9fb; --surface: #f5f7fa;
--surface-soft: #eef2f6; --surface-soft: #eef2f7;
--surface-strong: #e3e8ee; --surface-strong: #d9e1ec;
--card: #ffffff; --card: #ffffff;
--primary: #041627; --primary: #22313f;
--primary-soft: #1a2b3c; --primary-soft: #d3d7db;
--text: #191c1e; --text: #1f2933;
--muted: #5f6670; --muted: #667381;
--line: #d8dee6; --line: #c7d0dc;
--line-soft: #edf1f5; --line-soft: #f4f7fa;
--blue: #0f62c6; --blue: #2563eb;
--blue-soft: #e8f1ff; --blue-soft: #e8f1ff;
--green: #008c61; --green: #008c61;
--green-soft: #ddf8ee; --green-soft: #ddf8ee;
--red: #bd1e1e; --red: #bd1e1e;
--red-soft: #ffebe8; --red-soft: #ffebe8;
--shadow: 0 16px 32px rgba(4, 22, 39, 0.06); --shadow: 0 16px 32px rgba(4, 22, 39, 0.06);
--sidebar-bg: #f3f6f9; --sidebar-bg: #ffffff;
--nav-text: #303842; --nav-text: #334155;
--nav-active-bg: #e1e6ec; --nav-active-bg: #e8f0ff;
--topbar-bg: rgba(247, 249, 251, 0.94); --topbar-bg: rgba(245, 247, 250, 0.94);
--search-bg: #f1f4f8; --search-bg: #f6f8fb;
--control-bg: #fbfcfe; --control-bg: #ffffff;
--secondary-bg: #e8edf2; --secondary-bg: #eef2f7;
--secondary-hover-bg: #dde5ed; --secondary-hover-bg: #d9e1ec;
--secondary-hover-border: #c6d0db; --secondary-hover-border: #acb8c7;
--table-header-bg: #f5f7fa; --table-header-bg: #f5f8fb;
--table-row-hover-bg: #fafcff; --table-row-hover-bg: #f9fbff;
--modal-backdrop: rgba(4, 22, 39, 0.32); --modal-backdrop: rgba(34, 49, 63, 0.32);
--primary-rgb: 4, 22, 39; --primary-rgb: 34, 49, 63;
--accent-rgb: 15, 98, 198; --accent-rgb: 37, 99, 235;
--app-font-family: --app-font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Microsoft YaHei", sans-serif; "PingFang SC", "Microsoft YaHei", sans-serif;
--radius-sm: 7px; --radius-sm: 5px;
--radius-md: 8px; --radius-md: 7px;
--radius-lg: 12px; --radius-lg: 10px;
--topbar-height: 72px; --topbar-height: 64px;
--nav-height: 48px; --nav-height: 42px;
--control-height: 44px; --control-height: 40px;
--base-font-size: 16px; --base-font-size: 15px;
--page-padding: 32px; --page-padding: 24px;
--content-gap: 24px; --content-gap: 18px;
--panel-padding: 24px; --panel-padding: 20px;
--table-cell-y: 18px; --table-cell-y: 12px;
--table-cell-x: 24px; --table-cell-x: 18px;
--layout-sidebar-width: 280px; --layout-sidebar-width: 280px;
--layout-sidebar-offset: 280px; --layout-sidebar-offset: 280px;
--collapsed-sidebar-width: 88px; --collapsed-sidebar-width: 88px;
@ -561,7 +561,7 @@ a {
} }
.appearance-panel { .appearance-panel {
width: min(560px, calc(100vw - 20px)); width: min(480px, calc(100vw - 20px));
max-height: 100vh; max-height: 100vh;
margin: 0; margin: 0;
overflow-y: auto; overflow-y: auto;
@ -570,7 +570,7 @@ a {
background: var(--card); background: var(--card);
box-shadow: 0 28px 70px rgba(var(--primary-rgb), 0.26); box-shadow: 0 28px 70px rgba(var(--primary-rgb), 0.26);
color: var(--text); color: var(--text);
padding: 16px; padding: 14px;
transform: translateX(0); transform: translateX(0);
transition: transition:
opacity var(--motion-duration-fast) ease, opacity var(--motion-duration-fast) ease,
@ -628,8 +628,19 @@ a {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 10px;
margin-bottom: 14px; margin-bottom: 12px;
}
.appearance-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.appearance-recommend {
min-height: 30px;
padding-inline: 9px;
} }
.appearance-panel-header .icon-button { .appearance-panel-header .icon-button {
@ -649,7 +660,7 @@ a {
} }
.appearance-panel-header h2 { .appearance-panel-header h2 {
font-size: 18px; font-size: 17px;
} }
.appearance-panel-header p { .appearance-panel-header p {
@ -664,7 +675,7 @@ a {
} }
.appearance-section + .appearance-section { .appearance-section + .appearance-section {
margin-top: 16px; margin-top: 14px;
} }
.appearance-section-head { .appearance-section-head {
@ -674,7 +685,7 @@ a {
} }
.appearance-section-head h3 { .appearance-section-head h3 {
font-size: 16px; font-size: 14px;
} }
.appearance-reset { .appearance-reset {
@ -701,7 +712,7 @@ a {
.appearance-grid { .appearance-grid {
display: grid; display: grid;
gap: 10px; gap: 8px;
} }
.mode-grid, .mode-grid,
@ -712,7 +723,7 @@ a {
} }
.color-preset-grid { .color-preset-grid {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.radius-grid { .radius-grid {
@ -734,7 +745,7 @@ a {
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-lg); border-radius: var(--radius-md);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
padding: 6px; padding: 6px;
@ -757,7 +768,7 @@ a {
.appearance-choice strong, .appearance-choice strong,
.color-preset-choice strong { .color-preset-choice strong {
color: var(--primary); color: var(--primary);
font-size: 13px; font-size: 12px;
line-height: 1.25; line-height: 1.25;
} }
@ -800,7 +811,7 @@ a {
} }
.mode-preview { .mode-preview {
height: 78px; height: 66px;
border: 1px solid var(--line); border: 1px solid var(--line);
} }
@ -916,26 +927,102 @@ a {
} }
.color-preset-choice { .color-preset-choice {
min-height: 68px; min-height: 74px;
align-content: start; align-content: start;
background: transparent; border-color: var(--line);
border-color: transparent; background: var(--card);
padding: 0; padding: 6px;
} }
.color-preset-swatch { .color-preset-swatch {
position: relative;
display: block; display: block;
height: 42px; height: 44px;
overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-lg); border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--preset-from), var(--preset-to)); background: var(--preset-surface);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
} }
.color-preset-choice strong { .color-preset-choice strong {
text-align: center; text-align: center;
} }
.preset-sidebar {
position: absolute;
inset: 0 auto 0 0;
display: grid;
width: 34%;
align-content: start;
gap: 4px;
background: var(--preset-sidebar);
padding: 7px 6px;
}
.preset-sidebar b,
.preset-main b {
display: block;
height: 4px;
border-radius: 999px;
}
.preset-sidebar b {
background: var(--preset-primary);
opacity: 0.7;
}
.preset-sidebar b:first-child {
width: 18px;
}
.preset-sidebar b:nth-child(2) {
width: 30px;
background: var(--preset-active);
opacity: 1;
}
.preset-sidebar b:nth-child(3) {
width: 22px;
}
.preset-main {
position: absolute;
inset: 6px 6px 6px 42%;
display: grid;
align-content: start;
gap: 5px;
border-radius: var(--radius-sm);
background: var(--preset-card);
padding: 7px;
}
.preset-main b {
background: var(--preset-primary);
opacity: 0.22;
}
.preset-main b:first-child {
width: 68%;
}
.preset-main b:nth-child(2) {
width: 88%;
}
.preset-main b:nth-child(3) {
width: 52%;
}
.preset-accent {
position: absolute;
right: 10px;
bottom: 10px;
width: 22px;
height: 5px;
border-radius: 999px;
background: var(--preset-accent);
}
.font-choice { .font-choice {
min-height: 54px; min-height: 54px;
place-items: center; place-items: center;
@ -1748,6 +1835,293 @@ a {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.compact-field {
min-width: 170px;
}
.compact-field span {
font-size: 12px;
}
.compact-stats-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
padding: 0 var(--panel-padding) var(--panel-padding);
}
.compact-stat {
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface);
padding: 12px 14px;
}
.compact-stat span,
.workflow-steps span {
color: var(--muted);
font-size: 12px;
}
.compact-stat strong {
display: block;
margin-top: 6px;
color: var(--primary);
font-size: 19px;
line-height: 1.2;
}
.organization-summary-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--card);
box-shadow: 0 8px 20px rgba(var(--primary-rgb), 0.04);
padding: 10px;
}
.organization-summary-strip div {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 50px;
border-radius: var(--radius-md);
background: var(--surface);
padding: 10px 12px;
}
.organization-summary-strip svg {
color: var(--blue);
}
.organization-summary-strip span,
.organization-summary-strip small {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.organization-summary-strip strong {
min-width: 0;
overflow: hidden;
color: var(--primary);
font-size: 18px;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.organization-tree-layout {
display: grid;
grid-template-columns: minmax(340px, 0.9fr) minmax(520px, 1.45fr);
gap: var(--content-gap);
align-items: start;
}
.organization-tree-panel,
.organization-detail-panel {
min-height: 620px;
}
.organization-tree-tools,
.organization-detail-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.link-button.muted {
color: var(--muted);
}
.compact-button {
min-height: 36px;
padding: 0 12px;
font-size: 13px;
}
.organization-tree {
display: grid;
align-content: start;
gap: 4px;
max-height: calc(100vh - 330px);
min-height: 520px;
overflow: auto;
padding: 12px;
}
.organization-tree-empty {
display: flex;
min-height: 240px;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--muted);
font-weight: 800;
}
.tree-node {
display: grid;
grid-template-columns: 22px 32px minmax(0, 1fr) auto;
width: 100%;
min-height: 50px;
align-items: center;
gap: 8px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
padding: 7px 10px 7px calc(10px + (var(--tree-depth) * 20px));
text-align: left;
transition:
background 0.18s ease,
box-shadow 0.18s ease,
color 0.18s ease,
opacity 0.18s ease;
}
.tree-node:hover {
background: var(--surface);
}
.tree-node.is-selected {
background: var(--blue-soft);
box-shadow: inset 3px 0 0 var(--blue);
}
.tree-node.is-inactive {
opacity: 0.66;
}
.tree-toggle {
display: grid;
width: 22px;
height: 22px;
place-items: center;
border-radius: var(--radius-sm);
color: var(--muted);
}
.tree-toggle:not(.is-placeholder):hover {
background: rgba(var(--accent-rgb), 0.1);
color: var(--blue);
}
.tree-toggle.is-placeholder {
visibility: hidden;
}
.tree-node-icon {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border-radius: var(--radius-sm);
background: var(--surface-soft);
color: var(--primary);
}
.tree-node-position .tree-node-icon {
background: var(--green-soft);
color: var(--green);
}
.tree-node-main {
display: grid;
gap: 3px;
min-width: 0;
}
.tree-node-main strong,
.tree-node-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-node-main strong {
color: var(--primary);
font-size: 14px;
line-height: 1.2;
}
.tree-node-main small {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.tree-node .status-chip {
min-height: 22px;
padding: 0 8px;
font-size: 11px;
}
.organization-detail-header {
align-items: flex-start;
}
.organization-node-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
border-bottom: 1px solid var(--line-soft);
background: var(--surface);
padding: 12px var(--panel-padding);
}
.organization-node-summary div {
min-width: 0;
border: 1px solid var(--line-soft);
border-radius: var(--radius-md);
background: var(--card);
padding: 10px 12px;
}
.organization-node-summary span {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.organization-node-summary strong {
display: block;
min-width: 0;
overflow: hidden;
margin-top: 5px;
color: var(--primary);
font-size: 14px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.organization-editor-form {
padding-top: 18px;
}
.workflow-steps {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
padding: 0 var(--panel-padding) var(--panel-padding);
}
.workflow-steps span {
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface);
padding: 9px 10px;
text-align: center;
}
.user-form { .user-form {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@ -2049,14 +2423,14 @@ a {
.login-screen { .login-screen {
display: grid; display: grid;
min-height: 100vh; min-height: 100vh;
grid-template-columns: minmax(420px, 42vw) minmax(0, 1fr); grid-template-columns: minmax(400px, 40vw) minmax(0, 1fr);
background: var(--surface); background: var(--surface);
} }
.login-form-side { .login-form-side {
display: grid; display: grid;
place-items: center; place-items: center;
padding: 48px; padding: 42px 48px;
} }
.login-card { .login-card {
@ -2064,13 +2438,13 @@ a {
} }
.login-brand { .login-brand {
margin-bottom: 28px; margin-bottom: 24px;
} }
.login-brand h1 { .login-brand h1 {
margin: 22px 0 8px; margin: 18px 0 7px;
color: var(--primary); color: var(--primary);
font-size: 28px; font-size: 26px;
line-height: 1.2; line-height: 1.2;
} }
@ -2081,7 +2455,7 @@ a {
.form-stack { .form-stack {
display: grid; display: grid;
gap: 18px; gap: 16px;
} }
.input-with-icon { .input-with-icon {
@ -2118,9 +2492,9 @@ a {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 14px 22px; gap: 14px 22px;
margin-top: 42px; margin-top: 34px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
padding-top: 24px; padding-top: 20px;
color: var(--muted); color: var(--muted);
} }
@ -2139,43 +2513,42 @@ a {
display: grid; display: grid;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
background: border-left: 1px solid var(--line);
linear-gradient(135deg, rgba(var(--primary-rgb), 0.94), rgba(var(--accent-rgb), 0.86)), background: var(--surface-soft);
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0 1px, transparent 1px 74px), color: var(--text);
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.05) 0 1px, transparent 1px 74px); padding: 44px;
color: white;
padding: 48px;
} }
.ledger-visual { .ledger-visual {
width: min(620px, 100%); width: min(560px, 100%);
border: 1px solid rgba(255, 255, 255, 0.16); border: 1px solid var(--line);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.07); background: var(--card);
padding: 48px; padding: 32px;
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24); box-shadow: var(--shadow);
} }
.ledger-visual h2 { .ledger-visual h2 {
margin: 24px 0 18px; margin: 22px 0 14px;
font-size: 44px; color: var(--primary);
line-height: 1.18; font-size: 31px;
line-height: 1.25;
} }
.ledger-visual p { .ledger-visual p {
margin: 0; margin: 0;
color: rgba(255, 255, 255, 0.82); color: var(--muted);
font-size: 17px; font-size: 15px;
line-height: 1.7; line-height: 1.65;
} }
.visual-metrics { .visual-metrics {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--content-gap); gap: 18px;
margin-top: 34px; margin-top: 28px;
border-top: 1px solid rgba(255, 255, 255, 0.16); border-top: 1px solid var(--line);
padding-top: 28px; padding-top: 22px;
} }
.visual-metrics strong, .visual-metrics strong,
@ -2184,12 +2557,13 @@ a {
} }
.visual-metrics strong { .visual-metrics strong {
font-size: 32px; color: var(--primary);
font-size: 25px;
} }
.visual-metrics span { .visual-metrics span {
margin-top: 4px; margin-top: 4px;
color: rgba(255, 255, 255, 0.68); color: var(--muted);
} }
.security-line { .security-line {
@ -2199,7 +2573,7 @@ a {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: rgba(255, 255, 255, 0.58); color: var(--muted);
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
font-size: 13px; font-size: 13px;
} }
@ -2212,7 +2586,7 @@ a {
@media (max-width: 1120px) { @media (max-width: 1120px) {
.appearance-panel { .appearance-panel {
width: min(540px, calc(100vw - 16px)); width: min(480px, calc(100vw - 16px));
padding: 14px; padding: 14px;
} }
@ -2228,11 +2602,17 @@ a {
.stats-grid, .stats-grid,
.dashboard-grid, .dashboard-grid,
.action-grid, .action-grid,
.compact-stats-grid,
.form-grid, .form-grid,
.workflow-steps,
.password-form { .password-form {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.organization-tree-layout {
grid-template-columns: 1fr;
}
.login-screen { .login-screen {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -2344,14 +2724,17 @@ a {
} }
.stats-grid, .stats-grid,
.organization-summary-strip,
.dashboard-grid, .dashboard-grid,
.action-grid, .action-grid,
.compact-stats-grid,
.form-grid, .form-grid,
.user-form, .user-form,
.password-form, .password-form,
.search-form, .search-form,
.table-filter-bar, .table-filter-bar,
.visual-metrics { .visual-metrics,
.workflow-steps {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -2379,6 +2762,24 @@ a {
.page-header h1 { .page-header h1 {
font-size: 27px; font-size: 27px;
} }
.organization-tree {
max-height: none;
min-height: 360px;
}
.organization-tree-panel,
.organization-detail-panel {
min-height: auto;
}
.organization-detail-actions {
justify-content: flex-start;
}
.organization-node-summary {
grid-template-columns: 1fr;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@ -26,13 +26,13 @@ const modeOptions: Array<{ name: ThemeMode; label: string }> = [
]; ];
const fontOptions: Array<{ name: FontChoice; label: string }> = [ const fontOptions: Array<{ name: FontChoice; label: string }> = [
{ name: "auto", label: "Auto" }, { name: "auto", label: "系统" },
{ name: "sans", label: "Sans" }, { name: "sans", label: "现代" },
{ name: "serif", label: "Serif" }, { name: "serif", label: "宋体" },
]; ];
const radiusOptions: Array<{ name: RadiusChoice; label: string; previewRadius: string }> = [ const radiusOptions: Array<{ name: RadiusChoice; label: string; previewRadius: string }> = [
{ name: "auto", label: "Auto", previewRadius: "20px" }, { name: "auto", label: "默认", previewRadius: "20px" },
{ name: "0", label: "0", previewRadius: "0px" }, { name: "0", label: "0", previewRadius: "0px" },
{ name: "0.3", label: "0.3", previewRadius: "8px" }, { name: "0.3", label: "0.3", previewRadius: "8px" },
{ name: "0.5", label: "0.5", previewRadius: "14px" }, { name: "0.5", label: "0.5", previewRadius: "14px" },
@ -56,7 +56,7 @@ const sidebarOptions: Array<{ name: SidebarChoice; label: string }> = [
const layoutOptions: Array<{ name: LayoutChoice; label: string }> = [ const layoutOptions: Array<{ name: LayoutChoice; label: string }> = [
{ name: "default", label: "默认" }, { name: "default", label: "默认" },
{ name: "compact", label: "紧凑" }, { name: "compact", label: "紧凑" },
{ name: "fullscreen", label: "全屏布局" }, { name: "fullscreen", label: "全屏" },
]; ];
const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> = [ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> = [
@ -70,12 +70,18 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
<aside class="appearance-panel" role="dialog" aria-label="外观设置"> <aside class="appearance-panel" role="dialog" aria-label="外观设置">
<header class="appearance-panel-header"> <header class="appearance-panel-header">
<div> <div>
<h2>外观</h2> <h2>界面外观</h2>
<p>主题颜色与布局</p> <p>整套界面的颜色字体和布局</p>
</div> </div>
<div class="appearance-header-actions">
<button class="secondary-button appearance-recommend" type="button" @click="theme.resetAppearance">
<RotateCcw :size="16" aria-hidden="true" />
<span>推荐外观</span>
</button>
<button class="icon-button" type="button" title="关闭" @click="emit('close')"> <button class="icon-button" type="button" title="关闭" @click="emit('close')">
<X :size="21" aria-hidden="true" /> <X :size="21" aria-hidden="true" />
</button> </button>
</div>
</header> </header>
<section class="appearance-section"> <section class="appearance-section">
@ -118,7 +124,7 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
<section class="appearance-section"> <section class="appearance-section">
<div class="appearance-section-head"> <div class="appearance-section-head">
<h3>颜色预设</h3> <h3>整套配色</h3>
<button class="appearance-reset" type="button" title="恢复默认颜色" @click="theme.resetColor"> <button class="appearance-reset" type="button" title="恢复默认颜色" @click="theme.resetColor">
<RotateCcw :size="17" aria-hidden="true" /> <RotateCcw :size="17" aria-hidden="true" />
</button> </button>
@ -129,11 +135,30 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
:key="item.name" :key="item.name"
class="color-preset-choice" class="color-preset-choice"
:class="{ 'is-active': theme.current === item.name }" :class="{ 'is-active': theme.current === item.name }"
:style="{ '--preset-from': item.gradient[0], '--preset-to': item.gradient[1] }" :style="{
'--preset-surface': item.tokens.light.surface,
'--preset-sidebar': item.tokens.light.sidebarBg,
'--preset-active': item.tokens.light.navActiveBg,
'--preset-card': item.tokens.light.card,
'--preset-primary': item.tokens.light.primary,
'--preset-accent': item.tokens.light.accent,
}"
type="button" type="button"
@click="theme.setTheme(item.name)" @click="theme.setTheme(item.name)"
> >
<span class="color-preset-swatch" aria-hidden="true" /> <span class="color-preset-swatch" aria-hidden="true">
<i class="preset-sidebar">
<b />
<b />
<b />
</i>
<i class="preset-main">
<b />
<b />
<b />
</i>
<i class="preset-accent" />
</span>
<strong>{{ item.label }}</strong> <strong>{{ item.label }}</strong>
<span v-if="theme.current === item.name" class="choice-check" aria-hidden="true"> <span v-if="theme.current === item.name" class="choice-check" aria-hidden="true">
<Check :size="18" /> <Check :size="18" />

View File

@ -1,6 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
export type ThemeName = export type ThemeName =
| "enterprise"
| "default" | "default"
| "anthropic" | "anthropic"
| "mono" | "mono"
@ -103,26 +104,62 @@ const DENSITY_KEY = "financial-system-density";
const SIDEBAR_KEY = "financial-system-sidebar"; const SIDEBAR_KEY = "financial-system-sidebar";
const LAYOUT_KEY = "financial-system-layout"; const LAYOUT_KEY = "financial-system-layout";
const CONTENT_WIDTH_KEY = "financial-system-content-width"; const CONTENT_WIDTH_KEY = "financial-system-content-width";
const APPEARANCE_VERSION_KEY = "financial-system-appearance-version";
const CURRENT_APPEARANCE_VERSION = "2026-06-enterprise-clean";
const DEFAULTS = { export const DEFAULT_THEME_SETTINGS = {
theme: "anthropic" as ThemeName, theme: "enterprise" as ThemeName,
mode: "dark" as ThemeMode, mode: "light" as ThemeMode,
font: "serif" as FontChoice, font: "sans" as FontChoice,
radius: "1" as RadiusChoice, radius: "0.3" as RadiusChoice,
density: "compact" as DensityChoice, density: "compact" as DensityChoice,
sidebar: "floating" as SidebarChoice, sidebar: "embedded" as SidebarChoice,
layout: "default" as LayoutChoice, layout: "default" as LayoutChoice,
contentWidth: "full" as ContentWidthChoice, contentWidth: "full" as ContentWidthChoice,
}; };
const legacyThemeMap: Record<string, ThemeName> = { const legacyThemeMap: Record<string, ThemeName> = {
navy: "anthropic", navy: "enterprise",
blue: "ocean", blue: "ocean",
emerald: "forest", emerald: "forest",
burgundy: "rose", burgundy: "rose",
}; };
migrateLegacyAppearanceDefaults();
const themeSeeds: ThemeSeed[] = [ const themeSeeds: ThemeSeed[] = [
{
name: "enterprise",
label: "企业清爽",
primary: "#22313f",
accent: "#2563eb",
soft: "#e8f0ff",
gradient: ["#22313f", "#2563eb"],
light: createLightSeed({
primary: "#22313f",
accent: "#2563eb",
soft: "#e8f0ff",
surface: "#f5f7fa",
surfaceSoft: "#eef2f7",
surfaceStrong: "#d9e1ec",
sidebarBg: "#ffffff",
navActiveBg: "#e8f0ff",
}),
dark: createDarkSeed({
primary: "#edf4ff",
primarySoft: "#b8cff8",
accent: "#7aa7ff",
accentSoft: "rgba(122, 167, 255, 0.18)",
surface: "#101418",
surfaceSoft: "#171d23",
surfaceStrong: "#26313c",
card: "#151a20",
sidebarBg: "#141a20",
navActiveBg: "#233750",
line: "#303b46",
lineSoft: "#242d36",
}),
},
{ {
name: "default", name: "default",
label: "默认", label: "默认",
@ -467,13 +504,13 @@ export const themeOptions: ThemeOption[] = themeSeeds.map((seed) => ({
export const useThemeStore = defineStore("theme", { export const useThemeStore = defineStore("theme", {
state: () => ({ state: () => ({
current: readThemeName(), current: readThemeName(),
mode: readSetting(THEME_MODE_KEY, DEFAULTS.mode, ["system", "light", "dark"] as const), mode: readSetting(THEME_MODE_KEY, DEFAULT_THEME_SETTINGS.mode, ["system", "light", "dark"] as const),
font: readSetting(FONT_KEY, DEFAULTS.font, ["auto", "sans", "serif"] as const), font: readSetting(FONT_KEY, DEFAULT_THEME_SETTINGS.font, ["auto", "sans", "serif"] as const),
radius: readSetting(RADIUS_KEY, DEFAULTS.radius, ["auto", "0", "0.3", "0.5", "0.75", "1"] as const), radius: readSetting(RADIUS_KEY, DEFAULT_THEME_SETTINGS.radius, ["auto", "0", "0.3", "0.5", "0.75", "1"] as const),
density: readSetting(DENSITY_KEY, DEFAULTS.density, ["compact", "default", "comfortable", "large"] as const), density: readSetting(DENSITY_KEY, DEFAULT_THEME_SETTINGS.density, ["compact", "default", "comfortable", "large"] as const),
sidebar: readSetting(SIDEBAR_KEY, DEFAULTS.sidebar, ["embedded", "floating", "edge"] as const), sidebar: readSetting(SIDEBAR_KEY, DEFAULT_THEME_SETTINGS.sidebar, ["embedded", "floating", "edge"] as const),
layout: readSetting(LAYOUT_KEY, DEFAULTS.layout, ["default", "compact", "fullscreen"] as const), layout: readSetting(LAYOUT_KEY, DEFAULT_THEME_SETTINGS.layout, ["default", "compact", "fullscreen"] as const),
contentWidth: readSetting(CONTENT_WIDTH_KEY, DEFAULTS.contentWidth, ["full", "centered"] as const), contentWidth: readSetting(CONTENT_WIDTH_KEY, DEFAULT_THEME_SETTINGS.contentWidth, ["full", "centered"] as const),
}), }),
getters: { getters: {
activeTheme: (state) => themeOptions.find((item) => item.name === state.current) || themeOptions[0], activeTheme: (state) => themeOptions.find((item) => item.name === state.current) || themeOptions[0],
@ -579,36 +616,47 @@ export const useThemeStore = defineStore("theme", {
this.applyTheme(); this.applyTheme();
}, },
resetTheme(): void { resetTheme(): void {
this.current = DEFAULTS.theme; this.current = DEFAULT_THEME_SETTINGS.theme;
this.mode = DEFAULTS.mode; this.mode = DEFAULT_THEME_SETTINGS.mode;
this.applyTheme(); this.applyTheme();
}, },
resetColor(): void { resetColor(): void {
this.current = DEFAULTS.theme; this.current = DEFAULT_THEME_SETTINGS.theme;
this.applyTheme(); this.applyTheme();
}, },
resetFont(): void { resetFont(): void {
this.font = DEFAULTS.font; this.font = DEFAULT_THEME_SETTINGS.font;
this.applyTheme(); this.applyTheme();
}, },
resetRadius(): void { resetRadius(): void {
this.radius = DEFAULTS.radius; this.radius = DEFAULT_THEME_SETTINGS.radius;
this.applyTheme(); this.applyTheme();
}, },
resetDensity(): void { resetDensity(): void {
this.density = DEFAULTS.density; this.density = DEFAULT_THEME_SETTINGS.density;
this.applyTheme(); this.applyTheme();
}, },
resetSidebar(): void { resetSidebar(): void {
this.sidebar = DEFAULTS.sidebar; this.sidebar = DEFAULT_THEME_SETTINGS.sidebar;
this.applyTheme(); this.applyTheme();
}, },
resetLayout(): void { resetLayout(): void {
this.layout = DEFAULTS.layout; this.layout = DEFAULT_THEME_SETTINGS.layout;
this.applyTheme(); this.applyTheme();
}, },
resetContentWidth(): void { resetContentWidth(): void {
this.contentWidth = DEFAULTS.contentWidth; this.contentWidth = DEFAULT_THEME_SETTINGS.contentWidth;
this.applyTheme();
},
resetAppearance(): void {
this.current = DEFAULT_THEME_SETTINGS.theme;
this.mode = DEFAULT_THEME_SETTINGS.mode;
this.font = DEFAULT_THEME_SETTINGS.font;
this.radius = DEFAULT_THEME_SETTINGS.radius;
this.density = DEFAULT_THEME_SETTINGS.density;
this.sidebar = DEFAULT_THEME_SETTINGS.sidebar;
this.layout = DEFAULT_THEME_SETTINGS.layout;
this.contentWidth = DEFAULT_THEME_SETTINGS.contentWidth;
this.applyTheme(); this.applyTheme();
}, },
}, },
@ -760,8 +808,8 @@ function layoutVariables(
function readThemeName(): ThemeName { function readThemeName(): ThemeName {
const stored = readRawSetting(THEME_KEY); const stored = readRawSetting(THEME_KEY);
const migrated = stored ? legacyThemeMap[stored] || stored : DEFAULTS.theme; const migrated = stored ? legacyThemeMap[stored] || stored : DEFAULT_THEME_SETTINGS.theme;
return themeOptions.some((item) => item.name === migrated) ? (migrated as ThemeName) : DEFAULTS.theme; return themeOptions.some((item) => item.name === migrated) ? (migrated as ThemeName) : DEFAULT_THEME_SETTINGS.theme;
} }
function readSetting<T extends readonly StoredSetting[]>(key: string, fallback: T[number], values: T): T[number] { function readSetting<T extends readonly StoredSetting[]>(key: string, fallback: T[number], values: T): T[number] {
@ -787,6 +835,53 @@ function persistSetting(key: string, value: string): void {
} }
} }
function migrateLegacyAppearanceDefaults(): void {
try {
if (typeof localStorage === "undefined") {
return;
}
if (localStorage.getItem(APPEARANCE_VERSION_KEY) === CURRENT_APPEARANCE_VERSION) {
return;
}
const lookedLikeOldDefault = isLegacyDefaultAppearance(
localStorage.getItem(THEME_KEY),
localStorage.getItem(THEME_MODE_KEY),
localStorage.getItem(FONT_KEY),
localStorage.getItem(RADIUS_KEY),
localStorage.getItem(SIDEBAR_KEY),
);
if (lookedLikeOldDefault) {
localStorage.setItem(THEME_KEY, DEFAULT_THEME_SETTINGS.theme);
localStorage.setItem(THEME_MODE_KEY, DEFAULT_THEME_SETTINGS.mode);
localStorage.setItem(FONT_KEY, DEFAULT_THEME_SETTINGS.font);
localStorage.setItem(RADIUS_KEY, DEFAULT_THEME_SETTINGS.radius);
localStorage.setItem(DENSITY_KEY, DEFAULT_THEME_SETTINGS.density);
localStorage.setItem(SIDEBAR_KEY, DEFAULT_THEME_SETTINGS.sidebar);
localStorage.setItem(LAYOUT_KEY, DEFAULT_THEME_SETTINGS.layout);
localStorage.setItem(CONTENT_WIDTH_KEY, DEFAULT_THEME_SETTINGS.contentWidth);
}
localStorage.setItem(APPEARANCE_VERSION_KEY, CURRENT_APPEARANCE_VERSION);
} catch {
// Appearance migration is best-effort only.
}
}
export function isLegacyDefaultAppearance(
theme: string | null,
mode: string | null,
font: string | null,
radius: string | null,
sidebar: string | null,
): boolean {
return (!theme || theme === "anthropic" || theme === "navy")
&& (!mode || mode === "dark")
&& (!font || font === "serif")
&& (!radius || radius === "1")
&& (!sidebar || sidebar === "floating");
}
function resolveThemeMode(mode: ThemeMode): "light" | "dark" { function resolveThemeMode(mode: ThemeMode): "light" | "dark" {
if (mode !== "system") { if (mode !== "system") {
return mode; return mode;

View File

@ -1,5 +1,144 @@
<script setup lang="ts"> <script setup lang="ts">
import { Calculator, FileSpreadsheet, LockKeyhole, Upload } from "@lucide/vue"; import { Calculator, Download, FileSpreadsheet, Loader2, LockKeyhole, RefreshCw, Upload } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http";
import {
calculateMonthlyFromDingTalk,
calculateMonthlyFromExcel,
exportMonthlyRun,
getMonthlyRun,
listMonthlyRuns,
lockMonthlyRun,
} from "../api/monthlyPayroll";
import type { MonthlyPayrollDetailResponse, MonthlyPayrollRun } from "../api/types";
import ResultsTable from "../components/ResultsTable.vue";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const selectedMonth = ref(new Date().toISOString().slice(0, 7));
const runs = ref<MonthlyPayrollRun[]>([]);
const detail = ref<MonthlyPayrollDetailResponse | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const loading = ref(false);
const actionLoading = ref<"" | "excel" | "dingtalk" | "lock" | "export">("");
const errorMessage = ref("");
const successMessage = ref("");
const activeRun = computed(() => detail.value?.run || runs.value[0] || null);
const isLocked = computed(() => activeRun.value?.status === "locked");
const canCalculate = computed(() => auth.hasPermission("monthly_payroll:calculate"));
const canLock = computed(() => auth.hasPermission("monthly_payroll:lock"));
const canExport = computed(() => auth.hasPermission("monthly_payroll:export"));
const grossTotal = computed(() => money(activeRun.value?.gross_total || 0));
const netTotal = computed(() => money(activeRun.value?.net_total || 0));
const deductionTotal = computed(() => money(activeRun.value?.deduction_total || 0));
onMounted(() => {
void loadRuns();
});
async function loadRuns(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
runs.value = await listMonthlyRuns(selectedMonth.value);
if (runs.value.length) {
detail.value = await getMonthlyRun(runs.value[0].id);
} else {
detail.value = null;
}
} catch (error) {
runs.value = [];
detail.value = null;
errorMessage.value = error instanceof ApiError ? error.message : "月度核算数据加载失败";
} finally {
loading.value = false;
}
}
function chooseExcel(): void {
fileInput.value?.click();
}
async function handleExcelChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) {
return;
}
await runAction("excel", async () => {
detail.value = await calculateMonthlyFromExcel(file, selectedMonth.value);
successMessage.value = "Excel 已导入并完成月度核算";
await loadRuns();
});
}
async function calculateFromDingTalk(): Promise<void> {
await runAction("dingtalk", async () => {
detail.value = await calculateMonthlyFromDingTalk(selectedMonth.value);
successMessage.value = "钉钉考勤已同步并完成月度核算";
await loadRuns();
});
}
async function lockRun(): Promise<void> {
if (!activeRun.value || !window.confirm("锁定后本月工资不能重新计算,只能导出。确认锁定?")) {
return;
}
await runAction("lock", async () => {
await lockMonthlyRun(activeRun.value!.id);
detail.value = await getMonthlyRun(activeRun.value!.id);
successMessage.value = "本月工资已锁定";
await loadRuns();
});
}
async function exportRun(): Promise<void> {
if (!activeRun.value) {
return;
}
await runAction("export", async () => {
await exportMonthlyRun(activeRun.value!.id);
successMessage.value = "月度工资表已开始下载";
});
}
async function runAction(
action: "excel" | "dingtalk" | "lock" | "export",
executor: () => Promise<void>,
): Promise<void> {
actionLoading.value = action;
errorMessage.value = "";
successMessage.value = "";
try {
await executor();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "操作失败,请检查后重试";
} finally {
actionLoading.value = "";
}
}
function statusText(status: string | undefined): string {
const map: Record<string, string> = {
draft: "草稿",
calculated: "已计算",
reviewed: "已复核",
locked: "已锁定",
failed: "失败",
};
return status ? map[status] || status : "未开始";
}
function money(value: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(value);
}
</script> </script>
<template> <template>
@ -7,41 +146,131 @@ import { Calculator, FileSpreadsheet, LockKeyhole, Upload } from "@lucide/vue";
<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> <label class="field compact-field">
<Upload :size="18" aria-hidden="true" /> <span>工资月份</span>
<span>导入 Excel</span> <input v-model="selectedMonth" type="month" @change="loadRuns" />
</button> </label>
<button class="primary-button" type="button" disabled> <button class="secondary-button" type="button" :disabled="loading" @click="loadRuns">
<Calculator :size="18" aria-hidden="true" /> <Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<span>同步并计算</span> <RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button> </button>
</div> </div>
</header> </header>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>核算流程</h2> <h2>核算流程</h2>
<p>数据导入 -> 考勤转换 -> 异常处理 -> 工资计算 -> 复核锁定</p> <p>数据导入 -> 考勤转换 -> 异常处理 -> 工资计算 -> 复核锁定</p>
</div> </div>
<span class="status-chip neutral">草稿</span> <span class="status-chip" :class="isLocked ? 'green' : 'neutral'">{{ statusText(activeRun?.status) }}</span>
</div> </div>
<div class="empty-state">
<FileSpreadsheet :size="38" aria-hidden="true" /> <div class="workflow-steps">
<p>月度核算中心已接入菜单下一阶段接入月度计算异常清单和锁定逻辑</p> <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>
<strong>{{ netTotal }}</strong>
</div>
<div class="compact-stat">
<span>扣款合计</span>
<strong>{{ deductionTotal }}</strong>
</div>
</div>
<div class="form-actions">
<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" />
<Upload v-else :size="18" aria-hidden="true" />
<span>导入 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 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" />
<LockKeyhole v-else :size="18" aria-hidden="true" />
<span>锁定工资</span>
</button>
<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" />
<Download v-else :size="18" aria-hidden="true" />
<span>导出</span>
</button>
<input ref="fileInput" accept=".xlsx,.xls" hidden type="file" @change="handleExcelChange" />
</div>
</section>
<section v-if="detail?.exceptions.length" class="panel">
<div class="panel-header">
<div>
<h2>异常清单</h2>
<p>优先处理缺薪资档案缺卡迟到扣款和未匹配员工</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>员工</th>
<th>类型</th>
<th>级别</th>
<th>说明</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="item in detail.exceptions" :key="item.id">
<td>
<strong>{{ item.employee_name || "-" }}</strong>
<span>{{ item.employee_no || "-" }}</span>
</td>
<td>{{ item.exception_type }}</td>
<td>{{ item.severity }}</td>
<td>{{ item.message }}</td>
<td>{{ item.status }}</td>
</tr>
</tbody>
</table>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>锁定规则</h2> <h2>工资明细</h2>
<p>锁定后本月工资不可重新计算仅允许导出</p> <p>锁定后本月工资不能重新计算只能导出</p>
</div> </div>
<LockKeyhole :size="22" aria-hidden="true" /> <FileSpreadsheet :size="22" aria-hidden="true" />
</div>
<ResultsTable v-if="detail?.results.length" :results="detail.results" />
<div v-else class="empty-state">
<FileSpreadsheet :size="38" aria-hidden="true" />
<p>当前月份还没有核算结果请导入 Excel 或同步钉钉并计算</p>
</div> </div>
</section> </section>
</div> </div>

View File

@ -1,5 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { Building2, GitBranch, Loader2, Pencil, Plus, RefreshCw, Save } from "@lucide/vue"; import {
BriefcaseBusiness,
Building2,
ChevronDown,
ChevronRight,
FolderTree,
GitBranch,
Loader2,
Plus,
RefreshCw,
Save,
} from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { import {
@ -12,7 +23,26 @@ import {
} from "../api/organization"; } from "../api/organization";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import type { DepartmentRecord, DepartmentRequest, PositionRecord, PositionRequest } from "../api/types"; import type { DepartmentRecord, DepartmentRequest, PositionRecord, PositionRequest } from "../api/types";
import StatCard from "../components/StatCard.vue";
type OrganizationNodeType = "department" | "position";
type DetailEditor = "department" | "position";
interface OrganizationTreeNode {
key: string;
type: OrganizationNodeType;
id: number;
name: string;
code: string;
is_active: boolean;
parentId: number | null;
departmentId: number | null;
children: OrganizationTreeNode[];
}
interface OrganizationTreeRow {
node: OrganizationTreeNode;
depth: number;
}
const departments = ref<DepartmentRecord[]>([]); const departments = ref<DepartmentRecord[]>([]);
const positions = ref<PositionRecord[]>([]); const positions = ref<PositionRecord[]>([]);
@ -22,6 +52,10 @@ const savingPosition = ref(false);
const editingDepartmentId = ref<number | null>(null); const editingDepartmentId = ref<number | null>(null);
const editingPositionId = ref<number | null>(null); const editingPositionId = ref<number | null>(null);
const editingPositionCode = ref(""); const editingPositionCode = ref("");
const currentEditor = ref<DetailEditor>("department");
const selectedNodeKey = ref("");
const expandedDepartmentIds = ref<Set<number>>(new Set());
const hasInitializedExpansion = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
@ -42,9 +76,122 @@ const positionForm = reactive<PositionRequest>({
remark: "", remark: "",
}); });
const activeDepartments = computed(() => departments.value.filter((item) => item.is_active)); const sortedDepartments = computed(() => [...departments.value].sort(sortByOrderThenId));
const activePositions = computed(() => positions.value.filter((item) => item.is_active)); const sortedPositions = computed(() => [...positions.value].sort(sortByOrderThenId));
const positionRows = computed(() => positions.value); const activeDepartments = computed(() => sortedDepartments.value.filter((item) => item.is_active));
const activePositions = computed(() => sortedPositions.value.filter((item) => item.is_active));
const organizationTree = computed<OrganizationTreeNode[]>(() => {
const departmentNodeMap = new Map<number, OrganizationTreeNode>();
const roots: OrganizationTreeNode[] = [];
for (const department of sortedDepartments.value) {
departmentNodeMap.set(department.id, {
key: departmentKey(department.id),
type: "department",
id: department.id,
name: department.name,
code: department.department_code,
is_active: department.is_active,
parentId: department.parent_id,
departmentId: department.id,
children: [],
});
}
for (const department of sortedDepartments.value) {
const node = departmentNodeMap.get(department.id);
if (!node) {
continue;
}
const parentNode = department.parent_id ? departmentNodeMap.get(department.parent_id) : null;
if (parentNode) {
parentNode.children.push(node);
} else {
roots.push(node);
}
}
for (const position of sortedPositions.value) {
const parentNode = departmentNodeMap.get(position.department_id);
if (!parentNode) {
continue;
}
parentNode.children.push({
key: positionKey(position.id),
type: "position",
id: position.id,
name: position.name,
code: position.position_code,
is_active: position.is_active,
parentId: position.department_id,
departmentId: position.department_id,
children: [],
});
}
return roots;
});
const allTreeNodes = computed(() => flattenAllNodes(organizationTree.value));
const selectedNode = computed(() => allTreeNodes.value.find((node) => node.key === selectedNodeKey.value) || null);
const visibleTreeRows = computed<OrganizationTreeRow[]>(() => flattenVisibleNodes(organizationTree.value));
const availableParentDepartments = computed(() => {
const excludedIds = editingDepartmentId.value ? descendantDepartmentIds(editingDepartmentId.value) : new Set<number>();
if (editingDepartmentId.value) {
excludedIds.add(editingDepartmentId.value);
}
return sortedDepartments.value.filter((department) => !excludedIds.has(department.id));
});
const actionDepartmentId = computed(() => {
if (selectedNode.value?.type === "department") {
return selectedNode.value.id;
}
if (selectedNode.value?.type === "position") {
return selectedNode.value.departmentId || 0;
}
if (editingDepartmentId.value) {
return editingDepartmentId.value;
}
return positionForm.department_id || departmentForm.parent_id || 0;
});
const selectedDepartment = computed(() => {
if (!actionDepartmentId.value) {
return null;
}
return departments.value.find((department) => department.id === actionDepartmentId.value) || null;
});
const selectedDepartmentChildren = computed(() => {
if (!actionDepartmentId.value) {
return [];
}
return sortedDepartments.value.filter((department) => department.parent_id === actionDepartmentId.value);
});
const selectedDepartmentPositions = computed(() => {
if (!actionDepartmentId.value) {
return [];
}
return sortedPositions.value.filter((position) => position.department_id === actionDepartmentId.value);
});
const detailTitle = computed(() => {
if (currentEditor.value === "department") {
return editingDepartmentId.value ? "部门详情" : "新增部门";
}
return editingPositionId.value ? "岗位详情" : "新增岗位";
});
const detailSubtitle = computed(() => {
if (currentEditor.value === "department") {
return editingDepartmentId.value ? "调整部门编码、父级和启用状态" : "新增部门时可直接指定上级部门";
}
return editingPositionId.value ? "调整岗位名称、所属部门和启用状态" : "新增岗位会挂到当前选中的部门下";
});
onMounted(loadData); onMounted(loadData);
@ -58,6 +205,8 @@ async function loadData(): Promise<void> {
]); ]);
departments.value = departmentRows; departments.value = departmentRows;
positions.value = positionRowsData; positions.value = positionRowsData;
reconcileExpandedDepartments(departmentRows);
syncSelectionAfterLoad();
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "组织架构加载失败"; errorMessage.value = error instanceof ApiError ? error.message : "组织架构加载失败";
} finally { } finally {
@ -70,14 +219,11 @@ async function submitDepartment(): Promise<void> {
errorMessage.value = ""; errorMessage.value = "";
successMessage.value = ""; successMessage.value = "";
try { try {
if (editingDepartmentId.value) { const department = editingDepartmentId.value
await updateDepartment(editingDepartmentId.value, normalizedDepartment()); ? await updateDepartment(editingDepartmentId.value, normalizedDepartment())
successMessage.value = "部门已更新"; : await createDepartment(normalizedDepartment());
} else { selectedNodeKey.value = departmentKey(department.id);
await createDepartment(normalizedDepartment()); successMessage.value = editingDepartmentId.value ? "部门已更新" : "部门已新增";
successMessage.value = "部门已新增";
}
resetDepartmentForm();
await loadData(); await loadData();
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "部门保存失败"; errorMessage.value = error instanceof ApiError ? error.message : "部门保存失败";
@ -95,14 +241,11 @@ async function submitPosition(): Promise<void> {
errorMessage.value = ""; errorMessage.value = "";
successMessage.value = ""; successMessage.value = "";
try { try {
if (editingPositionId.value) { const position = editingPositionId.value
await updatePosition(editingPositionId.value, normalizedPosition()); ? await updatePosition(editingPositionId.value, normalizedPosition())
successMessage.value = "岗位已更新"; : await createPosition(normalizedPosition());
} else { selectedNodeKey.value = positionKey(position.id);
await createPosition(normalizedPosition()); successMessage.value = editingPositionId.value ? "岗位已更新" : "岗位已新增";
successMessage.value = "岗位已新增";
}
resetPositionForm();
await loadData(); await loadData();
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "岗位保存失败"; errorMessage.value = error instanceof ApiError ? error.message : "岗位保存失败";
@ -111,8 +254,27 @@ async function submitPosition(): Promise<void> {
} }
} }
function editDepartment(department: DepartmentRecord): void { function selectTreeNode(node: OrganizationTreeNode): void {
if (node.type === "department") {
const department = departments.value.find((item) => item.id === node.id);
if (department) {
selectDepartment(department);
}
return;
}
const position = positions.value.find((item) => item.id === node.id);
if (position) {
selectPosition(position);
}
}
function selectDepartment(department: DepartmentRecord): void {
currentEditor.value = "department";
selectedNodeKey.value = departmentKey(department.id);
editingDepartmentId.value = department.id; editingDepartmentId.value = department.id;
editingPositionId.value = null;
editingPositionCode.value = "";
expandDepartmentPath(department.id);
Object.assign(departmentForm, { Object.assign(departmentForm, {
parent_id: department.parent_id, parent_id: department.parent_id,
department_code: department.department_code, department_code: department.department_code,
@ -123,9 +285,13 @@ function editDepartment(department: DepartmentRecord): void {
}); });
} }
function editPosition(position: PositionRecord): void { function selectPosition(position: PositionRecord): void {
currentEditor.value = "position";
selectedNodeKey.value = positionKey(position.id);
editingPositionId.value = position.id; editingPositionId.value = position.id;
editingPositionCode.value = position.position_code; editingPositionCode.value = position.position_code;
editingDepartmentId.value = null;
expandDepartmentPath(position.department_id);
Object.assign(positionForm, { Object.assign(positionForm, {
department_id: position.department_id, department_id: position.department_id,
name: position.name, name: position.name,
@ -135,6 +301,34 @@ function editPosition(position: PositionRecord): void {
}); });
} }
function startNewRootDepartment(): void {
currentEditor.value = "department";
selectedNodeKey.value = "new-department";
resetDepartmentForm();
}
function startNewChildDepartment(): void {
const parentId = actionDepartmentId.value;
currentEditor.value = "department";
selectedNodeKey.value = "new-child-department";
resetDepartmentForm();
departmentForm.parent_id = parentId || null;
if (parentId) {
expandDepartmentPath(parentId);
}
}
function startNewPosition(): void {
const departmentId = actionDepartmentId.value || activeDepartments.value[0]?.id || 0;
currentEditor.value = "position";
selectedNodeKey.value = "new-position";
resetPositionForm();
positionForm.department_id = departmentId;
if (departmentId) {
expandDepartmentPath(departmentId);
}
}
function resetDepartmentForm(): void { function resetDepartmentForm(): void {
editingDepartmentId.value = null; editingDepartmentId.value = null;
Object.assign(departmentForm, { Object.assign(departmentForm, {
@ -159,6 +353,27 @@ function resetPositionForm(): void {
}); });
} }
function toggleDepartment(node: OrganizationTreeNode): void {
if (node.type !== "department") {
return;
}
const nextExpanded = new Set(expandedDepartmentIds.value);
if (nextExpanded.has(node.id)) {
nextExpanded.delete(node.id);
} else {
nextExpanded.add(node.id);
}
expandedDepartmentIds.value = nextExpanded;
}
function expandAllDepartments(): void {
expandedDepartmentIds.value = new Set(departments.value.map((department) => department.id));
}
function collapseAllDepartments(): void {
expandedDepartmentIds.value = new Set();
}
function normalizedDepartment(): DepartmentRequest { function normalizedDepartment(): DepartmentRequest {
return { return {
parent_id: departmentForm.parent_id || null, parent_id: departmentForm.parent_id || null,
@ -173,6 +388,7 @@ function normalizedDepartment(): DepartmentRequest {
function normalizedPosition(): PositionRequest { function normalizedPosition(): PositionRequest {
return { return {
department_id: Number(positionForm.department_id), department_id: Number(positionForm.department_id),
position_code: editingPositionCode.value,
name: positionForm.name.trim(), name: positionForm.name.trim(),
sort_order: Number(positionForm.sort_order || 0), sort_order: Number(positionForm.sort_order || 0),
is_active: positionForm.is_active, is_active: positionForm.is_active,
@ -180,11 +396,100 @@ function normalizedPosition(): PositionRequest {
}; };
} }
function departmentName(id: number | null): string { function syncSelectionAfterLoad(): void {
if (!id) { const node = allTreeNodes.value.find((item) => item.key === selectedNodeKey.value);
return "-"; if (node) {
selectTreeNode(node);
return;
} }
return departments.value.find((item) => item.id === id)?.name || "-"; if (sortedDepartments.value.length > 0) {
selectDepartment(sortedDepartments.value[0]);
}
}
function reconcileExpandedDepartments(nextDepartments: DepartmentRecord[]): void {
const availableIds = new Set(nextDepartments.map((department) => department.id));
if (!hasInitializedExpansion.value) {
expandedDepartmentIds.value = new Set(availableIds);
hasInitializedExpansion.value = true;
return;
}
expandedDepartmentIds.value = new Set(
[...expandedDepartmentIds.value].filter((departmentId) => availableIds.has(departmentId)),
);
}
function flattenAllNodes(nodes: OrganizationTreeNode[]): OrganizationTreeNode[] {
return nodes.flatMap((node) => [node, ...flattenAllNodes(node.children)]);
}
function flattenVisibleNodes(nodes: OrganizationTreeNode[], depth = 0): OrganizationTreeRow[] {
const rows: OrganizationTreeRow[] = [];
for (const node of nodes) {
rows.push({ node, depth });
if (node.type === "department" && expandedDepartmentIds.value.has(node.id)) {
rows.push(...flattenVisibleNodes(node.children, depth + 1));
}
}
return rows;
}
function expandDepartmentPath(departmentId: number): void {
const nextExpanded = new Set(expandedDepartmentIds.value);
let current = departments.value.find((department) => department.id === departmentId) || null;
while (current) {
nextExpanded.add(current.id);
current = current.parent_id
? departments.value.find((department) => department.id === current?.parent_id) || null
: null;
}
expandedDepartmentIds.value = nextExpanded;
}
function descendantDepartmentIds(departmentId: number): Set<number> {
const result = new Set<number>();
const collect = (parentId: number): void => {
for (const department of departments.value) {
if (department.parent_id === parentId) {
result.add(department.id);
collect(department.id);
}
}
};
collect(departmentId);
return result;
}
function departmentOptionLabel(department: DepartmentRecord): string {
return departmentPath(department.id).join(" / ");
}
function departmentPath(departmentId: number): string[] {
const path: string[] = [];
let current = departments.value.find((department) => department.id === departmentId) || null;
while (current) {
path.unshift(current.name);
current = current.parent_id
? departments.value.find((department) => department.id === current?.parent_id) || null
: null;
}
return path;
}
function treeNodeStyle(depth: number): Record<string, string> {
return { "--tree-depth": String(depth) };
}
function departmentKey(id: number): string {
return `department-${id}`;
}
function positionKey(id: number): string {
return `position-${id}`;
}
function sortByOrderThenId<T extends { sort_order: number; id: number }>(left: T, right: T): number {
return left.sort_order - right.sort_order || left.id - right.id;
} }
</script> </script>
@ -193,7 +498,7 @@ function departmentName(id: number | null): string {
<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="loadData"> <button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
@ -202,23 +507,145 @@ function departmentName(id: number | null): string {
</button> </button>
</header> </header>
<section class="stats-grid"> <section class="organization-summary-strip">
<StatCard title="部门总数" :value="String(departments.length)" :icon="Building2" tone="blue" meta="含停用" /> <div>
<StatCard title="启用部门" :value="String(activeDepartments.length)" :icon="Building2" tone="green" meta="可选" /> <Building2 :size="18" aria-hidden="true" />
<StatCard title="岗位总数" :value="String(positions.length)" :icon="GitBranch" tone="neutral" meta="含停用" /> <span>部门</span>
<StatCard title="启用岗位" :value="String(activePositions.length)" :icon="GitBranch" tone="green" meta="员工可选" /> <strong>{{ departments.length }}</strong>
<small>{{ activeDepartments.length }} 个启用</small>
</div>
<div>
<GitBranch :size="18" aria-hidden="true" />
<span>岗位</span>
<strong>{{ positions.length }}</strong>
<small>{{ activePositions.length }} 个启用</small>
</div>
<div>
<FolderTree :size="18" aria-hidden="true" />
<span>当前部门</span>
<strong>{{ selectedDepartment?.name || "-" }}</strong>
<small>{{ selectedDepartmentPositions.length }} 个直属岗位</small>
</div>
</section> </section>
<section class="dashboard-grid"> <p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<div class="panel"> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="organization-tree-layout">
<aside class="panel organization-tree-panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>{{ editingDepartmentId ? "编辑部门" : "新增部门" }}</h2> <h2>组织树</h2>
<p>部门可选择父级形成上下级关系</p> <p>{{ departments.length }} 个部门{{ positions.length }} 个岗位</p>
</div> </div>
<button class="secondary-button" type="button" @click="resetDepartmentForm">清空</button> <div class="organization-tree-tools">
<button class="link-button" type="button" @click="expandAllDepartments">展开</button>
<button class="link-button muted" type="button" @click="collapseAllDepartments">收起</button>
</div> </div>
<form class="form-grid compact-form" @submit.prevent="submitDepartment"> </div>
<div class="organization-tree" aria-label="组织架构树">
<div v-if="loading" class="organization-tree-empty">
<Loader2 class="spin" :size="18" aria-hidden="true" />
<span>正在加载组织架构</span>
</div>
<div v-else-if="visibleTreeRows.length === 0" class="organization-tree-empty">
<span>暂无组织架构请先新增部门</span>
</div>
<button
v-for="row in visibleTreeRows"
v-else
:key="row.node.key"
class="tree-node"
:class="[
`tree-node-${row.node.type}`,
{
'is-selected': selectedNodeKey === row.node.key,
'is-inactive': !row.node.is_active,
},
]"
:style="treeNodeStyle(row.depth)"
type="button"
@click="selectTreeNode(row.node)"
>
<span
v-if="row.node.type === 'department' && row.node.children.length"
class="tree-toggle"
role="button"
tabindex="-1"
@click.stop="toggleDepartment(row.node)"
>
<ChevronDown v-if="expandedDepartmentIds.has(row.node.id)" :size="15" aria-hidden="true" />
<ChevronRight v-else :size="15" aria-hidden="true" />
</span>
<span v-else class="tree-toggle is-placeholder" aria-hidden="true"></span>
<span class="tree-node-icon">
<Building2 v-if="row.node.type === 'department'" :size="16" aria-hidden="true" />
<BriefcaseBusiness v-else :size="16" aria-hidden="true" />
</span>
<span class="tree-node-main">
<strong>{{ row.node.name }}</strong>
<small>{{ row.node.code }}</small>
</span>
<span class="status-chip" :class="row.node.is_active ? 'green' : 'red'">
{{ row.node.is_active ? "启用" : "停用" }}
</span>
</button>
</div>
</aside>
<section class="panel organization-detail-panel">
<div class="panel-header organization-detail-header">
<div>
<h2>{{ detailTitle }}</h2>
<p>{{ detailSubtitle }}</p>
</div>
<div class="organization-detail-actions">
<button class="secondary-button compact-button" type="button" @click="startNewRootDepartment">
<Plus :size="16" aria-hidden="true" />
<span>新增部门</span>
</button>
<button
class="secondary-button compact-button"
type="button"
:disabled="!actionDepartmentId"
@click="startNewChildDepartment"
>
<Plus :size="16" aria-hidden="true" />
<span>新增下级</span>
</button>
<button
class="primary-button compact-button"
type="button"
:disabled="!actionDepartmentId && activeDepartments.length === 0"
@click="startNewPosition"
>
<Plus :size="16" aria-hidden="true" />
<span>新增岗位</span>
</button>
</div>
</div>
<div v-if="selectedDepartment" class="organization-node-summary">
<div>
<span>上级部门</span>
<strong>{{ selectedDepartment.parent_id ? departmentPath(selectedDepartment.parent_id).join(" / ") : "无上级" }}</strong>
</div>
<div>
<span>下级部门</span>
<strong>{{ selectedDepartmentChildren.length }} </strong>
</div>
<div>
<span>直属岗位</span>
<strong>{{ selectedDepartmentPositions.length }} </strong>
</div>
</div>
<form
v-if="currentEditor === 'department'"
class="form-grid compact-form organization-editor-form"
@submit.prevent="submitDepartment"
>
<label class="field"> <label class="field">
<span>部门编码</span> <span>部门编码</span>
<input v-model="departmentForm.department_code" required maxlength="64" type="text" /> <input v-model="departmentForm.department_code" required maxlength="64" type="text" />
@ -230,13 +657,9 @@ function departmentName(id: number | null): string {
<label class="field"> <label class="field">
<span>父级部门</span> <span>父级部门</span>
<select v-model.number="departmentForm.parent_id"> <select v-model.number="departmentForm.parent_id">
<option :value="null">无父级</option> <option :value="null">无上级部门</option>
<option <option v-for="department in availableParentDepartments" :key="department.id" :value="department.id">
v-for="department in departments.filter((item) => item.id !== editingDepartmentId)" {{ departmentOptionLabel(department) }}
:key="department.id"
:value="department.id"
>
{{ department.name }}
</option> </option>
</select> </select>
</label> </label>
@ -255,29 +678,19 @@ function departmentName(id: number | null): string {
<div class="form-actions align-right span-row"> <div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="savingDepartment"> <button class="primary-button" type="submit" :disabled="savingDepartment">
<Loader2 v-if="savingDepartment" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="savingDepartment" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingDepartmentId" :size="18" aria-hidden="true" /> <Save v-else :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" /> <span>{{ savingDepartment ? "保存中" : editingDepartmentId ? "保存部门" : "创建部门" }}</span>
<span>{{ savingDepartment ? "保存中" : editingDepartmentId ? "保存部门" : "新增部门" }}</span>
</button> </button>
</div> </div>
</form> </form>
</div>
<div class="panel"> <form v-else class="form-grid compact-form organization-editor-form" @submit.prevent="submitPosition">
<div class="panel-header">
<div>
<h2>{{ editingPositionId ? "编辑岗位" : "新增岗位" }}</h2>
<p>岗位必须归属到一个部门</p>
</div>
<button class="secondary-button" type="button" @click="resetPositionForm">清空</button>
</div>
<form class="form-grid compact-form" @submit.prevent="submitPosition">
<label class="field"> <label class="field">
<span>所属部门</span> <span>所属部门</span>
<select v-model.number="positionForm.department_id" required> <select v-model.number="positionForm.department_id" required>
<option :value="0">请选择部门</option> <option :value="0">请选择部门</option>
<option v-for="department in activeDepartments" :key="department.id" :value="department.id"> <option v-for="department in activeDepartments" :key="department.id" :value="department.id">
{{ department.name }} {{ departmentOptionLabel(department) }}
</option> </option>
</select> </select>
</label> </label>
@ -304,96 +717,12 @@ function departmentName(id: number | null): string {
<div class="form-actions align-right span-row"> <div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="savingPosition"> <button class="primary-button" type="submit" :disabled="savingPosition">
<Loader2 v-if="savingPosition" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="savingPosition" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingPositionId" :size="18" aria-hidden="true" /> <Save v-else :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" /> <span>{{ savingPosition ? "保存中" : editingPositionId ? "保存岗位" : "创建岗位" }}</span>
<span>{{ savingPosition ? "保存中" : editingPositionId ? "保存岗位" : "新增岗位" }}</span>
</button> </button>
</div> </div>
</form> </form>
</div>
</section> </section>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="dashboard-grid">
<div class="panel table-panel">
<div class="panel-header">
<div>
<h2>部门列表</h2>
<p>{{ departments.length }} 个部门</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>部门</th>
<th>父级</th>
<th>排序</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="department in departments" :key="department.id">
<td>
<strong>{{ department.name }}</strong>
<span>{{ department.department_code }}</span>
</td>
<td>{{ departmentName(department.parent_id) }}</td>
<td>{{ department.sort_order }}</td>
<td><span class="status-chip" :class="department.is_active ? 'green' : 'red'">{{ department.is_active ? "启用" : "停用" }}</span></td>
<td>
<button class="link-button" type="button" @click="editDepartment(department)">
<Pencil :size="15" aria-hidden="true" />
<span>编辑</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel table-panel">
<div class="panel-header">
<div>
<h2>岗位列表</h2>
<p>{{ positions.length }} 个岗位</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>岗位</th>
<th>部门</th>
<th>排序</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="position in positionRows" :key="position.id">
<td>
<strong>{{ position.name }}</strong>
<span>{{ position.position_code }}</span>
</td>
<td>{{ position.department.name }}</td>
<td>{{ position.sort_order }}</td>
<td><span class="status-chip" :class="position.is_active ? 'green' : 'red'">{{ position.is_active ? "启用" : "停用" }}</span></td>
<td>
<button class="link-button" type="button" @click="editPosition(position)">
<Pencil :size="15" aria-hidden="true" />
<span>编辑</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section> </section>
</div> </div>
</template> </template>

View File

@ -1,5 +1,86 @@
<script setup lang="ts"> <script setup lang="ts">
import { CalendarClock, CloudCog, RefreshCw } from "@lucide/vue"; import { CalendarClock, CloudCog, Loader2, RefreshCw } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http";
import { getTodayAttendance, syncRealtimeAttendance } from "../api/realtimeAttendance";
import type { RealtimeAttendanceResponse, TodayAttendanceItem } from "../api/types";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const selectedDate = ref(new Date().toISOString().slice(0, 10));
const response = ref<RealtimeAttendanceResponse | null>(null);
const loading = ref(false);
const syncing = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const summary = computed(() => response.value?.summary);
const rows = computed(() => response.value?.items || []);
const canSync = computed(() => auth.hasPermission("attendance:realtime:sync"));
onMounted(() => {
void loadTodayAttendance();
});
async function loadTodayAttendance(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
response.value = await getTodayAttendance(selectedDate.value);
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "实时考勤加载失败";
} finally {
loading.value = false;
}
}
async function syncDingTalk(): Promise<void> {
syncing.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
response.value = await syncRealtimeAttendance(selectedDate.value);
successMessage.value = "钉钉考勤已同步,薪资预估已刷新";
} catch (error) {
const message = error instanceof ApiError ? error.message : "钉钉同步失败";
errorMessage.value = message.includes("dingtalk.app_key")
? "钉钉配置缺失,请先在 config/app_settings.json 中配置 app_key 和 app_secret"
: message;
} finally {
syncing.value = false;
}
}
function statusText(status: string): string {
const map: Record<string, string> = {
present: "已打卡",
missing: "缺卡",
leave: "请假",
unchecked: "未打卡",
absent: "未打卡",
};
return map[status] || status || "-";
}
function money(value: number | null): string {
if (value === null) {
return "-";
}
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(value);
}
function numberText(value: number, unit = ""): string {
return `${Number(value || 0).toFixed(1)}${unit}`;
}
function rowKey(row: TodayAttendanceItem): string {
return row.employee_id ? String(row.employee_id) : `${row.employee_no}-${row.employee_name}`;
}
</script> </script>
<template> <template>
@ -10,28 +91,117 @@ import { CalendarClock, CloudCog, RefreshCw } from "@lucide/vue";
<p>查看今日打卡状态并预估本月薪资进度</p> <p>查看今日打卡状态并预估本月薪资进度</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="secondary-button" type="button" disabled> <label class="field compact-field">
<RefreshCw :size="18" aria-hidden="true" /> <span>考勤日期</span>
<input v-model="selectedDate" type="date" @change="loadTodayAttendance" />
</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> <span>刷新</span>
</button> </button>
<button class="primary-button" type="button" disabled> <button class="primary-button" type="button" :disabled="!canSync || syncing" @click="syncDingTalk">
<CloudCog :size="18" aria-hidden="true" /> <Loader2 v-if="syncing" class="spin" :size="18" aria-hidden="true" />
<span>同步钉钉</span> <CloudCog v-else :size="18" aria-hidden="true" />
<span>{{ syncing ? "同步中" : "同步钉钉" }}</span>
</button> </button>
</div> </div>
</header> </header>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>今日考勤与薪资预估</h2> <h2>今日考勤与薪资预估</h2>
<p>预估金额最终以月度核算锁定结果为准</p> <p>预估金额最终以月度核算锁定结果为准</p>
</div> </div>
<span class="status-chip neutral">待接入</span> <span class="status-chip" :class="response?.sync_job_id ? 'green' : 'neutral'">
{{ response?.sync_job_id ? "已同步" : "未同步" }}
</span>
</div> </div>
<div class="empty-state">
<div class="compact-stats-grid">
<div class="compact-stat">
<span>在职员工</span>
<strong>{{ summary?.employee_total || 0 }}</strong>
</div>
<div class="compact-stat">
<span>已打卡</span>
<strong>{{ summary?.checked_in_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>未打卡</span>
<strong>{{ summary?.unchecked_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>迟到</span>
<strong>{{ summary?.late_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>缺卡</span>
<strong>{{ summary?.missing_card_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>预估实发</span>
<strong>{{ money(summary?.estimated_net_total ?? 0) }}</strong>
</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>员工今日考勤</h2>
<p>{{ response?.synced_at ? `最近同步:${response.synced_at}` : "可先查看已入库数据,也可同步钉钉刷新。" }}</p>
</div>
</div>
<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 v-else class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>员工</th>
<th>部门/岗位</th>
<th>上班</th>
<th>下班</th>
<th>状态</th>
<th>迟到</th>
<th>缺卡</th>
<th>请假</th>
<th>今日加班</th>
<th>本月剩余加班</th>
<th>预估实发</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="rowKey(row)">
<td>
<strong>{{ row.employee_name }}</strong>
<span>{{ row.employee_no || "-" }}</span>
</td>
<td>
<strong>{{ row.department || "-" }}</strong>
<span>{{ row.position || "-" }}</span>
</td>
<td>{{ row.first_punch || "-" }}</td>
<td>{{ row.last_punch || "-" }}</td>
<td>{{ statusText(row.attendance_status) }}</td>
<td>{{ row.late_minutes ? `${row.late_minutes} 分钟` : "-" }}</td>
<td>{{ row.missing_card_count || "-" }}</td>
<td>{{ numberText(row.leave_hours, "h") }}</td>
<td>{{ numberText(row.today_overtime_hours, "h") }}</td>
<td>{{ numberText(row.month_remaining_overtime_hours, "h") }}</td>
<td>{{ money(row.estimated_net_salary) }}</td>
</tr>
</tbody>
</table>
</div> </div>
</section> </section>
</div> </div>

View File

@ -0,0 +1,36 @@
import {
DEFAULT_THEME_SETTINGS,
isLegacyDefaultAppearance,
themeOptions,
} from "../src/stores/theme";
assertEqual(DEFAULT_THEME_SETTINGS.theme, "enterprise");
assertEqual(DEFAULT_THEME_SETTINGS.mode, "light");
assertEqual(DEFAULT_THEME_SETTINGS.font, "sans");
assertEqual(DEFAULT_THEME_SETTINGS.radius, "0.3");
assertEqual(DEFAULT_THEME_SETTINGS.density, "compact");
assertEqual(DEFAULT_THEME_SETTINGS.sidebar, "embedded");
const enterprise = themeOptions.find((item) => item.name === "enterprise");
if (!enterprise) {
throw new Error("缺少企业清爽主题");
}
assertEqual(enterprise.label, "企业清爽");
assertEqual(enterprise.tokens.light.surface, "#f5f7fa");
assertEqual(enterprise.tokens.light.sidebarBg, "#ffffff");
assertEqual(enterprise.tokens.light.navActiveBg, "#e8f0ff");
assertTruthy(isLegacyDefaultAppearance("anthropic", "dark", "serif", "1", "floating"));
assertTruthy(isLegacyDefaultAppearance("navy", "dark", "serif", "1", "floating"));
function assertEqual(actual: string, expected: string): void {
if (actual !== expected) {
throw new Error(`期望 ${expected},实际 ${actual}`);
}
}
function assertTruthy(actual: boolean): void {
if (!actual) {
throw new Error("期望结果为 true");
}
}

View File

@ -0,0 +1,41 @@
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 view = readFileSync(join(process.cwd(), "src/views/OrganizationView.vue"), "utf-8");
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
assertIncludes(view, "organization-tree-layout");
assertIncludes(view, "organization-tree");
assertIncludes(view, "tree-node");
assertIncludes(view, "selectedNode");
assertIncludes(view, "部门详情");
assertIncludes(view, "岗位详情");
assertNotIncludes(view, "<h2>部门列表</h2>");
assertNotIncludes(view, "<h2>岗位列表</h2>");
assertIncludes(css, ".organization-tree-layout");
assertIncludes(css, ".organization-tree-panel");
assertIncludes(css, ".organization-detail-panel");
assertIncludes(css, ".tree-node");
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}`);
}
}

View File

@ -0,0 +1,56 @@
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 realtimeApiPath = join(root, "src/api/realtimeAttendance.ts");
const monthlyApiPath = join(root, "src/api/monthlyPayroll.ts");
const realtimeViewPath = join(root, "src/views/RealtimeAttendanceView.vue");
const monthlyViewPath = join(root, "src/views/MonthlyPayrollView.vue");
assertTruthy(existsSync(realtimeApiPath), "缺少实时考勤 API 客户端");
assertTruthy(existsSync(monthlyApiPath), "缺少月度核算 API 客户端");
const realtimeApi = readFileSync(realtimeApiPath, "utf-8");
assertIncludes(realtimeApi, "/api/attendance/realtime/today");
assertIncludes(realtimeApi, "/api/attendance/realtime/sync");
const monthlyApi = readFileSync(monthlyApiPath, "utf-8");
assertIncludes(monthlyApi, "/api/payroll/monthly/runs");
const realtimeView = readFileSync(realtimeViewPath, "utf-8");
assertNotIncludes(realtimeView, "待接入菜单");
assertNotIncludes(realtimeView, "待接入钉钉同步");
assertIncludes(realtimeView, "预估金额,最终以月度核算锁定结果为准");
const monthlyView = readFileSync(monthlyViewPath, "utf-8");
assertNotIncludes(monthlyView, "下一阶段接入");
assertIncludes(monthlyView, "锁定后本月工资不能重新计算");
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 assertTruthy(value: boolean, message: string): void {
if (!value) {
throw new Error(message);
}
}

View File

@ -0,0 +1,51 @@
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 loginVisual = cssBlock(".login-visual");
assertIncludes(loginVisual, "background: var(--surface-soft);");
assertIncludes(loginVisual, "color: var(--text);");
assertNotIncludes(loginVisual, "rgba(var(--primary-rgb), 0.94)");
const ledgerVisual = cssBlock(".ledger-visual");
assertIncludes(ledgerVisual, "background: var(--card);");
assertIncludes(ledgerVisual, "padding: 32px;");
const appearancePanel = cssBlock(".appearance-panel");
assertIncludes(appearancePanel, "width: min(480px, calc(100vw - 20px));");
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, "\\$&");
}