更新部分功能实现逻辑

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
+8 -4
View File
@@ -19,7 +19,7 @@
| `src/stores/` | Pinia 状态管理,目前主要是登录态、菜单、权限和主题色。 |
| `src/router/` | 路由配置和登录/权限守卫。 |
| `src/components/` | 通用布局、侧边栏、顶部栏、统计卡片和工资结果表格。 |
| `src/views/` | 登录、工作台、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 |
| `src/views/` | 登录、工作台、实时考勤、月度核算、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 |
| `src/assets/` | 全局样式和 UI 设计变量。 |
## 本地启动
@@ -55,7 +55,7 @@ http://127.0.0.1:5173/
/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`,并设置:
@@ -90,6 +90,8 @@ VITE_DEV_PROXY_TARGET=http://127.0.0.1:8001
| --- | --- | --- |
| 登录 | `/login` | 用户登录入口。 |
| 工作台 | `/dashboard` | 展示最近计算任务、统计卡片和趋势面板。 |
| 实时考勤 | `/attendance/realtime` | 查看今日打卡、迟到、缺卡、请假、加班和本月薪资预估,可同步钉钉刷新。 |
| 月度核算 | `/payroll/monthly` | 按工资月份完成 Excel 导入、钉钉同步计算、异常处理、锁定和导出。 |
| 工资计算 | `/payroll` | 支持 Excel 导入和钉钉实时计算。 |
| 计算记录 | `/payroll/jobs` | 支持按任务编号查询已落库工资结果。 |
| 提成管理 | `/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` | 拥有全部菜单和全部操作权限。 |
| `manager` | 可进行工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 |
| `viewer` | 可查看计算记录、下载结果和统计报表。 |
| `manager` | 可进行实时考勤同步、月度核算、工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 |
| `viewer` | 可查看实时考勤、计算记录、下载结果和统计报表。 |
## 构建
+68
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);
}
+16
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,
}),
});
}
+77
View File
@@ -376,3 +376,80 @@ export interface PayrollReportResponse {
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[];
}
+491 -90
View File
@@ -1,50 +1,50 @@
:root {
--surface: #f7f9fb;
--surface-soft: #eef2f6;
--surface-strong: #e3e8ee;
--surface: #f5f7fa;
--surface-soft: #eef2f7;
--surface-strong: #d9e1ec;
--card: #ffffff;
--primary: #041627;
--primary-soft: #1a2b3c;
--text: #191c1e;
--muted: #5f6670;
--line: #d8dee6;
--line-soft: #edf1f5;
--blue: #0f62c6;
--primary: #22313f;
--primary-soft: #d3d7db;
--text: #1f2933;
--muted: #667381;
--line: #c7d0dc;
--line-soft: #f4f7fa;
--blue: #2563eb;
--blue-soft: #e8f1ff;
--green: #008c61;
--green-soft: #ddf8ee;
--red: #bd1e1e;
--red-soft: #ffebe8;
--shadow: 0 16px 32px rgba(4, 22, 39, 0.06);
--sidebar-bg: #f3f6f9;
--nav-text: #303842;
--nav-active-bg: #e1e6ec;
--topbar-bg: rgba(247, 249, 251, 0.94);
--search-bg: #f1f4f8;
--control-bg: #fbfcfe;
--secondary-bg: #e8edf2;
--secondary-hover-bg: #dde5ed;
--secondary-hover-border: #c6d0db;
--table-header-bg: #f5f7fa;
--table-row-hover-bg: #fafcff;
--modal-backdrop: rgba(4, 22, 39, 0.32);
--primary-rgb: 4, 22, 39;
--accent-rgb: 15, 98, 198;
--sidebar-bg: #ffffff;
--nav-text: #334155;
--nav-active-bg: #e8f0ff;
--topbar-bg: rgba(245, 247, 250, 0.94);
--search-bg: #f6f8fb;
--control-bg: #ffffff;
--secondary-bg: #eef2f7;
--secondary-hover-bg: #d9e1ec;
--secondary-hover-border: #acb8c7;
--table-header-bg: #f5f8fb;
--table-row-hover-bg: #f9fbff;
--modal-backdrop: rgba(34, 49, 63, 0.32);
--primary-rgb: 34, 49, 63;
--accent-rgb: 37, 99, 235;
--app-font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Microsoft YaHei", sans-serif;
--radius-sm: 7px;
--radius-md: 8px;
--radius-lg: 12px;
--topbar-height: 72px;
--nav-height: 48px;
--control-height: 44px;
--base-font-size: 16px;
--page-padding: 32px;
--content-gap: 24px;
--panel-padding: 24px;
--table-cell-y: 18px;
--table-cell-x: 24px;
--radius-sm: 5px;
--radius-md: 7px;
--radius-lg: 10px;
--topbar-height: 64px;
--nav-height: 42px;
--control-height: 40px;
--base-font-size: 15px;
--page-padding: 24px;
--content-gap: 18px;
--panel-padding: 20px;
--table-cell-y: 12px;
--table-cell-x: 18px;
--layout-sidebar-width: 280px;
--layout-sidebar-offset: 280px;
--collapsed-sidebar-width: 88px;
@@ -561,7 +561,7 @@ a {
}
.appearance-panel {
width: min(560px, calc(100vw - 20px));
width: min(480px, calc(100vw - 20px));
max-height: 100vh;
margin: 0;
overflow-y: auto;
@@ -570,7 +570,7 @@ a {
background: var(--card);
box-shadow: 0 28px 70px rgba(var(--primary-rgb), 0.26);
color: var(--text);
padding: 16px;
padding: 14px;
transform: translateX(0);
transition:
opacity var(--motion-duration-fast) ease,
@@ -628,8 +628,19 @@ a {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
gap: 10px;
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 {
@@ -649,7 +660,7 @@ a {
}
.appearance-panel-header h2 {
font-size: 18px;
font-size: 17px;
}
.appearance-panel-header p {
@@ -664,7 +675,7 @@ a {
}
.appearance-section + .appearance-section {
margin-top: 16px;
margin-top: 14px;
}
.appearance-section-head {
@@ -674,7 +685,7 @@ a {
}
.appearance-section-head h3 {
font-size: 16px;
font-size: 14px;
}
.appearance-reset {
@@ -701,7 +712,7 @@ a {
.appearance-grid {
display: grid;
gap: 10px;
gap: 8px;
}
.mode-grid,
@@ -712,7 +723,7 @@ a {
}
.color-preset-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.radius-grid {
@@ -734,7 +745,7 @@ a {
gap: 6px;
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
padding: 6px;
@@ -757,7 +768,7 @@ a {
.appearance-choice strong,
.color-preset-choice strong {
color: var(--primary);
font-size: 13px;
font-size: 12px;
line-height: 1.25;
}
@@ -800,7 +811,7 @@ a {
}
.mode-preview {
height: 78px;
height: 66px;
border: 1px solid var(--line);
}
@@ -916,26 +927,102 @@ a {
}
.color-preset-choice {
min-height: 68px;
min-height: 74px;
align-content: start;
background: transparent;
border-color: transparent;
padding: 0;
border-color: var(--line);
background: var(--card);
padding: 6px;
}
.color-preset-swatch {
position: relative;
display: block;
height: 42px;
height: 44px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(135deg, var(--preset-from), var(--preset-to));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
border-radius: var(--radius-md);
background: var(--preset-surface);
}
.color-preset-choice strong {
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 {
min-height: 54px;
place-items: center;
@@ -1748,6 +1835,293 @@ a {
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 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -2049,14 +2423,14 @@ a {
.login-screen {
display: grid;
min-height: 100vh;
grid-template-columns: minmax(420px, 42vw) minmax(0, 1fr);
grid-template-columns: minmax(400px, 40vw) minmax(0, 1fr);
background: var(--surface);
}
.login-form-side {
display: grid;
place-items: center;
padding: 48px;
padding: 42px 48px;
}
.login-card {
@@ -2064,13 +2438,13 @@ a {
}
.login-brand {
margin-bottom: 28px;
margin-bottom: 24px;
}
.login-brand h1 {
margin: 22px 0 8px;
margin: 18px 0 7px;
color: var(--primary);
font-size: 28px;
font-size: 26px;
line-height: 1.2;
}
@@ -2081,7 +2455,7 @@ a {
.form-stack {
display: grid;
gap: 18px;
gap: 16px;
}
.input-with-icon {
@@ -2118,9 +2492,9 @@ a {
display: flex;
flex-wrap: wrap;
gap: 14px 22px;
margin-top: 42px;
margin-top: 34px;
border-top: 1px solid var(--line);
padding-top: 24px;
padding-top: 20px;
color: var(--muted);
}
@@ -2139,43 +2513,42 @@ a {
display: grid;
place-items: center;
overflow: hidden;
background:
linear-gradient(135deg, rgba(var(--primary-rgb), 0.94), rgba(var(--accent-rgb), 0.86)),
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0 1px, transparent 1px 74px),
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.05) 0 1px, transparent 1px 74px);
color: white;
padding: 48px;
border-left: 1px solid var(--line);
background: var(--surface-soft);
color: var(--text);
padding: 44px;
}
.ledger-visual {
width: min(620px, 100%);
border: 1px solid rgba(255, 255, 255, 0.16);
width: min(560px, 100%);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.07);
padding: 48px;
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24);
background: var(--card);
padding: 32px;
box-shadow: var(--shadow);
}
.ledger-visual h2 {
margin: 24px 0 18px;
font-size: 44px;
line-height: 1.18;
margin: 22px 0 14px;
color: var(--primary);
font-size: 31px;
line-height: 1.25;
}
.ledger-visual p {
margin: 0;
color: rgba(255, 255, 255, 0.82);
font-size: 17px;
line-height: 1.7;
color: var(--muted);
font-size: 15px;
line-height: 1.65;
}
.visual-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--content-gap);
margin-top: 34px;
border-top: 1px solid rgba(255, 255, 255, 0.16);
padding-top: 28px;
gap: 18px;
margin-top: 28px;
border-top: 1px solid var(--line);
padding-top: 22px;
}
.visual-metrics strong,
@@ -2184,12 +2557,13 @@ a {
}
.visual-metrics strong {
font-size: 32px;
color: var(--primary);
font-size: 25px;
}
.visual-metrics span {
margin-top: 4px;
color: rgba(255, 255, 255, 0.68);
color: var(--muted);
}
.security-line {
@@ -2199,7 +2573,7 @@ a {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(255, 255, 255, 0.58);
color: var(--muted);
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
font-size: 13px;
}
@@ -2212,7 +2586,7 @@ a {
@media (max-width: 1120px) {
.appearance-panel {
width: min(540px, calc(100vw - 16px));
width: min(480px, calc(100vw - 16px));
padding: 14px;
}
@@ -2228,11 +2602,17 @@ a {
.stats-grid,
.dashboard-grid,
.action-grid,
.compact-stats-grid,
.form-grid,
.workflow-steps,
.password-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.organization-tree-layout {
grid-template-columns: 1fr;
}
.login-screen {
grid-template-columns: 1fr;
}
@@ -2344,14 +2724,17 @@ a {
}
.stats-grid,
.organization-summary-strip,
.dashboard-grid,
.action-grid,
.compact-stats-grid,
.form-grid,
.user-form,
.password-form,
.search-form,
.table-filter-bar,
.visual-metrics {
.visual-metrics,
.workflow-steps {
grid-template-columns: 1fr;
}
@@ -2379,6 +2762,24 @@ a {
.page-header h1 {
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) {
+38 -13
View File
@@ -26,13 +26,13 @@ const modeOptions: Array<{ name: ThemeMode; label: string }> = [
];
const fontOptions: Array<{ name: FontChoice; label: string }> = [
{ name: "auto", label: "Auto" },
{ name: "sans", label: "Sans" },
{ name: "serif", label: "Serif" },
{ name: "auto", label: "系统" },
{ name: "sans", label: "现代" },
{ name: "serif", label: "宋体" },
];
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.3", label: "0.3", previewRadius: "8px" },
{ 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 }> = [
{ name: "default", label: "默认" },
{ name: "compact", label: "紧凑" },
{ name: "fullscreen", label: "全屏布局" },
{ name: "fullscreen", label: "全屏" },
];
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="外观设置">
<header class="appearance-panel-header">
<div>
<h2>外观</h2>
<p>主题、颜色与布局</p>
<h2>界面外观</h2>
<p>整套界面的颜色、字体和布局</p>
</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')">
<X :size="21" aria-hidden="true" />
</button>
</div>
<button class="icon-button" type="button" title="关闭" @click="emit('close')">
<X :size="21" aria-hidden="true" />
</button>
</header>
<section class="appearance-section">
@@ -118,7 +124,7 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
<section class="appearance-section">
<div class="appearance-section-head">
<h3>颜色预设</h3>
<h3>整套配色</h3>
<button class="appearance-reset" type="button" title="恢复默认颜色" @click="theme.resetColor">
<RotateCcw :size="17" aria-hidden="true" />
</button>
@@ -129,11 +135,30 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =
:key="item.name"
class="color-preset-choice"
: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"
@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>
<span v-if="theme.current === item.name" class="choice-check" aria-hidden="true">
<Check :size="18" />
+120 -25
View File
@@ -1,6 +1,7 @@
import { defineStore } from "pinia";
export type ThemeName =
| "enterprise"
| "default"
| "anthropic"
| "mono"
@@ -103,26 +104,62 @@ const DENSITY_KEY = "financial-system-density";
const SIDEBAR_KEY = "financial-system-sidebar";
const LAYOUT_KEY = "financial-system-layout";
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 = {
theme: "anthropic" as ThemeName,
mode: "dark" as ThemeMode,
font: "serif" as FontChoice,
radius: "1" as RadiusChoice,
export const DEFAULT_THEME_SETTINGS = {
theme: "enterprise" as ThemeName,
mode: "light" as ThemeMode,
font: "sans" as FontChoice,
radius: "0.3" as RadiusChoice,
density: "compact" as DensityChoice,
sidebar: "floating" as SidebarChoice,
sidebar: "embedded" as SidebarChoice,
layout: "default" as LayoutChoice,
contentWidth: "full" as ContentWidthChoice,
};
const legacyThemeMap: Record<string, ThemeName> = {
navy: "anthropic",
navy: "enterprise",
blue: "ocean",
emerald: "forest",
burgundy: "rose",
};
migrateLegacyAppearanceDefaults();
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",
label: "默认",
@@ -467,13 +504,13 @@ export const themeOptions: ThemeOption[] = themeSeeds.map((seed) => ({
export const useThemeStore = defineStore("theme", {
state: () => ({
current: readThemeName(),
mode: readSetting(THEME_MODE_KEY, DEFAULTS.mode, ["system", "light", "dark"] as const),
font: readSetting(FONT_KEY, DEFAULTS.font, ["auto", "sans", "serif"] as const),
radius: readSetting(RADIUS_KEY, DEFAULTS.radius, ["auto", "0", "0.3", "0.5", "0.75", "1"] as const),
density: readSetting(DENSITY_KEY, DEFAULTS.density, ["compact", "default", "comfortable", "large"] as const),
sidebar: readSetting(SIDEBAR_KEY, DEFAULTS.sidebar, ["embedded", "floating", "edge"] as const),
layout: readSetting(LAYOUT_KEY, DEFAULTS.layout, ["default", "compact", "fullscreen"] as const),
contentWidth: readSetting(CONTENT_WIDTH_KEY, DEFAULTS.contentWidth, ["full", "centered"] as const),
mode: readSetting(THEME_MODE_KEY, DEFAULT_THEME_SETTINGS.mode, ["system", "light", "dark"] as const),
font: readSetting(FONT_KEY, DEFAULT_THEME_SETTINGS.font, ["auto", "sans", "serif"] 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, DEFAULT_THEME_SETTINGS.density, ["compact", "default", "comfortable", "large"] as const),
sidebar: readSetting(SIDEBAR_KEY, DEFAULT_THEME_SETTINGS.sidebar, ["embedded", "floating", "edge"] as const),
layout: readSetting(LAYOUT_KEY, DEFAULT_THEME_SETTINGS.layout, ["default", "compact", "fullscreen"] as const),
contentWidth: readSetting(CONTENT_WIDTH_KEY, DEFAULT_THEME_SETTINGS.contentWidth, ["full", "centered"] as const),
}),
getters: {
activeTheme: (state) => themeOptions.find((item) => item.name === state.current) || themeOptions[0],
@@ -579,36 +616,47 @@ export const useThemeStore = defineStore("theme", {
this.applyTheme();
},
resetTheme(): void {
this.current = DEFAULTS.theme;
this.mode = DEFAULTS.mode;
this.current = DEFAULT_THEME_SETTINGS.theme;
this.mode = DEFAULT_THEME_SETTINGS.mode;
this.applyTheme();
},
resetColor(): void {
this.current = DEFAULTS.theme;
this.current = DEFAULT_THEME_SETTINGS.theme;
this.applyTheme();
},
resetFont(): void {
this.font = DEFAULTS.font;
this.font = DEFAULT_THEME_SETTINGS.font;
this.applyTheme();
},
resetRadius(): void {
this.radius = DEFAULTS.radius;
this.radius = DEFAULT_THEME_SETTINGS.radius;
this.applyTheme();
},
resetDensity(): void {
this.density = DEFAULTS.density;
this.density = DEFAULT_THEME_SETTINGS.density;
this.applyTheme();
},
resetSidebar(): void {
this.sidebar = DEFAULTS.sidebar;
this.sidebar = DEFAULT_THEME_SETTINGS.sidebar;
this.applyTheme();
},
resetLayout(): void {
this.layout = DEFAULTS.layout;
this.layout = DEFAULT_THEME_SETTINGS.layout;
this.applyTheme();
},
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();
},
},
@@ -760,8 +808,8 @@ function layoutVariables(
function readThemeName(): ThemeName {
const stored = readRawSetting(THEME_KEY);
const migrated = stored ? legacyThemeMap[stored] || stored : DEFAULTS.theme;
return themeOptions.some((item) => item.name === migrated) ? (migrated as ThemeName) : DEFAULTS.theme;
const migrated = stored ? legacyThemeMap[stored] || stored : DEFAULT_THEME_SETTINGS.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] {
@@ -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" {
if (mode !== "system") {
return mode;
+245 -16
View File
@@ -1,5 +1,144 @@
<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>
<template>
@@ -7,41 +146,131 @@ import { Calculator, FileSpreadsheet, LockKeyhole, Upload } from "@lucide/vue";
<header class="page-header">
<div>
<h1>月度核算</h1>
<p>按工资月份完成导入、计算、复核、锁定和导出。</p>
<p>按工资月份完成导入、计算、异常处理、锁定和导出。</p>
</div>
<div class="header-actions">
<button class="secondary-button" type="button" disabled>
<Upload :size="18" aria-hidden="true" />
<span>导入 Excel</span>
</button>
<button class="primary-button" type="button" disabled>
<Calculator :size="18" aria-hidden="true" />
<span>同步并计算</span>
<label class="field compact-field">
<span>工资月份</span>
<input v-model="selectedMonth" type="month" @change="loadRuns" />
</label>
<button class="secondary-button" type="button" :disabled="loading" @click="loadRuns">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div>
</header>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel">
<div class="panel-header">
<div>
<h2>核算流程</h2>
<p>数据导入 -> 考勤转换 -> 异常处理 -> 工资计算 -> 复核锁定</p>
</div>
<span class="status-chip neutral">草稿</span>
<span class="status-chip" :class="isLocked ? 'green' : 'neutral'">{{ statusText(activeRun?.status) }}</span>
</div>
<div class="empty-state">
<FileSpreadsheet :size="38" aria-hidden="true" />
<p>月度核算中心已接入菜单,下一阶段接入月度计算、异常清单和锁定逻辑。</p>
<div class="workflow-steps">
<span>1 数据导入</span>
<span>2 考勤转换</span>
<span>3 异常处理</span>
<span>4 工资计算</span>
<span>5 复核锁定</span>
</div>
<div class="compact-stats-grid">
<div class="compact-stat">
<span>员工数</span>
<strong>{{ activeRun?.employee_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>应发合计</span>
<strong>{{ grossTotal }}</strong>
</div>
<div class="compact-stat">
<span>实发合计</span>
<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>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>锁定规则</h2>
<p>锁定后本月工资不可重新计算,仅允许导出。</p>
<h2>工资明细</h2>
<p>锁定后本月工资不能重新计算,只能导出。</p>
</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>
</section>
</div>
+476 -147
View File
@@ -1,5 +1,16 @@
<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 {
@@ -12,7 +23,26 @@ import {
} from "../api/organization";
import { ApiError } from "../api/http";
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 positions = ref<PositionRecord[]>([]);
@@ -22,6 +52,10 @@ const savingPosition = ref(false);
const editingDepartmentId = ref<number | null>(null);
const editingPositionId = ref<number | null>(null);
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 successMessage = ref("");
@@ -42,9 +76,122 @@ const positionForm = reactive<PositionRequest>({
remark: "",
});
const activeDepartments = computed(() => departments.value.filter((item) => item.is_active));
const activePositions = computed(() => positions.value.filter((item) => item.is_active));
const positionRows = computed(() => positions.value);
const sortedDepartments = computed(() => [...departments.value].sort(sortByOrderThenId));
const sortedPositions = computed(() => [...positions.value].sort(sortByOrderThenId));
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);
@@ -58,6 +205,8 @@ async function loadData(): Promise<void> {
]);
departments.value = departmentRows;
positions.value = positionRowsData;
reconcileExpandedDepartments(departmentRows);
syncSelectionAfterLoad();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "组织架构加载失败";
} finally {
@@ -70,14 +219,11 @@ async function submitDepartment(): Promise<void> {
errorMessage.value = "";
successMessage.value = "";
try {
if (editingDepartmentId.value) {
await updateDepartment(editingDepartmentId.value, normalizedDepartment());
successMessage.value = "部门已更新";
} else {
await createDepartment(normalizedDepartment());
successMessage.value = "部门已新增";
}
resetDepartmentForm();
const department = editingDepartmentId.value
? await updateDepartment(editingDepartmentId.value, normalizedDepartment())
: await createDepartment(normalizedDepartment());
selectedNodeKey.value = departmentKey(department.id);
successMessage.value = editingDepartmentId.value ? "部门已更新" : "部门已新增";
await loadData();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "部门保存失败";
@@ -95,14 +241,11 @@ async function submitPosition(): Promise<void> {
errorMessage.value = "";
successMessage.value = "";
try {
if (editingPositionId.value) {
await updatePosition(editingPositionId.value, normalizedPosition());
successMessage.value = "岗位已更新";
} else {
await createPosition(normalizedPosition());
successMessage.value = "岗位已新增";
}
resetPositionForm();
const position = editingPositionId.value
? await updatePosition(editingPositionId.value, normalizedPosition())
: await createPosition(normalizedPosition());
selectedNodeKey.value = positionKey(position.id);
successMessage.value = editingPositionId.value ? "岗位已更新" : "岗位已新增";
await loadData();
} catch (error) {
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;
editingPositionId.value = null;
editingPositionCode.value = "";
expandDepartmentPath(department.id);
Object.assign(departmentForm, {
parent_id: department.parent_id,
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;
editingPositionCode.value = position.position_code;
editingDepartmentId.value = null;
expandDepartmentPath(position.department_id);
Object.assign(positionForm, {
department_id: position.department_id,
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 {
editingDepartmentId.value = null;
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 {
return {
parent_id: departmentForm.parent_id || null,
@@ -173,6 +388,7 @@ function normalizedDepartment(): DepartmentRequest {
function normalizedPosition(): PositionRequest {
return {
department_id: Number(positionForm.department_id),
position_code: editingPositionCode.value,
name: positionForm.name.trim(),
sort_order: Number(positionForm.sort_order || 0),
is_active: positionForm.is_active,
@@ -180,11 +396,100 @@ function normalizedPosition(): PositionRequest {
};
}
function departmentName(id: number | null): string {
if (!id) {
return "-";
function syncSelectionAfterLoad(): void {
const node = allTreeNodes.value.find((item) => item.key === selectedNodeKey.value);
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>
@@ -193,7 +498,7 @@ function departmentName(id: number | null): string {
<header class="page-header">
<div>
<h1>组织架构</h1>
<p>维护部门上下级和部门下的岗位,员工维护会按这里的关系联动。</p>
<p>用树状关系维护部门上下级和部门下岗位,员工维护会按这里的关系联动。</p>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
@@ -202,23 +507,145 @@ function departmentName(id: number | null): string {
</button>
</header>
<section class="stats-grid">
<StatCard title="部门总数" :value="String(departments.length)" :icon="Building2" tone="blue" meta="含停用" />
<StatCard title="启用部门" :value="String(activeDepartments.length)" :icon="Building2" tone="green" meta="可选" />
<StatCard title="岗位总数" :value="String(positions.length)" :icon="GitBranch" tone="neutral" meta="含停用" />
<StatCard title="启用岗位" :value="String(activePositions.length)" :icon="GitBranch" tone="green" meta="员工可选" />
<section class="organization-summary-strip">
<div>
<Building2 :size="18" aria-hidden="true" />
<span>部门</span>
<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 class="dashboard-grid">
<div class="panel">
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<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>
<h2>{{ editingDepartmentId ? "编辑部门" : "新增部门" }}</h2>
<p>部门可选择父级,形成上下级关系</p>
<h2>组织树</h2>
<p>{{ departments.length }} 个部门,{{ positions.length }} 个岗位</p>
</div>
<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>
<button class="secondary-button" type="button" @click="resetDepartmentForm">清空</button>
</div>
<form class="form-grid compact-form" @submit.prevent="submitDepartment">
<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">
<span>部门编码</span>
<input v-model="departmentForm.department_code" required maxlength="64" type="text" />
@@ -230,13 +657,9 @@ function departmentName(id: number | null): string {
<label class="field">
<span>父级部门</span>
<select v-model.number="departmentForm.parent_id">
<option :value="null">无父级</option>
<option
v-for="department in departments.filter((item) => item.id !== editingDepartmentId)"
:key="department.id"
:value="department.id"
>
{{ department.name }}
<option :value="null">无上级部门</option>
<option v-for="department in availableParentDepartments" :key="department.id" :value="department.id">
{{ departmentOptionLabel(department) }}
</option>
</select>
</label>
@@ -255,29 +678,19 @@ function departmentName(id: number | null): string {
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="savingDepartment">
<Loader2 v-if="savingDepartment" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingDepartmentId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ savingDepartment ? "保存中" : editingDepartmentId ? "保存部门" : "新增部门" }}</span>
<Save v-else :size="18" aria-hidden="true" />
<span>{{ savingDepartment ? "保存中" : editingDepartmentId ? "保存部门" : "创建部门" }}</span>
</button>
</div>
</form>
</div>
<div class="panel">
<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">
<form v-else class="form-grid compact-form organization-editor-form" @submit.prevent="submitPosition">
<label class="field">
<span>所属部门</span>
<select v-model.number="positionForm.department_id" required>
<option :value="0">请选择部门</option>
<option v-for="department in activeDepartments" :key="department.id" :value="department.id">
{{ department.name }}
{{ departmentOptionLabel(department) }}
</option>
</select>
</label>
@@ -304,96 +717,12 @@ function departmentName(id: number | null): string {
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="savingPosition">
<Loader2 v-if="savingPosition" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingPositionId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ savingPosition ? "保存中" : editingPositionId ? "保存岗位" : "新增岗位" }}</span>
<Save v-else :size="18" aria-hidden="true" />
<span>{{ savingPosition ? "保存中" : editingPositionId ? "保存岗位" : "创建岗位" }}</span>
</button>
</div>
</form>
</div>
</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>
</template>
+179 -9
View File
@@ -1,5 +1,86 @@
<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>
<template>
@@ -10,28 +91,117 @@ import { CalendarClock, CloudCog, RefreshCw } from "@lucide/vue";
<p>查看今日打卡状态,并预估本月薪资进度。</p>
</div>
<div class="header-actions">
<button class="secondary-button" type="button" disabled>
<RefreshCw :size="18" aria-hidden="true" />
<label class="field compact-field">
<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>
</button>
<button class="primary-button" type="button" disabled>
<CloudCog :size="18" aria-hidden="true" />
<span>同步钉钉</span>
<button class="primary-button" type="button" :disabled="!canSync || syncing" @click="syncDingTalk">
<Loader2 v-if="syncing" class="spin" :size="18" aria-hidden="true" />
<CloudCog v-else :size="18" aria-hidden="true" />
<span>{{ syncing ? "同步中" : "同步钉钉" }}</span>
</button>
</div>
</header>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel">
<div class="panel-header">
<div>
<h2>今日考勤与薪资预估</h2>
<p>预估金额,最终以月度核算锁定结果为准。</p>
</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 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" />
<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>
</section>
</div>
+36
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");
}
}
@@ -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}`);
}
}
+56
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);
}
}
+51
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, "\\$&");
}