初步提交

This commit is contained in:
焦龙言
2026-06-18 13:21:20 +08:00
commit 99c7ba776f
133 changed files with 20680 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=
VITE_DEV_PROXY_TARGET=http://127.0.0.1:8000
+141
View File
@@ -0,0 +1,141 @@
# 企业薪酬管理系统前端
这是 `Financial_System` 项目的前端子工程,使用 Vue3、Vite、TypeScript、Pinia 和 Vue Router 开发,用于对接后端 FastAPI 工资计算服务。
## 技术栈
- Vue3:页面和组件开发。
- Vite:本地开发服务和生产构建。
- TypeScript:接口类型、状态类型和页面逻辑类型约束。
- Pinia:登录用户、菜单和权限状态管理。
- Vue Router:页面路由和权限守卫。
- @lucide/vue:按钮、菜单和状态图标。
## 目录结构
| 目录 | 说明 |
| --- | --- |
| `src/api/` | 后端接口封装、请求工具、接口类型、文件下载逻辑。 |
| `src/stores/` | Pinia 状态管理,目前主要是登录态、菜单、权限和主题色。 |
| `src/router/` | 路由配置和登录/权限守卫。 |
| `src/components/` | 通用布局、侧边栏、顶部栏、统计卡片和工资结果表格。 |
| `src/views/` | 登录、工作台、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 |
| `src/assets/` | 全局样式和 UI 设计变量。 |
## 本地启动
先启动后端服务:
```bash
cd /Users/jiaolongyan/PycharmProjects/牛牛小屋/Financial_System
/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python main.py
```
再启动前端服务:
```bash
cd /Users/jiaolongyan/PycharmProjects/牛牛小屋/Financial_System/frontend
npm install
npm run dev
```
默认访问地址:
```text
http://127.0.0.1:5173/
```
## 后端接口代理
开发环境下,`vite.config.ts` 已将以下路径代理到后端:
```text
/api -> http://127.0.0.1:8000
/health -> http://127.0.0.1:8000
/static -> http://127.0.0.1:8000
```
因此前端代码中可以直接请求 `/api/auth/login`、`/api/payroll/excel` 等路径,头像静态资源也可以直接访问 `/static/uploads/avatars/...`。
如果部署时前端和后端不在同一个域名,可以复制 `.env.example` 为 `.env`,并设置:
```env
VITE_API_BASE_URL=http://127.0.0.1:8000
VITE_DEV_PROXY_TARGET=http://127.0.0.1:8000
```
如果后端 `8000` 端口被旧进程占用,新后端自动启动到了 `8001`,需要把开发代理改成:
```env
VITE_DEV_PROXY_TARGET=http://127.0.0.1:8001
```
修改 `.env` 后需要重启前端开发服务。
## 默认账号
后端首次启动会初始化超级管理员:
```text
用户名:admin
密码:Admin@123456
角色:superuser
```
登录成功后,前端会根据后端返回的 `menus` 渲染左侧菜单,根据 `permissions` 控制按钮和操作权限。
## 页面说明
| 页面 | 路由 | 说明 |
| --- | --- | --- |
| 登录 | `/login` | 用户登录入口。 |
| 工作台 | `/dashboard` | 展示最近计算任务、统计卡片和趋势面板。 |
| 工资计算 | `/payroll` | 支持 Excel 导入和钉钉实时计算。 |
| 计算记录 | `/payroll/jobs` | 支持按任务编号查询已落库工资结果。 |
| 提成管理 | `/payroll/commissions` | 支持手工维护提成和 Excel 批量导入。 |
| 统计报表 | `/reports` | 查看工资汇总、部门工资成本和考勤工时统计。 |
| 组织架构 | `/system/organization` | 维护部门上下级和部门下的岗位。 |
| 员工维护 | `/system/employees` | 维护员工编号、姓名、钉钉用户ID、部门、岗位和状态。 |
| 薪资维护 | `/system/salary-profiles` | 维护包月、计时、计件、试用期工资规则和三类加班费单价。 |
| 规则配置 | `/system/configs` | 维护考勤扣款、加班取整、请假关键字和默认加班单价。 |
| 用户管理 | `/system/users` | 超级用户创建账号、查看账号和角色权限。 |
| 操作日志 | `/system/operation-logs` | 支持按操作人、模块、动作、状态和关键字查看系统操作记录。 |
组织架构页面中的岗位编码由后端自动生成,新增或编辑岗位时不需要手工输入;员工维护页面会按所选部门联动展示该部门下的岗位,员工编号由后端按 `ZA0001`、`ZA0002` 规则自动生成。
## 个性化与个人资料
顶部栏提供两个常用入口:
- 调色盘按钮:切换主题色,支持深海蓝、专业蓝、翡翠绿、勃艮第,选择结果保存在浏览器本地。
- 头像/姓名区域:打开个人资料弹窗,可修改显示名称、邮箱、手机号、部门、职位,并上传头像。
头像上传接口为 `/api/auth/me/avatar`,后端会保存到 `storage.avatar_dir`,默认是:
```text
static/uploads/avatars
```
## 权限说明
| 角色 | 可见菜单和操作 |
| --- | --- |
| `superuser` | 拥有全部菜单和全部操作权限。 |
| `manager` | 可进行工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 |
| `viewer` | 可查看计算记录、下载结果和统计报表。 |
## 构建
```bash
cd /Users/jiaolongyan/PycharmProjects/牛牛小屋/Financial_System/frontend
npm run build
```
构建产物生成在 `dist/`,该目录已加入项目 `.gitignore`。
## 常见问题
1. 登录失败:确认后端已启动,并且 `/health` 返回 `{"status":"ok"}`。
2. 前端请求 401:重新登录,或确认当前用户拥有对应权限。
3. 下载结果失败:确认任务已生成 `output_file`,且后端 `outputs/` 中文件未被删除。
4. 端口被占用:可临时执行 `npm run dev -- --port 5174` 更换端口。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>企业薪酬管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1570
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "financial-system-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"@lucide/vue": "1.18.0",
"pinia": "2.1.7",
"vue": "3.4.15",
"vue-router": "4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "4.6.2",
"typescript": "5.3.3",
"vite": "4.5.14",
"vue-tsc": "1.8.27"
},
"engines": {
"node": ">=16.14.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+55
View File
@@ -0,0 +1,55 @@
import { apiFetch } from "./http";
import type {
ChangePasswordRequest,
ChangePasswordResponse,
CreateUserRequest,
LoginResponse,
UpdateProfileRequest,
UserProfile,
UserRecord,
} from "./types";
export function login(username: string, password: string): Promise<LoginResponse> {
return apiFetch<LoginResponse>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export function getCurrentUser(): Promise<UserProfile> {
return apiFetch<UserProfile>("/api/auth/me");
}
export function listUsers(): Promise<UserRecord[]> {
return apiFetch<UserRecord[]>("/api/auth/users");
}
export function createUser(payload: CreateUserRequest): Promise<UserRecord> {
return apiFetch<UserRecord>("/api/auth/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateProfile(payload: UpdateProfileRequest): Promise<UserProfile> {
return apiFetch<UserProfile>("/api/auth/me", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export function changePassword(payload: ChangePasswordRequest): Promise<ChangePasswordResponse> {
return apiFetch<ChangePasswordResponse>("/api/auth/me/password", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export function uploadAvatar(file: File): Promise<UserProfile> {
const formData = new FormData();
formData.append("file", file);
return apiFetch<UserProfile>("/api/auth/me/avatar", {
method: "POST",
body: formData,
});
}
+53
View File
@@ -0,0 +1,53 @@
import { apiFetch } from "./http";
import type {
CommissionImportResponse,
CommissionListResponse,
CommissionRecord,
CommissionRecordRequest,
} from "./types";
export interface CommissionQuery {
page?: number;
page_size?: number;
commission_month?: string;
keyword?: string;
}
export function listCommissions(query: CommissionQuery): Promise<CommissionListResponse> {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== "") {
params.set(key, String(value));
}
});
return apiFetch<CommissionListResponse>(`/api/commissions?${params.toString()}`);
}
export function createCommission(request: CommissionRecordRequest): Promise<CommissionRecord> {
return apiFetch<CommissionRecord>("/api/commissions", {
method: "POST",
body: JSON.stringify(request),
});
}
export function updateCommission(id: number, request: CommissionRecordRequest): Promise<CommissionRecord> {
return apiFetch<CommissionRecord>(`/api/commissions/${id}`, {
method: "PUT",
body: JSON.stringify(request),
});
}
export function deleteCommission(id: number): Promise<{ message: string }> {
return apiFetch<{ message: string }>(`/api/commissions/${id}`, {
method: "DELETE",
});
}
export function importCommissions(file: File): Promise<CommissionImportResponse> {
const form = new FormData();
form.append("file", file);
return apiFetch<CommissionImportResponse>("/api/commissions/import", {
method: "POST",
body: form,
});
}
+24
View File
@@ -0,0 +1,24 @@
import { apiFetch } from "./http";
import type { SalaryConfigRecord, SalaryConfigRequest } from "./types";
export function listSalaryConfigs(configGroup = ""): Promise<SalaryConfigRecord[]> {
const params = new URLSearchParams();
if (configGroup) {
params.set("config_group", configGroup);
}
const query = params.toString();
return apiFetch<SalaryConfigRecord[]>(`/api/configs${query ? `?${query}` : ""}`);
}
export function updateSalaryConfig(configKey: string, request: SalaryConfigRequest): Promise<SalaryConfigRecord> {
return apiFetch<SalaryConfigRecord>(`/api/configs/${encodeURIComponent(configKey)}`, {
method: "PUT",
body: JSON.stringify(request),
});
}
export function ensureDefaultConfigs(): Promise<{ created_count: number; message: string }> {
return apiFetch<{ created_count: number; message: string }>("/api/configs/defaults", {
method: "POST",
});
}
+48
View File
@@ -0,0 +1,48 @@
import { apiFetch } from "./http";
import type { EmployeeListResponse, EmployeeNoResponse, EmployeeRecord, EmployeeRequest } from "./types";
export function listEmployees(params: { keyword?: string; employment_status?: string } = {}): Promise<EmployeeRecord[]> {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== "") {
search.set(key, String(value));
}
});
const query = search.toString();
return apiFetch<EmployeeRecord[]>(`/api/employees${query ? `?${query}` : ""}`);
}
export function listEmployeePage(
params: { page?: number; page_size?: number; keyword?: string; employment_status?: string } = {},
): Promise<EmployeeListResponse> {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== "") {
search.set(key, String(value));
}
});
const query = search.toString();
return apiFetch<EmployeeListResponse>(`/api/employees/page${query ? `?${query}` : ""}`);
}
export function getEmployee(employeeId: number): Promise<EmployeeRecord> {
return apiFetch<EmployeeRecord>(`/api/employees/${employeeId}`);
}
export function previewNextEmployeeNo(): Promise<EmployeeNoResponse> {
return apiFetch<EmployeeNoResponse>("/api/employees/next-no");
}
export function createEmployee(payload: EmployeeRequest): Promise<EmployeeRecord> {
return apiFetch<EmployeeRecord>("/api/employees", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateEmployee(employeeId: number, payload: EmployeeRequest): Promise<EmployeeRecord> {
return apiFetch<EmployeeRecord>(`/api/employees/${employeeId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
+68
View File
@@ -0,0 +1,68 @@
const TOKEN_KEY = "financial-system-token";
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = "ApiError";
}
}
export function getStoredToken(): string {
return localStorage.getItem(TOKEN_KEY) || "";
}
export function setStoredToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearStoredToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export function buildApiUrl(path: string): string {
const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/$/, "");
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
const token = getStoredToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(buildApiUrl(path), {
...init,
headers,
});
if (!response.ok) {
throw new ApiError(await readErrorMessage(response), response.status);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
async function readErrorMessage(response: Response): Promise<string> {
try {
const payload = await response.json();
if (typeof payload.detail === "string") {
return payload.detail;
}
return JSON.stringify(payload.detail || payload);
} catch {
return `请求失败,状态码 ${response.status}`;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { apiFetch } from "./http";
import type { OperationLogListResponse, OperationLogQuery } from "./types";
export function listOperationLogs(query: OperationLogQuery = {}): Promise<OperationLogListResponse> {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== "") {
params.set(key, String(value));
}
});
const search = params.toString();
return apiFetch<OperationLogListResponse>(`/api/operation-logs${search ? `?${search}` : ""}`);
}
+55
View File
@@ -0,0 +1,55 @@
import { apiFetch } from "./http";
import type { DepartmentRecord, DepartmentRequest, PositionRecord, PositionRequest } from "./types";
export function listDepartments(activeOnly = true, keyword = ""): Promise<DepartmentRecord[]> {
const params = new URLSearchParams();
params.set("active_only", String(activeOnly));
if (keyword.trim()) {
params.set("keyword", keyword.trim());
}
return apiFetch<DepartmentRecord[]>(`/api/organization/departments?${params.toString()}`);
}
export function createDepartment(request: DepartmentRequest): Promise<DepartmentRecord> {
return apiFetch<DepartmentRecord>("/api/organization/departments", {
method: "POST",
body: JSON.stringify(request),
});
}
export function updateDepartment(id: number, request: DepartmentRequest): Promise<DepartmentRecord> {
return apiFetch<DepartmentRecord>(`/api/organization/departments/${id}`, {
method: "PUT",
body: JSON.stringify(request),
});
}
export function listPositions(
departmentId?: number,
activeOnly = true,
keyword = "",
): Promise<PositionRecord[]> {
const params = new URLSearchParams();
params.set("active_only", String(activeOnly));
if (departmentId) {
params.set("department_id", String(departmentId));
}
if (keyword.trim()) {
params.set("keyword", keyword.trim());
}
return apiFetch<PositionRecord[]>(`/api/organization/positions?${params.toString()}`);
}
export function createPosition(request: PositionRequest): Promise<PositionRecord> {
return apiFetch<PositionRecord>("/api/organization/positions", {
method: "POST",
body: JSON.stringify(request),
});
}
export function updatePosition(id: number, request: PositionRequest): Promise<PositionRecord> {
return apiFetch<PositionRecord>(`/api/organization/positions/${id}`, {
method: "PUT",
body: JSON.stringify(request),
});
}
+98
View File
@@ -0,0 +1,98 @@
import { ApiError, apiFetch, buildApiUrl, getStoredToken } from "./http";
import type {
DingTalkPayrollRequest,
PayrollJobResponse,
PayrollResponse,
RecentPayrollJob,
} from "./types";
const RECENT_JOBS_KEY = "financial-system-recent-payroll-jobs";
export function calculateFromExcel(
file: File,
exportExcel: boolean,
configPath?: string,
): Promise<PayrollResponse> {
const formData = new FormData();
formData.append("file", file);
formData.append("export_excel", String(exportExcel));
if (configPath) {
formData.append("config_path", configPath);
}
return apiFetch<PayrollResponse>("/api/payroll/excel", {
method: "POST",
body: formData,
});
}
export function calculateFromDingTalk(payload: DingTalkPayrollRequest): Promise<PayrollResponse> {
return apiFetch<PayrollResponse>("/api/payroll/dingtalk/realtime", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function getPayrollJob(jobId: string): Promise<PayrollJobResponse> {
return apiFetch<PayrollJobResponse>(`/api/payroll/jobs/${encodeURIComponent(jobId)}`);
}
export function getDownloadUrl(downloadUrl: string | null, outputFile?: string | null): string {
if (downloadUrl) {
return buildApiUrl(downloadUrl);
}
const filename = outputFile?.split(/[\\/]/).pop();
return filename ? buildApiUrl(`/api/payroll/download/${encodeURIComponent(filename)}`) : "";
}
export async function downloadPayrollFile(downloadUrl: string | null, outputFile?: string | null): Promise<void> {
const url = getDownloadUrl(downloadUrl, outputFile);
if (!url) {
throw new Error("没有可下载的结果文件");
}
const headers = new Headers();
const token = getStoredToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(url, { headers });
if (!response.ok) {
throw new ApiError("下载失败,请确认文件仍然存在", response.status);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const filename = outputFile?.split(/[\\/]/).pop() || "工资计算结果.xlsx";
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
export function readRecentJobs(): RecentPayrollJob[] {
try {
const raw = localStorage.getItem(RECENT_JOBS_KEY);
return raw ? (JSON.parse(raw) as RecentPayrollJob[]) : [];
} catch {
return [];
}
}
export function saveRecentJob(sourceType: string, response: PayrollResponse): void {
const nextJob: RecentPayrollJob = {
job_id: response.job_id,
source_type: sourceType,
employee_count: response.employee_count,
output_file: response.output_file,
download_url: response.download_url,
created_at: new Date().toISOString(),
};
const jobs = [nextJob, ...readRecentJobs().filter((item) => item.job_id !== response.job_id)].slice(0, 12);
localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(jobs));
}
+11
View File
@@ -0,0 +1,11 @@
import { apiFetch } from "./http";
import type { PayrollReportResponse } from "./types";
export function getPayrollReport(salaryMonth = ""): Promise<PayrollReportResponse> {
const params = new URLSearchParams();
if (salaryMonth) {
params.set("salary_month", salaryMonth);
}
const query = params.toString();
return apiFetch<PayrollReportResponse>(`/api/reports/payroll-summary${query ? `?${query}` : ""}`);
}
+18
View File
@@ -0,0 +1,18 @@
import { apiFetch } from "./http";
import type { SalaryProfileRecord, SalaryProfileRequest } from "./types";
export function listSalaryProfiles(keyword = ""): Promise<SalaryProfileRecord[]> {
const search = new URLSearchParams();
if (keyword.trim()) {
search.set("keyword", keyword.trim());
}
const query = search.toString();
return apiFetch<SalaryProfileRecord[]>(`/api/salary-profiles${query ? `?${query}` : ""}`);
}
export function saveSalaryProfile(payload: SalaryProfileRequest): Promise<SalaryProfileRecord> {
return apiFetch<SalaryProfileRecord>(`/api/salary-profiles/${payload.employee_id}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
+378
View File
@@ -0,0 +1,378 @@
export interface MenuItem {
code: string;
name: string;
path: string;
}
export type UserRole = "superuser" | "manager" | "viewer";
export interface UserProfile {
id: number;
username: string;
display_name: string;
avatar_url: string;
email: string;
phone: string;
department: string;
position_title: string;
role: UserRole;
is_active: boolean;
menus: MenuItem[];
permissions: string[];
}
export interface UserRecord extends UserProfile {
created_at: string;
updated_at: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
user: UserProfile;
menus: MenuItem[];
permissions: string[];
}
export interface CreateUserRequest {
username: string;
password: string;
role: UserRole;
display_name: string;
email: string;
phone: string;
department: string;
position_title: string;
}
export interface UpdateProfileRequest {
display_name: string;
email: string;
phone: string;
department: string;
position_title: string;
}
export interface ChangePasswordRequest {
old_password: string;
new_password: string;
confirm_password: string;
}
export interface ChangePasswordResponse {
message: string;
}
export interface PayrollResult {
name: string;
department: string;
position: string;
attendance_group: string;
salary_month: string;
salary_mode: string;
attendance_days: number;
absence_days: number;
actual_work_hours: number;
punch_overtime_hours: number;
leave_hours: number;
remaining_overtime_hours: number;
workday_overtime_hours: number;
weekend_overtime_hours: number;
holiday_overtime_hours: number;
minor_late_count: number;
major_late_count: number;
penalized_late_count: number;
late_deduction: number;
missing_card_count: number;
missing_card_deduction: number;
total_deduction: number;
base_salary: number | null;
base_pay: number | null;
commission_amount: number;
overtime_rate: number;
weekend_overtime_rate: number;
holiday_overtime_rate: number;
overtime_pay: number;
gross_salary: number | null;
net_salary: number | null;
note: string;
}
export interface PayrollResponse {
job_id: string;
employee_count: number;
output_file: string | null;
download_url: string | null;
results: PayrollResult[];
}
export interface PayrollJobResponse {
job_id: string;
source_type: string;
status: string;
input_file: string | null;
output_file: string | null;
employee_count: number;
error_message: string | null;
created_at: string;
updated_at: string;
results: PayrollResult[];
}
export interface DingTalkPayrollRequest {
user_ids: string[];
start_date: string;
end_date: string;
config_path?: string | null;
export_excel: boolean;
}
export interface RecentPayrollJob {
job_id: string;
source_type: string;
employee_count: number;
output_file: string | null;
download_url: string | null;
created_at: string;
}
export interface OperationLog {
id: number;
user_id: number | null;
username: string;
role: string;
module: string;
action: string;
target_type: string;
target_id: string;
status: string;
detail: string;
ip_address: string;
user_agent: string;
created_at: string;
}
export interface OperationLogListResponse {
items: OperationLog[];
total: number;
page: number;
page_size: number;
}
export interface OperationLogQuery {
page?: number;
page_size?: number;
username?: string;
module?: string;
action?: string;
status?: string;
keyword?: string;
start_time?: string;
end_time?: string;
}
export type EmploymentStatus = "active" | "inactive";
export interface EmployeeRecord {
id: number;
employee_no: string;
name: string;
dingtalk_user_id: string;
department: string;
position: string;
phone: string;
employment_status: EmploymentStatus;
remark: string;
created_at: string;
updated_at: string;
}
export interface EmployeeListResponse {
items: EmployeeRecord[];
total: number;
page: number;
page_size: number;
}
export interface EmployeeNoResponse {
employee_no: string;
}
export interface EmployeeRequest {
employee_no: string;
name: string;
dingtalk_user_id: string;
department: string;
position: string;
phone: string;
employment_status: EmploymentStatus;
remark: string;
}
export interface DepartmentRecord {
id: number;
parent_id: number | null;
department_code: string;
name: string;
sort_order: number;
is_active: boolean;
remark: string;
created_at: string;
updated_at: string;
}
export interface DepartmentRequest {
parent_id: number | null;
department_code: string;
name: string;
sort_order: number;
is_active: boolean;
remark: string;
}
export interface PositionRecord {
id: number;
department_id: number;
department: DepartmentRecord;
position_code: string;
name: string;
sort_order: number;
is_active: boolean;
remark: string;
created_at: string;
updated_at: string;
}
export interface PositionRequest {
department_id: number;
position_code?: string;
name: string;
sort_order: number;
is_active: boolean;
remark: string;
}
export interface SalaryProfileRecord {
id: number;
employee_id: number;
employee: EmployeeRecord;
salary_mode: string;
base_salary: number;
hourly_rate: number;
piece_quantity: number;
piece_unit_price: number;
probation_type: string;
probation_ratio: number;
probation_salary: number;
commission_amount: number;
overtime_rate: number;
weekend_overtime_rate: number;
holiday_overtime_rate: number;
effective_month: string;
remark: string;
created_at: string;
updated_at: string;
}
export interface SalaryProfileRequest {
employee_id: number;
salary_mode: string;
base_salary: number;
hourly_rate: number;
piece_quantity: number;
piece_unit_price: number;
probation_type: string;
probation_ratio: number;
probation_salary: number;
commission_amount: number;
overtime_rate: number;
weekend_overtime_rate: number;
holiday_overtime_rate: number;
effective_month: string;
remark: string;
}
export interface CommissionRecord {
id: number;
employee_id: number;
employee: EmployeeRecord;
commission_month: string;
commission_type: string;
amount: number;
source_type: string;
business_ref: string;
remark: string;
created_at: string;
updated_at: string;
}
export interface CommissionListResponse {
items: CommissionRecord[];
total: number;
page: number;
page_size: number;
}
export interface CommissionRecordRequest {
employee_id: number;
commission_month: string;
commission_type: string;
amount: number;
source_type: string;
business_ref: string;
remark: string;
}
export interface CommissionImportResponse {
imported_count: number;
message: string;
}
export interface SalaryConfigRecord {
id: number;
config_key: string;
config_name: string;
config_group: string;
config_value: string;
value_type: string;
is_enabled: boolean;
remark: string;
created_at: string;
updated_at: string;
}
export interface SalaryConfigRequest {
config_name: string;
config_group: string;
config_value: string;
value_type: string;
is_enabled: boolean;
remark: string;
}
export interface PayrollReportResponse {
salary_month: string | null;
totals: Record<string, number>;
attendance: Record<string, number>;
departments: Array<{
department: string;
employee_count: number;
gross_salary: number;
net_salary: number;
}>;
recent_records: Array<{
id: number;
employee_no: string;
employee_name: string;
department: string;
salary_month: string;
salary_mode: string;
base_pay: number;
commission_amount: number;
overtime_pay: number;
deduction_amount: number;
gross_salary: number;
net_salary: number;
created_at: string;
}>;
}
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import SidebarNav from "./SidebarNav.vue";
import TopBar from "./TopBar.vue";
const SIDEBAR_COLLAPSED_KEY = "financial-system-sidebar-collapsed";
const sidebarOpen = ref(false);
const sidebarCollapsed = ref(readSidebarCollapsed());
watch(sidebarCollapsed, (value) => {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? "1" : "0");
});
function readSidebarCollapsed(): boolean {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
}
</script>
<template>
<div class="app-shell" :class="{ 'is-sidebar-collapsed': sidebarCollapsed }">
<SidebarNav
:collapsed="sidebarCollapsed"
:open="sidebarOpen"
@close="sidebarOpen = false"
@toggle-collapse="sidebarCollapsed = !sidebarCollapsed"
/>
<Transition name="fade">
<div v-if="sidebarOpen" class="sidebar-scrim" @click="sidebarOpen = false" />
</Transition>
<div class="main-shell">
<TopBar @toggle-sidebar="sidebarOpen = true" />
<main class="page-shell">
<RouterView />
</main>
</div>
</div>
</template>
+316
View File
@@ -0,0 +1,316 @@
<script setup lang="ts">
import { Check, RotateCcw, X } from "@lucide/vue";
import {
themeOptions,
useThemeStore,
type ContentWidthChoice,
type DensityChoice,
type FontChoice,
type LayoutChoice,
type RadiusChoice,
type SidebarChoice,
type ThemeMode,
} from "../stores/theme";
const emit = defineEmits<{
close: [];
}>();
const theme = useThemeStore();
const modeOptions: Array<{ name: ThemeMode; label: string }> = [
{ name: "system", label: "系统" },
{ name: "light", label: "浅色" },
{ name: "dark", label: "深色" },
];
const fontOptions: Array<{ name: FontChoice; label: string }> = [
{ name: "auto", label: "Auto" },
{ name: "sans", label: "Sans" },
{ name: "serif", label: "Serif" },
];
const radiusOptions: Array<{ name: RadiusChoice; label: string; previewRadius: string }> = [
{ name: "auto", label: "Auto", previewRadius: "20px" },
{ name: "0", label: "0", previewRadius: "0px" },
{ name: "0.3", label: "0.3", previewRadius: "8px" },
{ name: "0.5", label: "0.5", previewRadius: "14px" },
{ name: "0.75", label: "0.75", previewRadius: "20px" },
{ name: "1", label: "1.0", previewRadius: "28px" },
];
const densityOptions: Array<{ name: DensityChoice; label: string; lines: number }> = [
{ name: "compact", label: "紧凑", lines: 5 },
{ name: "default", label: "默认", lines: 4 },
{ name: "comfortable", label: "宽松", lines: 3 },
{ name: "large", label: "超大", lines: 1 },
];
const sidebarOptions: Array<{ name: SidebarChoice; label: string }> = [
{ name: "embedded", label: "内嵌" },
{ name: "floating", label: "浮动" },
{ name: "edge", label: "侧边栏" },
];
const layoutOptions: Array<{ name: LayoutChoice; label: string }> = [
{ name: "default", label: "默认" },
{ name: "compact", label: "紧凑" },
{ name: "fullscreen", label: "全屏布局" },
];
const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> = [
{ name: "full", label: "全宽" },
{ name: "centered", label: "居中" },
];
</script>
<template>
<div class="appearance-panel-layer" @click.self="emit('close')">
<aside class="appearance-panel" role="dialog" aria-label="外观设置">
<header class="appearance-panel-header">
<div>
<h2>外观</h2>
<p>主题、颜色与布局</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="emit('close')">
<X :size="21" aria-hidden="true" />
</button>
</header>
<section class="appearance-section">
<div class="appearance-section-head">
<h3>主题</h3>
<button class="appearance-reset" type="button" title="恢复默认主题" @click="theme.resetTheme">
<RotateCcw :size="17" aria-hidden="true" />
</button>
</div>
<div class="appearance-grid mode-grid">
<button
v-for="item in modeOptions"
:key="item.name"
class="appearance-choice theme-mode-choice"
:class="[`is-${item.name}`, { 'is-active': theme.mode === item.name }]"
type="button"
@click="theme.setMode(item.name)"
>
<span class="mode-preview" aria-hidden="true">
<span class="preview-sidebar">
<i />
<i />
<i />
</span>
<span class="preview-chart">
<i />
<i />
<i />
</span>
<span class="preview-pie" />
<span class="preview-card" />
</span>
<strong>{{ item.label }}</strong>
<span v-if="theme.mode === item.name" class="choice-check" aria-hidden="true">
<Check :size="18" />
</span>
</button>
</div>
</section>
<section class="appearance-section">
<div class="appearance-section-head">
<h3>颜色预设</h3>
<button class="appearance-reset" type="button" title="恢复默认颜色" @click="theme.resetColor">
<RotateCcw :size="17" aria-hidden="true" />
</button>
</div>
<div class="appearance-grid color-preset-grid">
<button
v-for="item in themeOptions"
: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] }"
type="button"
@click="theme.setTheme(item.name)"
>
<span class="color-preset-swatch" aria-hidden="true" />
<strong>{{ item.label }}</strong>
<span v-if="theme.current === item.name" class="choice-check" aria-hidden="true">
<Check :size="18" />
</span>
</button>
</div>
</section>
<section class="appearance-section">
<div class="appearance-section-head">
<h3>字体</h3>
<button class="appearance-reset" type="button" title="恢复默认字体" @click="theme.resetFont">
<RotateCcw :size="17" aria-hidden="true" />
</button>
</div>
<div class="appearance-grid font-grid">
<button
v-for="item in fontOptions"
:key="item.name"
class="appearance-choice font-choice"
:class="[`font-${item.name}`, { 'is-active': theme.font === item.name }]"
type="button"
@click="theme.setFont(item.name)"
>
<span>Aa</span>
<strong>{{ item.label }}</strong>
<span v-if="theme.font === item.name" class="choice-check" aria-hidden="true">
<Check :size="18" />
</span>
</button>
</div>
</section>
<section class="appearance-section">
<div class="appearance-section-head">
<h3>圆角</h3>
<button class="appearance-reset" type="button" title="恢复默认圆角" @click="theme.resetRadius">
<RotateCcw :size="17" aria-hidden="true" />
</button>
</div>
<div class="appearance-grid radius-grid">
<button
v-for="item in radiusOptions"
:key="item.name"
class="appearance-choice radius-choice"
:class="{ 'is-active': theme.radius === item.name }"
:style="{ '--preview-radius': item.previewRadius }"
type="button"
@click="theme.setRadius(item.name)"
>
<span class="radius-preview" aria-hidden="true" />
<strong>{{ item.label }}</strong>
<span v-if="theme.radius === item.name" class="choice-check" aria-hidden="true">
<Check :size="18" />
</span>
</button>
</div>
</section>
<section class="appearance-section">
<div class="appearance-section-head">
<h3>密度</h3>
<button class="appearance-reset" type="button" title="恢复默认密度" @click="theme.resetDensity">
<RotateCcw :size="17" aria-hidden="true" />
</button>
</div>
<div class="appearance-grid density-grid">
<button
v-for="item in densityOptions"
:key="item.name"
class="appearance-choice density-choice"
:class="{ 'is-active': theme.density === item.name }"
type="button"
@click="theme.setDensity(item.name)"
>
<span class="density-preview" aria-hidden="true">
<i v-for="line in item.lines" :key="line" />
</span>
<strong>{{ item.label }}</strong>
<span v-if="theme.density === item.name" class="choice-check" aria-hidden="true">
<Check :size="18" />
</span>
</button>
</div>
</section>
<section class="appearance-section">
<div class="appearance-section-head">
<h3>侧边栏</h3>
<button class="appearance-reset" type="button" title="恢复默认侧边栏" @click="theme.resetSidebar">
<RotateCcw :size="17" aria-hidden="true" />
</button>
</div>
<div class="appearance-grid sidebar-grid">
<button
v-for="item in sidebarOptions"
:key="item.name"
class="appearance-choice layout-choice sidebar-choice"
:class="[`is-${item.name}`, { 'is-active': theme.sidebar === item.name }]"
type="button"
@click="theme.setSidebar(item.name)"
>
<span class="layout-preview" aria-hidden="true">
<span class="layout-side">
<i />
<i />
<i />
</span>
<span class="layout-main" />
</span>
<strong>{{ item.label }}</strong>
<span v-if="theme.sidebar === item.name" class="choice-check" aria-hidden="true">
<Check :size="18" />
</span>
</button>
</div>
</section>
<section class="appearance-section">
<div class="appearance-section-head">
<h3>布局</h3>
<button class="appearance-reset" type="button" title="恢复默认布局" @click="theme.resetLayout">
<RotateCcw :size="17" aria-hidden="true" />
</button>
</div>
<div class="appearance-grid layout-grid">
<button
v-for="item in layoutOptions"
:key="item.name"
class="appearance-choice layout-choice dashboard-layout-choice"
:class="[`is-${item.name}`, { 'is-active': theme.layout === item.name }]"
type="button"
@click="theme.setLayout(item.name)"
>
<span class="dashboard-preview" aria-hidden="true">
<i />
<i />
<i />
<i />
<i />
</span>
<strong>{{ item.label }}</strong>
<span v-if="theme.layout === item.name" class="choice-check" aria-hidden="true">
<Check :size="18" />
</span>
</button>
</div>
</section>
<section class="appearance-section">
<div class="appearance-section-head">
<h3>内容宽度</h3>
<button class="appearance-reset" type="button" title="恢复默认内容宽度" @click="theme.resetContentWidth">
<RotateCcw :size="17" aria-hidden="true" />
</button>
</div>
<div class="appearance-grid content-width-grid">
<button
v-for="item in contentWidthOptions"
:key="item.name"
class="appearance-choice content-width-choice"
:class="[`is-${item.name}`, { 'is-active': theme.contentWidth === item.name }]"
type="button"
@click="theme.setContentWidth(item.name)"
>
<span class="content-width-preview" aria-hidden="true">
<i />
<i />
<i />
</span>
<strong>{{ item.label }}</strong>
<span v-if="theme.contentWidth === item.name" class="choice-check" aria-hidden="true">
<Check :size="18" />
</span>
</button>
</div>
</section>
</aside>
</div>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { PayrollResult } from "../api/types";
defineProps<{
results: PayrollResult[];
}>();
function money(value: number | null): string {
if (value === null || Number.isNaN(value)) {
return "-";
}
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(value);
}
function hours(value: number): string {
return `${value.toFixed(1)}h`;
}
function modeLabel(value: string): string {
const labels: Record<string, string> = {
monthly: "包月",
hourly: "计时",
piecework: "计件",
probation: "试用期",
};
return labels[value] || value;
}
</script>
<template>
<section class="panel table-panel">
<div class="panel-header">
<div>
<h2>员工工资结果</h2>
<p>共 {{ results.length }} 条</p>
</div>
</div>
<div v-if="results.length" 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>
<th>底薪</th>
<th>基础工资</th>
<th>提成</th>
<th>加班单价</th>
<th>加班费</th>
<th>实发工资</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr v-for="item in results" :key="`${item.name}-${item.department}-${item.position}`">
<td>
<strong>{{ item.name }}</strong>
<span>{{ item.attendance_group || "默认考勤组" }}</span>
</td>
<td>
<strong>{{ item.salary_month || "-" }}</strong>
<span>{{ modeLabel(item.salary_mode) }}</span>
</td>
<td>{{ item.department || "-" }}</td>
<td>{{ item.position || "-" }}</td>
<td class="mono">
{{ item.attendance_days }} / {{ item.absence_days }}
<span>{{ hours(item.actual_work_hours) }}</span>
</td>
<td class="mono">{{ hours(item.punch_overtime_hours) }}</td>
<td class="mono">{{ hours(item.leave_hours) }}</td>
<td class="mono strong">{{ hours(item.remaining_overtime_hours) }}</td>
<td class="mono">
工作日 {{ hours(item.workday_overtime_hours) }}
<span>周末 {{ hours(item.weekend_overtime_hours) }}</span>
<span>节假日 {{ hours(item.holiday_overtime_hours) }}</span>
</td>
<td class="mono">{{ money(item.late_deduction) }}</td>
<td class="mono">{{ money(item.missing_card_deduction) }}</td>
<td class="mono">{{ money(item.base_salary) }}</td>
<td class="mono">{{ money(item.base_pay) }}</td>
<td class="mono">{{ money(item.commission_amount) }}</td>
<td class="mono">
{{ money(item.overtime_rate) }}
<span>{{ money(item.weekend_overtime_rate) }}</span>
<span>{{ money(item.holiday_overtime_rate) }}</span>
</td>
<td class="mono">{{ money(item.overtime_pay) }}</td>
<td class="mono strong">{{ money(item.net_salary) }}</td>
<td class="note-cell">{{ item.note || "-" }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-state">
<p>暂无工资结果</p>
</div>
</section>
</template>
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import {
Banknote,
BadgeDollarSign,
Building2,
ClipboardList,
FileChartColumn,
LayoutDashboard,
LogOut,
Network,
PanelLeftClose,
PanelLeftOpen,
ScrollText,
Settings2,
HandCoins,
UserCog,
UsersRound,
} from "@lucide/vue";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
defineProps<{
collapsed: boolean;
open: boolean;
}>();
const emit = defineEmits<{
close: [];
"toggle-collapse": [];
}>();
const auth = useAuthStore();
const router = useRouter();
const backendIconMap = {
payroll: Banknote,
jobs: ClipboardList,
employees: UserCog,
organization: Network,
salary_profiles: BadgeDollarSign,
commissions: HandCoins,
reports: FileChartColumn,
configs: Settings2,
users: UsersRound,
operation_logs: ScrollText,
};
const menuItems = computed(() => [
{
code: "dashboard",
name: "工作台",
path: "/dashboard",
icon: LayoutDashboard,
},
...auth.menus.map((menu) => ({
...menu,
icon: backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
})),
]);
function logout(): void {
auth.logout();
emit("close");
router.push("/login");
}
</script>
<template>
<aside class="sidebar" :class="{ 'is-open': open }">
<div class="brand">
<div class="brand-main">
<div class="brand-mark">
<Building2 :size="22" aria-hidden="true" />
</div>
<div class="brand-copy">
<p class="brand-title">企业薪酬管理</p>
<p class="brand-subtitle">管理门户</p>
</div>
</div>
</div>
<nav class="sidebar-nav" aria-label="主导航">
<RouterLink
v-for="item in menuItems"
:key="item.code"
:to="item.path"
class="nav-link"
active-class="is-active"
:title="item.name"
@click="emit('close')"
>
<component :is="item.icon" :size="21" aria-hidden="true" />
<span>{{ item.name }}</span>
</RouterLink>
</nav>
<div class="sidebar-footer">
<button
class="nav-link nav-button"
type="button"
:title="collapsed ? '展开侧边栏' : '收起侧边栏'"
@click="emit('toggle-collapse')"
>
<PanelLeftOpen v-if="collapsed" :size="21" aria-hidden="true" />
<PanelLeftClose v-else :size="21" aria-hidden="true" />
<span>{{ collapsed ? "展开侧栏" : "收起侧栏" }}</span>
</button>
<button class="nav-link nav-button danger" type="button" title="退出登录" @click="logout">
<LogOut :size="21" aria-hidden="true" />
<span>退出登录</span>
</button>
</div>
</aside>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { Component } from "vue";
defineProps<{
title: string;
value: string;
meta?: string;
tone?: "blue" | "green" | "red" | "neutral";
icon: Component;
}>();
</script>
<template>
<section class="stat-card" :class="`tone-${tone || 'neutral'}`">
<div class="stat-head">
<div class="stat-icon">
<component :is="icon" :size="23" aria-hidden="true" />
</div>
<span v-if="meta" class="stat-meta">{{ meta }}</span>
</div>
<p>{{ title }}</p>
<strong>{{ value }}</strong>
</section>
</template>
+292
View File
@@ -0,0 +1,292 @@
<script setup lang="ts">
import {
Bell,
CircleHelp,
KeyRound,
Loader2,
Menu,
Palette,
Save,
Search,
Upload,
X,
} from "@lucide/vue";
import { computed, reactive, ref, watch } from "vue";
import { ApiError, buildApiUrl } from "../api/http";
import AppearancePanel from "./AppearancePanel.vue";
import { useAuthStore } from "../stores/auth";
defineEmits<{
"toggle-sidebar": [];
}>();
const auth = useAuthStore();
const showThemePanel = ref(false);
const showProfileModal = ref(false);
const savingProfile = ref(false);
const savingPassword = ref(false);
const uploadingAvatar = ref(false);
const profileError = ref("");
const profileSuccess = ref("");
const passwordError = ref("");
const passwordSuccess = ref("");
const profileForm = reactive({
display_name: "",
email: "",
phone: "",
department: "",
position_title: "",
});
const passwordForm = reactive({
old_password: "",
new_password: "",
confirm_password: "",
});
const initials = computed(() => {
const source = auth.displayName || auth.user?.username || "U";
return source.slice(0, 2).toUpperCase();
});
const avatarSrc = computed(() => {
const avatarUrl = auth.user?.avatar_url || "";
if (!avatarUrl) {
return "";
}
return /^https?:\/\//.test(avatarUrl) || avatarUrl.startsWith("data:") ? avatarUrl : buildApiUrl(avatarUrl);
});
watch(
() => auth.user,
(user) => {
profileForm.display_name = user?.display_name || "";
profileForm.email = user?.email || "";
profileForm.phone = user?.phone || "";
profileForm.department = user?.department || "";
profileForm.position_title = user?.position_title || "";
},
{ immediate: true },
);
async function saveProfile(): Promise<void> {
savingProfile.value = true;
profileError.value = "";
profileSuccess.value = "";
try {
await auth.updateProfile({ ...profileForm });
profileSuccess.value = "个人资料已保存";
} catch (error) {
profileError.value = error instanceof ApiError ? error.message : "保存失败,请稍后重试";
} finally {
savingProfile.value = false;
}
}
async function savePassword(): Promise<void> {
savingPassword.value = true;
passwordError.value = "";
passwordSuccess.value = "";
if (passwordForm.new_password !== passwordForm.confirm_password) {
passwordError.value = "两次输入的新密码不一致";
savingPassword.value = false;
return;
}
try {
const message = await auth.changePassword({ ...passwordForm });
passwordSuccess.value = message || "密码修改成功";
clearPasswordForm(false);
} catch (error) {
passwordError.value = error instanceof ApiError ? error.message : "密码修改失败,请稍后重试";
} finally {
savingPassword.value = false;
}
}
function clearPasswordForm(clearMessages = true): void {
passwordForm.old_password = "";
passwordForm.new_password = "";
passwordForm.confirm_password = "";
if (clearMessages) {
passwordError.value = "";
passwordSuccess.value = "";
}
}
async function handleAvatarChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
uploadingAvatar.value = true;
profileError.value = "";
profileSuccess.value = "";
try {
await auth.uploadAvatar(file);
profileSuccess.value = "头像已更新";
} catch (error) {
profileError.value = error instanceof ApiError ? error.message : "头像上传失败";
} finally {
uploadingAvatar.value = false;
input.value = "";
}
}
</script>
<template>
<header class="topbar">
<div class="topbar-left">
<button class="icon-button mobile-menu" type="button" title="打开菜单" @click="$emit('toggle-sidebar')">
<Menu :size="22" aria-hidden="true" />
</button>
<div class="global-search">
<Search :size="20" aria-hidden="true" />
<input type="search" placeholder="搜索员工、任务或部门..." />
</div>
</div>
<div class="topbar-actions">
<div class="theme-switcher">
<button class="icon-button" type="button" title="切换肤色" @click="showThemePanel = !showThemePanel">
<Palette :size="21" aria-hidden="true" />
</button>
<Teleport to="body">
<Transition name="drawer-slide">
<AppearancePanel v-if="showThemePanel" @close="showThemePanel = false" />
</Transition>
</Teleport>
</div>
<button class="icon-button" type="button" title="通知">
<Bell :size="21" aria-hidden="true" />
<span class="notice-dot" aria-hidden="true" />
</button>
<button class="icon-button" type="button" title="帮助">
<CircleHelp :size="21" aria-hidden="true" />
</button>
<button class="user-summary profile-trigger" type="button" title="编辑个人资料" @click="showProfileModal = true">
<div class="user-text">
<strong>{{ auth.displayName }}</strong>
<span>{{ auth.user?.position_title || auth.roleLabel }}</span>
</div>
<div class="avatar" aria-hidden="true">
<img v-if="avatarSrc" :src="avatarSrc" alt="" />
<span v-else>{{ initials }}</span>
</div>
</button>
</div>
<Teleport to="body">
<div v-if="showProfileModal" class="modal-backdrop" @click.self="showProfileModal = false">
<section class="profile-modal" aria-label="个人资料">
<div class="modal-header">
<div>
<h2>个人资料</h2>
<p>修改头像、联系方式、岗位信息和登录密码</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="showProfileModal = false">
<X :size="21" aria-hidden="true" />
</button>
</div>
<div class="profile-avatar-row">
<div class="avatar large" aria-hidden="true">
<img v-if="avatarSrc" :src="avatarSrc" alt="" />
<span v-else>{{ initials }}</span>
</div>
<div>
<strong>{{ auth.user?.username }}</strong>
<p>{{ auth.roleLabel }}</p>
<label class="secondary-button avatar-upload">
<Loader2 v-if="uploadingAvatar" class="spin" :size="17" aria-hidden="true" />
<Upload v-else :size="17" aria-hidden="true" />
<span>{{ uploadingAvatar ? "上传中" : "上传头像" }}</span>
<input accept=".jpg,.jpeg,.png,.webp,.gif" type="file" @change="handleAvatarChange" />
</label>
</div>
</div>
<form class="profile-form" @submit.prevent="saveProfile">
<label class="field">
<span>显示名称</span>
<input v-model="profileForm.display_name" type="text" />
</label>
<label class="field">
<span>邮箱</span>
<input v-model="profileForm.email" type="email" />
</label>
<label class="field">
<span>手机号</span>
<input v-model="profileForm.phone" type="tel" />
</label>
<label class="field">
<span>部门</span>
<input v-model="profileForm.department" type="text" />
</label>
<label class="field span-row">
<span>职位</span>
<input v-model="profileForm.position_title" type="text" />
</label>
<p v-if="profileError" class="form-error span-row">{{ profileError }}</p>
<p v-if="profileSuccess" class="form-success span-row">{{ profileSuccess }}</p>
<div class="form-actions align-right span-row">
<button class="secondary-button" type="button" @click="showProfileModal = false">取消</button>
<button class="primary-button" type="submit" :disabled="savingProfile">
<Loader2 v-if="savingProfile" class="spin" :size="18" aria-hidden="true" />
<Save v-else :size="18" aria-hidden="true" />
<span>{{ savingProfile ? "保存中" : "保存资料" }}</span>
</button>
</div>
</form>
<form class="password-form" @submit.prevent="savePassword">
<div class="form-section-head span-row">
<div>
<h3>修改密码</h3>
<p>需要输入当前密码后才能更新登录密码</p>
</div>
</div>
<label class="field">
<span>当前密码</span>
<input v-model="passwordForm.old_password" autocomplete="current-password" required type="password" />
</label>
<label class="field">
<span>新密码</span>
<input v-model="passwordForm.new_password" autocomplete="new-password" minlength="6" required type="password" />
</label>
<label class="field">
<span>确认新密码</span>
<input
v-model="passwordForm.confirm_password"
autocomplete="new-password"
minlength="6"
required
type="password"
/>
</label>
<p v-if="passwordError" class="form-error span-row">{{ passwordError }}</p>
<p v-if="passwordSuccess" class="form-success span-row">{{ passwordSuccess }}</p>
<div class="form-actions align-right span-row">
<button class="secondary-button" type="button" @click="() => clearPasswordForm()">清空</button>
<button class="primary-button" type="submit" :disabled="savingPassword">
<Loader2 v-if="savingPassword" class="spin" :size="18" aria-hidden="true" />
<KeyRound v-else :size="18" aria-hidden="true" />
<span>{{ savingPassword ? "修改中" : "修改密码" }}</span>
</button>
</div>
</form>
</section>
</div>
</Teleport>
</header>
</template>
+14
View File
@@ -0,0 +1,14 @@
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import { useThemeStore } from "./stores/theme";
import "./assets/styles.css";
const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
useThemeStore().applyTheme();
app.use(router).mount("#app");
+173
View File
@@ -0,0 +1,173 @@
import { createRouter, createWebHistory } from "vue-router";
import AppLayout from "../components/AppLayout.vue";
import { useAuthStore } from "../stores/auth";
import CommissionsView from "../views/CommissionsView.vue";
import ConfigCenterView from "../views/ConfigCenterView.vue";
import DashboardView from "../views/DashboardView.vue";
import EmployeesView from "../views/EmployeesView.vue";
import JobsView from "../views/JobsView.vue";
import LoginView from "../views/LoginView.vue";
import OperationLogsView from "../views/OperationLogsView.vue";
import OrganizationView from "../views/OrganizationView.vue";
import PayrollView from "../views/PayrollView.vue";
import ReportsView from "../views/ReportsView.vue";
import SalaryProfilesView from "../views/SalaryProfilesView.vue";
import UsersView from "../views/UsersView.vue";
declare module "vue-router" {
interface RouteMeta {
public?: boolean;
permission?: string;
}
}
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/login",
name: "login",
component: LoginView,
meta: { public: true },
},
{
path: "/",
component: AppLayout,
children: [
{
path: "",
redirect: "/dashboard",
},
{
path: "dashboard",
name: "dashboard",
component: DashboardView,
},
{
path: "payroll",
name: "payroll",
component: PayrollView,
meta: { permission: "payroll:excel:calculate" },
},
{
path: "payroll/jobs",
name: "jobs",
component: JobsView,
meta: { permission: "payroll:job:view" },
},
{
path: "payroll/commissions",
name: "commissions",
component: CommissionsView,
meta: { permission: "commission:view" },
},
{
path: "reports",
name: "reports",
component: ReportsView,
meta: { permission: "report:view" },
},
{
path: "system/users",
name: "users",
component: UsersView,
meta: { permission: "user:list" },
},
{
path: "system/employees",
name: "employees",
component: EmployeesView,
meta: { permission: "employee:view" },
},
{
path: "system/organization",
name: "organization",
component: OrganizationView,
meta: { permission: "organization:view" },
},
{
path: "system/salary-profiles",
name: "salary-profiles",
component: SalaryProfilesView,
meta: { permission: "salary_profile:view" },
},
{
path: "system/configs",
name: "configs",
component: ConfigCenterView,
meta: { permission: "config:view" },
},
{
path: "system/operation-logs",
name: "operation-logs",
component: OperationLogsView,
meta: { permission: "operation_log:view" },
},
],
},
{
path: "/:pathMatch(.*)*",
redirect: "/dashboard",
},
],
});
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (!to.meta.public && !auth.loaded) {
await auth.loadSession();
}
if (to.meta.public) {
return auth.isAuthenticated ? "/dashboard" : true;
}
if (!auth.isAuthenticated) {
return { path: "/login", query: { redirect: to.fullPath } };
}
const permission = to.meta.permission;
if (permission && !auth.hasPermission(permission)) {
return firstAllowedPath(auth.permissions);
}
return true;
});
function firstAllowedPath(permissions: string[]): string {
if (permissions.includes("payroll:excel:calculate")) {
return "/payroll";
}
if (permissions.includes("payroll:job:view")) {
return "/payroll/jobs";
}
if (permissions.includes("commission:view")) {
return "/payroll/commissions";
}
if (permissions.includes("report:view")) {
return "/reports";
}
if (permissions.includes("user:list")) {
return "/system/users";
}
if (permissions.includes("employee:view")) {
return "/system/employees";
}
if (permissions.includes("organization:view")) {
return "/system/organization";
}
if (permissions.includes("salary_profile:view")) {
return "/system/salary-profiles";
}
if (permissions.includes("config:view")) {
return "/system/configs";
}
if (permissions.includes("operation_log:view")) {
return "/system/operation-logs";
}
return "/dashboard";
}
export default router;
+102
View File
@@ -0,0 +1,102 @@
import { defineStore } from "pinia";
import {
clearStoredToken,
getStoredToken,
setStoredToken,
} from "../api/http";
import {
changePassword as changePasswordRequest,
getCurrentUser,
login as loginRequest,
updateProfile as updateProfileRequest,
uploadAvatar as uploadAvatarRequest,
} from "../api/auth";
import type { ChangePasswordRequest, MenuItem, UpdateProfileRequest, UserProfile } from "../api/types";
interface AuthState {
token: string;
user: UserProfile | null;
menus: MenuItem[];
permissions: string[];
loaded: boolean;
}
export const useAuthStore = defineStore("auth", {
state: (): AuthState => ({
token: getStoredToken(),
user: null,
menus: [],
permissions: [],
loaded: false,
}),
getters: {
isAuthenticated: (state) => Boolean(state.token),
displayName: (state) => state.user?.display_name || state.user?.username || "未登录",
roleLabel: (state) => roleLabel(state.user?.role || ""),
},
actions: {
hasPermission(permission: string): boolean {
return this.permissions.includes(permission);
},
async login(username: string, password: string): Promise<void> {
const session = await loginRequest(username, password);
this.token = session.access_token;
this.user = session.user;
this.menus = session.menus;
this.permissions = session.permissions;
this.loaded = true;
setStoredToken(session.access_token);
},
async loadSession(): Promise<void> {
if (!this.token) {
this.loaded = true;
return;
}
try {
const profile = await getCurrentUser();
this.user = profile;
this.menus = profile.menus;
this.permissions = profile.permissions;
} catch (error) {
this.logout();
throw error;
} finally {
this.loaded = true;
}
},
async updateProfile(payload: UpdateProfileRequest): Promise<void> {
const profile = await updateProfileRequest(payload);
this.user = profile;
this.menus = profile.menus;
this.permissions = profile.permissions;
},
async uploadAvatar(file: File): Promise<void> {
const profile = await uploadAvatarRequest(file);
this.user = profile;
this.menus = profile.menus;
this.permissions = profile.permissions;
},
async changePassword(payload: ChangePasswordRequest): Promise<string> {
const response = await changePasswordRequest(payload);
return response.message;
},
logout(): void {
this.token = "";
this.user = null;
this.menus = [];
this.permissions = [];
this.loaded = true;
clearStoredToken();
},
},
});
function roleLabel(role: string): string {
const labels: Record<string, string> = {
superuser: "超级用户",
manager: "管理者",
viewer: "查看权限",
};
return labels[role] || "未授权";
}
+843
View File
@@ -0,0 +1,843 @@
import { defineStore } from "pinia";
export type ThemeName =
| "default"
| "anthropic"
| "mono"
| "midnight"
| "rose"
| "lagoon"
| "sunset"
| "forest"
| "ocean"
| "lavender";
export type ThemeMode = "system" | "light" | "dark";
export type FontChoice = "auto" | "sans" | "serif";
export type RadiusChoice = "auto" | "0" | "0.3" | "0.5" | "0.75" | "1";
export type DensityChoice = "compact" | "default" | "comfortable" | "large";
export type SidebarChoice = "embedded" | "floating" | "edge";
export type LayoutChoice = "default" | "compact" | "fullscreen";
export type ContentWidthChoice = "full" | "centered";
export interface ThemeOption {
name: ThemeName;
label: string;
primary: string;
accent: string;
soft: string;
gradient: [string, string];
tokens: {
light: ThemeTokens;
dark: ThemeTokens;
};
}
interface ThemeTokens {
surface: string;
surfaceSoft: string;
surfaceStrong: string;
card: string;
text: string;
muted: string;
line: string;
lineSoft: string;
primary: string;
primarySoft: string;
accent: string;
accentSoft: string;
sidebarBg: string;
navText: string;
navActiveBg: string;
topbarBg: string;
searchBg: string;
controlBg: string;
secondaryBg: string;
secondaryHoverBg: string;
secondaryHoverBorder: string;
tableHeaderBg: string;
tableRowHoverBg: string;
}
type LightThemeTokens = Omit<ThemeTokens, "primary" | "primarySoft" | "accent" | "accentSoft">;
type DarkThemeSeed = Omit<
ThemeTokens,
| "text"
| "muted"
| "navText"
| "topbarBg"
| "searchBg"
| "controlBg"
| "secondaryBg"
| "secondaryHoverBg"
| "secondaryHoverBorder"
| "tableHeaderBg"
| "tableRowHoverBg"
>;
interface ThemeSeed {
name: ThemeName;
label: string;
primary: string;
accent: string;
soft: string;
gradient: [string, string];
light: LightThemeTokens;
dark: ThemeTokens;
}
type StoredSetting =
| ThemeName
| ThemeMode
| FontChoice
| RadiusChoice
| DensityChoice
| SidebarChoice
| LayoutChoice
| ContentWidthChoice;
const THEME_KEY = "financial-system-theme";
const THEME_MODE_KEY = "financial-system-theme-mode";
const FONT_KEY = "financial-system-font";
const RADIUS_KEY = "financial-system-radius";
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 DEFAULTS = {
theme: "anthropic" as ThemeName,
mode: "dark" as ThemeMode,
font: "serif" as FontChoice,
radius: "1" as RadiusChoice,
density: "compact" as DensityChoice,
sidebar: "floating" as SidebarChoice,
layout: "default" as LayoutChoice,
contentWidth: "full" as ContentWidthChoice,
};
const legacyThemeMap: Record<string, ThemeName> = {
navy: "anthropic",
blue: "ocean",
emerald: "forest",
burgundy: "rose",
};
const themeSeeds: ThemeSeed[] = [
{
name: "default",
label: "默认",
primary: "#171717",
accent: "#8c8c8c",
soft: "#ececec",
gradient: ["#151515", "#f4f4f1"],
light: createLightSeed({
primary: "#171717",
accent: "#626262",
soft: "#ececec",
surface: "#f7f7f5",
surfaceSoft: "#eeeeeb",
surfaceStrong: "#dfdfdc",
sidebarBg: "#eeeeeb",
navActiveBg: "#e1e1dd",
}),
dark: createDarkSeed({
primary: "#f4f1eb",
primarySoft: "#ded8cf",
accent: "#b9b6ad",
accentSoft: "rgba(185, 182, 173, 0.18)",
surface: "#141411",
surfaceSoft: "#1d1c18",
surfaceStrong: "#2b2925",
card: "#191814",
sidebarBg: "#1a1915",
navActiveBg: "#28251f",
line: "#34312a",
lineSoft: "#282620",
}),
},
{
name: "anthropic",
label: "Anthropic",
primary: "#8f563e",
accent: "#ed8b6d",
soft: "#fde4d8",
gradient: ["#fbe2d4", "#ea7b5d"],
light: createLightSeed({
primary: "#6f3e2a",
accent: "#dd7154",
soft: "#fde4d8",
surface: "#fff8f4",
surfaceSoft: "#f6e7df",
surfaceStrong: "#edcdbf",
sidebarBg: "#f6e7df",
navActiveBg: "#f1d3c5",
}),
dark: createDarkSeed({
primary: "#f4dfd3",
primarySoft: "#e7b7a4",
accent: "#f28a6a",
accentSoft: "rgba(242, 138, 106, 0.18)",
surface: "#16130f",
surfaceSoft: "#211913",
surfaceStrong: "#392417",
card: "#1b1712",
sidebarBg: "#201713",
navActiveBg: "#5b3425",
line: "#403029",
lineSoft: "#2b211c",
}),
},
{
name: "mono",
label: "超大字体简易",
primary: "#202020",
accent: "#a1a1a1",
soft: "#eeeeee",
gradient: ["#202020", "#eeeeee"],
light: createLightSeed({
primary: "#202020",
accent: "#6d6d6d",
soft: "#eeeeee",
surface: "#fafafa",
surfaceSoft: "#eeeeee",
surfaceStrong: "#dcdcdc",
sidebarBg: "#eeeeee",
navActiveBg: "#e2e2e2",
}),
dark: createDarkSeed({
primary: "#f5f5f5",
primarySoft: "#dddddd",
accent: "#c8c8c8",
accentSoft: "rgba(200, 200, 200, 0.16)",
surface: "#121212",
surfaceSoft: "#1f1f1f",
surfaceStrong: "#303030",
card: "#181818",
sidebarBg: "#1b1b1b",
navActiveBg: "#2b2b2b",
line: "#3a3a3a",
lineSoft: "#2a2a2a",
}),
},
{
name: "midnight",
label: "暗夜",
primary: "#1d3150",
accent: "#2d5da8",
soft: "#e6efff",
gradient: ["#466f5a", "#9b7390"],
light: createLightSeed({
primary: "#1d3150",
accent: "#2d5da8",
soft: "#e6efff",
surface: "#f5f8fc",
surfaceSoft: "#e8eef6",
surfaceStrong: "#d3deed",
sidebarBg: "#e6edf6",
navActiveBg: "#d5e1f2",
}),
dark: createDarkSeed({
primary: "#dce8ff",
primarySoft: "#a8c5ef",
accent: "#6ca1ee",
accentSoft: "rgba(108, 161, 238, 0.18)",
surface: "#10151e",
surfaceSoft: "#162235",
surfaceStrong: "#213451",
card: "#131b28",
sidebarBg: "#17263a",
navActiveBg: "#1d3b64",
line: "#2c3d55",
lineSoft: "#213047",
}),
},
{
name: "rose",
label: "玫瑰花园",
primary: "#8f2044",
accent: "#f13d72",
soft: "#ffe2ea",
gradient: ["#f22366", "#f77c99"],
light: createLightSeed({
primary: "#8f2044",
accent: "#e53967",
soft: "#ffe2ea",
surface: "#fff7fa",
surfaceSoft: "#fde9f0",
surfaceStrong: "#f6ccd9",
sidebarBg: "#fde9f0",
navActiveBg: "#f8d4df",
}),
dark: createDarkSeed({
primary: "#ffdce7",
primarySoft: "#f7a5be",
accent: "#ff6d97",
accentSoft: "rgba(255, 109, 151, 0.18)",
surface: "#171116",
surfaceSoft: "#26141c",
surfaceStrong: "#471d2d",
card: "#1d1218",
sidebarBg: "#25131b",
navActiveBg: "#5a2136",
line: "#442934",
lineSoft: "#2f1d25",
}),
},
{
name: "lagoon",
label: "湖光",
primary: "#006b5e",
accent: "#08b997",
soft: "#dcfbf4",
gradient: ["#0fc69b", "#058b88"],
light: createLightSeed({
primary: "#006b5e",
accent: "#08a88c",
soft: "#dcfbf4",
surface: "#f4fbf9",
surfaceSoft: "#e0f4ef",
surfaceStrong: "#c4e7de",
sidebarBg: "#e0f4ef",
navActiveBg: "#caeadf",
}),
dark: createDarkSeed({
primary: "#d8fff6",
primarySoft: "#86e4d0",
accent: "#20d0ad",
accentSoft: "rgba(32, 208, 173, 0.18)",
surface: "#0f1715",
surfaceSoft: "#112923",
surfaceStrong: "#14483d",
card: "#101d1a",
sidebarBg: "#132820",
navActiveBg: "#195745",
line: "#27433b",
lineSoft: "#1c312b",
}),
},
{
name: "sunset",
label: "日落霞光",
primary: "#b93f32",
accent: "#f2765a",
soft: "#ffe4d9",
gradient: ["#df443f", "#fb805e"],
light: createLightSeed({
primary: "#9f392e",
accent: "#ea674c",
soft: "#ffe4d9",
surface: "#fff8f4",
surfaceSoft: "#f7e8df",
surfaceStrong: "#f0ccb9",
sidebarBg: "#f7e8df",
navActiveBg: "#f4d4c3",
}),
dark: createDarkSeed({
primary: "#ffe1d5",
primarySoft: "#f4a089",
accent: "#ff8a68",
accentSoft: "rgba(255, 138, 104, 0.18)",
surface: "#171210",
surfaceSoft: "#281914",
surfaceStrong: "#52271d",
card: "#1d1512",
sidebarBg: "#271814",
navActiveBg: "#653123",
line: "#442d25",
lineSoft: "#2f201b",
}),
},
{
name: "forest",
label: "森林低语",
primary: "#1d6253",
accent: "#4f7c86",
soft: "#e0f2ed",
gradient: ["#1b8d7e", "#51788c"],
light: createLightSeed({
primary: "#1d6253",
accent: "#3f7881",
soft: "#e0f2ed",
surface: "#f4faf7",
surfaceSoft: "#e4f1ec",
surfaceStrong: "#cfe2dc",
sidebarBg: "#e4f1ec",
navActiveBg: "#d4e9e2",
}),
dark: createDarkSeed({
primary: "#dbfff2",
primarySoft: "#9dd6c7",
accent: "#78aeb9",
accentSoft: "rgba(120, 174, 185, 0.18)",
surface: "#111817",
surfaceSoft: "#172623",
surfaceStrong: "#243f3b",
card: "#141d1c",
sidebarBg: "#172521",
navActiveBg: "#254d45",
line: "#2d4440",
lineSoft: "#22322f",
}),
},
{
name: "ocean",
label: "海风",
primary: "#2148d8",
accent: "#4964f2",
soft: "#e7ecff",
gradient: ["#315fe8", "#6464f3"],
light: createLightSeed({
primary: "#2148d8",
accent: "#4964f2",
soft: "#e7ecff",
surface: "#f6f8ff",
surfaceSoft: "#e9eeff",
surfaceStrong: "#d6ddfb",
sidebarBg: "#e9eeff",
navActiveBg: "#d9e0ff",
}),
dark: createDarkSeed({
primary: "#e0e6ff",
primarySoft: "#aebcff",
accent: "#7c92ff",
accentSoft: "rgba(124, 146, 255, 0.18)",
surface: "#11131d",
surfaceSoft: "#181d35",
surfaceStrong: "#24336c",
card: "#151827",
sidebarBg: "#171d34",
navActiveBg: "#25387c",
line: "#2c365d",
lineSoft: "#222944",
}),
},
{
name: "lavender",
label: "薰衣草梦",
primary: "#7f58c2",
accent: "#8aaec4",
soft: "#ece5ff",
gradient: ["#8e63ca", "#9cc8d4"],
light: createLightSeed({
primary: "#7653b7",
accent: "#7fa9bc",
soft: "#ece5ff",
surface: "#faf8ff",
surfaceSoft: "#eee8fa",
surfaceStrong: "#ddd5ee",
sidebarBg: "#eee8fa",
navActiveBg: "#e1d7f2",
}),
dark: createDarkSeed({
primary: "#eee6ff",
primarySoft: "#c4a8ef",
accent: "#a8cfdd",
accentSoft: "rgba(168, 207, 221, 0.18)",
surface: "#15131b",
surfaceSoft: "#201a2c",
surfaceStrong: "#382a55",
card: "#1a1622",
sidebarBg: "#201a2a",
navActiveBg: "#49366f",
line: "#3c334c",
lineSoft: "#2b2537",
}),
},
];
export const themeOptions: ThemeOption[] = themeSeeds.map((seed) => ({
name: seed.name,
label: seed.label,
primary: seed.primary,
accent: seed.accent,
soft: seed.soft,
gradient: seed.gradient,
tokens: {
light: {
...seed.light,
primary: seed.primary,
primarySoft: mix(seed.primary, "#ffffff", 18),
accent: seed.accent,
accentSoft: seed.soft,
},
dark: seed.dark,
},
}));
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),
}),
getters: {
activeTheme: (state) => themeOptions.find((item) => item.name === state.current) || themeOptions[0],
resolvedMode: (state): "light" | "dark" => resolveThemeMode(state.mode),
},
actions: {
applyTheme(themeName?: ThemeName): void {
if (themeName) {
this.current = themeName;
}
const theme = themeOptions.find((item) => item.name === this.current) || themeOptions[0];
const mode = resolveThemeMode(this.mode);
const tokens = theme.tokens[mode];
const root = document.documentElement;
const cssVariables: Record<string, string> = {
"--surface": tokens.surface,
"--surface-soft": tokens.surfaceSoft,
"--surface-strong": tokens.surfaceStrong,
"--card": tokens.card,
"--text": tokens.text,
"--muted": tokens.muted,
"--line": tokens.line,
"--line-soft": tokens.lineSoft,
"--primary": tokens.primary,
"--primary-soft": tokens.primarySoft,
"--blue": tokens.accent,
"--blue-soft": tokens.accentSoft,
"--sidebar-bg": tokens.sidebarBg,
"--nav-text": tokens.navText,
"--nav-active-bg": tokens.navActiveBg,
"--topbar-bg": tokens.topbarBg,
"--search-bg": tokens.searchBg,
"--control-bg": tokens.controlBg,
"--secondary-bg": tokens.secondaryBg,
"--secondary-hover-bg": tokens.secondaryHoverBg,
"--secondary-hover-border": tokens.secondaryHoverBorder,
"--table-header-bg": tokens.tableHeaderBg,
"--table-row-hover-bg": tokens.tableRowHoverBg,
"--modal-backdrop": hexToRgba(mode === "dark" ? "#000000" : theme.primary, mode === "dark" ? 0.56 : 0.32),
"--primary-rgb": hexToRgbCsv(mode === "dark" ? theme.primary : tokens.primary),
"--accent-rgb": hexToRgbCsv(tokens.accent),
"--shadow": mode === "dark" ? "0 20px 44px rgba(0, 0, 0, 0.34)" : `0 16px 32px ${hexToRgba(theme.primary, 0.08)}`,
...fontVariables(this.font),
...radiusVariables(this.radius),
...densityVariables(this.density),
...layoutVariables(this.layout, this.sidebar, this.contentWidth),
};
Object.entries(cssVariables).forEach(([name, value]) => {
root.style.setProperty(name, value);
});
root.dataset.themeMode = mode;
root.dataset.themePreset = theme.name;
root.dataset.font = this.font;
root.dataset.radius = this.radius;
root.dataset.density = this.density;
root.dataset.sidebar = this.sidebar;
root.dataset.layout = this.layout;
root.dataset.contentWidth = this.contentWidth;
persistSetting(THEME_KEY, theme.name);
persistSetting(THEME_MODE_KEY, this.mode);
persistSetting(FONT_KEY, this.font);
persistSetting(RADIUS_KEY, this.radius);
persistSetting(DENSITY_KEY, this.density);
persistSetting(SIDEBAR_KEY, this.sidebar);
persistSetting(LAYOUT_KEY, this.layout);
persistSetting(CONTENT_WIDTH_KEY, this.contentWidth);
watchSystemTheme(() => this.applyTheme());
},
setTheme(themeName: ThemeName): void {
this.current = themeName;
this.applyTheme();
},
setMode(mode: ThemeMode): void {
this.mode = mode;
this.applyTheme();
},
setFont(font: FontChoice): void {
this.font = font;
this.applyTheme();
},
setRadius(radius: RadiusChoice): void {
this.radius = radius;
this.applyTheme();
},
setDensity(density: DensityChoice): void {
this.density = density;
this.applyTheme();
},
setSidebar(sidebar: SidebarChoice): void {
this.sidebar = sidebar;
this.applyTheme();
},
setLayout(layout: LayoutChoice): void {
this.layout = layout;
this.applyTheme();
},
setContentWidth(contentWidth: ContentWidthChoice): void {
this.contentWidth = contentWidth;
this.applyTheme();
},
resetTheme(): void {
this.current = DEFAULTS.theme;
this.mode = DEFAULTS.mode;
this.applyTheme();
},
resetColor(): void {
this.current = DEFAULTS.theme;
this.applyTheme();
},
resetFont(): void {
this.font = DEFAULTS.font;
this.applyTheme();
},
resetRadius(): void {
this.radius = DEFAULTS.radius;
this.applyTheme();
},
resetDensity(): void {
this.density = DEFAULTS.density;
this.applyTheme();
},
resetSidebar(): void {
this.sidebar = DEFAULTS.sidebar;
this.applyTheme();
},
resetLayout(): void {
this.layout = DEFAULTS.layout;
this.applyTheme();
},
resetContentWidth(): void {
this.contentWidth = DEFAULTS.contentWidth;
this.applyTheme();
},
},
});
function createLightSeed(seed: {
primary: string;
accent: string;
soft: string;
surface: string;
surfaceSoft: string;
surfaceStrong: string;
sidebarBg: string;
navActiveBg: string;
}): LightThemeTokens {
return {
surface: seed.surface,
surfaceSoft: seed.surfaceSoft,
surfaceStrong: seed.surfaceStrong,
card: "#ffffff",
text: "#191c1e",
muted: "#5f6670",
line: mix(seed.surfaceStrong, "#88919c", 26),
lineSoft: mix(seed.surfaceSoft, "#ffffff", 54),
sidebarBg: seed.sidebarBg,
navText: "#303842",
navActiveBg: seed.navActiveBg,
topbarBg: hexToRgba(seed.surface, 0.94),
searchBg: mix(seed.surfaceSoft, "#ffffff", 28),
controlBg: "#ffffff",
secondaryBg: seed.surfaceSoft,
secondaryHoverBg: seed.surfaceStrong,
secondaryHoverBorder: mix(seed.surfaceStrong, "#87919c", 34),
tableHeaderBg: mix(seed.surfaceSoft, "#ffffff", 34),
tableRowHoverBg: mix(seed.surface, "#ffffff", 38),
};
}
function createDarkSeed(seed: DarkThemeSeed): ThemeTokens {
return {
...seed,
text: "#f4f0e8",
muted: "#b8b2a8",
navText: "#d6cec4",
topbarBg: hexToRgba(seed.surface, 0.88),
searchBg: seed.surfaceSoft,
controlBg: seed.surfaceSoft,
secondaryBg: seed.surfaceStrong,
secondaryHoverBg: seed.navActiveBg,
secondaryHoverBorder: seed.line,
tableHeaderBg: seed.surfaceSoft,
tableRowHoverBg: seed.surfaceStrong,
};
}
function fontVariables(font: FontChoice): Record<string, string> {
const fontMap: Record<FontChoice, string> = {
auto: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
sans: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
serif: 'Georgia, "Times New Roman", "Songti SC", "SimSun", serif',
};
return {
"--app-font-family": fontMap[font],
};
}
function radiusVariables(radius: RadiusChoice): Record<string, string> {
const radiusMap: Record<RadiusChoice, [string, string, string]> = {
auto: ["7px", "8px", "12px"],
"0": ["0px", "0px", "0px"],
"0.3": ["5px", "7px", "10px"],
"0.5": ["8px", "11px", "14px"],
"0.75": ["12px", "16px", "20px"],
"1": ["18px", "24px", "28px"],
};
const [sm, md, lg] = radiusMap[radius];
return {
"--radius-sm": sm,
"--radius-md": md,
"--radius-lg": lg,
};
}
function densityVariables(density: DensityChoice): Record<string, string> {
const densityMap: Record<DensityChoice, Record<string, string>> = {
compact: {
"--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",
},
default: {
"--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",
},
comfortable: {
"--topbar-height": "78px",
"--nav-height": "54px",
"--control-height": "48px",
"--base-font-size": "16px",
"--page-padding": "36px",
"--content-gap": "28px",
"--panel-padding": "28px",
"--table-cell-y": "22px",
"--table-cell-x": "28px",
},
large: {
"--topbar-height": "88px",
"--nav-height": "62px",
"--control-height": "56px",
"--base-font-size": "18px",
"--page-padding": "44px",
"--content-gap": "34px",
"--panel-padding": "34px",
"--table-cell-y": "28px",
"--table-cell-x": "32px",
},
};
return densityMap[density];
}
function layoutVariables(
layout: LayoutChoice,
sidebar: SidebarChoice,
contentWidth: ContentWidthChoice,
): Record<string, string> {
const sidebarWidth = layout === "compact" ? "252px" : "280px";
const sidebarOffset = layout === "fullscreen" ? "0px" : sidebar === "floating" ? `calc(${sidebarWidth} + 24px)` : sidebarWidth;
return {
"--layout-sidebar-width": sidebarWidth,
"--layout-sidebar-offset": sidebarOffset,
"--content-max-width": contentWidth === "centered" ? "1180px" : "none",
};
}
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;
}
function readSetting<T extends readonly StoredSetting[]>(key: string, fallback: T[number], values: T): T[number] {
const stored = readRawSetting(key) as T[number] | null;
return values.includes(stored as T[number]) ? stored! : fallback;
}
function readRawSetting(key: string): string | null {
try {
return typeof localStorage === "undefined" ? null : localStorage.getItem(key);
} catch {
return null;
}
}
function persistSetting(key: string, value: string): void {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(key, value);
}
} catch {
// Local storage can be disabled in embedded browser contexts.
}
}
function resolveThemeMode(mode: ThemeMode): "light" | "dark" {
if (mode !== "system") {
return mode;
}
return typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
let systemThemeWatching = false;
function watchSystemTheme(apply: () => void): void {
if (systemThemeWatching || typeof window === "undefined" || !window.matchMedia) {
return;
}
const query = window.matchMedia("(prefers-color-scheme: dark)");
query.addEventListener?.("change", apply);
systemThemeWatching = true;
}
function mix(hex: string, targetHex: string, percent: number): string {
const source = hexToRgb(hex);
const target = hexToRgb(targetHex);
const ratio = percent / 100;
return rgbToHex({
r: Math.round(source.r + (target.r - source.r) * ratio),
g: Math.round(source.g + (target.g - source.g) * ratio),
b: Math.round(source.b + (target.b - source.b) * ratio),
});
}
function hexToRgba(hex: string, alpha: number): string {
const { r, g, b } = hexToRgb(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function hexToRgbCsv(hex: string): string {
const { r, g, b } = hexToRgb(hex);
return `${r}, ${g}, ${b}`;
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const normalized = hex.replace("#", "");
return {
r: parseInt(normalized.slice(0, 2), 16),
g: parseInt(normalized.slice(2, 4), 16),
b: parseInt(normalized.slice(4, 6), 16),
};
}
function rgbToHex({ r, g, b }: { r: number; g: number; b: number }): string {
return `#${[r, g, b]
.map((value) => value.toString(16).padStart(2, "0"))
.join("")}`;
}
+360
View File
@@ -0,0 +1,360 @@
<script setup lang="ts">
import {
FileSpreadsheet,
HandCoins,
Loader2,
Pencil,
Plus,
RefreshCw,
Save,
Search,
Trash2,
Upload,
} from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue";
import {
createCommission,
deleteCommission,
importCommissions,
listCommissions,
updateCommission,
} from "../api/commissions";
import { listEmployees } from "../api/employees";
import { ApiError } from "../api/http";
import type { CommissionRecord, CommissionRecordRequest, EmployeeRecord } from "../api/types";
import StatCard from "../components/StatCard.vue";
const employees = ref<EmployeeRecord[]>([]);
const records = ref<CommissionRecord[]>([]);
const loading = ref(false);
const saving = ref(false);
const importing = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const editingId = ref<number | null>(null);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const filters = reactive({
commission_month: new Date().toISOString().slice(0, 7),
keyword: "",
});
const form = reactive<CommissionRecordRequest>({
employee_id: 0,
commission_month: new Date().toISOString().slice(0, 7),
commission_type: "销售提成",
amount: 0,
source_type: "manual",
business_ref: "",
remark: "",
});
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const totalAmount = computed(() => records.value.reduce((sum, item) => sum + Number(item.amount || 0), 0));
onMounted(loadData);
async function loadData(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [employeeRows, pageData] = await Promise.all([
listEmployees({ employment_status: "active" }),
listCommissions({
page: page.value,
page_size: pageSize.value,
commission_month: filters.commission_month,
keyword: filters.keyword.trim(),
}),
]);
employees.value = employeeRows;
records.value = pageData.items;
total.value = pageData.total;
page.value = pageData.page;
pageSize.value = pageData.page_size;
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "提成数据加载失败";
} finally {
loading.value = false;
}
}
async function submit(): Promise<void> {
if (!form.employee_id) {
errorMessage.value = "请选择员工";
return;
}
saving.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
if (editingId.value) {
await updateCommission(editingId.value, normalizedForm());
successMessage.value = "提成记录已更新";
} else {
await createCommission(normalizedForm());
successMessage.value = "提成记录已新增";
}
resetForm();
await loadData();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "提成记录保存失败";
} finally {
saving.value = false;
}
}
async function removeRecord(record: CommissionRecord): Promise<void> {
if (!window.confirm(`确认删除 ${record.employee.name} 的 ${record.commission_month} 提成?`)) {
return;
}
try {
await deleteCommission(record.id);
successMessage.value = "提成记录已删除";
await loadData();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "提成记录删除失败";
}
}
async function handleImport(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
importing.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
const response = await importCommissions(file);
successMessage.value = `导入完成:${response.imported_count} 条`;
await loadData();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "提成导入失败";
} finally {
importing.value = false;
input.value = "";
}
}
function editRecord(record: CommissionRecord): void {
editingId.value = record.id;
Object.assign(form, {
employee_id: record.employee_id,
commission_month: record.commission_month,
commission_type: record.commission_type,
amount: record.amount,
source_type: record.source_type,
business_ref: record.business_ref,
remark: record.remark,
});
}
function resetForm(): void {
editingId.value = null;
Object.assign(form, {
employee_id: 0,
commission_month: filters.commission_month || new Date().toISOString().slice(0, 7),
commission_type: "销售提成",
amount: 0,
source_type: "manual",
business_ref: "",
remark: "",
});
}
function searchRecords(): void {
page.value = 1;
loadData();
}
function previousPage(): void {
if (page.value <= 1) {
return;
}
page.value -= 1;
loadData();
}
function nextPage(): void {
if (page.value >= totalPages.value) {
return;
}
page.value += 1;
loadData();
}
function normalizedForm(): CommissionRecordRequest {
return {
employee_id: Number(form.employee_id),
commission_month: form.commission_month,
commission_type: form.commission_type.trim() || "销售提成",
amount: Number(form.amount || 0),
source_type: "manual",
business_ref: form.business_ref.trim(),
remark: form.remark.trim(),
};
}
function money(value: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(Number(value || 0));
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>提成管理</h1>
<p>维护销售提成、项目奖金和绩效奖励,工资计算时按月份自动汇总。</p>
</div>
<div class="header-actions">
<label class="secondary-button file-button">
<Upload :size="18" aria-hidden="true" />
<span>{{ importing ? "导入中" : "导入Excel" }}</span>
<input accept=".xlsx,.xls" type="file" :disabled="importing" @change="handleImport" />
</label>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div>
</header>
<section class="stats-grid">
<StatCard title="当前记录" :value="String(records.length)" :icon="FileSpreadsheet" tone="blue" meta="本页" />
<StatCard title="筛选总数" :value="String(total)" :icon="HandCoins" tone="neutral" meta="数据库" />
<StatCard title="本页提成" :value="money(totalAmount)" :icon="HandCoins" tone="green" meta="当前页合计" />
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>{{ editingId ? "编辑提成" : "新增提成" }}</h2>
<p>按员工和月份维护提成金额</p>
</div>
<button class="secondary-button" type="button" @click="resetForm">清空</button>
</div>
<form class="form-grid" @submit.prevent="submit">
<label class="field">
<span>员工</span>
<select v-model.number="form.employee_id" required>
<option :value="0">请选择员工</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.name }} / {{ employee.employee_no }}
</option>
</select>
</label>
<label class="field">
<span>月份</span>
<input v-model="form.commission_month" type="month" required />
</label>
<label class="field">
<span>提成类型</span>
<input v-model="form.commission_type" type="text" placeholder="销售提成、项目奖金..." />
</label>
<label class="field">
<span>金额</span>
<input v-model.number="form.amount" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>业务单号</span>
<input v-model="form.business_ref" type="text" />
</label>
<label class="field span-row">
<span>备注</span>
<textarea v-model="form.remark" />
</label>
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : editingId ? "保存修改" : "新增提成" }}</span>
</button>
</div>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
</section>
<section class="panel table-panel">
<div class="panel-header">
<div>
<h2>提成列表</h2>
<p>第 {{ page }} / {{ totalPages }} 页,共 {{ total }} 条</p>
</div>
</div>
<form class="table-filter-bar" @submit.prevent="searchRecords">
<label class="field">
<span>关键字</span>
<input v-model="filters.keyword" type="search" placeholder="员工、类型或业务单号" />
</label>
<label class="field">
<span>月份</span>
<input v-model="filters.commission_month" type="month" />
</label>
<button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
</form>
<div 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>
</tr>
</thead>
<tbody>
<tr v-for="record in records" :key="record.id">
<td>
<strong>{{ record.employee.name }}</strong>
<span>{{ record.employee.employee_no }}</span>
</td>
<td class="mono">{{ record.commission_month }}</td>
<td>{{ record.commission_type }}</td>
<td class="mono strong">{{ money(record.amount) }}</td>
<td><span class="status-chip neutral">{{ record.source_type }}</span></td>
<td>{{ record.business_ref || "-" }}</td>
<td class="note-cell">{{ record.remark || "-" }}</td>
<td>
<button class="link-button" type="button" @click="editRecord(record)">
<Pencil :size="15" aria-hidden="true" />
<span>编辑</span>
</button>
<button class="link-button danger" type="button" @click="removeRecord(record)">
<Trash2 :size="15" aria-hidden="true" />
<span>删除</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!records.length && !loading" class="empty-state">
<p>暂无提成记录</p>
</div>
<div class="form-actions align-right">
<button class="secondary-button" type="button" :disabled="loading || page <= 1" @click="previousPage">上一页</button>
<button class="secondary-button" type="button" :disabled="loading || page >= totalPages" @click="nextPage">下一页</button>
</div>
</section>
</div>
</template>
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { Loader2, RefreshCw, Save, Settings2, SlidersHorizontal } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { ensureDefaultConfigs, listSalaryConfigs, updateSalaryConfig } from "../api/configs";
import { ApiError } from "../api/http";
import type { SalaryConfigRecord } from "../api/types";
import StatCard from "../components/StatCard.vue";
const configs = ref<SalaryConfigRecord[]>([]);
const activeGroup = ref("");
const loading = ref(false);
const savingKey = ref("");
const errorMessage = ref("");
const successMessage = ref("");
const groups = computed(() => Array.from(new Set(configs.value.map((item) => item.config_group))));
const visibleConfigs = computed(() => {
if (!activeGroup.value) {
return configs.value;
}
return configs.value.filter((item) => item.config_group === activeGroup.value);
});
const enabledCount = computed(() => configs.value.filter((item) => item.is_enabled).length);
onMounted(loadData);
async function loadData(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
configs.value = await listSalaryConfigs();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "配置加载失败";
} finally {
loading.value = false;
}
}
async function seedDefaults(): Promise<void> {
loading.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
const response = await ensureDefaultConfigs();
successMessage.value = `${response.message},新增 ${response.created_count} 项`;
await loadData();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "默认配置初始化失败";
} finally {
loading.value = false;
}
}
async function saveConfig(config: SalaryConfigRecord): Promise<void> {
savingKey.value = config.config_key;
errorMessage.value = "";
successMessage.value = "";
try {
const updated = await updateSalaryConfig(config.config_key, {
config_name: config.config_name,
config_group: config.config_group,
config_value: config.config_value,
value_type: config.value_type,
is_enabled: config.is_enabled,
remark: config.remark,
});
configs.value = configs.value.map((item) => (item.id === updated.id ? updated : item));
successMessage.value = "配置已保存";
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "配置保存失败";
} finally {
savingKey.value = "";
}
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>规则配置</h1>
<p>维护考勤扣款、加班取整、加班单价和请假识别规则。</p>
</div>
<div class="header-actions">
<button class="secondary-button" type="button" :disabled="loading" @click="seedDefaults">
<Settings2 :size="18" aria-hidden="true" />
<span>补齐默认项</span>
</button>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div>
</header>
<section class="stats-grid">
<StatCard title="配置项" :value="String(configs.length)" :icon="SlidersHorizontal" tone="blue" meta="全部规则" />
<StatCard title="启用项" :value="String(enabledCount)" :icon="Settings2" tone="green" meta="参与计算" />
<StatCard title="当前分组" :value="activeGroup || '全部'" :icon="Settings2" tone="neutral" meta="筛选" />
</section>
<section class="panel table-panel">
<div class="panel-header">
<div>
<h2>配置列表</h2>
<p>{{ visibleConfigs.length }} 项</p>
</div>
<select v-model="activeGroup" class="compact-select">
<option value="">全部分组</option>
<option v-for="group in groups" :key="group" :value="group">{{ group }}</option>
</select>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>配置</th>
<th>分组</th>
<th>类型</th>
<th>值</th>
<th>启用</th>
<th>说明</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="config in visibleConfigs" :key="config.config_key">
<td>
<strong>{{ config.config_name }}</strong>
<span>{{ config.config_key }}</span>
</td>
<td>
<input v-model="config.config_group" class="table-input" type="text" />
</td>
<td>
<select v-model="config.value_type" class="table-input">
<option value="string">string</option>
<option value="integer">integer</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
<option value="json">json</option>
</select>
</td>
<td>
<textarea v-model="config.config_value" class="table-textarea" />
</td>
<td>
<label class="checkbox-line">
<input v-model="config.is_enabled" type="checkbox" />
<span>{{ config.is_enabled ? "启用" : "停用" }}</span>
</label>
</td>
<td>
<textarea v-model="config.remark" class="table-textarea" />
</td>
<td>
<button class="link-button" type="button" :disabled="savingKey === config.config_key" @click="saveConfig(config)">
<Loader2 v-if="savingKey === config.config_key" class="spin" :size="15" aria-hidden="true" />
<Save v-else :size="15" aria-hidden="true" />
<span>保存</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!visibleConfigs.length && !loading" class="empty-state">
<p>暂无配置项</p>
</div>
</section>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
</div>
</template>
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
import {
Banknote,
ClipboardList,
Gauge,
ShieldCheck,
UsersRound,
} from "@lucide/vue";
import { computed, ref } from "vue";
import { downloadPayrollFile, readRecentJobs } from "../api/payroll";
import type { RecentPayrollJob } from "../api/types";
import StatCard from "../components/StatCard.vue";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const recentJobs = ref<RecentPayrollJob[]>(readRecentJobs());
const latestJob = computed(() => recentJobs.value[0]);
const totalEmployees = computed(() => recentJobs.value.reduce((total, job) => total + job.employee_count, 0));
const chartBars = [32, 48, 58, 76, 68, 88];
async function download(job: RecentPayrollJob): Promise<void> {
await downloadPayrollFile(job.download_url, job.output_file);
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>系统概览</h1>
<p>实时财务状况与薪资指标。</p>
</div>
<div class="header-actions">
<RouterLink v-if="auth.hasPermission('payroll:excel:calculate')" class="primary-button" to="/payroll">
<Banknote :size="19" aria-hidden="true" />
<span>新建计算</span>
</RouterLink>
<RouterLink v-if="auth.hasPermission('payroll:job:view')" class="secondary-button" to="/payroll/jobs">
<ClipboardList :size="19" aria-hidden="true" />
<span>计算记录</span>
</RouterLink>
</div>
</header>
<section class="stats-grid">
<StatCard
title="最近计算员工"
:value="String(latestJob?.employee_count || 0)"
:icon="UsersRound"
tone="blue"
meta="当前浏览器"
/>
<StatCard
title="任务记录"
:value="String(recentJobs.length)"
:icon="ClipboardList"
tone="neutral"
meta="最近 12 条"
/>
<StatCard
title="累计员工次数"
:value="String(totalEmployees)"
:icon="Gauge"
tone="green"
meta="本地记录"
/>
<StatCard
title="当前权限"
:value="auth.roleLabel"
:icon="ShieldCheck"
tone="neutral"
:meta="auth.user?.username || '-'"
/>
</section>
<section class="dashboard-grid">
<div class="panel sync-panel">
<div class="panel-header">
<div>
<h2>钉钉同步</h2>
<p>API 连接状态</p>
</div>
<span class="status-chip green">可用</span>
</div>
<dl class="compact-list">
<div>
<dt>最近任务</dt>
<dd>{{ latestJob?.job_id || "暂无" }}</dd>
</div>
<div>
<dt>任务来源</dt>
<dd>{{ latestJob?.source_type || "-" }}</dd>
</div>
</dl>
<RouterLink v-if="auth.hasPermission('payroll:dingtalk:calculate')" class="primary-button full" to="/payroll">
<span>立即同步</span>
</RouterLink>
</div>
<div class="panel trend-panel">
<div class="panel-header">
<div>
<h2>月度薪资趋势</h2>
<p>最近 6 个月</p>
</div>
<div class="legend-line">
<span><i class="legend-blue" />实发薪资</span>
<span><i />奖金</span>
</div>
</div>
<div class="bar-chart" aria-label="月度薪资趋势">
<div v-for="(height, index) in chartBars" :key="index" class="bar-item">
<span class="bonus-bar" :style="{ height: `${Math.max(12, height - 22)}%` }" />
<strong :style="{ height: `${height}%` }" />
<em>{{ index + 1 }}月</em>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>最近计算记录</h2>
<p>{{ recentJobs.length }} 条</p>
</div>
<RouterLink class="link-button" to="/payroll/jobs">查看全部</RouterLink>
</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="job in recentJobs.slice(0, 5)" :key="job.job_id">
<td class="mono strong">{{ job.job_id }}</td>
<td><span class="status-chip neutral">{{ job.source_type }}</span></td>
<td>{{ job.employee_count }}</td>
<td>{{ new Date(job.created_at).toLocaleString() }}</td>
<td>
<button class="link-button" type="button" @click="download(job)">下载</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!recentJobs.length" class="empty-state">
<p>暂无计算记录</p>
</div>
</section>
</div>
</template>
+490
View File
@@ -0,0 +1,490 @@
<script setup lang="ts">
import {
Loader2,
Pencil,
Plus,
RefreshCw,
Search,
UsersRound,
X,
} from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue";
import { ApiError } from "../api/http";
import { createEmployee, getEmployee, listEmployeePage, previewNextEmployeeNo, updateEmployee } from "../api/employees";
import { listDepartments, listPositions } from "../api/organization";
import type { DepartmentRecord, EmployeeRecord, EmployeeRequest, EmploymentStatus, PositionRecord } from "../api/types";
const employees = ref<EmployeeRecord[]>([]);
const departments = ref<DepartmentRecord[]>([]);
const positions = ref<PositionRecord[]>([]);
const loading = ref(false);
const loadingPositions = ref(false);
const loadingEmployeeNo = ref(false);
const saving = ref(false);
const editingId = ref<number | null>(null);
const showEmployeeModal = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const activeEmployeeTotal = ref(0);
const filters = reactive({
keyword: "",
employment_status: "",
});
const form = reactive<EmployeeRequest>({
employee_no: "",
name: "",
dingtalk_user_id: "",
department: "",
position: "",
phone: "",
employment_status: "active",
remark: "",
});
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const selectedDepartment = computed(() => departments.value.find((department) => department.name === form.department));
const positionOptions = computed(() => positions.value);
const departmentNames = computed(() => departments.value.map((department) => department.name));
const positionNames = computed(() => positionOptions.value.map((position) => position.name));
let positionRequestVersion = 0;
onMounted(() => {
refreshEmployees();
loadOrganization();
});
async function refreshEmployees(): Promise<void> {
await Promise.all([loadEmployees(), loadActiveEmployeeTotal()]);
}
async function loadNextEmployeeNo(): Promise<void> {
if (editingId.value) {
return;
}
loadingEmployeeNo.value = true;
try {
const response = await previewNextEmployeeNo();
if (!editingId.value) {
form.employee_no = response.employee_no;
}
} catch (error) {
form.employee_no = "";
errorMessage.value = error instanceof ApiError ? error.message : "员工编号生成失败";
} finally {
loadingEmployeeNo.value = false;
}
}
async function loadOrganization(): Promise<void> {
try {
const departmentRows = await listDepartments(true);
departments.value = departmentRows;
if (form.department) {
await loadPositionsByDepartment(true);
}
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "组织架构加载失败";
}
}
async function loadPositionsByDepartment(keepCurrentPosition = false): Promise<void> {
const department = selectedDepartment.value;
positionRequestVersion += 1;
const currentRequestVersion = positionRequestVersion;
if (!department) {
positions.value = [];
if (!keepCurrentPosition) {
form.position = "";
}
return;
}
positions.value = [];
loadingPositions.value = true;
try {
const positionRows = await listPositions(department.id, true);
if (currentRequestVersion !== positionRequestVersion) {
return;
}
positions.value = positionRows;
if (!keepCurrentPosition && !positionRows.some((position) => position.name === form.position)) {
form.position = "";
}
} catch (error) {
if (currentRequestVersion === positionRequestVersion) {
positions.value = [];
errorMessage.value = error instanceof ApiError ? error.message : "岗位列表加载失败";
}
} finally {
if (currentRequestVersion === positionRequestVersion) {
loadingPositions.value = false;
}
}
}
async function loadEmployees(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const response = await listEmployeePage({
page: page.value,
page_size: pageSize.value,
keyword: filters.keyword.trim(),
employment_status: filters.employment_status,
});
employees.value = response.items;
total.value = response.total;
page.value = response.page;
pageSize.value = response.page_size;
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "员工列表加载失败";
} finally {
loading.value = false;
}
}
async function loadActiveEmployeeTotal(): Promise<void> {
try {
const response = await listEmployeePage({
page: 1,
page_size: 1,
employment_status: "active",
});
activeEmployeeTotal.value = response.total;
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "在职员工总数加载失败";
}
}
async function submit(): Promise<void> {
saving.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
const payload = normalizedForm();
const wasEditing = Boolean(editingId.value);
if (editingId.value) {
await updateEmployee(editingId.value, payload);
} else {
await createEmployee(payload);
}
successMessage.value = wasEditing ? "员工档案已更新" : "员工档案已创建";
resetForm();
showEmployeeModal.value = false;
if (!wasEditing) {
page.value = 1;
}
await refreshEmployees();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "员工档案保存失败";
} finally {
saving.value = false;
}
}
function openCreateModal(): void {
errorMessage.value = "";
successMessage.value = "";
resetForm();
showEmployeeModal.value = true;
void loadNextEmployeeNo();
}
async function editEmployee(employee: EmployeeRecord): Promise<void> {
errorMessage.value = "";
successMessage.value = "";
let detail = employee;
try {
detail = await getEmployee(employee.id);
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "员工详情加载失败";
return;
}
editingId.value = detail.id;
Object.assign(form, {
employee_no: detail.employee_no,
name: detail.name,
dingtalk_user_id: detail.dingtalk_user_id,
department: detail.department,
position: detail.position,
phone: detail.phone,
employment_status: detail.employment_status,
remark: detail.remark,
});
showEmployeeModal.value = true;
void loadPositionsByDepartment(true);
}
function closeEmployeeModal(): void {
showEmployeeModal.value = false;
resetForm();
}
function resetForm(): void {
editingId.value = null;
positions.value = [];
Object.assign(form, {
employee_no: "",
name: "",
dingtalk_user_id: "",
department: "",
position: "",
phone: "",
employment_status: "active",
remark: "",
});
}
function normalizedForm(): EmployeeRequest {
return {
employee_no: editingId.value ? form.employee_no.trim() : "",
name: form.name.trim(),
dingtalk_user_id: form.dingtalk_user_id.trim(),
department: form.department.trim(),
position: form.position.trim(),
phone: form.phone.trim(),
employment_status: form.employment_status as EmploymentStatus,
remark: form.remark.trim(),
};
}
function handleDepartmentChange(): void {
form.position = "";
void loadPositionsByDepartment();
}
function statusLabel(status: string): string {
return status === "active" ? "在职" : "离职";
}
function searchEmployees(): void {
page.value = 1;
loadEmployees();
}
function previousPage(): void {
if (page.value <= 1) {
return;
}
page.value -= 1;
loadEmployees();
}
function nextPage(): void {
if (page.value >= totalPages.value) {
return;
}
page.value += 1;
loadEmployees();
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>员工维护</h1>
<p>维护员工档案、钉钉用户 ID 与在职状态。</p>
</div>
<div class="employee-header-actions">
<div class="employee-inline-metric" aria-label="在职员工">
<span class="employee-inline-icon">
<UsersRound :size="21" aria-hidden="true" />
</span>
<div>
<span>在职员工</span>
<strong>{{ activeEmployeeTotal }}</strong>
</div>
<em>可参与薪资计算</em>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="refreshEmployees">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div>
</header>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel table-panel">
<div class="panel-header">
<div>
<h2>员工列表</h2>
</div>
<button class="primary-button" type="button" @click="openCreateModal">
<Plus :size="18" aria-hidden="true" />
<span>新增员工</span>
</button>
</div>
<form class="table-filter-bar employee-filter-bar" @submit.prevent="searchEmployees">
<label class="field">
<span>关键字</span>
<input v-model="filters.keyword" type="search" placeholder="编号、姓名、钉钉ID、部门或岗位" />
</label>
<label class="field">
<span>状态</span>
<select v-model="filters.employment_status">
<option value="">全部状态</option>
<option value="active">在职</option>
<option value="inactive">离职</option>
</select>
</label>
<button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
</form>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>员工</th>
<th>钉钉用户ID</th>
<th>部门/岗位</th>
<th>手机号</th>
<th>状态</th>
<th>备注</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="employee in employees" :key="employee.id">
<td>
<strong>{{ employee.name }}</strong>
<span>{{ employee.employee_no }}</span>
</td>
<td class="mono">{{ employee.dingtalk_user_id || "-" }}</td>
<td>
<strong>{{ employee.department || "-" }}</strong>
<span>{{ employee.position || "-" }}</span>
</td>
<td>{{ employee.phone || "-" }}</td>
<td>
<span class="status-chip" :class="employee.employment_status === 'active' ? 'green' : 'red'">
{{ statusLabel(employee.employment_status) }}
</span>
</td>
<td class="note-cell">{{ employee.remark || "-" }}</td>
<td>{{ new Date(employee.updated_at).toLocaleString() }}</td>
<td>
<button class="link-button" type="button" @click="editEmployee(employee)">编辑</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!employees.length && !loading" class="empty-state">
<p>暂无员工档案</p>
</div>
<div class="table-pagination">
<p>第 {{ page }} / {{ totalPages }} 页,共 {{ total }} 条</p>
<div class="form-actions align-right">
<button class="secondary-button" type="button" :disabled="loading || page <= 1" @click="previousPage">
上一页
</button>
<button class="secondary-button" type="button" :disabled="loading || page >= totalPages" @click="nextPage">
下一页
</button>
</div>
</div>
</section>
<Teleport to="body">
<div v-if="showEmployeeModal" class="modal-backdrop" @click.self="closeEmployeeModal">
<section class="profile-modal employee-modal" aria-label="员工维护">
<div class="modal-header">
<div>
<h2>{{ editingId ? "编辑员工" : "新增员工" }}</h2>
<p>员工编号由系统生成,部门和岗位按组织架构联动</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="closeEmployeeModal">
<X :size="21" aria-hidden="true" />
</button>
</div>
<form class="form-grid employee-modal-form" @submit.prevent="submit">
<label class="field">
<span>员工编号</span>
<input
:value="form.employee_no || (loadingEmployeeNo ? '编号生成中' : '保存后自动生成')"
readonly
type="text"
/>
</label>
<label class="field">
<span>姓名</span>
<input v-model="form.name" required maxlength="128" type="text" />
</label>
<label class="field">
<span>钉钉用户ID</span>
<input v-model="form.dingtalk_user_id" maxlength="128" type="text" />
</label>
<label class="field">
<span>状态</span>
<select v-model="form.employment_status">
<option value="active">在职</option>
<option value="inactive">离职</option>
</select>
</label>
<label class="field">
<span>部门</span>
<select v-model="form.department" @change="handleDepartmentChange">
<option value="">请选择部门</option>
<option v-if="form.department && !departmentNames.includes(form.department)" :value="form.department">
{{ form.department }}
</option>
<option v-for="department in departments" :key="department.id" :value="department.name">
{{ department.name }}
</option>
</select>
</label>
<label class="field">
<span>岗位</span>
<select v-model="form.position" :disabled="!form.department || loadingPositions">
<option value="">
{{ !form.department ? "请先选择部门" : loadingPositions ? "岗位加载中" : "请选择岗位" }}
</option>
<option v-if="form.position && !positionNames.includes(form.position)" :value="form.position">
{{ form.position }}
</option>
<option v-if="form.department && !loadingPositions && !positionOptions.length" disabled value="">
该部门暂无岗位
</option>
<option v-for="position in positionOptions" :key="position.id" :value="position.name">
{{ position.name }}
</option>
</select>
</label>
<label class="field">
<span>手机号</span>
<input v-model="form.phone" maxlength="32" type="tel" />
</label>
<label class="field span-row">
<span>备注</span>
<textarea v-model="form.remark" />
</label>
<p v-if="errorMessage" class="form-error span-row">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success span-row">{{ successMessage }}</p>
<div class="form-actions align-right span-row">
<button class="secondary-button" type="button" @click="closeEmployeeModal">取消</button>
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Plus v-else-if="!editingId" :size="18" aria-hidden="true" />
<Pencil v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : editingId ? "保存修改" : "新增员工" }}</span>
</button>
</div>
</form>
</section>
</div>
</Teleport>
</div>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import {
Download,
FileClock,
Loader2,
Search,
} from "@lucide/vue";
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ApiError } from "../api/http";
import {
downloadPayrollFile,
getPayrollJob,
readRecentJobs,
} from "../api/payroll";
import type { PayrollJobResponse, RecentPayrollJob } from "../api/types";
import ResultsTable from "../components/ResultsTable.vue";
const route = useRoute();
const router = useRouter();
const queryJobId = ref("");
const loading = ref(false);
const errorMessage = ref("");
const selectedJob = ref<PayrollJobResponse | null>(null);
const recentJobs = ref<RecentPayrollJob[]>(readRecentJobs());
onMounted(() => {
const jobId = typeof route.query.job_id === "string" ? route.query.job_id : "";
if (jobId) {
queryJobId.value = jobId;
searchJob();
}
});
async function searchJob(): Promise<void> {
const jobId = queryJobId.value.trim();
if (!jobId) {
errorMessage.value = "请输入任务编号";
return;
}
loading.value = true;
errorMessage.value = "";
try {
selectedJob.value = await getPayrollJob(jobId);
router.replace({ path: "/payroll/jobs", query: { job_id: jobId } });
} catch (error) {
selectedJob.value = null;
errorMessage.value = error instanceof ApiError ? error.message : "任务查询失败";
} finally {
loading.value = false;
}
}
function chooseRecent(job: RecentPayrollJob): void {
queryJobId.value = job.job_id;
searchJob();
}
async function downloadCurrent(): Promise<void> {
if (!selectedJob.value) {
return;
}
await downloadPayrollFile(null, selectedJob.value.output_file);
}
async function downloadRecent(job: RecentPayrollJob): Promise<void> {
await downloadPayrollFile(job.download_url, job.output_file);
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>计算记录</h1>
<p>按任务编号查询已落库的工资计算结果。</p>
</div>
<button class="primary-button" type="button" :disabled="!selectedJob?.output_file" @click="downloadCurrent">
<Download :size="18" aria-hidden="true" />
<span>下载当前结果</span>
</button>
</header>
<section class="panel">
<div class="panel-header">
<div>
<h2>任务查询</h2>
<p>工资计算任务 ID</p>
</div>
</div>
<form class="search-form" @submit.prevent="searchJob">
<label class="field">
<span>任务编号</span>
<input v-model="queryJobId" type="text" placeholder="例如 8c21f..." />
</label>
<button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
</section>
<section v-if="selectedJob" class="panel">
<div class="panel-header">
<div>
<h2>任务详情</h2>
<p>{{ selectedJob.job_id }}</p>
</div>
<span class="status-chip" :class="selectedJob.status === 'completed' ? 'green' : 'neutral'">
{{ selectedJob.status }}
</span>
</div>
<dl class="detail-grid">
<div>
<dt>任务来源</dt>
<dd>{{ selectedJob.source_type }}</dd>
</div>
<div>
<dt>员工数量</dt>
<dd>{{ selectedJob.employee_count }}</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ new Date(selectedJob.created_at).toLocaleString() }}</dd>
</div>
<div>
<dt>更新时间</dt>
<dd>{{ new Date(selectedJob.updated_at).toLocaleString() }}</dd>
</div>
</dl>
<p v-if="selectedJob.error_message" class="form-error">{{ selectedJob.error_message }}</p>
</section>
<ResultsTable v-if="selectedJob" :results="selectedJob.results" />
<section class="panel">
<div class="panel-header">
<div>
<h2>最近任务</h2>
<p>当前浏览器记录</p>
</div>
</div>
<div class="recent-list">
<button v-for="job in recentJobs" :key="job.job_id" class="recent-item" type="button" @click="chooseRecent(job)">
<FileClock :size="20" aria-hidden="true" />
<span>
<strong>{{ job.job_id }}</strong>
<em>{{ job.source_type }} · {{ job.employee_count }} 人</em>
</span>
<small>{{ new Date(job.created_at).toLocaleDateString() }}</small>
</button>
</div>
<div v-if="!recentJobs.length" class="empty-state">
<p>暂无最近任务</p>
</div>
<div v-if="recentJobs.length" class="form-actions align-right">
<button
v-for="job in recentJobs.slice(0, 3)"
:key="`download-${job.job_id}`"
class="secondary-button"
type="button"
@click="downloadRecent(job)"
>
<Download :size="17" aria-hidden="true" />
<span>{{ job.job_id.slice(0, 8) }}</span>
</button>
</div>
</section>
</div>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import {
ArrowRight,
Building2,
Eye,
EyeOff,
Lock,
ShieldCheck,
UserRound,
} from "@lucide/vue";
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ApiError } from "../api/http";
import { useAuthStore } from "../stores/auth";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const username = ref("");
const password = ref("");
const remember = ref(true);
const showPassword = ref(false);
const loading = ref(false);
const errorMessage = ref("");
const passwordType = computed(() => (showPassword.value ? "text" : "password"));
async function submit(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
await auth.login(username.value.trim(), password.value);
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/dashboard";
router.push(redirect);
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "登录失败,请稍后重试";
} finally {
loading.value = false;
}
}
</script>
<template>
<main class="login-screen">
<section class="login-form-side">
<div class="login-card">
<div class="login-brand">
<div class="brand-mark large">
<Building2 :size="30" aria-hidden="true" />
</div>
<h1>企业薪酬管理系统</h1>
<p>管理门户登录</p>
</div>
<form class="form-stack" @submit.prevent="submit">
<label class="field">
<span>用户名</span>
<div class="input-with-icon">
<UserRound :size="20" aria-hidden="true" />
<input v-model="username" autocomplete="username" type="text" />
</div>
</label>
<label class="field">
<span>密码</span>
<div class="input-with-icon">
<Lock :size="20" aria-hidden="true" />
<input v-model="password" autocomplete="current-password" :type="passwordType" />
<button class="input-action" type="button" title="显示或隐藏密码" @click="showPassword = !showPassword">
<EyeOff v-if="showPassword" :size="20" aria-hidden="true" />
<Eye v-else :size="20" aria-hidden="true" />
</button>
</div>
</label>
<label class="checkbox-line">
<input v-model="remember" type="checkbox" />
<span>记住设备</span>
</label>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<button class="primary-button full" type="submit" :disabled="loading">
<span>{{ loading ? "登录中..." : "登录" }}</span>
<ArrowRight :size="20" aria-hidden="true" />
</button>
</form>
<div class="login-footer">
<p>仅限授权人员访问。</p>
<span>安全策略</span>
<span>联系 IT 支持</span>
</div>
</div>
</section>
<section class="login-visual">
<div class="ledger-visual">
<span class="status-chip green">企业版</span>
<h2>智能 · 精准 · 值得信赖</h2>
<p>面向考勤、加班、请假和扣款规则的薪酬管理平台。</p>
<div class="visual-metrics">
<div>
<strong>99.9%</strong>
<span>运行稳定性</span>
</div>
<div>
<strong>RBAC</strong>
<span>权限控制</span>
</div>
</div>
</div>
<div class="security-line">
<ShieldCheck :size="18" aria-hidden="true" />
<span>EPMS v0.1.0 · 本地 API 已启用</span>
</div>
</section>
</main>
</template>
+290
View File
@@ -0,0 +1,290 @@
<script setup lang="ts">
import {
CheckCircle2,
Clock3,
Loader2,
RefreshCw,
ScrollText,
Search,
XCircle,
} from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue";
import { ApiError } from "../api/http";
import { listOperationLogs } from "../api/operationLogs";
import type { OperationLog, OperationLogListResponse } from "../api/types";
import StatCard from "../components/StatCard.vue";
const loading = ref(false);
const errorMessage = ref("");
const page = ref(1);
const pageSize = ref(20);
const response = ref<OperationLogListResponse>({
items: [],
total: 0,
page: 1,
page_size: 20,
});
const filters = reactive({
keyword: "",
username: "",
module: "",
action: "",
status: "",
});
const moduleOptions = [
{ value: "", label: "全部模块" },
{ value: "auth", label: "认证" },
{ value: "payroll", label: "工资" },
{ value: "system", label: "系统" },
];
const actionOptions = [
{ value: "", label: "全部动作" },
{ value: "login", label: "用户登录" },
{ value: "profile.update", label: "修改资料" },
{ value: "avatar.update", label: "修改头像" },
{ value: "password.change", label: "修改密码" },
{ value: "user.create", label: "创建用户" },
{ value: "user.list", label: "查看用户" },
{ value: "employee.list", label: "查看员工" },
{ value: "employee.page", label: "分页员工" },
{ value: "employee.create", label: "新增员工" },
{ value: "employee.update", label: "更新员工" },
{ value: "salary_profile.list", label: "查看薪资" },
{ value: "salary_profile.upsert", label: "保存薪资" },
{ value: "payroll.excel.calculate", label: "Excel计算" },
{ value: "payroll.dingtalk.calculate", label: "钉钉计算" },
{ value: "payroll.job.view", label: "查看任务" },
{ value: "payroll.file.download", label: "下载结果" },
{ value: "operation_log.view", label: "查看日志" },
];
const logs = computed(() => response.value.items);
const totalPages = computed(() => Math.max(1, Math.ceil(response.value.total / pageSize.value)));
const successCount = computed(() => logs.value.filter((log) => log.status === "success").length);
const failedCount = computed(() => logs.value.filter((log) => log.status === "failed").length);
const latestLogAction = computed(() => (logs.value[0] ? actionLabel(logs.value[0].action) : "暂无"));
onMounted(loadLogs);
async function loadLogs(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
response.value = await listOperationLogs({
page: page.value,
page_size: pageSize.value,
keyword: filters.keyword.trim(),
username: filters.username.trim(),
module: filters.module,
action: filters.action,
status: filters.status,
});
page.value = response.value.page;
pageSize.value = response.value.page_size;
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "操作日志加载失败";
} finally {
loading.value = false;
}
}
function searchLogs(): void {
page.value = 1;
loadLogs();
}
function resetFilters(): void {
Object.assign(filters, {
keyword: "",
username: "",
module: "",
action: "",
status: "",
});
searchLogs();
}
function previousPage(): void {
if (page.value <= 1) {
return;
}
page.value -= 1;
loadLogs();
}
function nextPage(): void {
if (page.value >= totalPages.value) {
return;
}
page.value += 1;
loadLogs();
}
function formatDate(value: string): string {
return new Date(value).toLocaleString();
}
function moduleLabel(module: string): string {
return moduleOptions.find((item) => item.value === module)?.label || module || "-";
}
function actionLabel(action: string): string {
return actionOptions.find((item) => item.value === action)?.label || action || "-";
}
function roleLabel(role: string): string {
const labels: Record<string, string> = {
superuser: "超级用户",
manager: "管理者",
viewer: "查看权限",
};
return labels[role] || role || "匿名";
}
function statusLabel(status: string): string {
return status === "success" ? "成功" : "失败";
}
function statusClass(status: string): string {
return status === "success" ? "green" : "red";
}
function targetLabel(log: OperationLog): string {
if (!log.target_type && !log.target_id) {
return "-";
}
return [log.target_type, log.target_id].filter(Boolean).join(" / ");
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>操作日志</h1>
<p>查看登录、工资计算、用户管理和下载等关键操作。</p>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadLogs">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header>
<section class="stats-grid">
<StatCard title="日志总数" :value="String(response.total)" :icon="ScrollText" tone="blue" meta="全部记录" />
<StatCard title="本页成功" :value="String(successCount)" :icon="CheckCircle2" tone="green" meta="当前筛选" />
<StatCard title="本页失败" :value="String(failedCount)" :icon="XCircle" tone="red" meta="当前筛选" />
<StatCard title="最新操作" :value="latestLogAction" :icon="Clock3" tone="neutral" meta="当前页" />
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>筛选条件</h2>
<p>按操作人、模块、动作、状态或关键字查询</p>
</div>
</div>
<form class="form-grid log-filter-form" @submit.prevent="searchLogs">
<label class="field">
<span>关键字</span>
<input v-model="filters.keyword" type="search" placeholder="动作、对象、详情..." />
</label>
<label class="field">
<span>操作人</span>
<input v-model="filters.username" type="text" placeholder="用户名" />
</label>
<label class="field">
<span>模块</span>
<select v-model="filters.module">
<option v-for="item in moduleOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
</label>
<label class="field">
<span>动作</span>
<select v-model="filters.action">
<option v-for="item in actionOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
</label>
<label class="field">
<span>状态</span>
<select v-model="filters.status">
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="failed">失败</option>
</select>
</label>
<div class="form-actions align-right span-row">
<button class="secondary-button" type="button" :disabled="loading" @click="resetFilters">重置</button>
<button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
</div>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
</section>
<section class="panel table-panel">
<div class="panel-header">
<div>
<h2>日志列表</h2>
<p>第 {{ page }} / {{ totalPages }} 页,共 {{ response.total }} 条</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table operation-log-table">
<thead>
<tr>
<th>时间</th>
<th>操作人</th>
<th>模块/动作</th>
<th>对象</th>
<th>状态</th>
<th>来源</th>
<th>详情</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs" :key="log.id">
<td class="mono">{{ formatDate(log.created_at) }}</td>
<td>
<strong>{{ log.username || "匿名" }}</strong>
<span>{{ roleLabel(log.role) }}</span>
</td>
<td>
<strong>{{ moduleLabel(log.module) }}</strong>
<span>{{ actionLabel(log.action) }}</span>
</td>
<td class="mono">{{ targetLabel(log) }}</td>
<td>
<span class="status-chip" :class="statusClass(log.status)">{{ statusLabel(log.status) }}</span>
</td>
<td>
<strong>{{ log.ip_address || "-" }}</strong>
<span :title="log.user_agent">{{ log.user_agent || "-" }}</span>
</td>
<td class="log-detail-cell" :title="log.detail">{{ log.detail || "-" }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!logs.length && !loading" class="empty-state">
<p>暂无操作日志</p>
</div>
<div class="form-actions align-right">
<button class="secondary-button" type="button" :disabled="loading || page <= 1" @click="previousPage">
上一页
</button>
<button class="secondary-button" type="button" :disabled="loading || page >= totalPages" @click="nextPage">
下一页
</button>
</div>
</section>
</div>
</template>
+399
View File
@@ -0,0 +1,399 @@
<script setup lang="ts">
import { Building2, GitBranch, Loader2, Pencil, Plus, RefreshCw, Save } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue";
import {
createDepartment,
createPosition,
listDepartments,
listPositions,
updateDepartment,
updatePosition,
} from "../api/organization";
import { ApiError } from "../api/http";
import type { DepartmentRecord, DepartmentRequest, PositionRecord, PositionRequest } from "../api/types";
import StatCard from "../components/StatCard.vue";
const departments = ref<DepartmentRecord[]>([]);
const positions = ref<PositionRecord[]>([]);
const loading = ref(false);
const savingDepartment = ref(false);
const savingPosition = ref(false);
const editingDepartmentId = ref<number | null>(null);
const editingPositionId = ref<number | null>(null);
const editingPositionCode = ref("");
const errorMessage = ref("");
const successMessage = ref("");
const departmentForm = reactive<DepartmentRequest>({
parent_id: null,
department_code: "",
name: "",
sort_order: 0,
is_active: true,
remark: "",
});
const positionForm = reactive<PositionRequest>({
department_id: 0,
name: "",
sort_order: 0,
is_active: true,
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);
onMounted(loadData);
async function loadData(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [departmentRows, positionRowsData] = await Promise.all([
listDepartments(false),
listPositions(undefined, false),
]);
departments.value = departmentRows;
positions.value = positionRowsData;
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "组织架构加载失败";
} finally {
loading.value = false;
}
}
async function submitDepartment(): Promise<void> {
savingDepartment.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
if (editingDepartmentId.value) {
await updateDepartment(editingDepartmentId.value, normalizedDepartment());
successMessage.value = "部门已更新";
} else {
await createDepartment(normalizedDepartment());
successMessage.value = "部门已新增";
}
resetDepartmentForm();
await loadData();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "部门保存失败";
} finally {
savingDepartment.value = false;
}
}
async function submitPosition(): Promise<void> {
if (!positionForm.department_id) {
errorMessage.value = "请选择岗位所属部门";
return;
}
savingPosition.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
if (editingPositionId.value) {
await updatePosition(editingPositionId.value, normalizedPosition());
successMessage.value = "岗位已更新";
} else {
await createPosition(normalizedPosition());
successMessage.value = "岗位已新增";
}
resetPositionForm();
await loadData();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "岗位保存失败";
} finally {
savingPosition.value = false;
}
}
function editDepartment(department: DepartmentRecord): void {
editingDepartmentId.value = department.id;
Object.assign(departmentForm, {
parent_id: department.parent_id,
department_code: department.department_code,
name: department.name,
sort_order: department.sort_order,
is_active: department.is_active,
remark: department.remark,
});
}
function editPosition(position: PositionRecord): void {
editingPositionId.value = position.id;
editingPositionCode.value = position.position_code;
Object.assign(positionForm, {
department_id: position.department_id,
name: position.name,
sort_order: position.sort_order,
is_active: position.is_active,
remark: position.remark,
});
}
function resetDepartmentForm(): void {
editingDepartmentId.value = null;
Object.assign(departmentForm, {
parent_id: null,
department_code: "",
name: "",
sort_order: 0,
is_active: true,
remark: "",
});
}
function resetPositionForm(): void {
editingPositionId.value = null;
editingPositionCode.value = "";
Object.assign(positionForm, {
department_id: 0,
name: "",
sort_order: 0,
is_active: true,
remark: "",
});
}
function normalizedDepartment(): DepartmentRequest {
return {
parent_id: departmentForm.parent_id || null,
department_code: departmentForm.department_code.trim(),
name: departmentForm.name.trim(),
sort_order: Number(departmentForm.sort_order || 0),
is_active: departmentForm.is_active,
remark: departmentForm.remark.trim(),
};
}
function normalizedPosition(): PositionRequest {
return {
department_id: Number(positionForm.department_id),
name: positionForm.name.trim(),
sort_order: Number(positionForm.sort_order || 0),
is_active: positionForm.is_active,
remark: positionForm.remark.trim(),
};
}
function departmentName(id: number | null): string {
if (!id) {
return "-";
}
return departments.value.find((item) => item.id === id)?.name || "-";
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>组织架构</h1>
<p>维护部门上下级和部门下的岗位,员工维护会按这里的关系联动。</p>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header>
<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>
<section class="dashboard-grid">
<div class="panel">
<div class="panel-header">
<div>
<h2>{{ editingDepartmentId ? "编辑部门" : "新增部门" }}</h2>
<p>部门可选择父级,形成上下级关系</p>
</div>
<button class="secondary-button" type="button" @click="resetDepartmentForm">清空</button>
</div>
<form class="form-grid compact-form" @submit.prevent="submitDepartment">
<label class="field">
<span>部门编码</span>
<input v-model="departmentForm.department_code" required maxlength="64" type="text" />
</label>
<label class="field">
<span>部门名称</span>
<input v-model="departmentForm.name" required maxlength="128" type="text" />
</label>
<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>
</select>
</label>
<label class="field">
<span>排序</span>
<input v-model.number="departmentForm.sort_order" type="number" />
</label>
<label class="checkbox-card span-row">
<input v-model="departmentForm.is_active" type="checkbox" />
<span>启用部门</span>
</label>
<label class="field span-row">
<span>备注</span>
<textarea v-model="departmentForm.remark" />
</label>
<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>
</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">
<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 }}
</option>
</select>
</label>
<label class="field">
<span>岗位编码</span>
<input :value="editingPositionCode || '保存后自动生成'" readonly type="text" />
</label>
<label class="field">
<span>岗位名称</span>
<input v-model="positionForm.name" required maxlength="128" type="text" />
</label>
<label class="field">
<span>排序</span>
<input v-model.number="positionForm.sort_order" type="number" />
</label>
<label class="checkbox-card span-row">
<input v-model="positionForm.is_active" type="checkbox" />
<span>启用岗位</span>
</label>
<label class="field span-row">
<span>备注</span>
<textarea v-model="positionForm.remark" />
</label>
<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>
</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>
</div>
</template>
+238
View File
@@ -0,0 +1,238 @@
<script setup lang="ts">
import {
ArrowRight,
Banknote,
CloudCog,
Download,
FileSpreadsheet,
Loader2,
Play,
Trash2,
Upload,
} from "@lucide/vue";
import { computed, ref } from "vue";
import { ApiError } from "../api/http";
import {
calculateFromDingTalk,
calculateFromExcel,
downloadPayrollFile,
saveRecentJob,
} from "../api/payroll";
import type { PayrollResponse } from "../api/types";
import ResultsTable from "../components/ResultsTable.vue";
import StatCard from "../components/StatCard.vue";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const selectedFile = ref<File | null>(null);
const configPath = ref("");
const exportExcel = ref(true);
const userIdsText = ref("001\n002");
const startDate = ref("2026-05-01");
const endDate = ref("2026-05-31");
const activeResult = ref<PayrollResponse | null>(null);
const loading = ref<"excel" | "dingtalk" | "">("");
const errorMessage = ref("");
const successMessage = ref("");
const canCalculateExcel = computed(() => auth.hasPermission("payroll:excel:calculate"));
const canCalculateDingTalk = computed(() => auth.hasPermission("payroll:dingtalk:calculate"));
const totalOvertime = computed(() => sumResults("remaining_overtime_hours").toFixed(1));
const totalDeduction = computed(() => money(sumResults("total_deduction")));
const totalNetSalary = computed(() => money(sumResults("net_salary")));
function handleFileChange(event: Event): void {
const input = event.target as HTMLInputElement;
selectedFile.value = input.files?.[0] || null;
}
async function submitExcel(): Promise<void> {
if (!selectedFile.value) {
errorMessage.value = "请选择 Excel 文件";
return;
}
await runCalculation("excel", async () => {
const response = await calculateFromExcel(selectedFile.value!, exportExcel.value, configPath.value.trim());
saveRecentJob("excel", response);
return response;
});
}
async function submitDingTalk(): Promise<void> {
const user_ids = userIdsText.value
.split(/[\n,,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
if (!user_ids.length) {
errorMessage.value = "请输入钉钉员工 userId";
return;
}
await runCalculation("dingtalk", async () => {
const response = await calculateFromDingTalk({
user_ids,
start_date: startDate.value,
end_date: endDate.value,
config_path: configPath.value.trim() || null,
export_excel: exportExcel.value,
});
saveRecentJob("dingtalk", response);
return response;
});
}
async function runCalculation(
source: "excel" | "dingtalk",
executor: () => Promise<PayrollResponse>,
): Promise<void> {
loading.value = source;
errorMessage.value = "";
successMessage.value = "";
try {
activeResult.value = await executor();
successMessage.value = `计算完成:${activeResult.value.employee_count} 名员工`;
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "计算失败,请检查数据后重试";
} finally {
loading.value = "";
}
}
async function downloadCurrent(): Promise<void> {
if (!activeResult.value) {
return;
}
await downloadPayrollFile(activeResult.value.download_url, activeResult.value.output_file);
}
function clearResult(): void {
activeResult.value = null;
successMessage.value = "";
errorMessage.value = "";
}
function sumResults(field: "remaining_overtime_hours" | "total_deduction" | "net_salary"): number {
return (activeResult.value?.results || []).reduce((total, item) => total + (item[field] || 0), 0);
}
function money(value: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(value);
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>薪资计算与集成</h1>
<p>Excel 月度汇总与钉钉实时考勤统一计算。</p>
</div>
<div class="header-actions">
<button class="secondary-button" type="button" :disabled="!activeResult" @click="clearResult">
<Trash2 :size="18" aria-hidden="true" />
<span>清空数据</span>
</button>
<button class="primary-button" type="button" :disabled="!activeResult?.download_url && !activeResult?.output_file" @click="downloadCurrent">
<Download :size="18" aria-hidden="true" />
<span>下载结果</span>
</button>
</div>
</header>
<section class="action-grid">
<div class="action-card">
<div class="action-icon blue">
<CloudCog :size="29" aria-hidden="true" />
</div>
<h2>实时同步钉钉考勤</h2>
<p>通过后端钉钉接口获取打卡、请假及加班数据。</p>
<button class="primary-button" type="button" :disabled="!canCalculateDingTalk || loading === 'dingtalk'" @click="submitDingTalk">
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<ArrowRight v-else :size="18" aria-hidden="true" />
<span>{{ loading === "dingtalk" ? "同步中" : "连接并抓取" }}</span>
</button>
</div>
<div class="action-card">
<div class="action-icon green">
<FileSpreadsheet :size="29" aria-hidden="true" />
</div>
<h2>离线 Excel 导入</h2>
<p>上传钉钉月度汇总表,按当前工资规则计算。</p>
<label class="file-button">
<Upload :size="18" aria-hidden="true" />
<span>{{ selectedFile?.name || "上传文件" }}</span>
<input accept=".xlsx,.xls" type="file" @change="handleFileChange" />
</label>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>计算参数</h2>
<p>{{ exportExcel ? "生成 Excel 结果文件" : "仅返回接口结果" }}</p>
</div>
<span class="status-chip green">API 已连接</span>
</div>
<div class="form-grid">
<label class="field">
<span>配置文件路径</span>
<input v-model="configPath" type="text" placeholder="默认规则配置" />
</label>
<label class="field">
<span>开始日期</span>
<input v-model="startDate" type="date" />
</label>
<label class="field">
<span>结束日期</span>
<input v-model="endDate" type="date" />
</label>
<label class="checkbox-card">
<input v-model="exportExcel" type="checkbox" />
<span>导出 Excel</span>
</label>
</div>
<label class="field">
<span>钉钉员工 userId</span>
<textarea v-model="userIdsText" rows="4" />
</label>
<div class="form-actions">
<button class="secondary-button" type="button" :disabled="!canCalculateExcel || loading === 'excel'" @click="submitExcel">
<Loader2 v-if="loading === 'excel'" class="spin" :size="18" aria-hidden="true" />
<Play v-else :size="18" aria-hidden="true" />
<span>{{ loading === "excel" ? "计算中" : "开始计算 Excel" }}</span>
</button>
<button class="primary-button" type="button" :disabled="!canCalculateDingTalk || loading === 'dingtalk'" @click="submitDingTalk">
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<Play v-else :size="18" aria-hidden="true" />
<span>{{ loading === "dingtalk" ? "计算中" : "开始计算钉钉" }}</span>
</button>
</div>
</section>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section v-if="activeResult" class="stats-grid">
<StatCard title="员工数量" :value="String(activeResult.employee_count)" :icon="FileSpreadsheet" tone="blue" :meta="activeResult.job_id" />
<StatCard title="剩余加班合计" :value="`${totalOvertime}h`" :icon="CloudCog" tone="green" meta="加班-请假" />
<StatCard title="扣款合计" :value="totalDeduction" :icon="Trash2" tone="red" meta="迟到+缺卡" />
<StatCard title="实发合计" :value="totalNetSalary" :icon="Banknote" tone="neutral" meta="已计算" />
</section>
<ResultsTable :results="activeResult?.results || []" />
</div>
</template>
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { Banknote, Building2, Clock3, Loader2, RefreshCw, Search, WalletCards } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http";
import { getPayrollReport } from "../api/reports";
import type { PayrollReportResponse } from "../api/types";
import StatCard from "../components/StatCard.vue";
const salaryMonth = ref(new Date().toISOString().slice(0, 7));
const report = ref<PayrollReportResponse | null>(null);
const loading = ref(false);
const errorMessage = ref("");
const totals = computed(() => report.value?.totals || {});
const attendance = computed(() => report.value?.attendance || {});
onMounted(loadReport);
async function loadReport(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
report.value = await getPayrollReport(salaryMonth.value);
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "统计报表加载失败";
} finally {
loading.value = false;
}
}
function money(value: number | undefined): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(Number(value || 0));
}
function modeLabel(value: string): string {
const labels: Record<string, string> = {
monthly: "包月",
hourly: "计时",
piecework: "计件",
probation: "试用期",
};
return labels[value] || value;
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>统计报表</h1>
<p>查看工资汇总、部门成本和考勤工时统计。</p>
</div>
<form class="header-actions" @submit.prevent="loadReport">
<label class="field inline-field">
<span>工资月份</span>
<input v-model="salaryMonth" type="month" />
</label>
<button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
<button class="secondary-button" type="button" :disabled="loading" @click="loadReport">
<RefreshCw :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</form>
</header>
<section class="stats-grid">
<StatCard title="员工数" :value="String(totals.employee_count || 0)" :icon="WalletCards" tone="blue" :meta="salaryMonth" />
<StatCard title="应发工资" :value="money(totals.gross_salary)" :icon="Banknote" tone="neutral" meta="基础+提成+加班" />
<StatCard title="实发工资" :value="money(totals.net_salary)" :icon="Banknote" tone="green" meta="扣款后" />
<StatCard title="扣款合计" :value="money(totals.deduction_amount)" :icon="Clock3" tone="red" meta="迟到+缺卡" />
</section>
<section class="stats-grid">
<StatCard title="提成合计" :value="money(totals.commission_amount)" :icon="Banknote" tone="green" meta="当月维护" />
<StatCard title="加班费合计" :value="money(totals.overtime_pay)" :icon="Clock3" tone="blue" meta="三类加班" />
<StatCard title="加班工时" :value="`${Number(attendance.overtime_hours || 0).toFixed(1)}h`" :icon="Clock3" tone="neutral" meta="打卡推算" />
<StatCard title="请假工时" :value="`${Number(attendance.leave_hours || 0).toFixed(1)}h`" :icon="Clock3" tone="red" meta="统一折算" />
</section>
<section class="dashboard-grid">
<div class="panel table-panel">
<div class="panel-header">
<div>
<h2>部门工资汇总</h2>
<p>{{ report?.departments.length || 0 }} 个部门</p>
</div>
<Building2 :size="22" aria-hidden="true" />
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>部门</th>
<th>人数</th>
<th>应发</th>
<th>实发</th>
</tr>
</thead>
<tbody>
<tr v-for="item in report?.departments || []" :key="item.department">
<td><strong>{{ item.department }}</strong></td>
<td>{{ item.employee_count }}</td>
<td class="mono">{{ money(item.gross_salary) }}</td>
<td class="mono strong">{{ money(item.net_salary) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!report?.departments.length && !loading" class="empty-state">
<p>暂无部门汇总</p>
</div>
</div>
<div class="panel table-panel">
<div class="panel-header">
<div>
<h2>最近工资记录</h2>
<p>{{ report?.recent_records.length || 0 }} 条</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 report?.recent_records || []" :key="item.id">
<td>
<strong>{{ item.employee_name }}</strong>
<span>{{ item.employee_no || item.department }}</span>
</td>
<td><span class="status-chip neutral">{{ modeLabel(item.salary_mode) }}</span></td>
<td class="mono">{{ money(item.base_pay) }}</td>
<td class="mono">{{ money(item.commission_amount) }}</td>
<td class="mono strong">{{ money(item.net_salary) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!report?.recent_records.length && !loading" class="empty-state">
<p>暂无工资记录</p>
</div>
</div>
</section>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
</div>
</template>
+379
View File
@@ -0,0 +1,379 @@
<script setup lang="ts">
import {
BadgeDollarSign,
HandCoins,
Loader2,
Pencil,
RefreshCw,
Save,
Search,
TimerReset,
WalletCards,
} from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue";
import { listEmployees } from "../api/employees";
import { ApiError } from "../api/http";
import { listSalaryProfiles, saveSalaryProfile } from "../api/salaryProfiles";
import type { EmployeeRecord, SalaryProfileRecord, SalaryProfileRequest } from "../api/types";
import StatCard from "../components/StatCard.vue";
const employees = ref<EmployeeRecord[]>([]);
const profiles = ref<SalaryProfileRecord[]>([]);
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const keyword = ref("");
const form = reactive<SalaryProfileRequest>({
employee_id: 0,
salary_mode: "monthly",
base_salary: 0,
hourly_rate: 0,
piece_quantity: 0,
piece_unit_price: 0,
probation_type: "ratio",
probation_ratio: 1,
probation_salary: 0,
commission_amount: 0,
overtime_rate: 0,
weekend_overtime_rate: 0,
holiday_overtime_rate: 0,
effective_month: "",
remark: "",
});
const salaryModeOptions = [
{ value: "monthly", label: "包月" },
{ value: "hourly", label: "计时" },
{ value: "piecework", label: "计件" },
{ value: "probation", label: "试用期" },
];
const totalBaseSalary = computed(() => profiles.value.reduce((total, item) => total + Number(item.base_salary || 0), 0));
const totalCommission = computed(() => profiles.value.reduce((total, item) => total + Number(item.commission_amount || 0), 0));
const avgOvertimeRate = computed(() => {
if (!profiles.value.length) {
return 0;
}
return profiles.value.reduce((total, item) => total + Number(item.overtime_rate || 0), 0) / profiles.value.length;
});
onMounted(loadData);
async function loadData(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [employeeRows, profileRows] = await Promise.all([
listEmployees({ employment_status: "active" }),
listSalaryProfiles(keyword.value.trim()),
]);
employees.value = employeeRows;
profiles.value = profileRows;
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "薪资档案加载失败";
} finally {
loading.value = false;
}
}
async function submit(): Promise<void> {
if (!form.employee_id) {
errorMessage.value = "请选择关联员工";
return;
}
saving.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
const profile = await saveSalaryProfile({
employee_id: Number(form.employee_id),
salary_mode: form.salary_mode,
base_salary: Number(form.base_salary || 0),
hourly_rate: Number(form.hourly_rate || 0),
piece_quantity: Number(form.piece_quantity || 0),
piece_unit_price: Number(form.piece_unit_price || 0),
probation_type: form.probation_type,
probation_ratio: Number(form.probation_ratio || 1),
probation_salary: Number(form.probation_salary || 0),
commission_amount: Number(form.commission_amount || 0),
overtime_rate: Number(form.overtime_rate || 0),
weekend_overtime_rate: Number(form.weekend_overtime_rate || 0),
holiday_overtime_rate: Number(form.holiday_overtime_rate || 0),
effective_month: form.effective_month || "",
remark: form.remark.trim(),
});
profiles.value = [profile, ...profiles.value.filter((item) => item.employee_id !== profile.employee_id)];
successMessage.value = "薪资档案已保存";
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "薪资档案保存失败";
} finally {
saving.value = false;
}
}
function editProfile(profile: SalaryProfileRecord): void {
Object.assign(form, {
employee_id: profile.employee_id,
salary_mode: profile.salary_mode,
base_salary: profile.base_salary,
hourly_rate: profile.hourly_rate,
piece_quantity: profile.piece_quantity,
piece_unit_price: profile.piece_unit_price,
probation_type: profile.probation_type,
probation_ratio: profile.probation_ratio,
probation_salary: profile.probation_salary,
commission_amount: profile.commission_amount,
overtime_rate: profile.overtime_rate,
weekend_overtime_rate: profile.weekend_overtime_rate,
holiday_overtime_rate: profile.holiday_overtime_rate,
effective_month: profile.effective_month,
remark: profile.remark,
});
successMessage.value = "";
errorMessage.value = "";
}
function resetForm(): void {
Object.assign(form, {
employee_id: 0,
salary_mode: "monthly",
base_salary: 0,
hourly_rate: 0,
piece_quantity: 0,
piece_unit_price: 0,
probation_type: "ratio",
probation_ratio: 1,
probation_salary: 0,
commission_amount: 0,
overtime_rate: 0,
weekend_overtime_rate: 0,
holiday_overtime_rate: 0,
effective_month: "",
remark: "",
});
}
function salaryModeLabel(value: string): string {
return salaryModeOptions.find((item) => item.value === value)?.label || value;
}
function profileRuleText(profile: SalaryProfileRecord): string {
if (profile.salary_mode === "hourly") {
return `${money(profile.hourly_rate)}/h`;
}
if (profile.salary_mode === "piecework") {
return `${profile.piece_quantity} × ${money(profile.piece_unit_price)}`;
}
if (profile.salary_mode === "probation") {
return profile.probation_type === "fixed"
? `固定 ${money(profile.probation_salary)}`
: `${Math.round(profile.probation_ratio * 100)}%`;
}
return money(profile.base_salary);
}
function money(value: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(Number(value || 0));
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>薪资维护</h1>
<p>关联员工维护包月、计时、计件、试用期规则和独立加班费单价。</p>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header>
<section class="stats-grid">
<StatCard title="薪资档案" :value="String(profiles.length)" :icon="WalletCards" tone="blue" meta="已维护" />
<StatCard title="基础工资合计" :value="money(totalBaseSalary)" :icon="BadgeDollarSign" tone="neutral" meta="当前筛选" />
<StatCard title="提成合计" :value="money(totalCommission)" :icon="HandCoins" tone="green" meta="手工维护" />
<StatCard title="平均加班单价" :value="money(avgOvertimeRate)" :icon="TimerReset" tone="red" meta="独立维护" />
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>查询薪资档案</h2>
<p>按员工编号、姓名、部门或岗位查询</p>
</div>
</div>
<form class="search-form" @submit.prevent="loadData">
<label class="field">
<span>关键字</span>
<input v-model="keyword" type="search" placeholder="员工姓名、编号、部门..." />
</label>
<button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
</form>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>维护薪资档案</h2>
<p>一名员工维护一条当前薪资档案</p>
</div>
<button class="secondary-button" type="button" @click="resetForm">清空</button>
</div>
<form class="form-grid salary-profile-form" @submit.prevent="submit">
<label class="field">
<span>关联员工</span>
<select v-model.number="form.employee_id" required>
<option :value="0">请选择员工</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.name }} / {{ employee.employee_no }}
</option>
</select>
</label>
<label class="field">
<span>薪资模式</span>
<select v-model="form.salary_mode">
<option v-for="item in salaryModeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
</label>
<label class="field">
<span>基础工资/底薪</span>
<input v-model.number="form.base_salary" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计时小时单价</span>
<input v-model.number="form.hourly_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计件数量</span>
<input v-model.number="form.piece_quantity" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计件单价</span>
<input v-model.number="form.piece_unit_price" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>试用期方式</span>
<select v-model="form.probation_type">
<option value="ratio">固定比例</option>
<option value="fixed">固定金额</option>
</select>
</label>
<label class="field">
<span>试用期比例</span>
<input v-model.number="form.probation_ratio" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>试用期固定金额</span>
<input v-model.number="form.probation_salary" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>默认提成</span>
<input v-model.number="form.commission_amount" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>工作日加班单价</span>
<input v-model.number="form.overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>周末加班单价</span>
<input v-model.number="form.weekend_overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>节假日加班单价</span>
<input v-model.number="form.holiday_overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>生效月份</span>
<input v-model="form.effective_month" type="month" />
</label>
<label class="field span-row">
<span>备注</span>
<textarea v-model="form.remark" />
</label>
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : "保存薪资" }}</span>
</button>
</div>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
</section>
<section class="panel table-panel">
<div class="panel-header">
<div>
<h2>薪资档案列表</h2>
<p>{{ profiles.length }} 条薪资档案</p>
</div>
</div>
<div 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>
</tr>
</thead>
<tbody>
<tr v-for="profile in profiles" :key="profile.id">
<td>
<strong>{{ profile.employee.name }}</strong>
<span>{{ profile.employee.employee_no }}</span>
</td>
<td>
<strong>{{ profile.employee.department || "-" }}</strong>
<span>{{ profile.employee.position || "-" }}</span>
</td>
<td><span class="status-chip neutral">{{ salaryModeLabel(profile.salary_mode) }}</span></td>
<td class="mono strong">{{ profileRuleText(profile) }}</td>
<td class="mono">{{ money(profile.commission_amount) }}</td>
<td class="mono">
工作日 {{ money(profile.overtime_rate) }}/h
<span>周末 {{ money(profile.weekend_overtime_rate) }}/h</span>
<span>节假日 {{ money(profile.holiday_overtime_rate) }}/h</span>
</td>
<td>{{ profile.effective_month || "长期" }}</td>
<td class="note-cell">{{ profile.remark || "-" }}</td>
<td>
<button class="link-button" type="button" @click="editProfile(profile)">
<Pencil :size="15" aria-hidden="true" />
<span>编辑</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!profiles.length && !loading" class="empty-state">
<p>暂无薪资档案</p>
</div>
</section>
</div>
</template>
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import {
Loader2,
Plus,
ShieldCheck,
UsersRound,
} from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue";
import { ApiError } from "../api/http";
import { createUser, listUsers } from "../api/auth";
import type { UserRecord, UserRole } from "../api/types";
import StatCard from "../components/StatCard.vue";
const users = ref<UserRecord[]>([]);
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const form = reactive({
username: "",
display_name: "",
email: "",
phone: "",
department: "",
position_title: "",
password: "",
role: "viewer" as UserRole,
});
const superuserCount = computed(() => users.value.filter((user) => user.role === "superuser").length);
const managerCount = computed(() => users.value.filter((user) => user.role === "manager").length);
const viewerCount = computed(() => users.value.filter((user) => user.role === "viewer").length);
onMounted(loadUsers);
async function loadUsers(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
users.value = await listUsers();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "用户列表加载失败";
} finally {
loading.value = false;
}
}
async function submit(): Promise<void> {
saving.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
const user = await createUser({
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
department: form.department.trim(),
position_title: form.position_title.trim(),
password: form.password,
role: form.role,
});
users.value = [user, ...users.value.filter((item) => item.id !== user.id)];
Object.assign(form, {
username: "",
display_name: "",
email: "",
phone: "",
department: "",
position_title: "",
password: "",
role: "viewer",
});
successMessage.value = "用户已创建";
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "用户创建失败";
} finally {
saving.value = false;
}
}
function roleLabel(role: string): string {
const labels: Record<string, string> = {
superuser: "超级用户",
manager: "管理者",
viewer: "查看权限",
};
return labels[role] || role;
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>用户管理</h1>
<p>账号、角色与菜单权限。</p>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadUsers">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<UsersRound v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header>
<section class="stats-grid">
<StatCard title="超级用户" :value="String(superuserCount)" :icon="ShieldCheck" tone="blue" meta="全部权限" />
<StatCard title="管理者" :value="String(managerCount)" :icon="UsersRound" tone="green" meta="计算权限" />
<StatCard title="查看权限" :value="String(viewerCount)" :icon="UsersRound" tone="neutral" meta="只读权限" />
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>新增用户</h2>
<p>角色决定可见菜单和操作权限</p>
</div>
</div>
<form class="form-grid user-form" @submit.prevent="submit">
<label class="field">
<span>用户名</span>
<input v-model="form.username" required minlength="2" type="text" />
</label>
<label class="field">
<span>显示名称</span>
<input v-model="form.display_name" type="text" />
</label>
<label class="field">
<span>初始密码</span>
<input v-model="form.password" required minlength="6" type="password" />
</label>
<label class="field">
<span>角色</span>
<select v-model="form.role">
<option value="viewer">查看权限</option>
<option value="manager">管理者</option>
<option value="superuser">超级用户</option>
</select>
</label>
<label class="field">
<span>邮箱</span>
<input v-model="form.email" type="email" />
</label>
<label class="field">
<span>手机号</span>
<input v-model="form.phone" type="tel" />
</label>
<label class="field">
<span>部门</span>
<input v-model="form.department" type="text" />
</label>
<label class="field">
<span>职位</span>
<input v-model="form.position_title" type="text" />
</label>
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "创建中" : "创建用户" }}</span>
</button>
</div>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
</section>
<section class="panel table-panel">
<div class="panel-header">
<div>
<h2>用户列表</h2>
<p>{{ users.length }} 个账号</p>
</div>
</div>
<div 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>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>
<strong>{{ user.username }}</strong>
<span>ID {{ user.id }}</span>
</td>
<td>{{ user.display_name || "-" }}</td>
<td>
<strong>{{ user.email || "-" }}</strong>
<span>{{ user.phone || "-" }}</span>
</td>
<td>
<strong>{{ user.department || "-" }}</strong>
<span>{{ user.position_title || "-" }}</span>
</td>
<td><span class="status-chip neutral">{{ roleLabel(user.role) }}</span></td>
<td>
<span class="status-chip" :class="user.is_active ? 'green' : 'red'">
{{ user.is_active ? "启用" : "停用" }}
</span>
</td>
<td>{{ user.menus.map((menu) => menu.name).join("、") || "-" }}</td>
<td>{{ new Date(user.created_at).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!users.length && !loading" class="empty-state">
<p>暂无用户</p>
</div>
</section>
</div>
</template>
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, ".", "");
const backendTarget = env.VITE_DEV_PROXY_TARGET || "http://127.0.0.1:8000";
return {
plugins: [vue()],
server: {
proxy: {
"/api": {
target: backendTarget,
changeOrigin: true,
},
"/health": {
target: backendTarget,
changeOrigin: true,
},
"/static": {
target: backendTarget,
changeOrigin: true,
},
},
},
};
});