forked from jiaoly/financial_system
初步提交
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=
|
||||
VITE_DEV_PROXY_TARGET=http://127.0.0.1:8000
|
||||
@@ -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` 更换端口。
|
||||
@@ -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>
|
||||
Generated
+1570
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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}` : ""}`);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}` : ""}`);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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] || "未授权";
|
||||
}
|
||||
@@ -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("")}`;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user