forked from jiaoly/financial_system
更新部分功能实现逻辑
This commit is contained in:
+8
-4
@@ -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` | 可查看实时考勤、计算记录、下载结果和统计报表。 |
|
||||
|
||||
## 构建
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user