Initial commit
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Backend
|
||||
backend/target/
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.env
|
||||
frontend/.env.*
|
||||
frontend/vite.log
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
288
FRONTEND_HANDOFF.md
Normal file
288
FRONTEND_HANDOFF.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# Smart Office 前端交付文档(给另一个模型使用)
|
||||
|
||||
> 目标:在没有上下文的情况下,仅凭本文档生成**与当前后端完全兼容**的前端界面。
|
||||
> 技术栈:React 18 + Vite
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概览
|
||||
- 系统名称:Smart Office(智能办公管理系统)
|
||||
- 当前版本:v1.0(MVP)
|
||||
- 已实现模块:登录/权限、考勤打卡、请假审批、通知、用户管理
|
||||
- 角色:`ADMIN` / `MANAGER` / `EMPLOYEE`
|
||||
|
||||
### 默认账号
|
||||
- admin / admin123
|
||||
- manager / manager123
|
||||
- employee / employee123
|
||||
|
||||
### 目标页面
|
||||
- 登录
|
||||
- 概览(Dashboard)
|
||||
- 考勤(打卡、个人记录)
|
||||
- 请假(提交、我的请假)
|
||||
- 审批(经理/管理员可见)
|
||||
- 用户管理(管理员/经理可见,创建仅管理员)
|
||||
- 通知
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术与接入约定
|
||||
- API Base:`/api/v1`
|
||||
- 本地后端:`http://localhost:8080`(如在 8081,可通过 `VITE_PROXY_TARGET` 或 `VITE_API_BASE` 配置)
|
||||
- Token:JWT,存储在 LocalStorage,Header:`Authorization: Bearer <token>`
|
||||
- 后端响应结构统一:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": {},
|
||||
"timestamp": "2026-01-28T11:31:58.291Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 权限规则(前端必须遵守)
|
||||
- `ADMIN`:全部页面、可创建用户/修改状态、可审批
|
||||
- `MANAGER`:可查看用户列表、审批
|
||||
- `EMPLOYEE`:仅个人考勤、请假、通知、概览
|
||||
|
||||
UI 级别必须隐藏不可访问页面,但后端也会校验权限。
|
||||
|
||||
---
|
||||
|
||||
## 4. 路由建议
|
||||
- `/login` 登录页
|
||||
- `/` 概览
|
||||
- `/attendance` 考勤
|
||||
- `/leave` 请假
|
||||
- `/approvals` 审批(ADMIN/MANAGER)
|
||||
- `/users` 用户(ADMIN/MANAGER)
|
||||
- `/notifications` 通知
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端接口文档(完整)
|
||||
|
||||
### 5.1 Auth
|
||||
#### 登录
|
||||
- **POST** `/auth/login`
|
||||
```json
|
||||
{ "username": "admin", "password": "admin123" }
|
||||
```
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"token": "jwt-token",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"fullName": "管理员",
|
||||
"role": "ADMIN",
|
||||
"status": "ACTIVE",
|
||||
"lastLoginAt": "2026-01-28T11:31:58.275Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 注册
|
||||
- **POST** `/auth/register`
|
||||
```json
|
||||
{
|
||||
"username": "u1",
|
||||
"password": "u1123",
|
||||
"fullName": "Test User",
|
||||
"email": "u1@example.com",
|
||||
"phone": "123456"
|
||||
}
|
||||
```
|
||||
响应:`UserDto`
|
||||
|
||||
#### 当前用户
|
||||
- **GET** `/auth/me`
|
||||
- Header:`Authorization`
|
||||
- 响应:`UserDto`
|
||||
|
||||
---
|
||||
|
||||
### 5.2 用户管理 Users
|
||||
> ADMIN/MANAGER 可访问;创建/状态变更仅 ADMIN
|
||||
|
||||
#### 列表
|
||||
- **GET** `/users`
|
||||
- 响应:`UserDto[]`
|
||||
|
||||
#### 详情
|
||||
- **GET** `/users/{id}`
|
||||
- 响应:`UserDto`
|
||||
|
||||
#### 创建用户
|
||||
- **POST** `/users`
|
||||
```json
|
||||
{
|
||||
"username": "u1",
|
||||
"password": "u1123",
|
||||
"fullName": "Test User",
|
||||
"email": "u1@example.com",
|
||||
"phone": "123456",
|
||||
"role": "EMPLOYEE"
|
||||
}
|
||||
```
|
||||
|
||||
#### 修改状态
|
||||
- **PATCH** `/users/{id}/status`
|
||||
```json
|
||||
{ "status": "ACTIVE" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.3 考勤 Attendance
|
||||
#### 上班打卡
|
||||
- **POST** `/attendance/check-in`
|
||||
```json
|
||||
{ "location": "HQ" }
|
||||
```
|
||||
|
||||
#### 下班签退
|
||||
- **POST** `/attendance/check-out`
|
||||
|
||||
#### 我的考勤记录
|
||||
- **GET** `/attendance/me`
|
||||
|
||||
---
|
||||
|
||||
### 5.4 请假 Leave Requests
|
||||
#### 提交请假
|
||||
- **POST** `/leave-requests`
|
||||
```json
|
||||
{
|
||||
"type": "ANNUAL",
|
||||
"startTime": "2026-01-29T09:00:00Z",
|
||||
"endTime": "2026-01-29T18:00:00Z",
|
||||
"reason": "Family matters"
|
||||
}
|
||||
```
|
||||
|
||||
#### 我的请假
|
||||
- **GET** `/leave-requests/my`
|
||||
|
||||
#### 待审批列表(ADMIN/MANAGER)
|
||||
- **GET** `/leave-requests/pending`
|
||||
|
||||
#### 审批通过
|
||||
- **POST** `/leave-requests/{id}/approve`
|
||||
```json
|
||||
{ "note": "Approved" }
|
||||
```
|
||||
|
||||
#### 审批拒绝
|
||||
- **POST** `/leave-requests/{id}/reject`
|
||||
```json
|
||||
{ "note": "Rejected" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.5 通知 Notifications
|
||||
#### 通知列表
|
||||
- **GET** `/notifications`
|
||||
|
||||
#### 标记已读
|
||||
- **POST** `/notifications/{id}/read`
|
||||
|
||||
---
|
||||
|
||||
## 6. DTO 数据结构
|
||||
|
||||
### UserDto
|
||||
```ts
|
||||
{
|
||||
id: number
|
||||
username: string
|
||||
fullName: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role: 'ADMIN' | 'MANAGER' | 'EMPLOYEE'
|
||||
status: 'ACTIVE' | 'DISABLED' | 'LOCKED'
|
||||
lastLoginAt?: string
|
||||
}
|
||||
```
|
||||
|
||||
### AttendanceDto
|
||||
```ts
|
||||
{
|
||||
id: number
|
||||
date: string
|
||||
checkInTime?: string
|
||||
checkOutTime?: string
|
||||
workHours: number
|
||||
status: string
|
||||
location?: string
|
||||
}
|
||||
```
|
||||
|
||||
### LeaveDto
|
||||
```ts
|
||||
{
|
||||
id: number
|
||||
requester: UserDto
|
||||
type: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
reason: string
|
||||
status: string
|
||||
approver?: UserDto
|
||||
decidedAt?: string
|
||||
createdAt: string
|
||||
}
|
||||
```
|
||||
|
||||
### NotificationDto
|
||||
```ts
|
||||
{
|
||||
id: number
|
||||
type: string
|
||||
title: string
|
||||
content: string
|
||||
readAt?: string
|
||||
createdAt: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 枚举
|
||||
- 角色:`ADMIN | MANAGER | EMPLOYEE`
|
||||
- 用户状态:`ACTIVE | DISABLED | LOCKED`
|
||||
- 请假类型:`ANNUAL | SICK | PERSONAL | MARRIAGE | MATERNITY | BEREAVEMENT`
|
||||
|
||||
---
|
||||
|
||||
## 8. 错误码参考
|
||||
- 400:参数校验失败
|
||||
- 401:未登录 / Token 无效
|
||||
- 403:无权限
|
||||
- 404:资源不存在
|
||||
- 409:冲突(如重复、已处理)
|
||||
- 500:服务器异常
|
||||
|
||||
---
|
||||
|
||||
## 9. 设计要求(给生成前端用)
|
||||
- 必须生成 **企业级、现代、干净** 的 UI
|
||||
- 页面需覆盖所有模块,角色权限需隐藏
|
||||
- 所有接口调用必须与上述 API 完全一致
|
||||
- Token 保存到 LocalStorage,并在每次请求中带上
|
||||
|
||||
---
|
||||
|
||||
## 10. v1.1 增量(目前无 API,前端可暂不实现)
|
||||
- 考勤规则配置实体
|
||||
- 部门结构实体
|
||||
> 后端暂未提供接口,仅模型存在
|
||||
70
README.md
Normal file
70
README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Smart Office(智能办公管理系统)
|
||||
|
||||
本仓库已根据需求文档完成 v1.0(MVP)完整实现,包含 **后端 Spring Boot** 与 **前端 React**。MVP 聚焦“可运行、可演示、流程闭环”的核心能力。
|
||||
|
||||
## 已实现范围(v1.0)
|
||||
- 认证与权限:JWT + 角色分级(管理员/经理/员工)
|
||||
- 用户管理:用户新增、查询、状态管理
|
||||
- 考勤管理:上班/下班打卡、个人考勤记录
|
||||
- 请假审批:请假申请、审批通过/拒绝
|
||||
- 通知服务:站内通知(审批结果)
|
||||
|
||||
## 目录结构
|
||||
```
|
||||
backend/ # Spring Boot 3.2 后端
|
||||
frontend/ # React 18 + Vite 前端
|
||||
plans/ # 需求/架构/数据库文档
|
||||
scripts/ # 数据库初始化脚本(MySQL)
|
||||
```
|
||||
|
||||
## 启动方式
|
||||
|
||||
### 后端
|
||||
```bash
|
||||
cd backend
|
||||
mvn spring-boot:run
|
||||
```
|
||||
- 默认使用 H2 内存数据库,访问控制台:`http://localhost:8080/h2`
|
||||
- Swagger: `http://localhost:8080/swagger`
|
||||
|
||||
可切换 MySQL:
|
||||
```bash
|
||||
mvn spring-boot:run -Dspring-boot.run.profiles=mysql
|
||||
```
|
||||
|
||||
### 前端
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
访问:`http://localhost:5173`
|
||||
|
||||
如后端运行在非 8080 端口(例如 8081),可在前端设置代理目标:
|
||||
```bash
|
||||
# macOS/Linux
|
||||
VITE_PROXY_TARGET=http://localhost:8081 npm run dev
|
||||
|
||||
# Windows PowerShell
|
||||
$env:VITE_PROXY_TARGET="http://localhost:8081"; npm run dev
|
||||
```
|
||||
|
||||
也可以直接配置 API Base(绕过代理):
|
||||
```bash
|
||||
# macOS/Linux
|
||||
VITE_API_BASE=http://localhost:8081/api/v1 npm run dev
|
||||
|
||||
# Windows PowerShell
|
||||
$env:VITE_API_BASE="http://localhost:8081/api/v1"; npm run dev
|
||||
```
|
||||
|
||||
## 默认账号
|
||||
- 管理员:admin / admin123
|
||||
- 经理:manager / manager123
|
||||
- 员工:employee / employee123
|
||||
|
||||
## 前端外观
|
||||
- 登录页背景图文件:`frontend/public/login-bg.jpg`(可替换为自定义图片)
|
||||
|
||||
## 需求文档
|
||||
见 `plans/project_requirements_document.md`,已在 v1.0 标注了 MVP 交付边界与后续迭代项。
|
||||
15
ROOT_STRUCTURE.md
Normal file
15
ROOT_STRUCTURE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 根目录结构说明
|
||||
|
||||
> 目录:`E:\50425\Documents\Github\Test`(对应 WSL 路径:`/mnt/e/50425/Documents/Github/Test`)
|
||||
|
||||
- `backend/`:后端 Spring Boot 3.2 源码与构建产物(Java)。
|
||||
- `frontend/`:前端 React 18 + Vite 工程(UI、路由、API 调用、样式等)。
|
||||
- `plans/`:需求与架构类文档(需求说明、数据库设计等)。
|
||||
- `scripts/`:数据库初始化与相关脚本(如 MySQL)。
|
||||
- `docker-compose.yml`:一键启动 MySQL 与后端的容器编排配置。
|
||||
- `FRONTEND_HANDOFF.md`:前端接口交付说明(API、字段、权限与页面约束)。
|
||||
- `README.md`:项目说明与启动方式(后端/前端、默认账号)。
|
||||
- `.gitignore`:Git 忽略规则(如 `node_modules`、`dist`、后端构建产物)。
|
||||
- `NUL`:占位文件/系统兼容文件(保留即可,不影响运行)。
|
||||
|
||||
如需补充更细的子目录说明(例如 `frontend/src` 或 `backend/src`),告诉我我可以继续扩展。
|
||||
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM maven:3.9.6-eclipse-temurin-17 AS builder
|
||||
WORKDIR /app
|
||||
COPY pom.xml .
|
||||
COPY src ./src
|
||||
RUN mvn -q -DskipTests package
|
||||
|
||||
FROM eclipse-temurin:17-jre
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/*.jar app.jar
|
||||
ENV SPRING_PROFILES_ACTIVE=mysql
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
128
backend/pom.xml
Normal file
128
backend/pom.xml
Normal file
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.smartoffice</groupId>
|
||||
<artifactId>smart-office-backend</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<name>smart-office-backend</name>
|
||||
<description>Smart Office Management System - Backend</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<spring-boot.version>3.2.2</spring-boot.version>
|
||||
<jjwt.version>0.11.5</jjwt.version>
|
||||
<mysql.version>8.0.33</mysql.version>
|
||||
<spring-boot-maven-plugin.version>${spring-boot.version}</spring-boot-maven-plugin.version>
|
||||
<maven.compiler.release>${java.version}</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.3.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>mysql</groupId>
|
||||
<artifactId>mysql-connector-java</artifactId>
|
||||
<version>${mysql.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<configuration>
|
||||
<useModulePath>false</useModulePath>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<release>${java.version}</release>
|
||||
<encoding>${project.build.sourceEncoding}</encoding>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot-maven-plugin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
1
backend/run-backend.out.log
Normal file
1
backend/run-backend.out.log
Normal file
@@ -0,0 +1 @@
|
||||
test
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.smartoffice;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class SmartOfficeApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(SmartOfficeApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.smartoffice.attendance;
|
||||
|
||||
import com.smartoffice.auth.UserPrincipal;
|
||||
import com.smartoffice.common.ApiResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/attendance")
|
||||
public class AttendanceController {
|
||||
private final AttendanceService attendanceService;
|
||||
|
||||
public AttendanceController(AttendanceService attendanceService) {
|
||||
this.attendanceService = attendanceService;
|
||||
}
|
||||
|
||||
@PostMapping("/check-in")
|
||||
public ApiResponse<AttendanceDto> checkIn(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody(required = false) CheckInRequest request) {
|
||||
if (request == null) {
|
||||
request = new CheckInRequest();
|
||||
}
|
||||
return ApiResponse.created(attendanceService.checkIn(principal.getUsername(), request));
|
||||
}
|
||||
|
||||
@PostMapping("/check-out")
|
||||
public ApiResponse<AttendanceDto> checkOut(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return ApiResponse.ok(attendanceService.checkOut(principal.getUsername()));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<List<AttendanceDto>> myRecords(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return ApiResponse.ok(attendanceService.myRecords(principal.getUsername()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.smartoffice.attendance;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record AttendanceDto(
|
||||
Long id,
|
||||
LocalDate date,
|
||||
Instant checkInTime,
|
||||
Instant checkOutTime,
|
||||
double workHours,
|
||||
AttendanceStatus status,
|
||||
String location
|
||||
) {
|
||||
public static AttendanceDto from(AttendanceRecord record) {
|
||||
return new AttendanceDto(
|
||||
record.getId(),
|
||||
record.getAttendanceDate(),
|
||||
record.getCheckInTime(),
|
||||
record.getCheckOutTime(),
|
||||
record.getWorkHours(),
|
||||
record.getStatus(),
|
||||
record.getLocation()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.smartoffice.attendance;
|
||||
|
||||
import com.smartoffice.user.User;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "attendance_records")
|
||||
public class AttendanceRecord {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
private User user;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate attendanceDate;
|
||||
|
||||
private Instant checkInTime;
|
||||
|
||||
private Instant checkOutTime;
|
||||
|
||||
private double workHours;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private AttendanceStatus status = AttendanceStatus.NORMAL;
|
||||
|
||||
private String location;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public LocalDate getAttendanceDate() {
|
||||
return attendanceDate;
|
||||
}
|
||||
|
||||
public void setAttendanceDate(LocalDate attendanceDate) {
|
||||
this.attendanceDate = attendanceDate;
|
||||
}
|
||||
|
||||
public Instant getCheckInTime() {
|
||||
return checkInTime;
|
||||
}
|
||||
|
||||
public void setCheckInTime(Instant checkInTime) {
|
||||
this.checkInTime = checkInTime;
|
||||
}
|
||||
|
||||
public Instant getCheckOutTime() {
|
||||
return checkOutTime;
|
||||
}
|
||||
|
||||
public void setCheckOutTime(Instant checkOutTime) {
|
||||
this.checkOutTime = checkOutTime;
|
||||
}
|
||||
|
||||
public double getWorkHours() {
|
||||
return workHours;
|
||||
}
|
||||
|
||||
public void setWorkHours(double workHours) {
|
||||
this.workHours = workHours;
|
||||
}
|
||||
|
||||
public AttendanceStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(AttendanceStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.smartoffice.attendance;
|
||||
|
||||
import com.smartoffice.user.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AttendanceRepository extends JpaRepository<AttendanceRecord, Long> {
|
||||
Optional<AttendanceRecord> findByUserAndAttendanceDate(User user, LocalDate date);
|
||||
List<AttendanceRecord> findAllByUserOrderByAttendanceDateDesc(User user);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.smartoffice.attendance;
|
||||
|
||||
import com.smartoffice.organization.Department;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "attendance_rule_config")
|
||||
public class AttendanceRuleConfig {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String ruleName;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalTime workStartTime;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalTime workEndTime;
|
||||
|
||||
// Comma-separated ISO day-of-week numbers (1=Mon ... 7=Sun)
|
||||
@Column(nullable = false)
|
||||
private String workDays = "1,2,3,4,5";
|
||||
|
||||
private LocalTime checkInWindowStart;
|
||||
|
||||
private LocalTime checkInWindowEnd;
|
||||
|
||||
private LocalTime checkOutWindowStart;
|
||||
|
||||
private LocalTime checkOutWindowEnd;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer lateMinutesThreshold = 0;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer earlyLeaveMinutesThreshold = 0;
|
||||
|
||||
private LocalDate effectiveFrom;
|
||||
|
||||
private LocalDate effectiveTo;
|
||||
|
||||
private boolean enabled = true;
|
||||
|
||||
private boolean allowMakeup = false;
|
||||
|
||||
private Integer maxMakeupPerMonth = 0;
|
||||
|
||||
private Integer flexMinutes = 0;
|
||||
|
||||
private String workCalendarCode;
|
||||
|
||||
private String workCalendarName;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "department_id")
|
||||
private Department department;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
this.createdAt = Instant.now();
|
||||
this.updatedAt = this.createdAt;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getRuleName() {
|
||||
return ruleName;
|
||||
}
|
||||
|
||||
public void setRuleName(String ruleName) {
|
||||
this.ruleName = ruleName;
|
||||
}
|
||||
|
||||
public LocalTime getWorkStartTime() {
|
||||
return workStartTime;
|
||||
}
|
||||
|
||||
public void setWorkStartTime(LocalTime workStartTime) {
|
||||
this.workStartTime = workStartTime;
|
||||
}
|
||||
|
||||
public LocalTime getWorkEndTime() {
|
||||
return workEndTime;
|
||||
}
|
||||
|
||||
public void setWorkEndTime(LocalTime workEndTime) {
|
||||
this.workEndTime = workEndTime;
|
||||
}
|
||||
|
||||
public String getWorkDays() {
|
||||
return workDays;
|
||||
}
|
||||
|
||||
public void setWorkDays(String workDays) {
|
||||
this.workDays = workDays;
|
||||
}
|
||||
|
||||
public LocalTime getCheckInWindowStart() {
|
||||
return checkInWindowStart;
|
||||
}
|
||||
|
||||
public void setCheckInWindowStart(LocalTime checkInWindowStart) {
|
||||
this.checkInWindowStart = checkInWindowStart;
|
||||
}
|
||||
|
||||
public LocalTime getCheckInWindowEnd() {
|
||||
return checkInWindowEnd;
|
||||
}
|
||||
|
||||
public void setCheckInWindowEnd(LocalTime checkInWindowEnd) {
|
||||
this.checkInWindowEnd = checkInWindowEnd;
|
||||
}
|
||||
|
||||
public LocalTime getCheckOutWindowStart() {
|
||||
return checkOutWindowStart;
|
||||
}
|
||||
|
||||
public void setCheckOutWindowStart(LocalTime checkOutWindowStart) {
|
||||
this.checkOutWindowStart = checkOutWindowStart;
|
||||
}
|
||||
|
||||
public LocalTime getCheckOutWindowEnd() {
|
||||
return checkOutWindowEnd;
|
||||
}
|
||||
|
||||
public void setCheckOutWindowEnd(LocalTime checkOutWindowEnd) {
|
||||
this.checkOutWindowEnd = checkOutWindowEnd;
|
||||
}
|
||||
|
||||
public boolean isAllowMakeup() {
|
||||
return allowMakeup;
|
||||
}
|
||||
|
||||
public void setAllowMakeup(boolean allowMakeup) {
|
||||
this.allowMakeup = allowMakeup;
|
||||
}
|
||||
|
||||
public Integer getMaxMakeupPerMonth() {
|
||||
return maxMakeupPerMonth;
|
||||
}
|
||||
|
||||
public void setMaxMakeupPerMonth(Integer maxMakeupPerMonth) {
|
||||
this.maxMakeupPerMonth = maxMakeupPerMonth;
|
||||
}
|
||||
|
||||
public Integer getFlexMinutes() {
|
||||
return flexMinutes;
|
||||
}
|
||||
|
||||
public void setFlexMinutes(Integer flexMinutes) {
|
||||
this.flexMinutes = flexMinutes;
|
||||
}
|
||||
|
||||
public String getWorkCalendarCode() {
|
||||
return workCalendarCode;
|
||||
}
|
||||
|
||||
public void setWorkCalendarCode(String workCalendarCode) {
|
||||
this.workCalendarCode = workCalendarCode;
|
||||
}
|
||||
|
||||
public String getWorkCalendarName() {
|
||||
return workCalendarName;
|
||||
}
|
||||
|
||||
public void setWorkCalendarName(String workCalendarName) {
|
||||
this.workCalendarName = workCalendarName;
|
||||
}
|
||||
|
||||
public Integer getLateMinutesThreshold() {
|
||||
return lateMinutesThreshold;
|
||||
}
|
||||
|
||||
public void setLateMinutesThreshold(Integer lateMinutesThreshold) {
|
||||
this.lateMinutesThreshold = lateMinutesThreshold;
|
||||
}
|
||||
|
||||
public Integer getEarlyLeaveMinutesThreshold() {
|
||||
return earlyLeaveMinutesThreshold;
|
||||
}
|
||||
|
||||
public void setEarlyLeaveMinutesThreshold(Integer earlyLeaveMinutesThreshold) {
|
||||
this.earlyLeaveMinutesThreshold = earlyLeaveMinutesThreshold;
|
||||
}
|
||||
|
||||
public LocalDate getEffectiveFrom() {
|
||||
return effectiveFrom;
|
||||
}
|
||||
|
||||
public void setEffectiveFrom(LocalDate effectiveFrom) {
|
||||
this.effectiveFrom = effectiveFrom;
|
||||
}
|
||||
|
||||
public LocalDate getEffectiveTo() {
|
||||
return effectiveTo;
|
||||
}
|
||||
|
||||
public void setEffectiveTo(LocalDate effectiveTo) {
|
||||
this.effectiveTo = effectiveTo;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public Department getDepartment() {
|
||||
return department;
|
||||
}
|
||||
|
||||
public void setDepartment(Department department) {
|
||||
this.department = department;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.smartoffice.attendance;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AttendanceRuleConfigRepository extends JpaRepository<AttendanceRuleConfig, Long> {
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.smartoffice.attendance;
|
||||
|
||||
import com.smartoffice.common.ApiException;
|
||||
import com.smartoffice.user.User;
|
||||
import com.smartoffice.user.UserRepository;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AttendanceService {
|
||||
private final AttendanceRepository attendanceRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public AttendanceService(AttendanceRepository attendanceRepository, UserRepository userRepository) {
|
||||
this.attendanceRepository = attendanceRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AttendanceDto checkIn(String username, CheckInRequest request) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new ApiException(404, "User not found"));
|
||||
LocalDate today = LocalDate.now();
|
||||
if (attendanceRepository.findByUserAndAttendanceDate(user, today).isPresent()) {
|
||||
throw new ApiException(409, "Already checked in today");
|
||||
}
|
||||
AttendanceRecord record = new AttendanceRecord();
|
||||
record.setUser(user);
|
||||
record.setAttendanceDate(today);
|
||||
record.setCheckInTime(Instant.now());
|
||||
record.setLocation(request.getLocation());
|
||||
attendanceRepository.save(record);
|
||||
return AttendanceDto.from(record);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AttendanceDto checkOut(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new ApiException(404, "User not found"));
|
||||
AttendanceRecord record = attendanceRepository.findByUserAndAttendanceDate(user, LocalDate.now())
|
||||
.orElseThrow(() -> new ApiException(404, "No check-in record today"));
|
||||
if (record.getCheckOutTime() != null) {
|
||||
throw new ApiException(409, "Already checked out today");
|
||||
}
|
||||
record.setCheckOutTime(Instant.now());
|
||||
Duration duration = Duration.between(record.getCheckInTime(), record.getCheckOutTime());
|
||||
record.setWorkHours(Math.round(duration.toMinutes() / 60.0 * 100.0) / 100.0);
|
||||
attendanceRepository.save(record);
|
||||
return AttendanceDto.from(record);
|
||||
}
|
||||
|
||||
public List<AttendanceDto> myRecords(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new ApiException(404, "User not found"));
|
||||
return attendanceRepository.findAllByUserOrderByAttendanceDateDesc(user)
|
||||
.stream().map(AttendanceDto::from).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.smartoffice.attendance;
|
||||
|
||||
public enum AttendanceStatus {
|
||||
NORMAL,
|
||||
LATE,
|
||||
EARLY_LEAVE,
|
||||
ABSENT
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.smartoffice.attendance;
|
||||
|
||||
public class CheckInRequest {
|
||||
private String location;
|
||||
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.smartoffice.auth;
|
||||
|
||||
import com.smartoffice.common.ApiResponse;
|
||||
import com.smartoffice.user.UserDto;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
public class AuthController {
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
return ApiResponse.ok(authService.login(request));
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<UserDto> register(@Valid @RequestBody RegisterRequest request) {
|
||||
return ApiResponse.created(authService.register(request));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<UserDto> me(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return ApiResponse.ok(UserDto.from(principal.getUser()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.smartoffice.auth;
|
||||
|
||||
import com.smartoffice.user.UserDto;
|
||||
|
||||
public record AuthResponse(String token, UserDto user) {
|
||||
}
|
||||
102
backend/src/main/java/com/smartoffice/auth/AuthService.java
Normal file
102
backend/src/main/java/com/smartoffice/auth/AuthService.java
Normal file
@@ -0,0 +1,102 @@
|
||||
package com.smartoffice.auth;
|
||||
|
||||
import com.smartoffice.common.ApiException;
|
||||
import com.smartoffice.user.Role;
|
||||
import com.smartoffice.user.User;
|
||||
import com.smartoffice.user.UserDto;
|
||||
import com.smartoffice.user.UserRepository;
|
||||
import com.smartoffice.user.UserStatus;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Service
|
||||
public class AuthService {
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final JwtService jwtService;
|
||||
private final int maxAttempts;
|
||||
private final int lockMinutes;
|
||||
|
||||
public AuthService(UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
AuthenticationManager authenticationManager,
|
||||
JwtService jwtService,
|
||||
@Value("${app.security.lockout.max-attempts}") int maxAttempts,
|
||||
@Value("${app.security.lockout.lock-minutes}") int lockMinutes) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.jwtService = jwtService;
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.lockMinutes = lockMinutes;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
User user = userRepository.findByUsername(request.getUsername())
|
||||
.orElseThrow(() -> new ApiException(401, "Invalid credentials"));
|
||||
|
||||
if (user.getStatus() == UserStatus.DISABLED) {
|
||||
throw new ApiException(403, "Account disabled");
|
||||
}
|
||||
if (user.getLockedUntil() != null && user.getLockedUntil().isAfter(Instant.now())) {
|
||||
throw new ApiException(423, "Account locked until " + user.getLockedUntil());
|
||||
}
|
||||
if (user.getStatus() == UserStatus.LOCKED && user.getLockedUntil() == null) {
|
||||
throw new ApiException(423, "Account locked");
|
||||
}
|
||||
if (user.getLockedUntil() != null && user.getLockedUntil().isBefore(Instant.now())) {
|
||||
user.setLockedUntil(null);
|
||||
user.setStatus(UserStatus.ACTIVE);
|
||||
user.setFailedLoginAttempts(0);
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
try {
|
||||
Authentication authentication = authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
|
||||
UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
|
||||
user.setFailedLoginAttempts(0);
|
||||
user.setLockedUntil(null);
|
||||
user.setLastLoginAt(Instant.now());
|
||||
user.setStatus(UserStatus.ACTIVE);
|
||||
userRepository.save(user);
|
||||
String token = jwtService.generateToken(principal);
|
||||
return new AuthResponse(token, UserDto.from(user));
|
||||
} catch (Exception ex) {
|
||||
int attempts = user.getFailedLoginAttempts() + 1;
|
||||
user.setFailedLoginAttempts(attempts);
|
||||
if (attempts >= maxAttempts) {
|
||||
user.setLockedUntil(Instant.now().plusSeconds(lockMinutes * 60L));
|
||||
user.setStatus(UserStatus.LOCKED);
|
||||
}
|
||||
userRepository.save(user);
|
||||
throw new ApiException(401, "Invalid credentials");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserDto register(RegisterRequest request) {
|
||||
if (userRepository.existsByUsername(request.getUsername())) {
|
||||
throw new ApiException(409, "Username already exists");
|
||||
}
|
||||
User user = new User();
|
||||
user.setUsername(request.getUsername());
|
||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||
user.setFullName(request.getFullName());
|
||||
user.setEmail(request.getEmail());
|
||||
user.setPhone(request.getPhone());
|
||||
user.setRole(Role.EMPLOYEE);
|
||||
user.setStatus(UserStatus.ACTIVE);
|
||||
userRepository.save(user);
|
||||
return UserDto.from(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.smartoffice.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
private final JwtService jwtService;
|
||||
private final UserPrincipalService userPrincipalService;
|
||||
|
||||
public JwtAuthFilter(JwtService jwtService, UserPrincipalService userPrincipalService) {
|
||||
this.jwtService = jwtService;
|
||||
this.userPrincipalService = userPrincipalService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7);
|
||||
try {
|
||||
Claims claims = jwtService.parse(token);
|
||||
String username = claims.getSubject();
|
||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
UserPrincipal principal = (UserPrincipal) userPrincipalService.loadUserByUsername(username);
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities());
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// invalid token -> continue without auth
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
45
backend/src/main/java/com/smartoffice/auth/JwtService.java
Normal file
45
backend/src/main/java/com/smartoffice/auth/JwtService.java
Normal file
@@ -0,0 +1,45 @@
|
||||
package com.smartoffice.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.SignatureAlgorithm;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
|
||||
@Service
|
||||
public class JwtService {
|
||||
private final byte[] secret;
|
||||
private final long expirationMinutes;
|
||||
|
||||
public JwtService(@Value("${app.security.jwt.secret}") String secret,
|
||||
@Value("${app.security.jwt.expiration-minutes}") long expirationMinutes) {
|
||||
this.secret = secret.getBytes(StandardCharsets.UTF_8);
|
||||
this.expirationMinutes = expirationMinutes;
|
||||
}
|
||||
|
||||
public String generateToken(UserPrincipal principal) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiry = now.plus(expirationMinutes, ChronoUnit.MINUTES);
|
||||
return Jwts.builder()
|
||||
.setSubject(principal.getUsername())
|
||||
.claim("role", principal.getUser().getRole().name())
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(expiry))
|
||||
.signWith(Keys.hmacShaKeyFor(secret), SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parse(String token) {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(Keys.hmacShaKeyFor(secret))
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
}
|
||||
}
|
||||
27
backend/src/main/java/com/smartoffice/auth/LoginRequest.java
Normal file
27
backend/src/main/java/com/smartoffice/auth/LoginRequest.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.smartoffice.auth;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class LoginRequest {
|
||||
@NotBlank
|
||||
private String username;
|
||||
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.smartoffice.auth;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class RegisterRequest {
|
||||
@NotBlank
|
||||
private String username;
|
||||
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
@NotBlank
|
||||
private String fullName;
|
||||
|
||||
@Email
|
||||
private String email;
|
||||
|
||||
private String phone;
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.smartoffice.auth;
|
||||
|
||||
import com.smartoffice.user.User;
|
||||
import com.smartoffice.user.UserStatus;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public class UserPrincipal implements UserDetails {
|
||||
private final User user;
|
||||
|
||||
public UserPrincipal(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return user.getPasswordHash();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return user.getUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return user.getStatus() != UserStatus.LOCKED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return user.getStatus() == UserStatus.ACTIVE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.smartoffice.auth;
|
||||
|
||||
import com.smartoffice.user.User;
|
||||
import com.smartoffice.user.UserRepository;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class UserPrincipalService implements UserDetailsService {
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public UserPrincipalService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
return new UserPrincipal(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.smartoffice.common;
|
||||
|
||||
public class ApiException extends RuntimeException {
|
||||
private final int status;
|
||||
|
||||
public ApiException(int status, String message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.smartoffice.common;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public class ApiResponse<T> {
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
private Instant timestamp;
|
||||
|
||||
public ApiResponse() {
|
||||
}
|
||||
|
||||
public ApiResponse(int code, String message, T data) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
this.timestamp = Instant.now();
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> ok(T data) {
|
||||
return new ApiResponse<>(200, "OK", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> created(T data) {
|
||||
return new ApiResponse<>(201, "Created", data);
|
||||
}
|
||||
|
||||
public static ApiResponse<Void> error(int code, String message) {
|
||||
return new ApiResponse<>(code, message, null);
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public Instant getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.smartoffice.common;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(ApiException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleApi(ApiException ex) {
|
||||
return ResponseEntity.status(ex.getStatus())
|
||||
.body(ApiResponse.error(ex.getStatus(), ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
Map<String, String> errors = new HashMap<>();
|
||||
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
|
||||
errors.put(error.getField(), error.getDefaultMessage());
|
||||
}
|
||||
return ResponseEntity.badRequest().body(new ApiResponse<>(400, "Validation error", errors));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleGeneral(Exception ex) {
|
||||
return ResponseEntity.internalServerError().body(ApiResponse.error(500, ex.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.smartoffice.common;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
public class HealthController {
|
||||
@GetMapping("/api/v1/health")
|
||||
public ApiResponse<Map<String, String>> health() {
|
||||
return ApiResponse.ok(Map.of("status", "UP"));
|
||||
}
|
||||
}
|
||||
24
backend/src/main/java/com/smartoffice/config/CorsConfig.java
Normal file
24
backend/src/main/java/com/smartoffice/config/CorsConfig.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.smartoffice.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class CorsConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(List.of("http://localhost:5173"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*") );
|
||||
config.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
40
backend/src/main/java/com/smartoffice/config/DataSeeder.java
Normal file
40
backend/src/main/java/com/smartoffice/config/DataSeeder.java
Normal file
@@ -0,0 +1,40 @@
|
||||
package com.smartoffice.config;
|
||||
|
||||
import com.smartoffice.user.Role;
|
||||
import com.smartoffice.user.User;
|
||||
import com.smartoffice.user.UserRepository;
|
||||
import com.smartoffice.user.UserStatus;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class DataSeeder implements CommandLineRunner {
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public DataSeeder(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (userRepository.count() > 0) {
|
||||
return;
|
||||
}
|
||||
userRepository.save(createUser("admin", "管理员", Role.ADMIN));
|
||||
userRepository.save(createUser("manager", "部门经理", Role.MANAGER));
|
||||
userRepository.save(createUser("employee", "普通员工", Role.EMPLOYEE));
|
||||
}
|
||||
|
||||
private User createUser(String username, String fullName, Role role) {
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setPasswordHash(passwordEncoder.encode(username + "123"));
|
||||
user.setFullName(fullName);
|
||||
user.setRole(role);
|
||||
user.setStatus(UserStatus.ACTIVE);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.smartoffice.config;
|
||||
|
||||
import com.smartoffice.auth.JwtAuthFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
private final JwtAuthFilter jwtAuthFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
|
||||
this.jwtAuthFilter = jwtAuthFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http.cors(cors -> {})
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/v1/auth/**", "/swagger/**", "/api-docs/**", "/h2/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/health").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.headers(headers -> headers.frameOptions(frame -> frame.disable()))
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.smartoffice.leave;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class DecisionRequest {
|
||||
@NotBlank
|
||||
private String note;
|
||||
|
||||
public String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
public void setNote(String note) {
|
||||
this.note = note;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.smartoffice.leave;
|
||||
|
||||
import com.smartoffice.auth.UserPrincipal;
|
||||
import com.smartoffice.common.ApiResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/leave-requests")
|
||||
public class LeaveController {
|
||||
private final LeaveService leaveService;
|
||||
|
||||
public LeaveController(LeaveService leaveService) {
|
||||
this.leaveService = leaveService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<LeaveDto> create(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody LeaveRequestCreate request) {
|
||||
return ApiResponse.created(leaveService.create(principal.getUsername(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/my")
|
||||
public ApiResponse<List<LeaveDto>> myRequests(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return ApiResponse.ok(leaveService.myRequests(principal.getUsername()));
|
||||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ApiResponse<List<LeaveDto>> pending() {
|
||||
return ApiResponse.ok(leaveService.pendingRequests());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ApiResponse<LeaveDto> approve(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable("id") Long id,
|
||||
@Valid @RequestBody DecisionRequest request) {
|
||||
return ApiResponse.ok(leaveService.approve(principal.getUsername(), id, request.getNote(), true));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ApiResponse<LeaveDto> reject(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable("id") Long id,
|
||||
@Valid @RequestBody DecisionRequest request) {
|
||||
return ApiResponse.ok(leaveService.approve(principal.getUsername(), id, request.getNote(), false));
|
||||
}
|
||||
}
|
||||
33
backend/src/main/java/com/smartoffice/leave/LeaveDto.java
Normal file
33
backend/src/main/java/com/smartoffice/leave/LeaveDto.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.smartoffice.leave;
|
||||
|
||||
import com.smartoffice.user.UserDto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record LeaveDto(
|
||||
Long id,
|
||||
UserDto requester,
|
||||
LeaveType type,
|
||||
Instant startTime,
|
||||
Instant endTime,
|
||||
String reason,
|
||||
LeaveStatus status,
|
||||
UserDto approver,
|
||||
Instant decidedAt,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static LeaveDto from(LeaveRequest request) {
|
||||
return new LeaveDto(
|
||||
request.getId(),
|
||||
UserDto.from(request.getUser()),
|
||||
request.getType(),
|
||||
request.getStartTime(),
|
||||
request.getEndTime(),
|
||||
request.getReason(),
|
||||
request.getStatus(),
|
||||
request.getApprover() == null ? null : UserDto.from(request.getApprover()),
|
||||
request.getDecidedAt(),
|
||||
request.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.smartoffice.leave;
|
||||
|
||||
import com.smartoffice.user.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface LeaveRepository extends JpaRepository<LeaveRequest, Long> {
|
||||
List<LeaveRequest> findAllByUserOrderByCreatedAtDesc(User user);
|
||||
List<LeaveRequest> findAllByStatusOrderByCreatedAtDesc(LeaveStatus status);
|
||||
}
|
||||
117
backend/src/main/java/com/smartoffice/leave/LeaveRequest.java
Normal file
117
backend/src/main/java/com/smartoffice/leave/LeaveRequest.java
Normal file
@@ -0,0 +1,117 @@
|
||||
package com.smartoffice.leave;
|
||||
|
||||
import com.smartoffice.user.User;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "leave_requests")
|
||||
public class LeaveRequest {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
private User user;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private LeaveType type;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant startTime;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant endTime;
|
||||
|
||||
@Column(length = 500)
|
||||
private String reason;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private LeaveStatus status = LeaveStatus.PENDING;
|
||||
|
||||
@ManyToOne
|
||||
private User approver;
|
||||
|
||||
private Instant decidedAt;
|
||||
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public LeaveType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(LeaveType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Instant getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
public void setStartTime(Instant startTime) {
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
public Instant getEndTime() {
|
||||
return endTime;
|
||||
}
|
||||
|
||||
public void setEndTime(Instant endTime) {
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public LeaveStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(LeaveStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public User getApprover() {
|
||||
return approver;
|
||||
}
|
||||
|
||||
public void setApprover(User approver) {
|
||||
this.approver = approver;
|
||||
}
|
||||
|
||||
public Instant getDecidedAt() {
|
||||
return decidedAt;
|
||||
}
|
||||
|
||||
public void setDecidedAt(Instant decidedAt) {
|
||||
this.decidedAt = decidedAt;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.smartoffice.leave;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public class LeaveRequestCreate {
|
||||
@NotNull
|
||||
private LeaveType type;
|
||||
|
||||
@NotNull
|
||||
private Instant startTime;
|
||||
|
||||
@NotNull
|
||||
private Instant endTime;
|
||||
|
||||
@NotBlank
|
||||
private String reason;
|
||||
|
||||
public LeaveType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(LeaveType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Instant getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
public void setStartTime(Instant startTime) {
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
public Instant getEndTime() {
|
||||
return endTime;
|
||||
}
|
||||
|
||||
public void setEndTime(Instant endTime) {
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.smartoffice.leave;
|
||||
|
||||
import com.smartoffice.common.ApiException;
|
||||
import com.smartoffice.notification.NotificationService;
|
||||
import com.smartoffice.notification.NotificationType;
|
||||
import com.smartoffice.user.User;
|
||||
import com.smartoffice.user.UserRepository;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class LeaveService {
|
||||
private final LeaveRepository leaveRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public LeaveService(LeaveRepository leaveRepository, UserRepository userRepository, NotificationService notificationService) {
|
||||
this.leaveRepository = leaveRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LeaveDto create(String username, LeaveRequestCreate request) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new ApiException(404, "User not found"));
|
||||
if (request.getEndTime().isBefore(request.getStartTime())) {
|
||||
throw new ApiException(400, "End time must be after start time");
|
||||
}
|
||||
LeaveRequest leave = new LeaveRequest();
|
||||
leave.setUser(user);
|
||||
leave.setType(request.getType());
|
||||
leave.setStartTime(request.getStartTime());
|
||||
leave.setEndTime(request.getEndTime());
|
||||
leave.setReason(request.getReason());
|
||||
leaveRepository.save(leave);
|
||||
return LeaveDto.from(leave);
|
||||
}
|
||||
|
||||
public List<LeaveDto> myRequests(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new ApiException(404, "User not found"));
|
||||
return leaveRepository.findAllByUserOrderByCreatedAtDesc(user)
|
||||
.stream().map(LeaveDto::from).toList();
|
||||
}
|
||||
|
||||
public List<LeaveDto> pendingRequests() {
|
||||
return leaveRepository.findAllByStatusOrderByCreatedAtDesc(LeaveStatus.PENDING)
|
||||
.stream().map(LeaveDto::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LeaveDto approve(String approverUsername, Long id, String note, boolean approved) {
|
||||
User approver = userRepository.findByUsername(approverUsername)
|
||||
.orElseThrow(() -> new ApiException(404, "User not found"));
|
||||
LeaveRequest leave = leaveRepository.findById(id)
|
||||
.orElseThrow(() -> new ApiException(404, "Leave request not found"));
|
||||
if (leave.getStatus() != LeaveStatus.PENDING) {
|
||||
throw new ApiException(409, "Leave request already processed");
|
||||
}
|
||||
leave.setApprover(approver);
|
||||
leave.setDecidedAt(Instant.now());
|
||||
leave.setStatus(approved ? LeaveStatus.APPROVED : LeaveStatus.REJECTED);
|
||||
String title = approved ? "请假已批准" : "请假被拒绝";
|
||||
String content = title + " - " + note;
|
||||
notificationService.notifyUser(leave.getUser(), NotificationType.APPROVAL, title, content);
|
||||
return LeaveDto.from(leave);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.smartoffice.leave;
|
||||
|
||||
public enum LeaveStatus {
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED,
|
||||
CANCELLED
|
||||
}
|
||||
10
backend/src/main/java/com/smartoffice/leave/LeaveType.java
Normal file
10
backend/src/main/java/com/smartoffice/leave/LeaveType.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.smartoffice.leave;
|
||||
|
||||
public enum LeaveType {
|
||||
ANNUAL,
|
||||
SICK,
|
||||
PERSONAL,
|
||||
MARRIAGE,
|
||||
MATERNITY,
|
||||
BEREAVEMENT
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.smartoffice.notification;
|
||||
|
||||
import com.smartoffice.user.User;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "notifications")
|
||||
public class Notification {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
private User user;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private NotificationType type;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(nullable = false, length = 1000)
|
||||
private String content;
|
||||
|
||||
private Instant readAt;
|
||||
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public NotificationType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(NotificationType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public Instant getReadAt() {
|
||||
return readAt;
|
||||
}
|
||||
|
||||
public void setReadAt(Instant readAt) {
|
||||
this.readAt = readAt;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.smartoffice.notification;
|
||||
|
||||
import com.smartoffice.auth.UserPrincipal;
|
||||
import com.smartoffice.common.ApiResponse;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/notifications")
|
||||
public class NotificationController {
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public NotificationController(NotificationService notificationService) {
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<NotificationDto>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return ApiResponse.ok(notificationService.listFor(principal.getUsername()));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/read")
|
||||
public ApiResponse<NotificationDto> markRead(@AuthenticationPrincipal UserPrincipal principal, @PathVariable Long id) {
|
||||
return ApiResponse.ok(notificationService.markRead(principal.getUsername(), id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.smartoffice.notification;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record NotificationDto(
|
||||
Long id,
|
||||
NotificationType type,
|
||||
String title,
|
||||
String content,
|
||||
Instant readAt,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static NotificationDto from(Notification notification) {
|
||||
return new NotificationDto(
|
||||
notification.getId(),
|
||||
notification.getType(),
|
||||
notification.getTitle(),
|
||||
notification.getContent(),
|
||||
notification.getReadAt(),
|
||||
notification.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.smartoffice.notification;
|
||||
|
||||
import com.smartoffice.user.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface NotificationRepository extends JpaRepository<Notification, Long> {
|
||||
List<Notification> findAllByUserOrderByCreatedAtDesc(User user);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.smartoffice.notification;
|
||||
|
||||
import com.smartoffice.common.ApiException;
|
||||
import com.smartoffice.user.User;
|
||||
import com.smartoffice.user.UserRepository;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class NotificationService {
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public NotificationService(NotificationRepository notificationRepository, UserRepository userRepository) {
|
||||
this.notificationRepository = notificationRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void notifyUser(User user, NotificationType type, String title, String content) {
|
||||
Notification notification = new Notification();
|
||||
notification.setUser(user);
|
||||
notification.setType(type);
|
||||
notification.setTitle(title);
|
||||
notification.setContent(content);
|
||||
notificationRepository.save(notification);
|
||||
}
|
||||
|
||||
public List<NotificationDto> listFor(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new ApiException(404, "User not found"));
|
||||
return notificationRepository.findAllByUserOrderByCreatedAtDesc(user)
|
||||
.stream().map(NotificationDto::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public NotificationDto markRead(String username, Long id) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new ApiException(404, "User not found"));
|
||||
Notification notification = notificationRepository.findById(id)
|
||||
.orElseThrow(() -> new ApiException(404, "Notification not found"));
|
||||
if (!notification.getUser().getId().equals(user.getId())) {
|
||||
throw new ApiException(403, "Not allowed");
|
||||
}
|
||||
notification.setReadAt(Instant.now());
|
||||
return NotificationDto.from(notification);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.smartoffice.notification;
|
||||
|
||||
public enum NotificationType {
|
||||
SYSTEM,
|
||||
ATTENDANCE,
|
||||
APPROVAL
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.smartoffice.organization;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "sys_department")
|
||||
public class Department {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "dept_code", nullable = false, unique = true)
|
||||
private String deptCode;
|
||||
|
||||
@Column(name = "dept_name", nullable = false)
|
||||
private String deptName;
|
||||
|
||||
@Column(name = "parent_id")
|
||||
private Long parentId = 0L;
|
||||
|
||||
private String ancestors;
|
||||
|
||||
private Integer level = 1;
|
||||
|
||||
private String path;
|
||||
|
||||
@Column(name = "leader_id")
|
||||
private Long leaderId;
|
||||
|
||||
@Column(name = "sort")
|
||||
private Integer sort = 0;
|
||||
|
||||
private Integer status = 1;
|
||||
|
||||
private String phone;
|
||||
|
||||
private String email;
|
||||
|
||||
private String address;
|
||||
|
||||
private String remark;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
private boolean deleted = false;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
this.createdAt = Instant.now();
|
||||
this.updatedAt = this.createdAt;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getDeptCode() {
|
||||
return deptCode;
|
||||
}
|
||||
|
||||
public void setDeptCode(String deptCode) {
|
||||
this.deptCode = deptCode;
|
||||
}
|
||||
|
||||
public String getDeptName() {
|
||||
return deptName;
|
||||
}
|
||||
|
||||
public void setDeptName(String deptName) {
|
||||
this.deptName = deptName;
|
||||
}
|
||||
|
||||
public Long getParentId() {
|
||||
return parentId;
|
||||
}
|
||||
|
||||
public void setParentId(Long parentId) {
|
||||
this.parentId = parentId;
|
||||
}
|
||||
|
||||
public String getAncestors() {
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
public void setAncestors(String ancestors) {
|
||||
this.ancestors = ancestors;
|
||||
}
|
||||
|
||||
public Integer getLevel() {
|
||||
return level;
|
||||
}
|
||||
|
||||
public void setLevel(Integer level) {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public void setPath(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public Long getLeaderId() {
|
||||
return leaderId;
|
||||
}
|
||||
|
||||
public void setLeaderId(Long leaderId) {
|
||||
this.leaderId = leaderId;
|
||||
}
|
||||
|
||||
public Integer getSort() {
|
||||
return sort;
|
||||
}
|
||||
|
||||
public void setSort(Integer sort) {
|
||||
this.sort = sort;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public void setAddress(String address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark) {
|
||||
this.remark = remark;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public boolean isDeleted() {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public void setDeleted(boolean deleted) {
|
||||
this.deleted = deleted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.smartoffice.organization;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface DepartmentRepository extends JpaRepository<Department, Long> {
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.smartoffice.user;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public class CreateUserRequest {
|
||||
@NotBlank
|
||||
private String username;
|
||||
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
@NotBlank
|
||||
private String fullName;
|
||||
|
||||
@Email
|
||||
private String email;
|
||||
|
||||
private String phone;
|
||||
|
||||
@NotNull
|
||||
private Role role;
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public Role getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(Role role) {
|
||||
this.role = role;
|
||||
}
|
||||
}
|
||||
7
backend/src/main/java/com/smartoffice/user/Role.java
Normal file
7
backend/src/main/java/com/smartoffice/user/Role.java
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.smartoffice.user;
|
||||
|
||||
public enum Role {
|
||||
ADMIN,
|
||||
MANAGER,
|
||||
EMPLOYEE
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.smartoffice.user;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public class UpdateUserStatusRequest {
|
||||
@NotNull
|
||||
private UserStatus status;
|
||||
|
||||
public UserStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(UserStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
147
backend/src/main/java/com/smartoffice/user/User.java
Normal file
147
backend/src/main/java/com/smartoffice/user/User.java
Normal file
@@ -0,0 +1,147 @@
|
||||
package com.smartoffice.user;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String fullName;
|
||||
|
||||
private String email;
|
||||
|
||||
private String phone;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private Role role;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private UserStatus status = UserStatus.ACTIVE;
|
||||
|
||||
private int failedLoginAttempts;
|
||||
|
||||
private Instant lockedUntil;
|
||||
|
||||
private Instant lastLoginAt;
|
||||
|
||||
private Instant createdAt;
|
||||
|
||||
private Instant updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
this.createdAt = Instant.now();
|
||||
this.updatedAt = this.createdAt;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public void setPasswordHash(String passwordHash) {
|
||||
this.passwordHash = passwordHash;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public Role getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(Role role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public UserStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(UserStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int getFailedLoginAttempts() {
|
||||
return failedLoginAttempts;
|
||||
}
|
||||
|
||||
public void setFailedLoginAttempts(int failedLoginAttempts) {
|
||||
this.failedLoginAttempts = failedLoginAttempts;
|
||||
}
|
||||
|
||||
public Instant getLockedUntil() {
|
||||
return lockedUntil;
|
||||
}
|
||||
|
||||
public void setLockedUntil(Instant lockedUntil) {
|
||||
this.lockedUntil = lockedUntil;
|
||||
}
|
||||
|
||||
public Instant getLastLoginAt() {
|
||||
return lastLoginAt;
|
||||
}
|
||||
|
||||
public void setLastLoginAt(Instant lastLoginAt) {
|
||||
this.lastLoginAt = lastLoginAt;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.smartoffice.user;
|
||||
|
||||
import com.smartoffice.common.ApiResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
|
||||
public UserController(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ApiResponse<List<UserDto>> list() {
|
||||
return ApiResponse.ok(userService.list());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ApiResponse<UserDto> get(@PathVariable Long id) {
|
||||
return ApiResponse.ok(userService.get(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ApiResponse<UserDto> create(@Valid @RequestBody CreateUserRequest request) {
|
||||
return ApiResponse.created(userService.create(request));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/status")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ApiResponse<UserDto> updateStatus(@PathVariable Long id, @Valid @RequestBody UpdateUserStatusRequest request) {
|
||||
return ApiResponse.ok(userService.updateStatus(id, request));
|
||||
}
|
||||
}
|
||||
27
backend/src/main/java/com/smartoffice/user/UserDto.java
Normal file
27
backend/src/main/java/com/smartoffice/user/UserDto.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.smartoffice.user;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record UserDto(
|
||||
Long id,
|
||||
String username,
|
||||
String fullName,
|
||||
String email,
|
||||
String phone,
|
||||
Role role,
|
||||
UserStatus status,
|
||||
Instant lastLoginAt
|
||||
) {
|
||||
public static UserDto from(User user) {
|
||||
return new UserDto(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getFullName(),
|
||||
user.getEmail(),
|
||||
user.getPhone(),
|
||||
user.getRole(),
|
||||
user.getStatus(),
|
||||
user.getLastLoginAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.smartoffice.user;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
Optional<User> findByUsername(String username);
|
||||
boolean existsByUsername(String username);
|
||||
}
|
||||
53
backend/src/main/java/com/smartoffice/user/UserService.java
Normal file
53
backend/src/main/java/com/smartoffice/user/UserService.java
Normal file
@@ -0,0 +1,53 @@
|
||||
package com.smartoffice.user;
|
||||
|
||||
import com.smartoffice.common.ApiException;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
public List<UserDto> list() {
|
||||
return userRepository.findAll().stream().map(UserDto::from).toList();
|
||||
}
|
||||
|
||||
public UserDto get(Long id) {
|
||||
return userRepository.findById(id).map(UserDto::from)
|
||||
.orElseThrow(() -> new ApiException(404, "User not found"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserDto create(CreateUserRequest request) {
|
||||
if (userRepository.existsByUsername(request.getUsername())) {
|
||||
throw new ApiException(409, "Username already exists");
|
||||
}
|
||||
User user = new User();
|
||||
user.setUsername(request.getUsername());
|
||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||
user.setFullName(request.getFullName());
|
||||
user.setEmail(request.getEmail());
|
||||
user.setPhone(request.getPhone());
|
||||
user.setRole(request.getRole());
|
||||
user.setStatus(UserStatus.ACTIVE);
|
||||
userRepository.save(user);
|
||||
return UserDto.from(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserDto updateStatus(Long id, UpdateUserStatusRequest request) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new ApiException(404, "User not found"));
|
||||
user.setStatus(request.getStatus());
|
||||
return UserDto.from(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.smartoffice.user;
|
||||
|
||||
public enum UserStatus {
|
||||
ACTIVE,
|
||||
DISABLED,
|
||||
LOCKED
|
||||
}
|
||||
9
backend/src/main/resources/application-mysql.yml
Normal file
9
backend/src/main/resources/application-mysql.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/smart_office?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
username: root
|
||||
password: root
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
41
backend/src/main/resources/application.yml
Normal file
41
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: smart-office-backend
|
||||
datasource:
|
||||
url: jdbc:h2:mem:smartoffice;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
open-in-view: false
|
||||
h2:
|
||||
console:
|
||||
enabled: true
|
||||
path: /h2
|
||||
|
||||
app:
|
||||
security:
|
||||
jwt:
|
||||
secret: "change-me-to-a-long-random-secret"
|
||||
expiration-minutes: 1440
|
||||
lockout:
|
||||
max-attempts: 5
|
||||
lock-minutes: 30
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security: INFO
|
||||
106
backend/src/test/java/com/smartoffice/ApiFlowTest.java
Normal file
106
backend/src/test/java/com/smartoffice/ApiFlowTest.java
Normal file
@@ -0,0 +1,106 @@
|
||||
package com.smartoffice;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class ApiFlowTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void login_returns_token_and_user() throws Exception {
|
||||
JsonNode response = login("admin", "admin123");
|
||||
assertThat(response.path("data").path("token").asText()).isNotBlank();
|
||||
assertThat(response.path("data").path("user").path("username").asText()).isEqualTo("admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void check_in_creates_attendance_record() throws Exception {
|
||||
String token = tokenFor("employee", "employee123");
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("location", "HQ");
|
||||
|
||||
mockMvc.perform(post("/api/v1/attendance/check-in")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manager_can_approve_leave_request() throws Exception {
|
||||
String employeeToken = tokenFor("employee", "employee123");
|
||||
ObjectNode leaveRequest = objectMapper.createObjectNode();
|
||||
Instant start = Instant.now().plusSeconds(3600);
|
||||
leaveRequest.put("type", "ANNUAL");
|
||||
leaveRequest.put("startTime", start.toString());
|
||||
leaveRequest.put("endTime", start.plusSeconds(3600).toString());
|
||||
leaveRequest.put("reason", "Family matters");
|
||||
|
||||
String createdResponse = mockMvc.perform(post("/api/v1/leave-requests")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "Bearer " + employeeToken)
|
||||
.content(objectMapper.writeValueAsString(leaveRequest)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
JsonNode createdJson = objectMapper.readTree(createdResponse);
|
||||
Long leaveId = createdJson.path("data").path("id").asLong();
|
||||
|
||||
String managerToken = tokenFor("manager", "manager123");
|
||||
ObjectNode decision = objectMapper.createObjectNode();
|
||||
decision.put("note", "Approved");
|
||||
|
||||
String approvedResponse = mockMvc.perform(post("/api/v1/leave-requests/" + leaveId + "/approve")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "Bearer " + managerToken)
|
||||
.content(objectMapper.writeValueAsString(decision)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
JsonNode approvedJson = objectMapper.readTree(approvedResponse);
|
||||
assertThat(approvedJson.path("data").path("status").asText()).isEqualTo("APPROVED");
|
||||
}
|
||||
|
||||
private String tokenFor(String username, String password) throws Exception {
|
||||
return login(username, password).path("data").path("token").asText();
|
||||
}
|
||||
|
||||
private JsonNode login(String username, String password) throws Exception {
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("username", username);
|
||||
payload.put("password", password);
|
||||
|
||||
String response = mockMvc.perform(post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
return objectMapper.readTree(response);
|
||||
}
|
||||
}
|
||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_DATABASE: smart_office
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: mysql
|
||||
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/smart_office?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||
SPRING_DATASOURCE_USERNAME: root
|
||||
SPRING_DATASOURCE_PASSWORD: root
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Smart Office</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1768
frontend/package-lock.json
generated
Normal file
1768
frontend/package-lock.json
generated
Normal file
@@ -0,0 +1,1768 @@
|
||||
{
|
||||
"name": "smart-office",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "smart-office",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
||||
"integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
|
||||
"integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
"json5": "^2.2.3",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/babel"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
|
||||
"integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-plugin-utils": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
||||
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
|
||||
"integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
||||
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
|
||||
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
|
||||
"integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.28.6",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
|
||||
"integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz",
|
||||
"integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz",
|
||||
"integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz",
|
||||
"integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz",
|
||||
"integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz",
|
||||
"integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz",
|
||||
"integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz",
|
||||
"integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz",
|
||||
"integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz",
|
||||
"integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz",
|
||||
"integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz",
|
||||
"integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz",
|
||||
"integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz",
|
||||
"integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz",
|
||||
"integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz",
|
||||
"integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz",
|
||||
"integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz",
|
||||
"integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz",
|
||||
"integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz",
|
||||
"integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
"@types/babel__generator": "*",
|
||||
"@types/babel__template": "*",
|
||||
"@types/babel__traverse": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__generator": {
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
||||
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__template": {
|
||||
"version": "7.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
||||
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.1.0",
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__traverse": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.18",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
|
||||
"integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001766",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
|
||||
"integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.279",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz",
|
||||
"integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jsesc": "bin/jsesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
|
||||
"integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.0",
|
||||
"@rollup/rollup-android-arm64": "4.57.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.0",
|
||||
"@rollup/rollup-darwin-x64": "4.57.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escalade": "^3.2.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"update-browserslist-db": "cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
"rollup": "^4.20.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"lightningcss": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "smart-office",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/login-bg.jpg
Normal file
BIN
frontend/public/login-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
48
frontend/src/App.tsx
Normal file
48
frontend/src/App.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import RequireAuth from './components/RequireAuth';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Attendance from './pages/Attendance';
|
||||
import Leave from './pages/Leave';
|
||||
import Approvals from './pages/Approvals';
|
||||
import Users from './pages/Users';
|
||||
import Notifications from './pages/Notifications';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="/attendance" element={<Attendance />} />
|
||||
<Route path="/leave" element={<Leave />} />
|
||||
<Route
|
||||
path="/approvals"
|
||||
element={
|
||||
<RequireAuth roles={['ADMIN', 'MANAGER']}>
|
||||
<Approvals />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<RequireAuth roles={['ADMIN', 'MANAGER']}>
|
||||
<Users />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/Layout.tsx
Normal file
58
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { Role } from '../types';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: '概览', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] },
|
||||
{ to: '/attendance', label: '考勤', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] },
|
||||
{ to: '/leave', label: '请假', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] },
|
||||
{ to: '/approvals', label: '审批', roles: ['ADMIN', 'MANAGER'] as Role[] },
|
||||
{ to: '/users', label: '用户管理', roles: ['ADMIN', 'MANAGER'] as Role[] },
|
||||
{ to: '/notifications', label: '通知', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const role = user?.role;
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark" />
|
||||
<div>
|
||||
<div className="brand-title">Smart Office</div>
|
||||
<div className="brand-sub">Enterprise Suite</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{NAV.filter((n) => role && n.roles.includes(role)).map((n) => (
|
||||
<NavLink key={n.to} to={n.to} className="nav-link">
|
||||
{n.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="main">
|
||||
<header className="topbar">
|
||||
<div className="topbar-title">智能办公管理系统</div>
|
||||
<div className="topbar-user">
|
||||
<span className="badge">{user?.role}</span>
|
||||
<div className="user-meta">
|
||||
<div className="user-name">{user?.fullName}</div>
|
||||
<div className="user-sub">@{user?.username}</div>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={logout}>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/RequireAuth.tsx
Normal file
20
frontend/src/components/RequireAuth.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Role } from '../types';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export default function RequireAuth({
|
||||
roles,
|
||||
children,
|
||||
}: {
|
||||
roles?: Role[];
|
||||
children: React.ReactElement;
|
||||
}) {
|
||||
const { user, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) return <div className="page">加载中...</div>;
|
||||
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
if (roles && !roles.includes(user.role)) return <Navigate to="/" replace />;
|
||||
return children;
|
||||
}
|
||||
49
frontend/src/lib/api.ts
Normal file
49
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ApiResponse } from '../types';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? '/api/v1';
|
||||
const TOKEN_KEY = 'smartoffice_token';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}) {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
if (options.body && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const json = (await res.json()) as ApiResponse<T>;
|
||||
if (!res.ok || json.code !== 200) {
|
||||
throw new Error(json.message || `HTTP ${res.status}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
|
||||
};
|
||||
|
||||
export const storage = {
|
||||
token: () => localStorage.getItem(TOKEN_KEY),
|
||||
setToken: (token: string) => localStorage.setItem(TOKEN_KEY, token),
|
||||
clearToken: () => localStorage.removeItem(TOKEN_KEY),
|
||||
};
|
||||
71
frontend/src/lib/auth.tsx
Normal file
71
frontend/src/lib/auth.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { api, storage } from './api';
|
||||
import { Role, UserDto } from '../types';
|
||||
|
||||
const USER_KEY = 'smartoffice_user';
|
||||
|
||||
type AuthState = {
|
||||
user: UserDto | null;
|
||||
role: Role | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<UserDto | null>(() => {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? (JSON.parse(raw) as UserDto) : null;
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = storage.token();
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<UserDto>('/auth/me')
|
||||
.then((me) => {
|
||||
setUser(me);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(me));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [user]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const data = await api.post<{ token: string; user: UserDto }>('/auth/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
storage.setToken(data.token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||
setUser(data.user);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
storage.clearToken();
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const value = useMemo<AuthState>(
|
||||
() => ({ user, role: user?.role ?? null, loading, login, logout }),
|
||||
[user, loading]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('AuthProvider missing');
|
||||
return ctx;
|
||||
}
|
||||
16
frontend/src/main.tsx
Normal file
16
frontend/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider } from './lib/auth';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
74
frontend/src/pages/Approvals.tsx
Normal file
74
frontend/src/pages/Approvals.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { LeaveDto } from '../types';
|
||||
|
||||
export default function Approvals() {
|
||||
const [list, setList] = useState<LeaveDto[]>([]);
|
||||
const [notes, setNotes] = useState<Record<number, string>>({});
|
||||
|
||||
const load = () => api.get<LeaveDto[]>('/leave-requests/pending').then(setList);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const act = async (id: number, action: 'approve' | 'reject') => {
|
||||
await api.post(`/leave-requests/${id}/${action}`, {
|
||||
note: notes[id] || '',
|
||||
});
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>审批</h1>
|
||||
<p>待审批的请假请求</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>申请人</th>
|
||||
<th>类型</th>
|
||||
<th>时间</th>
|
||||
<th>原因</th>
|
||||
<th>备注</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td>{l.requester.fullName}</td>
|
||||
<td>{l.type}</td>
|
||||
<td>
|
||||
{new Date(l.startTime).toLocaleString()} → {new Date(l.endTime).toLocaleString()}
|
||||
</td>
|
||||
<td>{l.reason}</td>
|
||||
<td>
|
||||
<input
|
||||
className="inline-input"
|
||||
value={notes[l.id] || ''}
|
||||
onChange={(e) => setNotes({ ...notes, [l.id]: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="row">
|
||||
<button className="btn btn-primary" onClick={() => act(l.id, 'approve')}>
|
||||
通过
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => act(l.id, 'reject')}>
|
||||
拒绝
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{!list.length && <div className="muted">暂无待审批记录</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/Attendance.tsx
Normal file
90
frontend/src/pages/Attendance.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { AttendanceDto } from '../types';
|
||||
|
||||
export default function Attendance() {
|
||||
const [records, setRecords] = useState<AttendanceDto[]>([]);
|
||||
const [location, setLocation] = useState('HQ');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [action, setAction] = useState('');
|
||||
|
||||
const load = () =>
|
||||
api.get<AttendanceDto[]>('/attendance/me').then((data) => {
|
||||
setRecords(data);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const checkIn = async () => {
|
||||
setAction('checkin');
|
||||
await api.post('/attendance/check-in', { location });
|
||||
await load();
|
||||
setAction('');
|
||||
};
|
||||
|
||||
const checkOut = async () => {
|
||||
setAction('checkout');
|
||||
await api.post('/attendance/check-out');
|
||||
await load();
|
||||
setAction('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>考勤</h1>
|
||||
<p>打卡与个人记录</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">打卡</div>
|
||||
<div className="row">
|
||||
<input value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
<button className="btn btn-primary" onClick={checkIn} disabled={action === 'checkin'}>
|
||||
上班打卡
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={checkOut} disabled={action === 'checkout'}>
|
||||
下班签退
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">我的考勤记录</div>
|
||||
{loading ? (
|
||||
<div className="muted">加载中...</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>签到</th>
|
||||
<th>签退</th>
|
||||
<th>工时</th>
|
||||
<th>地点</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.date}</td>
|
||||
<td>{r.checkInTime || '-'}</td>
|
||||
<td>{r.checkOutTime || '-'}</td>
|
||||
<td>{r.workHours}</td>
|
||||
<td>{r.location || '-'}</td>
|
||||
<td>
|
||||
<span className="badge">{r.status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/src/pages/Dashboard.tsx
Normal file
93
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { AttendanceDto, LeaveDto, NotificationDto } from '../types';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [attendance, setAttendance] = useState<AttendanceDto[]>([]);
|
||||
const [leaves, setLeaves] = useState<LeaveDto[]>([]);
|
||||
const [notifications, setNotifications] = useState<NotificationDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get<AttendanceDto[]>('/attendance/me'),
|
||||
api.get<LeaveDto[]>('/leave-requests/my'),
|
||||
api.get<NotificationDto[]>('/notifications'),
|
||||
])
|
||||
.then(([a, l, n]) => {
|
||||
setAttendance(a);
|
||||
setLeaves(l);
|
||||
setNotifications(n);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>概览</h1>
|
||||
<p>关键指标一览与近期动态</p>
|
||||
</div>
|
||||
|
||||
<div className="grid stats">
|
||||
<div className="card">
|
||||
<div className="card-title">本月考勤记录</div>
|
||||
<div className="stat">{attendance.length}</div>
|
||||
<div className="stat-sub">来自个人记录</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">我的请假申请</div>
|
||||
<div className="stat">{leaves.length}</div>
|
||||
<div className="stat-sub">当前与历史</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">通知</div>
|
||||
<div className="stat">{notifications.length}</div>
|
||||
<div className="stat-sub">含未读与已读</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div className="card">
|
||||
<div className="card-title">最近考勤</div>
|
||||
{loading ? (
|
||||
<div className="muted">加载中...</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>签到</th>
|
||||
<th>签退</th>
|
||||
<th>工时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attendance.slice(0, 5).map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>{a.date}</td>
|
||||
<td>{a.checkInTime || '-'}</td>
|
||||
<td>{a.checkOutTime || '-'}</td>
|
||||
<td>{a.workHours}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">最近通知</div>
|
||||
<div className="list-stagger">
|
||||
{notifications.slice(0, 5).map((n) => (
|
||||
<div key={n.id} className={`list-item ${n.readAt ? 'read' : ''}`}>
|
||||
<div className="list-title">{n.title}</div>
|
||||
<div className="list-sub">{n.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/src/pages/Leave.tsx
Normal file
100
frontend/src/pages/Leave.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { LeaveDto, LeaveType } from '../types';
|
||||
|
||||
const TYPES: LeaveType[] = ['ANNUAL', 'SICK', 'PERSONAL', 'MARRIAGE', 'MATERNITY', 'BEREAVEMENT'];
|
||||
|
||||
export default function Leave() {
|
||||
const [list, setList] = useState<LeaveDto[]>([]);
|
||||
const [type, setType] = useState<LeaveType>('ANNUAL');
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const load = () => api.get<LeaveDto[]>('/leave-requests/my').then(setList);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await api.post('/leave-requests', {
|
||||
type,
|
||||
startTime: new Date(startTime).toISOString(),
|
||||
endTime: new Date(endTime).toISOString(),
|
||||
reason,
|
||||
});
|
||||
setReason('');
|
||||
setStartTime('');
|
||||
setEndTime('');
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>请假</h1>
|
||||
<p>提交申请与查看记录</p>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div className="card">
|
||||
<div className="card-title">提交请假</div>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<label>
|
||||
类型
|
||||
<select value={type} onChange={(e) => setType(e.target.value as LeaveType)}>
|
||||
{TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
开始时间
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
结束时间
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
原因
|
||||
<textarea value={reason} onChange={(e) => setReason(e.target.value)} required />
|
||||
</label>
|
||||
<button className="btn btn-primary">提交</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">我的请假</div>
|
||||
<div className="list-stagger">
|
||||
{list.map((l) => (
|
||||
<div key={l.id} className="list-item">
|
||||
<div className="list-title">
|
||||
{l.type} · {l.status}
|
||||
</div>
|
||||
<div className="list-sub">
|
||||
{new Date(l.startTime).toLocaleString()} → {new Date(l.endTime).toLocaleString()}
|
||||
</div>
|
||||
<div className="list-sub">{l.reason}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
frontend/src/pages/Login.tsx
Normal file
71
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export default function Login() {
|
||||
const { login, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('admin123');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate('/');
|
||||
}, [user, navigate]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError((err as Error).message || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card animate-in">
|
||||
<div className="login-brand">
|
||||
<div className="brand-mark" />
|
||||
<div>
|
||||
<div className="brand-title">Smart Office</div>
|
||||
<div className="brand-sub">Sign in to continue</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="form" onSubmit={submit}>
|
||||
<label>
|
||||
用户名
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <div className="alert">{error}</div>}
|
||||
|
||||
<button className="btn btn-primary" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-hint">
|
||||
<div className="chip">admin / admin123</div>
|
||||
<div className="chip">manager / manager123</div>
|
||||
<div className="chip">employee / employee123</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
frontend/src/pages/Notifications.tsx
Normal file
45
frontend/src/pages/Notifications.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { NotificationDto } from '../types';
|
||||
|
||||
export default function Notifications() {
|
||||
const [list, setList] = useState<NotificationDto[]>([]);
|
||||
|
||||
const load = () => api.get<NotificationDto[]>('/notifications').then(setList);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const markRead = async (id: number) => {
|
||||
await api.post(`/notifications/${id}/read`);
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>通知</h1>
|
||||
<p>系统与业务提醒</p>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-2">
|
||||
{list.map((n) => (
|
||||
<div key={n.id} className={`card ${n.readAt ? 'read' : ''}`}>
|
||||
<div className="card-title">{n.title}</div>
|
||||
<div className="muted">{n.type}</div>
|
||||
<div className="card-content">{n.content}</div>
|
||||
<div className="card-footer">
|
||||
<span className="muted">{new Date(n.createdAt).toLocaleString()}</span>
|
||||
{!n.readAt && (
|
||||
<button className="btn btn-outline" onClick={() => markRead(n.id)}>
|
||||
标记已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/pages/Users.tsx
Normal file
146
frontend/src/pages/Users.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { Role, UserDto, UserStatus } from '../types';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const ROLES: Role[] = ['ADMIN', 'MANAGER', 'EMPLOYEE'];
|
||||
const STATUSES: UserStatus[] = ['ACTIVE', 'DISABLED', 'LOCKED'];
|
||||
|
||||
export default function Users() {
|
||||
const { role } = useAuth();
|
||||
const [list, setList] = useState<UserDto[]>([]);
|
||||
const [form, setForm] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
fullName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role: 'EMPLOYEE' as Role,
|
||||
});
|
||||
|
||||
const load = () => api.get<UserDto[]>('/users').then(setList);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const create = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await api.post('/users', form);
|
||||
setForm({ username: '', password: '', fullName: '', email: '', phone: '', role: 'EMPLOYEE' });
|
||||
load();
|
||||
};
|
||||
|
||||
const updateStatus = async (id: number, status: UserStatus) => {
|
||||
await api.patch(`/users/${id}/status`, { status });
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>用户管理</h1>
|
||||
<p>组织内用户与状态</p>
|
||||
</div>
|
||||
|
||||
{role === 'ADMIN' && (
|
||||
<div className="card">
|
||||
<div className="card-title">创建用户</div>
|
||||
<form className="form grid cols-2" onSubmit={create}>
|
||||
<label>
|
||||
用户名
|
||||
<input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
姓名
|
||||
<input
|
||||
value={form.fullName}
|
||||
onChange={(e) => setForm({ ...form, fullName: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
角色
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as Role })}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
邮箱
|
||||
<input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
电话
|
||||
<input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
</label>
|
||||
<div>
|
||||
<button className="btn btn-primary">创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">用户列表</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>姓名</th>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>邮箱</th>
|
||||
<th>电话</th>
|
||||
<th>最近登录</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.fullName}</td>
|
||||
<td>{u.username}</td>
|
||||
<td>{u.role}</td>
|
||||
<td>
|
||||
{role === 'ADMIN' ? (
|
||||
<select
|
||||
value={u.status}
|
||||
onChange={(e) => updateStatus(u.id, e.target.value as UserStatus)}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="badge">{u.status}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{u.email || '-'}</td>
|
||||
<td>{u.phone || '-'}</td>
|
||||
<td>{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
486
frontend/src/styles.css
Normal file
486
frontend/src/styles.css
Normal file
@@ -0,0 +1,486 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&family=Source+Serif+4:wght@400;600&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #f2efe9;
|
||||
--surface: #ffffff;
|
||||
--ink: #161616;
|
||||
--ink-2: #3f3f3f;
|
||||
--muted: #7a7a7a;
|
||||
--border: rgba(12, 14, 20, 0.12);
|
||||
--accent: #0f6b5f;
|
||||
--accent-2: #c7a677;
|
||||
--danger: #b9483a;
|
||||
--shadow: 0 20px 40px -30px rgba(0, 0, 0, 0.45);
|
||||
--radius: 18px;
|
||||
--font-sans: 'Sora', system-ui, sans-serif;
|
||||
--font-serif: 'Source Serif 4', serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1000px 800px at 10% 0%, rgba(15, 107, 95, 0.08), transparent 60%),
|
||||
radial-gradient(900px 700px at 100% 10%, rgba(199, 166, 119, 0.08), transparent 55%),
|
||||
linear-gradient(180deg, #f7f4ee 0%, #efebe3 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-serif);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 28px 24px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--accent), #1a8a7a);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
.nav-link:hover {
|
||||
background: rgba(15, 107, 95, 0.08);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 18px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topbar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card.read {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
margin: 12px 0;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font-sans);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.inline-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.alert {
|
||||
background: #ffe9e5;
|
||||
border: 1px solid #f3c1b8;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
color: #7a2f25;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(11, 28, 25, 0.45) 0%, rgba(11, 28, 25, 0.2) 100%),
|
||||
url('/login-bg.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.login-page::before,
|
||||
.login-page::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
border-radius: 50%;
|
||||
filter: blur(10px);
|
||||
opacity: 0.5;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-page::before {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(15, 107, 95, 0.4), transparent 60%);
|
||||
top: -180px;
|
||||
left: -140px;
|
||||
}
|
||||
|
||||
.login-page::after {
|
||||
background: radial-gradient(circle at 70% 30%, rgba(199, 166, 119, 0.4), transparent 60%);
|
||||
bottom: -200px;
|
||||
right: -160px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(420px, 92vw);
|
||||
padding: 28px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 30px 70px -40px rgba(0, 0, 0, 0.65);
|
||||
border: 1px solid rgba(15, 107, 95, 0.18);
|
||||
backdrop-filter: blur(8px);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-brand .brand-mark {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(140deg, #0f6b5f 0%, #1f8f7f 60%, #c7a677 140%);
|
||||
position: relative;
|
||||
box-shadow: 0 18px 40px -28px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.login-brand .brand-mark::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 9px;
|
||||
border-radius: 12px;
|
||||
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.8), transparent 55%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.login-brand .brand-mark::after {
|
||||
content: 'S';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.login-card .brand-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.login-card .brand-sub {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f1f1f1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.list-stagger .list-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
animation: floatIn 0.35s ease both;
|
||||
}
|
||||
|
||||
.list-item.read {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list-sub {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeUp 0.45s ease both;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cols-2,
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
59
frontend/src/types.ts
Normal file
59
frontend/src/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type Role = 'ADMIN' | 'MANAGER' | 'EMPLOYEE';
|
||||
export type UserStatus = 'ACTIVE' | 'DISABLED' | 'LOCKED';
|
||||
export type LeaveType =
|
||||
| 'ANNUAL'
|
||||
| 'SICK'
|
||||
| 'PERSONAL'
|
||||
| 'MARRIAGE'
|
||||
| 'MATERNITY'
|
||||
| 'BEREAVEMENT';
|
||||
|
||||
export interface UserDto {
|
||||
id: number;
|
||||
username: string;
|
||||
fullName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role: Role;
|
||||
status: UserStatus;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
export interface AttendanceDto {
|
||||
id: number;
|
||||
date: string;
|
||||
checkInTime?: string;
|
||||
checkOutTime?: string;
|
||||
workHours: number;
|
||||
status: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface LeaveDto {
|
||||
id: number;
|
||||
requester: UserDto;
|
||||
type: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
approver?: UserDto;
|
||||
decidedAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationDto {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
readAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
25
frontend/vite.config.ts
Normal file
25
frontend/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const proxyTarget = env.VITE_PROXY_TARGET || 'http://localhost:8080';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/v1': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
proxyReq.removeHeader('origin');
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
236
plans/architecture_design.md
Normal file
236
plans/architecture_design.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# 智能办公管理系统 - 架构设计文档
|
||||
|
||||
## 项目概述
|
||||
智能办公管理系统是一个企业级应用,包含考勤管理、审批流程、文档协作和即时通讯四大核心模块。系统采用微服务架构思想,前后端分离设计。
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端技术栈
|
||||
- **框架**: Spring Boot 3.2.x
|
||||
- **安全**: Spring Security 6 + JWT
|
||||
- **数据库**:
|
||||
- PostgreSQL 15 (主业务数据)
|
||||
- MongoDB 7 (文档存储)
|
||||
- Redis 7 (缓存/会话)
|
||||
- **消息队列**: RabbitMQ 3.12
|
||||
- **实时通信**: WebSocket + STOMP
|
||||
- **API文档**: SpringDoc OpenAPI 3
|
||||
- **监控**: Spring Boot Actuator + Prometheus + Grafana
|
||||
- **测试**: JUnit 5 + Mockito + Testcontainers
|
||||
|
||||
### 前端技术栈
|
||||
- **框架**: React 18 + TypeScript
|
||||
- **UI库**: Ant Design Pro 7.x
|
||||
- **状态管理**: Redux Toolkit + RTK Query
|
||||
- **路由**: React Router 6
|
||||
- **构建工具**: Vite 5
|
||||
- **图表**: ECharts 5
|
||||
- **实时**: Socket.IO Client
|
||||
|
||||
### 基础设施
|
||||
- **容器化**: Docker + Docker Compose
|
||||
- **CI/CD**: GitHub Actions
|
||||
- **部署**: 可部署到Kubernetes
|
||||
- **日志**: ELK Stack (Elasticsearch, Logstash, Kibana)
|
||||
|
||||
## 系统架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "客户端层"
|
||||
Web[Web浏览器]
|
||||
Mobile[移动端]
|
||||
Desktop[桌面应用]
|
||||
end
|
||||
|
||||
subgraph "负载均衡层"
|
||||
Nginx[Nginx反向代理]
|
||||
end
|
||||
|
||||
subgraph "应用服务层"
|
||||
Gateway[API网关]
|
||||
|
||||
subgraph "微服务集群"
|
||||
Auth[认证服务]
|
||||
User[用户服务]
|
||||
Attendance[考勤服务]
|
||||
Approval[审批服务]
|
||||
Document[文档服务]
|
||||
Chat[聊天服务]
|
||||
Notification[通知服务]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph "数据存储层"
|
||||
PostgreSQL[(PostgreSQL)]
|
||||
MongoDB[(MongoDB)]
|
||||
Redis[(Redis)]
|
||||
RabbitMQ[(RabbitMQ)]
|
||||
end
|
||||
|
||||
subgraph "监控层"
|
||||
Prometheus[Prometheus]
|
||||
Grafana[Grafana]
|
||||
ELK[ELK Stack]
|
||||
end
|
||||
|
||||
Web --> Nginx
|
||||
Mobile --> Nginx
|
||||
Desktop --> Nginx
|
||||
|
||||
Nginx --> Gateway
|
||||
|
||||
Gateway --> Auth
|
||||
Gateway --> User
|
||||
Gateway --> Attendance
|
||||
Gateway --> Approval
|
||||
Gateway --> Document
|
||||
Gateway --> Chat
|
||||
Gateway --> Notification
|
||||
|
||||
Auth --> PostgreSQL
|
||||
User --> PostgreSQL
|
||||
Attendance --> PostgreSQL
|
||||
Approval --> PostgreSQL
|
||||
Document --> MongoDB
|
||||
Chat --> Redis
|
||||
Chat --> RabbitMQ
|
||||
Notification --> RabbitMQ
|
||||
|
||||
Auth --> Redis
|
||||
User --> Redis
|
||||
|
||||
Prometheus --> Auth
|
||||
Prometheus --> User
|
||||
Prometheus --> Attendance
|
||||
Grafana --> Prometheus
|
||||
ELK --> Auth
|
||||
ELK --> User
|
||||
```
|
||||
|
||||
## 核心模块设计
|
||||
|
||||
### 1. 用户认证授权模块
|
||||
- **功能**: 用户注册、登录、JWT令牌管理、权限控制
|
||||
- **技术**: Spring Security + OAuth2 + JWT
|
||||
- **数据库表**: users, roles, permissions, user_roles
|
||||
|
||||
### 2. 考勤管理模块
|
||||
- **功能**: 打卡记录、请假申请、加班申请、考勤统计
|
||||
- **技术**: 地理围栏、人脸识别集成、考勤规则引擎
|
||||
- **数据库表**: attendance_records, leave_requests, overtime_requests
|
||||
|
||||
### 3. 审批流程模块
|
||||
- **功能**: 工作流定义、审批节点、流程实例、审批历史
|
||||
- **技术**: Activiti/Flowable工作流引擎
|
||||
- **数据库表**: workflows, workflow_instances, approval_nodes
|
||||
|
||||
### 4. 文档协作模块
|
||||
- **功能**: 文档创建、在线编辑、版本控制、权限管理
|
||||
- **技术**: MongoDB GridFS + WebSocket协同编辑
|
||||
- **数据库表**: documents, document_versions, collaborations
|
||||
|
||||
### 5. 即时通讯模块
|
||||
- **功能**: 一对一聊天、群组聊天、文件传输、消息状态
|
||||
- **技术**: WebSocket + STOMP + Redis Pub/Sub
|
||||
- **数据库表**: chat_rooms, messages, participants
|
||||
|
||||
## API设计原则
|
||||
1. RESTful API设计
|
||||
2. 版本控制 (v1, v2)
|
||||
3. 统一响应格式
|
||||
4. 全局异常处理
|
||||
5. 请求限流和防重放
|
||||
6. API接口文档自动生成
|
||||
|
||||
## 安全设计
|
||||
1. HTTPS强制使用
|
||||
2. JWT令牌认证
|
||||
3. RBAC权限模型
|
||||
4. 敏感数据加密存储
|
||||
5. SQL注入防护
|
||||
6. XSS/CSRF防护
|
||||
7. 请求频率限制
|
||||
|
||||
## 性能优化
|
||||
1. Redis缓存热点数据
|
||||
2. 数据库读写分离
|
||||
3. CDN静态资源加速
|
||||
4. 图片压缩和懒加载
|
||||
5. API响应压缩
|
||||
6. 数据库连接池优化
|
||||
|
||||
## 部署架构
|
||||
1. 开发环境: Docker Compose本地部署
|
||||
2. 测试环境: Kubernetes集群
|
||||
3. 生产环境: 多可用区高可用部署
|
||||
4. 数据库: 主从复制 + 自动备份
|
||||
5. 文件存储: 对象存储(S3兼容)
|
||||
|
||||
## 监控告警
|
||||
1. 应用性能监控(APM)
|
||||
2. 业务指标监控
|
||||
3. 日志集中收集
|
||||
4. 错误追踪和告警
|
||||
5. 健康检查和自愈
|
||||
|
||||
## 项目目录结构
|
||||
```
|
||||
smart-office-system/
|
||||
├── backend/ # 后端项目
|
||||
│ ├── smart-office-auth/ # 认证服务
|
||||
│ ├── smart-office-user/ # 用户服务
|
||||
│ ├── smart-office-attendance/# 考勤服务
|
||||
│ ├── smart-office-approval/ # 审批服务
|
||||
│ ├── smart-office-document/ # 文档服务
|
||||
│ ├── smart-office-chat/ # 聊天服务
|
||||
│ ├── smart-office-gateway/ # API网关
|
||||
│ └── smart-office-common/ # 公共模块
|
||||
├── frontend/ # 前端项目
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API接口
|
||||
│ │ ├── components/ # 公共组件
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ ├── store/ # 状态管理
|
||||
│ │ ├── utils/ # 工具函数
|
||||
│ │ └── styles/ # 样式文件
|
||||
│ ├── public/ # 静态资源
|
||||
│ └── config/ # 配置文件
|
||||
├── infrastructure/ # 基础设施
|
||||
│ ├── docker/ # Docker配置
|
||||
│ ├── k8s/ # Kubernetes配置
|
||||
│ ├── scripts/ # 部署脚本
|
||||
│ └── monitoring/ # 监控配置
|
||||
├── docs/ # 项目文档
|
||||
├── tests/ # 测试文件
|
||||
└── plans/ # 设计文档
|
||||
```
|
||||
|
||||
## 开发计划
|
||||
1. 第一阶段: 基础框架搭建 (2周)
|
||||
2. 第二阶段: 核心模块开发 (4周)
|
||||
3. 第三阶段: 前端界面开发 (3周)
|
||||
4. 第四阶段: 集成测试和优化 (2周)
|
||||
5. 第五阶段: 部署和监控 (1周)
|
||||
|
||||
## 风险评估和应对
|
||||
1. **技术风险**: 微服务复杂度高
|
||||
- 应对: 先采用单体架构,逐步拆分
|
||||
2. **性能风险**: 实时通信压力大
|
||||
- 应对: 使用Redis集群和消息队列削峰
|
||||
3. **安全风险**: 敏感数据泄露
|
||||
- 应对: 多层安全防护和审计日志
|
||||
4. **部署风险**: 多环境配置复杂
|
||||
- 应对: 使用配置中心和容器化部署
|
||||
|
||||
## 成功指标
|
||||
1. 系统可用性: 99.9%
|
||||
2. API响应时间: <200ms (P95)
|
||||
3. 并发用户数: 支持1000+同时在线
|
||||
4. 数据一致性: 事务成功率>99.99%
|
||||
5. 系统扩展性: 支持水平扩展
|
||||
|
||||
---
|
||||
*文档版本: v1.0*
|
||||
*最后更新: 2026-01-27*
|
||||
*作者: 架构设计团队*
|
||||
495
plans/database_design.md
Normal file
495
plans/database_design.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# 智能办公管理系统 - 数据库设计文档
|
||||
|
||||
## 数据库架构概述
|
||||
系统采用多数据库设计,根据业务特点选择最适合的数据库类型:
|
||||
- **PostgreSQL**: 核心业务数据(用户、角色、权限、考勤、审批等)
|
||||
- **MongoDB**: 文档协作、聊天记录、文件元数据等非结构化数据
|
||||
- **Redis**: 缓存、会话、分布式锁、消息队列
|
||||
- **Elasticsearch**: 日志、搜索、分析
|
||||
|
||||
## PostgreSQL数据库设计
|
||||
|
||||
### 1. 认证授权模块 (auth_service)
|
||||
#### 1.1 用户表 (sys_user)
|
||||
```sql
|
||||
CREATE TABLE sys_user (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password VARCHAR(200) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
phone VARCHAR(20),
|
||||
nickname VARCHAR(50),
|
||||
real_name VARCHAR(50),
|
||||
avatar VARCHAR(500),
|
||||
gender INTEGER DEFAULT 0,
|
||||
birthday TIMESTAMP,
|
||||
dept_id BIGINT,
|
||||
position VARCHAR(100),
|
||||
status INTEGER DEFAULT 1,
|
||||
last_login_time TIMESTAMP,
|
||||
last_login_ip VARCHAR(50),
|
||||
login_count INTEGER DEFAULT 0,
|
||||
failed_login_count INTEGER DEFAULT 0,
|
||||
lock_time TIMESTAMP,
|
||||
password_reset_time TIMESTAMP,
|
||||
is_super_admin BOOLEAN DEFAULT FALSE,
|
||||
remark VARCHAR(500),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
create_by BIGINT,
|
||||
update_by BIGINT,
|
||||
deleted BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_username ON sys_user(username);
|
||||
CREATE INDEX idx_email ON sys_user(email);
|
||||
CREATE INDEX idx_phone ON sys_user(phone);
|
||||
CREATE INDEX idx_dept_id ON sys_user(dept_id);
|
||||
```
|
||||
|
||||
#### 1.2 角色表 (sys_role)
|
||||
```sql
|
||||
CREATE TABLE sys_role (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
role_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
role_name VARCHAR(50) NOT NULL,
|
||||
description VARCHAR(200),
|
||||
data_scope INTEGER DEFAULT 1,
|
||||
status INTEGER DEFAULT 1,
|
||||
sort INTEGER DEFAULT 0,
|
||||
is_system BOOLEAN DEFAULT FALSE,
|
||||
remark VARCHAR(500),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
create_by BIGINT,
|
||||
update_by BIGINT,
|
||||
deleted BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_role_code ON sys_role(role_code);
|
||||
CREATE INDEX idx_role_name ON sys_role(role_name);
|
||||
```
|
||||
|
||||
#### 1.3 权限表 (sys_permission)
|
||||
```sql
|
||||
CREATE TABLE sys_permission (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
permission_code VARCHAR(100) NOT NULL UNIQUE,
|
||||
permission_name VARCHAR(50) NOT NULL,
|
||||
permission_type INTEGER NOT NULL,
|
||||
parent_id BIGINT DEFAULT 0,
|
||||
path VARCHAR(200),
|
||||
component VARCHAR(200),
|
||||
icon VARCHAR(100),
|
||||
sort INTEGER DEFAULT 0,
|
||||
status INTEGER DEFAULT 1,
|
||||
is_visible BOOLEAN DEFAULT TRUE,
|
||||
is_cache BOOLEAN DEFAULT FALSE,
|
||||
is_external BOOLEAN DEFAULT FALSE,
|
||||
external_url VARCHAR(500),
|
||||
request_method VARCHAR(20),
|
||||
api_path VARCHAR(500),
|
||||
remark VARCHAR(500),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
create_by BIGINT,
|
||||
update_by BIGINT,
|
||||
deleted BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_permission_code ON sys_permission(permission_code);
|
||||
CREATE INDEX idx_parent_id ON sys_permission(parent_id);
|
||||
CREATE INDEX idx_sort ON sys_permission(sort);
|
||||
```
|
||||
|
||||
#### 1.4 关联表
|
||||
```sql
|
||||
-- 用户角色关联表
|
||||
CREATE TABLE sys_user_role (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
role_id BIGINT NOT NULL,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, role_id)
|
||||
);
|
||||
|
||||
-- 角色权限关联表
|
||||
CREATE TABLE sys_role_permission (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
role_id BIGINT NOT NULL,
|
||||
permission_id BIGINT NOT NULL,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(role_id, permission_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 2. 考勤管理模块 (attendance_service)
|
||||
#### 2.1 考勤记录表 (attendance_record)
|
||||
```sql
|
||||
CREATE TABLE attendance_record (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
dept_id BIGINT,
|
||||
attendance_date DATE NOT NULL,
|
||||
check_in_time TIMESTAMP,
|
||||
check_out_time TIMESTAMP,
|
||||
work_hours DECIMAL(5,2),
|
||||
late_minutes INTEGER DEFAULT 0,
|
||||
early_minutes INTEGER DEFAULT 0,
|
||||
overtime_hours DECIMAL(5,2) DEFAULT 0,
|
||||
attendance_status INTEGER DEFAULT 0, -- 0:正常, 1:迟到, 2:早退, 3:缺勤, 4:请假, 5:加班
|
||||
location VARCHAR(200),
|
||||
latitude DECIMAL(10,8),
|
||||
longitude DECIMAL(11,8),
|
||||
device_info VARCHAR(200),
|
||||
ip_address VARCHAR(50),
|
||||
remark VARCHAR(500),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_date ON attendance_record(user_id, attendance_date);
|
||||
CREATE INDEX idx_dept_date ON attendance_record(dept_id, attendance_date);
|
||||
```
|
||||
|
||||
#### 2.2 请假申请表 (leave_request)
|
||||
```sql
|
||||
CREATE TABLE leave_request (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
leave_type INTEGER NOT NULL, -- 1:年假, 2:病假, 3:事假, 4:婚假, 5:产假, 6:丧假
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP NOT NULL,
|
||||
duration_days DECIMAL(5,2),
|
||||
reason VARCHAR(500),
|
||||
attachment_url VARCHAR(500),
|
||||
status INTEGER DEFAULT 0, -- 0:待审批, 1:已通过, 2:已拒绝, 3:已取消
|
||||
approval_flow_id BIGINT,
|
||||
current_approver_id BIGINT,
|
||||
remark VARCHAR(500),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_status ON leave_request(user_id, status);
|
||||
CREATE INDEX idx_approval_flow ON leave_request(approval_flow_id);
|
||||
```
|
||||
|
||||
#### 2.3 加班申请表 (overtime_request)
|
||||
```sql
|
||||
CREATE TABLE overtime_request (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
overtime_date DATE NOT NULL,
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP NOT NULL,
|
||||
duration_hours DECIMAL(5,2),
|
||||
reason VARCHAR(500),
|
||||
compensation_type INTEGER DEFAULT 1, -- 1:调休, 2:加班费
|
||||
status INTEGER DEFAULT 0,
|
||||
approval_flow_id BIGINT,
|
||||
current_approver_id BIGINT,
|
||||
remark VARCHAR(500),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### 3. 审批流程模块 (approval_service)
|
||||
#### 3.1 工作流定义表 (workflow_definition)
|
||||
```sql
|
||||
CREATE TABLE workflow_definition (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workflow_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
workflow_name VARCHAR(100) NOT NULL,
|
||||
workflow_type INTEGER NOT NULL, -- 1:请假, 2:加班, 3:报销, 4:采购, 5:通用
|
||||
description VARCHAR(500),
|
||||
version INTEGER DEFAULT 1,
|
||||
status INTEGER DEFAULT 1,
|
||||
form_schema JSONB,
|
||||
process_definition JSONB,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
create_by BIGINT,
|
||||
update_by BIGINT
|
||||
);
|
||||
```
|
||||
|
||||
#### 3.2 工作流实例表 (workflow_instance)
|
||||
```sql
|
||||
CREATE TABLE workflow_instance (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workflow_definition_id BIGINT NOT NULL,
|
||||
business_key VARCHAR(100),
|
||||
business_type VARCHAR(50),
|
||||
business_data JSONB,
|
||||
initiator_id BIGINT NOT NULL,
|
||||
current_node_id BIGINT,
|
||||
current_assignee_id BIGINT,
|
||||
status INTEGER DEFAULT 0, -- 0:进行中, 1:已完成, 2:已终止, 3:已取消
|
||||
start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
end_time TIMESTAMP,
|
||||
duration_days INTEGER,
|
||||
remark VARCHAR(500),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_business_key ON workflow_instance(business_key);
|
||||
CREATE INDEX idx_initiator_status ON workflow_instance(initiator_id, status);
|
||||
```
|
||||
|
||||
#### 3.3 审批节点表 (approval_node)
|
||||
```sql
|
||||
CREATE TABLE approval_node (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workflow_instance_id BIGINT NOT NULL,
|
||||
node_code VARCHAR(50) NOT NULL,
|
||||
node_name VARCHAR(100) NOT NULL,
|
||||
node_type INTEGER NOT NULL, -- 1:开始, 2:审批, 3:会签, 4:或签, 5:结束
|
||||
assignee_type INTEGER NOT NULL, -- 1:指定人, 2:角色, 3:部门负责人, 4:发起人自选
|
||||
assignee_ids JSONB,
|
||||
status INTEGER DEFAULT 0, -- 0:待处理, 1:已通过, 2:已拒绝, 3:已跳过
|
||||
start_time TIMESTAMP,
|
||||
end_time TIMESTAMP,
|
||||
duration_hours DECIMAL(5,2),
|
||||
comment VARCHAR(500),
|
||||
attachment_url VARCHAR(500),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### 4. 文档协作模块 (document_service)
|
||||
#### 4.1 文档表 (document) - MongoDB集合
|
||||
```json
|
||||
{
|
||||
"_id": ObjectId,
|
||||
"document_id": String,
|
||||
"title": String,
|
||||
"content": String,
|
||||
"content_type": String, // "text", "markdown", "html"
|
||||
"owner_id": Number,
|
||||
"owner_name": String,
|
||||
"space_id": String,
|
||||
"parent_id": String,
|
||||
"tags": [String],
|
||||
"permissions": {
|
||||
"view": [Number], // 用户ID列表
|
||||
"edit": [Number],
|
||||
"comment": [Number],
|
||||
"share": [Number]
|
||||
},
|
||||
"version": Number,
|
||||
"current_version_id": String,
|
||||
"is_published": Boolean,
|
||||
"published_version": Number,
|
||||
"status": String, // "draft", "published", "archived"
|
||||
"metadata": {
|
||||
"word_count": Number,
|
||||
"page_count": Number,
|
||||
"language": String,
|
||||
"created_from": String // "web", "mobile", "import"
|
||||
},
|
||||
"statistics": {
|
||||
"view_count": Number,
|
||||
"edit_count": Number,
|
||||
"comment_count": Number,
|
||||
"share_count": Number
|
||||
},
|
||||
"created_at": Date,
|
||||
"updated_at": Date,
|
||||
"deleted_at": Date
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 文档版本表 (document_version) - MongoDB集合
|
||||
```json
|
||||
{
|
||||
"_id": ObjectId,
|
||||
"version_id": String,
|
||||
"document_id": String,
|
||||
"version_number": Number,
|
||||
"title": String,
|
||||
"content": String,
|
||||
"content_hash": String,
|
||||
"change_summary": String,
|
||||
"author_id": Number,
|
||||
"author_name": String,
|
||||
"created_at": Date,
|
||||
"metadata": {
|
||||
"operation": String, // "create", "edit", "restore"
|
||||
"client_info": String,
|
||||
"ip_address": String
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 即时通讯模块 (chat_service)
|
||||
#### 5.1 聊天室表 (chat_room) - MongoDB集合
|
||||
```json
|
||||
{
|
||||
"_id": ObjectId,
|
||||
"room_id": String,
|
||||
"room_name": String,
|
||||
"room_type": String, // "private", "group", "channel"
|
||||
"avatar": String,
|
||||
"description": String,
|
||||
"owner_id": Number,
|
||||
"admin_ids": [Number],
|
||||
"member_ids": [Number],
|
||||
"settings": {
|
||||
"mute_all": Boolean,
|
||||
"allow_invite": Boolean,
|
||||
"need_approval": Boolean,
|
||||
"max_members": Number
|
||||
},
|
||||
"last_message": {
|
||||
"message_id": String,
|
||||
"content": String,
|
||||
"sender_id": Number,
|
||||
"sender_name": String,
|
||||
"timestamp": Date
|
||||
},
|
||||
"unread_count": Map, // userId -> count
|
||||
"created_at": Date,
|
||||
"updated_at": Date
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.2 消息表 (message) - MongoDB集合
|
||||
```json
|
||||
{
|
||||
"_id": ObjectId,
|
||||
"message_id": String,
|
||||
"room_id": String,
|
||||
"sender_id": Number,
|
||||
"sender_name": String,
|
||||
"sender_avatar": String,
|
||||
"content_type": String, // "text", "image", "file", "voice", "video"
|
||||
"content": String,
|
||||
"metadata": {
|
||||
"file_url": String,
|
||||
"file_name": String,
|
||||
"file_size": Number,
|
||||
"duration": Number,
|
||||
"width": Number,
|
||||
"height": Number
|
||||
},
|
||||
"reply_to": String, // 回复的消息ID
|
||||
"mentions": [Number], // 被@的用户ID
|
||||
"reactions": Map, // emoji -> [userId]
|
||||
"read_by": [Number], // 已读用户ID
|
||||
"deleted_for": [Number], // 对哪些用户不可见
|
||||
"is_system": Boolean,
|
||||
"is_edited": Boolean,
|
||||
"edited_at": Date,
|
||||
"is_recalled": Boolean,
|
||||
"recalled_at": Date,
|
||||
"created_at": Date,
|
||||
"updated_at": Date
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 系统管理模块
|
||||
#### 6.1 部门表 (sys_department)
|
||||
```sql
|
||||
CREATE TABLE sys_department (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dept_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
dept_name VARCHAR(100) NOT NULL,
|
||||
parent_id BIGINT DEFAULT 0,
|
||||
ancestors VARCHAR(500),
|
||||
leader_id BIGINT,
|
||||
sort INTEGER DEFAULT 0,
|
||||
status INTEGER DEFAULT 1,
|
||||
phone VARCHAR(20),
|
||||
email VARCHAR(100),
|
||||
address VARCHAR(200),
|
||||
remark VARCHAR(500),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
create_by BIGINT,
|
||||
update_by BIGINT,
|
||||
deleted BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dept_code ON sys_department(dept_code);
|
||||
CREATE INDEX idx_parent_id ON sys_department(parent_id);
|
||||
```
|
||||
|
||||
#### 6.2 操作日志表 (sys_operation_log)
|
||||
```sql
|
||||
CREATE TABLE sys_operation_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
username VARCHAR(50),
|
||||
real_name VARCHAR(50),
|
||||
operation_type VARCHAR(50),
|
||||
operation_module VARCHAR(100),
|
||||
operation_description VARCHAR(500),
|
||||
request_method VARCHAR(10),
|
||||
request_url VARCHAR(500),
|
||||
request_params TEXT,
|
||||
request_ip VARCHAR(50),
|
||||
request_location VARCHAR(100),
|
||||
user_agent VARCHAR(500),
|
||||
execute_time BIGINT,
|
||||
status INTEGER DEFAULT 1,
|
||||
error_message TEXT,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_time ON sys_operation_log(user_id, create_time);
|
||||
CREATE INDEX idx_module_time ON sys_operation_log(operation_module, create_time);
|
||||
```
|
||||
|
||||
## 数据库优化策略
|
||||
|
||||
### 1. 索引优化
|
||||
- 为所有外键字段创建索引
|
||||
- 为频繁查询的组合字段创建复合索引
|
||||
- 为时间范围查询字段创建索引
|
||||
|
||||
### 2. 分区策略
|
||||
- 按时间分区:操作日志、考勤记录等时间序列数据
|
||||
- 按业务分区:不同租户的数据分离
|
||||
|
||||
### 3. 读写分离
|
||||
- 主库:写操作和实时读操作
|
||||
- 从库:报表查询、数据分析等非实时读操作
|
||||
|
||||
### 4. 缓存策略
|
||||
- Redis缓存热点数据:用户信息、权限信息、配置信息
|
||||
- 缓存查询结果:复杂查询结果缓存5-30分钟
|
||||
- 分布式会话:用户会话信息存储在Redis中
|
||||
|
||||
### 5. 数据归档
|
||||
- 历史数据归档到冷存储
|
||||
- 定期清理过期数据
|
||||
- 数据备份和恢复策略
|
||||
|
||||
## 数据库初始化脚本
|
||||
系统启动时会自动执行以下初始化:
|
||||
1. 创建数据库和用户
|
||||
2. 创建表结构和索引
|
||||
3. 插入基础数据(系统角色、权限、管理员用户)
|
||||
4. 创建数据库函数和触发器
|
||||
|
||||
## 数据迁移策略
|
||||
1. 使用Flyway进行数据库版本管理
|
||||
2. 支持回滚操作
|
||||
3. 数据迁移前备份
|
||||
4. 灰度发布,逐步迁移
|
||||
|
||||
## 监控和告警
|
||||
1. 数据库连接池监控
|
||||
2. 慢查询监控和优化
|
||||
3. 死锁检测和解决
|
||||
4. 空间使用监控
|
||||
5. 备份状态监控
|
||||
|
||||
---
|
||||
*文档版本: v1.0*
|
||||
*最后更新: 2026-01-27*
|
||||
*作者: 数据库设计团队*
|
||||
908
plans/project_requirements_document.md
Normal file
908
plans/project_requirements_document.md
Normal file
@@ -0,0 +1,908 @@
|
||||
# 智能办公管理系统 - 项目需求文档
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **文档版本** | v1.0 |
|
||||
| **最后更新** | 2026-01-28 |
|
||||
| **作者** | 架构设计团队 |
|
||||
| **审核状态** | 待审核 |
|
||||
| **适用范围** | 项目重新开发技术规范 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
### 1.1 项目背景与目标
|
||||
|
||||
**项目背景:**
|
||||
随着企业数字化转型的加速,传统办公管理方式已无法满足现代企业的需求。现有办公系统存在以下问题:
|
||||
1. 功能分散,系统间数据孤岛严重
|
||||
2. 用户体验差,操作复杂
|
||||
3. 扩展性不足,难以适应业务变化
|
||||
4. 安全性薄弱,存在数据泄露风险
|
||||
|
||||
**项目目标:**
|
||||
1. 构建统一、智能的办公管理平台
|
||||
2. 实现业务流程数字化、自动化
|
||||
3. 提升员工工作效率和协作体验
|
||||
4. 保障企业数据安全和合规性
|
||||
5. 支持灵活扩展和定制化需求
|
||||
|
||||
### 1.2 项目范围
|
||||
|
||||
**包含范围:**
|
||||
- 用户认证与权限管理
|
||||
- 考勤管理(打卡、请假、加班)
|
||||
- 审批流程管理
|
||||
- 用户与组织架构管理
|
||||
- 文档协作(基础功能)
|
||||
- 即时通讯(基础功能)
|
||||
- 通知服务
|
||||
- 系统监控与运维
|
||||
|
||||
**排除范围:**
|
||||
- 财务报销系统(二期规划)
|
||||
- 项目管理模块(二期规划)
|
||||
- 客户关系管理(三期规划)
|
||||
- 移动端原生应用(后续开发)
|
||||
|
||||
### 1.3 目标用户群体
|
||||
|
||||
| 用户角色 | 主要需求 | 使用频率 |
|
||||
|----------|----------|----------|
|
||||
| **普通员工** | 日常打卡、请假申请、查看通知、文档协作 | 每日多次 |
|
||||
| **部门经理** | 审批申请、查看团队考勤、管理文档权限 | 每日多次 |
|
||||
| **HR管理员** | 用户管理、考勤统计、权限配置 | 每日使用 |
|
||||
| **系统管理员** | 系统配置、监控告警、数据备份 | 定期使用 |
|
||||
| **企业领导** | 数据报表、审批决策、系统概览 | 定期查看 |
|
||||
|
||||
### 1.4 核心价值主张
|
||||
|
||||
1. **一体化平台**:整合多个办公场景,消除系统孤岛
|
||||
2. **智能自动化**:基于规则的自动审批、智能考勤统计
|
||||
3. **安全可靠**:企业级安全防护,数据加密存储
|
||||
4. **移动友好**:响应式设计,支持多端访问
|
||||
5. **开放扩展**:微服务架构,支持快速功能扩展
|
||||
|
||||
### 1.5 实施阶段与交付边界(本仓库版本)
|
||||
|
||||
为保证可落地交付,本仓库的 **v1.0(MVP)** 采用“可运行的单体应用 + 前后端分离”的方式实现核心流程,微服务架构作为后续演进方向。
|
||||
|
||||
**v1.0 必须交付的模块:**
|
||||
- 认证与权限:用户名/密码登录、JWT、角色分级(员工/经理/管理员)
|
||||
- 用户管理:用户新增、查询、状态管理(启用/禁用/锁定)
|
||||
- 考勤管理:手动打卡(上班/下班)、个人考勤记录
|
||||
- 请假审批:请假申请、经理/管理员审批、审批记录
|
||||
- 通知服务:站内通知(审批结果通知)
|
||||
|
||||
**v1.0 明确不交付(规划至二期及以后):**
|
||||
- 文档协作、即时通讯、外部消息渠道(短信/邮件/企业微信)
|
||||
- 地理围栏/人脸识别打卡、流程设计器、复杂规则引擎
|
||||
- 多租户、微服务拆分、分布式事务、日志与监控平台全量接入
|
||||
|
||||
**说明:**
|
||||
- 本期目标是“可部署、可演示、流程闭环”,与后续微服务化不冲突。
|
||||
- 若项目进入二期,将根据稳定版本逐步拆分服务与引入基础设施。
|
||||
|
||||
---
|
||||
|
||||
## 2. 功能需求
|
||||
|
||||
### 2.1 用户认证与权限管理模块
|
||||
|
||||
#### 2.1.1 功能描述
|
||||
- 用户注册、登录、登出
|
||||
- 密码管理(修改、重置、找回)
|
||||
- 多因素认证支持
|
||||
- JWT令牌管理(签发、刷新、验证)
|
||||
- 基于角色的访问控制(RBAC)
|
||||
- 权限细粒度控制
|
||||
|
||||
#### 2.1.2 验收标准
|
||||
- v1.0 默认支持用户名/密码登录;手机号/邮箱登录作为二期扩展
|
||||
- 登录失败5次后账户锁定30分钟
|
||||
- JWT令牌有效期可配置(默认24小时)
|
||||
- v1.0 先实现角色级权限控制,权限继承与组合为后续增强
|
||||
- 提供完整的权限审计日志
|
||||
|
||||
#### 2.1.3 技术实现建议
|
||||
- 使用Spring Security 6 + JWT
|
||||
- Redis存储会话和令牌黑名单
|
||||
- 密码使用BCrypt加密存储
|
||||
- 集成验证码防暴力破解
|
||||
|
||||
### 2.2 考勤管理模块
|
||||
|
||||
#### 2.2.1 功能描述
|
||||
- 上下班打卡(支持地理位置、WiFi验证)
|
||||
- 请假申请与审批
|
||||
- 加班申请与审批
|
||||
- 考勤规则配置(工作时间、迟到早退阈值)
|
||||
- 考勤统计与报表
|
||||
- 异常考勤提醒
|
||||
|
||||
#### 2.2.2 验收标准
|
||||
- v1.0 支持手动打卡与可选的地点描述;位置/WiFi打卡为二期
|
||||
- 请假类型支持:年假、病假、事假、婚假、产假、丧假
|
||||
- 加班补偿方式:调休、加班费
|
||||
- 考勤报表支持按日、周、月、季度、年统计
|
||||
- 异常考勤自动发送通知
|
||||
|
||||
#### 2.2.3 技术实现建议
|
||||
- 使用地理围栏技术验证打卡位置
|
||||
- 集成人脸识别(可选)
|
||||
- 考勤规则引擎支持复杂规则
|
||||
- 使用Quartz调度器处理定时统计任务
|
||||
|
||||
### 2.3 审批流程模块
|
||||
|
||||
#### 2.3.1 功能描述
|
||||
- 工作流定义与配置
|
||||
- 审批流程实例化
|
||||
- 多级审批(串行、并行、会签)
|
||||
- 审批节点配置(审批人、抄送人)
|
||||
- 审批历史记录
|
||||
- 流程监控与统计
|
||||
|
||||
#### 2.3.2 验收标准
|
||||
- v1.0 支持固定审批流程(员工 → 经理/管理员),不提供可视化设计器
|
||||
- 审批节点条件分支、超时自动处理、流程版本管理在二期实现
|
||||
- v1.0 提供审批记录列表与状态变更时间
|
||||
|
||||
#### 2.3.3 技术实现建议
|
||||
- 集成Flowable工作流引擎
|
||||
- 使用BPMN 2.0标准定义流程
|
||||
- 支持动态审批人指定(角色、部门、特定人员)
|
||||
- 审批数据与业务数据分离存储
|
||||
|
||||
### 2.4 用户管理模块
|
||||
|
||||
#### 2.4.1 功能描述
|
||||
- 用户信息管理(增删改查)
|
||||
- 组织架构管理(部门、岗位)
|
||||
- 角色与权限管理
|
||||
- 用户导入导出
|
||||
- 用户状态管理(启用、禁用、锁定)
|
||||
|
||||
#### 2.4.2 验收标准
|
||||
- 支持批量用户导入(Excel模板)
|
||||
- 组织架构支持树形结构
|
||||
- 角色权限支持细粒度控制
|
||||
- 用户操作记录完整审计
|
||||
- 支持用户数据脱敏导出
|
||||
|
||||
#### 2.4.3 技术实现建议
|
||||
- 使用部门表实现组织树
|
||||
- 权限表支持菜单权限和API权限
|
||||
- 集成LDAP/AD域认证(可选)
|
||||
- 用户数据分页查询和条件过滤
|
||||
|
||||
### 2.5 文档协作模块(待实现)
|
||||
|
||||
#### 2.5.1 功能描述
|
||||
- 文档创建、编辑、删除
|
||||
- 版本控制与历史记录
|
||||
- 文档权限管理(查看、编辑、评论、分享)
|
||||
- 在线协同编辑
|
||||
- 文档模板管理
|
||||
- 文档搜索与分类
|
||||
|
||||
#### 2.5.2 验收标准
|
||||
- 支持Markdown、富文本编辑
|
||||
- 文档版本可回滚
|
||||
- 协同编辑实时同步
|
||||
- 文档权限支持继承
|
||||
- 全文搜索支持关键词高亮
|
||||
|
||||
#### 2.5.3 技术实现建议
|
||||
- 使用MongoDB存储文档内容
|
||||
- 集成Operational Transformation算法实现协同编辑
|
||||
- 使用Elasticsearch实现全文搜索
|
||||
- 文档版本使用增量存储
|
||||
|
||||
### 2.6 即时通讯模块(待实现)
|
||||
|
||||
#### 2.6.1 功能描述
|
||||
- 一对一聊天
|
||||
- 群组聊天
|
||||
- 文件传输
|
||||
- 消息状态(已发送、已送达、已读)
|
||||
- 聊天记录查询
|
||||
- 消息撤回、删除
|
||||
|
||||
#### 2.6.2 验收标准
|
||||
- 消息实时推送延迟<1秒
|
||||
- 支持文本、图片、文件、表情消息
|
||||
- 聊天记录支持分页查询
|
||||
- 群组管理(创建、解散、成员管理)
|
||||
- 消息加密传输
|
||||
|
||||
#### 2.6.3 技术实现建议
|
||||
- 使用WebSocket + STOMP协议
|
||||
- Redis Pub/Sub实现消息广播
|
||||
- 消息使用AES加密
|
||||
- 大文件使用分片上传
|
||||
|
||||
### 2.7 通知服务模块(基础实现)
|
||||
|
||||
#### 2.7.1 功能描述
|
||||
- 系统通知(公告、提醒)
|
||||
- 业务通知(审批结果、考勤异常)
|
||||
- v1.0 仅提供站内通知;外部渠道推送为二期
|
||||
- 通知模板管理(二期)
|
||||
- 通知记录与统计(基础)
|
||||
|
||||
#### 2.7.2 验收标准
|
||||
- v1.0 支持即时发送站内通知、已读状态与统计
|
||||
- 定时通知、优先级、用户偏好、去重合并为二期
|
||||
|
||||
#### 2.7.3 技术实现建议
|
||||
- 使用消息队列(RabbitMQ)解耦
|
||||
- 集成多个推送渠道SDK
|
||||
- 通知模板支持变量替换
|
||||
- 使用线程池处理批量发送
|
||||
|
||||
---
|
||||
|
||||
## 3. 非功能需求
|
||||
|
||||
### 3.1 性能需求
|
||||
|
||||
| 指标 | 要求 | 说明 |
|
||||
|------|------|------|
|
||||
| **响应时间** | API P95 < 200ms | 95%的API请求响应时间在200ms内 |
|
||||
| **并发用户** | 支持1000+同时在线 | 系统应支持1000个用户同时在线操作 |
|
||||
| **吞吐量** | 1000 TPS | 核心接口支持每秒1000个事务处理 |
|
||||
| **数据量** | 支持百万级用户数据 | 用户表、考勤记录表支持百万级数据量 |
|
||||
| **页面加载** | 首屏加载 < 3秒 | 前端页面首屏加载时间不超过3秒 |
|
||||
|
||||
> 注:以上为生产目标值;v1.0 本地演示环境以功能正确与可用性为优先。
|
||||
|
||||
### 3.2 安全性需求
|
||||
|
||||
1. **认证安全**
|
||||
- 密码强度策略(最小长度8位,包含大小写字母和数字)
|
||||
- 登录失败锁定机制
|
||||
- 会话超时自动退出
|
||||
- 防止暴力破解
|
||||
|
||||
2. **数据安全**
|
||||
- 敏感数据加密存储(密码、手机号、身份证号)
|
||||
- 数据传输使用HTTPS
|
||||
- 数据库访问权限最小化
|
||||
- 定期安全漏洞扫描
|
||||
|
||||
3. **权限安全**
|
||||
- 基于角色的访问控制
|
||||
- 接口级别权限验证
|
||||
- 防止越权访问
|
||||
- 操作日志完整记录
|
||||
|
||||
4. **审计安全**
|
||||
- 所有关键操作记录审计日志
|
||||
- 日志不可篡改
|
||||
- 支持日志查询和导出
|
||||
- 符合等保2.0三级要求
|
||||
|
||||
### 3.3 可用性需求
|
||||
|
||||
1. **系统可用性**
|
||||
- 生产环境可用性 ≥ 99.9%
|
||||
- 支持7×24小时不间断运行
|
||||
- 计划内维护提前通知
|
||||
|
||||
2. **容错能力**
|
||||
- 单点故障不影响核心功能
|
||||
- 数据库故障自动切换
|
||||
- 服务异常自动重启
|
||||
- 数据一致性保证
|
||||
|
||||
3. **灾难恢复**
|
||||
- RTO(恢复时间目标)≤ 4小时
|
||||
- RPO(恢复点目标)≤ 1小时
|
||||
- 定期灾难恢复演练
|
||||
|
||||
### 3.4 可维护性需求
|
||||
|
||||
1. **代码质量**
|
||||
- 代码规范遵循阿里巴巴Java开发手册
|
||||
- 单元测试覆盖率 ≥ 80%
|
||||
- 集成测试覆盖核心业务流程
|
||||
- 代码审查流程规范化
|
||||
|
||||
2. **文档完整性**
|
||||
- API文档自动生成(OpenAPI 3.0)
|
||||
- 数据库设计文档完整
|
||||
- 部署运维手册详细
|
||||
- 用户操作手册齐全
|
||||
|
||||
3. **监控告警**
|
||||
- 应用性能监控(APM)
|
||||
- 业务指标监控
|
||||
- 异常告警及时通知
|
||||
- 日志集中收集和分析
|
||||
|
||||
### 3.5 可扩展性需求
|
||||
|
||||
1. **架构扩展性**
|
||||
- 微服务架构支持水平扩展
|
||||
- 数据库支持读写分离
|
||||
- 缓存支持集群部署
|
||||
- 消息队列支持横向扩展
|
||||
|
||||
2. **功能扩展性**
|
||||
- 模块化设计,支持插件式开发
|
||||
- API版本管理,支持平滑升级
|
||||
- 配置中心支持动态配置
|
||||
- 支持第三方系统集成
|
||||
|
||||
3. **数据扩展性**
|
||||
- 数据库支持分库分表
|
||||
- 大数据量支持数据归档
|
||||
- 支持多种数据源接入
|
||||
- 数据迁移工具完善
|
||||
|
||||
---
|
||||
|
||||
## 4. 技术架构需求
|
||||
|
||||
### 4.1 系统架构设计
|
||||
|
||||
#### 4.1.1 整体架构
|
||||
系统目标采用微服务架构,前后端分离设计;**v1.0 采用单体应用承载核心模块**,后续再拆分为微服务。整体分为以下层次:
|
||||
|
||||
1. **客户端层**:Web浏览器、移动端H5、桌面应用
|
||||
2. **接入层**:Nginx反向代理、API网关、负载均衡
|
||||
3. **应用服务层**:多个微服务(认证、用户、考勤、审批等)
|
||||
4. **数据存储层**:关系数据库、NoSQL数据库、缓存、消息队列
|
||||
5. **基础设施层**:容器平台、监控告警、日志收集
|
||||
|
||||
#### 4.1.2 架构图
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "客户端层"
|
||||
Web[Web浏览器]
|
||||
Mobile[移动端H5]
|
||||
Desktop[桌面应用]
|
||||
end
|
||||
|
||||
subgraph "接入层"
|
||||
Nginx[Nginx反向代理]
|
||||
Gateway[API网关]
|
||||
LB[负载均衡器]
|
||||
end
|
||||
|
||||
subgraph "应用服务层"
|
||||
Auth[认证服务]
|
||||
User[用户服务]
|
||||
Attendance[考勤服务]
|
||||
Approval[审批服务]
|
||||
Document[文档服务]
|
||||
Chat[聊天服务]
|
||||
Notification[通知服务]
|
||||
Common[公共服务]
|
||||
end
|
||||
|
||||
subgraph "数据存储层"
|
||||
MySQL[(MySQL)]
|
||||
MongoDB[(MongoDB)]
|
||||
Redis[(Redis)]
|
||||
RabbitMQ[(RabbitMQ)]
|
||||
ES[(Elasticsearch)]
|
||||
end
|
||||
|
||||
subgraph "基础设施层"
|
||||
Docker[Docker容器]
|
||||
K8s[Kubernetes]
|
||||
Monitor[监控告警]
|
||||
Log[日志收集]
|
||||
end
|
||||
|
||||
Web --> Nginx
|
||||
Mobile --> Nginx
|
||||
Desktop --> Nginx
|
||||
|
||||
Nginx --> Gateway
|
||||
Gateway --> Auth
|
||||
Gateway --> User
|
||||
Gateway --> Attendance
|
||||
Gateway --> Approval
|
||||
Gateway --> Document
|
||||
Gateway --> Chat
|
||||
Gateway --> Notification
|
||||
|
||||
Auth --> MySQL
|
||||
User --> MySQL
|
||||
Attendance --> MySQL
|
||||
Approval --> MySQL
|
||||
Document --> MongoDB
|
||||
Chat --> Redis
|
||||
Chat --> RabbitMQ
|
||||
Notification --> RabbitMQ
|
||||
Common --> ES
|
||||
|
||||
Monitor --> Auth
|
||||
Monitor --> User
|
||||
Log --> Auth
|
||||
Log --> User
|
||||
```
|
||||
|
||||
### 4.2 技术栈选择
|
||||
|
||||
#### 4.2.1 后端技术栈
|
||||
- **开发框架**:Spring Boot 3.2.x + Spring Cloud 2023.x
|
||||
- **安全框架**:Spring Security 6 + JWT
|
||||
- **数据库**:
|
||||
- 关系数据库:MySQL 8.0(主业务数据)
|
||||
- v1.0 本地演示可使用H2内存模式以降低部署复杂度
|
||||
- NoSQL数据库:MongoDB 7.0(文档、聊天记录)
|
||||
- 缓存:Redis 7.0(会话、热点数据)
|
||||
- 搜索:Elasticsearch 8.0(全文搜索、日志)
|
||||
- **消息队列**:RabbitMQ 3.12(异步处理、解耦)
|
||||
- **API文档**:SpringDoc OpenAPI 3
|
||||
- **服务注册发现**:Nacos 2.3 / Eureka
|
||||
- **配置中心**:Nacos Config
|
||||
- **分布式事务**:Seata 1.7
|
||||
- **监控**:Spring Boot Actuator + Prometheus + Grafana
|
||||
- **测试**:JUnit 5 + Mockito + Testcontainers
|
||||
|
||||
#### 4.2.2 前端技术栈
|
||||
- **框架**:React 18 + TypeScript 5.x
|
||||
- **UI库**:Ant Design Pro 7.x
|
||||
- **状态管理**:Redux Toolkit + RTK Query
|
||||
- **路由**:React Router 6
|
||||
- **构建工具**:Vite 5
|
||||
- **图表**:ECharts 5
|
||||
- **实时通信**:Socket.IO Client
|
||||
- **代码规范**:ESLint + Prettier + Husky
|
||||
|
||||
#### 4.2.3 基础设施
|
||||
- **容器化**:Docker 24.x + Docker Compose
|
||||
- **编排**:Kubernetes 1.28(生产环境)
|
||||
- **CI/CD**:GitHub Actions / Jenkins
|
||||
- **代码仓库**:Git + GitHub
|
||||
- **镜像仓库**:Harbor / Docker Hub
|
||||
- **日志收集**:ELK Stack(Elasticsearch, Logstash, Kibana)
|
||||
- **监控告警**:Prometheus + AlertManager + Grafana
|
||||
- **对象存储**:MinIO(兼容S3协议)
|
||||
|
||||
### 4.3 数据库设计
|
||||
|
||||
#### 4.3.1 数据库选型策略
|
||||
- **MySQL**:核心业务数据,需要事务支持的表
|
||||
- **MongoDB**:非结构化数据,文档内容,聊天记录
|
||||
- **Redis**:缓存、会话、分布式锁、消息队列
|
||||
- **Elasticsearch**:日志、搜索、分析
|
||||
|
||||
#### 4.3.2 数据库分库分表策略
|
||||
1. **垂直分库**:按业务模块分库
|
||||
- `smart_office_auth`:认证授权相关表
|
||||
- `smart_office_attendance`:考勤相关表
|
||||
- `smart_office_approval`:审批相关表
|
||||
- `smart_office_user`:用户组织相关表
|
||||
|
||||
2. **水平分表**:大数据量表按时间或用户ID分表
|
||||
- 考勤记录表按月份分表
|
||||
- 操作日志表按季度分表
|
||||
- 消息记录表按用户ID哈希分表
|
||||
|
||||
#### 4.3.3 数据库优化策略
|
||||
- 为所有外键字段创建索引
|
||||
- 为频繁查询的组合字段创建复合索引
|
||||
- 为时间范围查询字段创建索引
|
||||
- 使用数据库连接池(HikariCP)
|
||||
- 定期执行慢查询分析和优化
|
||||
- 使用读写分离提升读性能
|
||||
|
||||
### 4.4 部署架构
|
||||
|
||||
#### 4.4.1 环境规划
|
||||
1. **开发环境**:本地Docker Compose部署,便于开发调试
|
||||
2. **测试环境**:Kubernetes集群,模拟生产环境
|
||||
3. **预发布环境**:与生产环境配置一致,用于最终测试
|
||||
4. **生产环境**:多可用区高可用部署,保障业务连续性
|
||||
|
||||
#### 4.4.2 部署模式
|
||||
- **容器化部署**:所有服务打包为Docker镜像
|
||||
- **服务网格**:使用Istio进行服务治理(可选)
|
||||
- **自动扩缩容**:基于CPU/内存使用率自动扩缩容
|
||||
- **蓝绿部署**:支持零停机部署和回滚
|
||||
- **金丝雀发布**:逐步将流量切换到新版本
|
||||
|
||||
#### 4.4.3 高可用设计
|
||||
- 每个服务至少部署2个实例
|
||||
- 数据库主从复制+自动故障转移
|
||||
- Redis集群模式,数据分片存储
|
||||
- 负载均衡器健康检查
|
||||
- 多可用区部署,避免单点故障
|
||||
|
||||
---
|
||||
|
||||
## 5. 接口需求
|
||||
|
||||
### 5.1 内部服务接口
|
||||
|
||||
#### 5.1.1 服务间通信协议
|
||||
- **同步调用**:使用OpenFeign + LoadBalancer
|
||||
- **异步通信**:使用RabbitMQ消息队列
|
||||
- **实时通信**:使用WebSocket + STOMP
|
||||
- **文件传输**:使用MinIO对象存储
|
||||
|
||||
#### 5.1.2 服务发现与注册
|
||||
- 所有微服务向Nacos注册中心注册
|
||||
- 服务消费者通过服务名调用
|
||||
- 支持服务健康检查
|
||||
- 服务下线自动通知
|
||||
|
||||
#### 5.1.3 服务熔断与降级
|
||||
- 使用Resilience4j实现熔断器
|
||||
- 服务调用超时自动熔断
|
||||
- 降级策略:返回默认值或缓存数据
|
||||
- 熔断器状态监控
|
||||
|
||||
### 5.2 外部系统接口
|
||||
|
||||
#### 5.2.1 第三方认证集成
|
||||
- 企业微信/钉钉单点登录
|
||||
- LDAP/AD域认证
|
||||
- OAuth 2.0第三方登录
|
||||
- 短信验证码服务
|
||||
|
||||
#### 5.2.2 消息推送接口
|
||||
- 企业微信机器人
|
||||
- 钉钉工作通知
|
||||
- 邮件SMTP服务
|
||||
- 短信网关接口
|
||||
|
||||
#### 5.2.3 文件存储接口
|
||||
- 阿里云OSS
|
||||
- 腾讯云COS
|
||||
- 七牛云存储
|
||||
- 本地MinIO
|
||||
|
||||
### 5.3 API设计规范
|
||||
|
||||
#### 5.3.1 RESTful API设计原则
|
||||
1. **资源导向**:使用名词表示资源,动词表示操作
|
||||
2. **HTTP方法**:
|
||||
- GET:获取资源
|
||||
- POST:创建资源
|
||||
- PUT:更新完整资源
|
||||
- PATCH:部分更新资源
|
||||
- DELETE:删除资源
|
||||
3. **版本管理**:URL路径包含版本号 `/api/v1/users`
|
||||
4. **状态码**:正确使用HTTP状态码
|
||||
5. **错误处理**:统一错误响应格式
|
||||
|
||||
#### 5.3.2 API响应格式
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "操作成功",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"username": "admin"
|
||||
},
|
||||
"timestamp": "2026-01-28T08:38:44.921Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.3.3 API安全规范
|
||||
1. **认证**:所有API需要JWT令牌认证(除登录注册接口)
|
||||
2. **授权**:接口级别权限验证
|
||||
3. **限流**:接口访问频率限制
|
||||
4. **防重放**:请求签名和时间戳验证
|
||||
5. **参数校验**:输入参数严格校验
|
||||
|
||||
#### 5.3.4 API文档规范
|
||||
- 使用OpenAPI 3.0规范
|
||||
- 接口文档自动生成
|
||||
- 提供在线测试功能
|
||||
- 版本变更记录清晰
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据需求
|
||||
|
||||
### 6.1 数据模型设计
|
||||
|
||||
#### 6.1.1 核心实体关系
|
||||
1. **用户体系**:用户 ↔ 角色 ↔ 权限 ↔ 部门
|
||||
2. **考勤体系**:用户 ↔ 考勤记录 ↔ 请假申请 ↔ 加班申请
|
||||
3. **审批体系**:工作流定义 ↔ 工作流实例 ↔ 审批节点
|
||||
4. **文档体系**:文档 ↔ 文档版本 ↔ 协作记录
|
||||
5. **通讯体系**:聊天室 ↔ 消息 ↔ 参与者
|
||||
|
||||
#### 6.1.2 数据一致性要求
|
||||
1. **强一致性**:用户账户、权限数据需要强一致性
|
||||
2. **最终一致性**:统计报表、通知消息可以最终一致性
|
||||
3. **事务边界**:跨服务事务使用分布式事务解决方案
|
||||
4. **数据同步**:主从数据库数据同步延迟 < 1秒
|
||||
|
||||
### 6.2 数据存储策略
|
||||
|
||||
#### 6.2.1 数据分类存储
|
||||
- **热数据**:用户会话、权限信息 → Redis缓存
|
||||
- **温数据**:近期考勤记录、审批记录 → MySQL
|
||||
- **冷数据**:历史日志、归档数据 → 对象存储/归档数据库
|
||||
- **分析数据**:统计报表、分析结果 → Elasticsearch
|
||||
|
||||
#### 6.2.2 数据生命周期管理
|
||||
1. **实时数据**(0-30天):在线访问,高性能存储
|
||||
2. **近期数据**(30-180天):在线访问,标准存储
|
||||
3. **历史数据**(180-365天):离线访问,归档存储
|
||||
4. **归档数据**(365天以上):只读访问,低成本存储
|
||||
|
||||
#### 6.2.3 数据备份策略
|
||||
- **全量备份**:每周一次,保留4周
|
||||
- **增量备份**:每天一次,保留30天
|
||||
- **日志备份**:每小时一次,保留7天
|
||||
- **异地备份**:每月一次,保留12个月
|
||||
|
||||
### 6.3 数据安全要求
|
||||
|
||||
#### 6.3.1 数据加密
|
||||
- **传输加密**:所有数据传输使用TLS 1.3
|
||||
- **存储加密**:敏感数据加密存储(AES-256)
|
||||
- **密钥管理**:使用KMS管理加密密钥
|
||||
- **密码存储**:使用BCrypt加盐哈希
|
||||
|
||||
#### 6.3.2 数据脱敏
|
||||
- **显示脱敏**:前端显示时脱敏(手机号、身份证号)
|
||||
- **查询脱敏**:非授权用户查询时自动脱敏
|
||||
- **导出脱敏**:数据导出时根据权限脱敏
|
||||
- **日志脱敏**:日志中不记录敏感信息
|
||||
|
||||
#### 6.3.3 数据访问控制
|
||||
- **行级权限**:用户只能访问自己有权限的数据
|
||||
- **字段级权限**:敏感字段根据角色控制访问
|
||||
- **操作审计**:所有数据访问操作记录审计日志
|
||||
- **数据血缘**:记录数据来源和变更历史
|
||||
|
||||
#### 6.3.4 数据合规性
|
||||
- **个人信息保护**:符合《个人信息保护法》要求
|
||||
- **数据出境**:敏感数据不出境
|
||||
- **数据留存**:按照法规要求保留数据
|
||||
- **数据删除**:支持用户数据彻底删除
|
||||
|
||||
---
|
||||
|
||||
## 7. 部署与运维需求
|
||||
|
||||
### 7.1 部署环境
|
||||
|
||||
#### 7.1.1 硬件要求
|
||||
| 环境 | CPU | 内存 | 存储 | 网络 |
|
||||
|------|-----|------|------|------|
|
||||
| **开发环境** | 4核 | 8GB | 100GB | 100Mbps |
|
||||
| **测试环境** | 8核 | 16GB | 200GB | 1Gbps |
|
||||
| **生产环境** | 16核 | 32GB | 500GB | 10Gbps |
|
||||
|
||||
#### 7.1.2 软件要求
|
||||
- **操作系统**:Ubuntu 22.04 LTS / CentOS 8
|
||||
- **容器运行时**:Docker 24.x / containerd
|
||||
- **容器编排**:Kubernetes 1.28+
|
||||
- **Java环境**:OpenJDK 17
|
||||
- **Node.js环境**:Node.js 18 LTS
|
||||
- **数据库**:MySQL 8.0, MongoDB 7.0, Redis 7.0
|
||||
|
||||
#### 7.1.3 网络要求
|
||||
- **域名**:至少一个备案域名
|
||||
- **SSL证书**:有效的SSL证书(Let's Encrypt或商业证书)
|
||||
- **防火墙**:只开放必要端口(80, 443, 22)
|
||||
- **CDN**:静态资源使用CDN加速
|
||||
- **负载均衡**:Nginx或云厂商负载均衡器
|
||||
|
||||
### 7.2 监控与告警
|
||||
|
||||
#### 7.2.1 监控指标
|
||||
1. **基础设施监控**
|
||||
- CPU使用率、内存使用率、磁盘使用率
|
||||
- 网络流量、连接数、错误率
|
||||
- 容器状态、Pod状态、节点状态
|
||||
|
||||
2. **应用监控**
|
||||
- JVM内存、GC次数、线程数
|
||||
- 接口响应时间、QPS、错误率
|
||||
- 数据库连接数、慢查询、锁等待
|
||||
- 缓存命中率、消息队列堆积
|
||||
|
||||
3. **业务监控**
|
||||
- 用户活跃数、在线用户数
|
||||
- 考勤打卡成功率、审批处理时长
|
||||
- 通知发送成功率、消息送达率
|
||||
- 系统关键业务流程成功率
|
||||
|
||||
#### 7.2.2 告警策略
|
||||
- **紧急告警**:服务不可用、数据库宕机 → 电话通知
|
||||
- **重要告警**:性能下降、错误率升高 → 企业微信/钉钉
|
||||
- **一般告警**:资源使用率高、备份失败 → 邮件通知
|
||||
- **预警**:趋势异常、容量不足 → 定期报告
|
||||
|
||||
#### 7.2.3 监控工具栈
|
||||
- **指标收集**:Prometheus + node_exporter
|
||||
- **可视化**:Grafana
|
||||
- **日志收集**:ELK Stack(Elasticsearch, Logstash, Kibana)
|
||||
- **链路追踪**:Jaeger / SkyWalking
|
||||
- **告警管理**:AlertManager
|
||||
|
||||
### 7.3 备份与恢复
|
||||
|
||||
#### 7.3.1 备份策略
|
||||
1. **数据库备份**
|
||||
- 全量备份:每周日02:00执行
|
||||
- 增量备份:每天02:00执行(除周日)
|
||||
- 备份保留:全量备份保留4周,增量备份保留30天
|
||||
- 备份验证:每周验证一次备份可恢复性
|
||||
|
||||
2. **文件备份**
|
||||
- 上传文件:实时同步到备份存储
|
||||
- 配置文件:版本控制+定期备份
|
||||
- 日志文件:集中存储+定期归档
|
||||
|
||||
3. **配置备份**
|
||||
- 应用配置:Git版本控制
|
||||
- 环境变量:配置中心备份
|
||||
- 密钥证书:专用密钥管理系统
|
||||
|
||||
#### 7.3.2 恢复策略
|
||||
1. **数据恢复**
|
||||
- 恢复点目标(RPO):≤ 1小时
|
||||
- 恢复时间目标(RTO):≤ 4小时
|
||||
- 恢复验证:恢复后必须验证数据完整性
|
||||
|
||||
2. **服务恢复**
|
||||
- 服务故障:自动重启或切换到备用实例
|
||||
- 数据库故障:主从切换或从备份恢复
|
||||
- 全站故障:从最近备份恢复,优先恢复核心服务
|
||||
|
||||
3. **灾难恢复**
|
||||
- 灾难恢复计划(DRP)文档完整
|
||||
- 定期灾难恢复演练(每季度一次)
|
||||
- 异地备份数据可恢复性验证
|
||||
|
||||
#### 7.3.3 备份存储
|
||||
- **本地存储**:NAS或专用备份服务器
|
||||
- **云存储**:对象存储(兼容S3协议)
|
||||
- **异地存储**:不同地域的云存储
|
||||
- **加密存储**:备份数据加密存储
|
||||
|
||||
---
|
||||
|
||||
## 8. 项目约束与假设
|
||||
|
||||
### 8.1 技术约束
|
||||
|
||||
#### 8.1.1 开发约束
|
||||
- 必须使用Java 17+和Spring Boot 3.x框架
|
||||
- 前端必须使用React + TypeScript
|
||||
- 数据库必须支持MySQL 8.0+和MongoDB 7.0+
|
||||
- 必须支持Docker容器化部署
|
||||
- 必须提供完整的API文档(OpenAPI 3.0)
|
||||
|
||||
#### 8.1.2 安全约束
|
||||
- 所有用户密码必须加密存储(BCrypt)
|
||||
- 敏感数据传输必须使用HTTPS
|
||||
- 必须实现基于角色的访问控制(RBAC)
|
||||
- 必须记录完整的操作审计日志
|
||||
- 必须通过安全漏洞扫描
|
||||
|
||||
#### 8.1.3 性能约束
|
||||
- API响应时间P95必须 < 200ms
|
||||
- 系统必须支持1000+并发用户
|
||||
- 页面首屏加载时间必须 < 3秒
|
||||
- 数据库查询必须使用索引优化
|
||||
|
||||
### 8.2 时间约束
|
||||
|
||||
#### 8.2.1 项目里程碑
|
||||
1. **需求分析与设计阶段**:2周
|
||||
2. **基础框架搭建阶段**:2周
|
||||
3. **核心功能开发阶段**:6周
|
||||
4. **前端界面开发阶段**:4周
|
||||
5. **集成测试与优化阶段**:2周
|
||||
6. **部署上线与运维阶段**:1周
|
||||
|
||||
#### 8.2.2 关键时间节点
|
||||
- 项目启动:第1周
|
||||
- 需求评审完成:第2周
|
||||
- 架构设计完成:第3周
|
||||
- 核心功能开发完成:第9周
|
||||
- 集成测试完成:第11周
|
||||
- 系统上线:第12周
|
||||
- 项目验收:第13周
|
||||
|
||||
### 8.3 资源约束
|
||||
|
||||
#### 8.3.1 人力资源
|
||||
- **后端开发**:3人(Java开发工程师)
|
||||
- **前端开发**:2人(React开发工程师)
|
||||
- **测试工程师**:1人
|
||||
- **运维工程师**:1人(兼)
|
||||
- **产品经理**:1人
|
||||
- **项目经理**:1人(兼)
|
||||
|
||||
#### 8.3.2 硬件资源
|
||||
- **开发环境**:每人一台开发机(16GB内存,512GB SSD)
|
||||
- **测试环境**:专用服务器或云服务器(8核16GB)
|
||||
- **生产环境**:云服务器集群或物理服务器集群
|
||||
|
||||
#### 8.3.3 软件资源
|
||||
- **开发工具**:IntelliJ IDEA Ultimate、VS Code
|
||||
- **版本控制**:Git + GitHub/GitLab
|
||||
- **项目管理**:Jira/禅道
|
||||
- **文档管理**:Confluence/语雀
|
||||
- **沟通工具**:企业微信/钉钉/Slack
|
||||
|
||||
### 8.4 假设条件
|
||||
|
||||
#### 8.4.1 技术假设
|
||||
1. 用户网络环境稳定,能够正常访问互联网
|
||||
2. 用户设备支持现代浏览器(Chrome 90+、Firefox 88+、Safari 14+)
|
||||
3. 服务器资源充足,能够满足系统运行需求
|
||||
4. 第三方服务(短信、邮件、存储)稳定可用
|
||||
5. 团队成员具备相关技术栈的开发经验
|
||||
|
||||
#### 8.4.2 业务假设
|
||||
1. 企业组织架构相对稳定,不会频繁变动
|
||||
2. 考勤规则相对固定,不会每天变化
|
||||
3. 审批流程相对规范,不会频繁调整
|
||||
4. 用户数量在可预测范围内增长
|
||||
5. 业务需求在项目周期内不会发生重大变更
|
||||
|
||||
#### 8.4.3 环境假设
|
||||
1. 生产环境网络稳定,延迟在可接受范围内
|
||||
2. 数据库性能满足业务增长需求
|
||||
3. 监控告警系统能够及时发现问题
|
||||
4. 备份恢复机制能够保障数据安全
|
||||
5. 安全防护措施能够抵御常见攻击
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### A. 术语表
|
||||
|
||||
| 术语 | 解释 |
|
||||
|------|------|
|
||||
| **RBAC** | 基于角色的访问控制(Role-Based Access Control) |
|
||||
| **JWT** | JSON Web Token,用于身份验证的开放标准 |
|
||||
| **API网关** | 所有外部请求的统一入口,负责路由、认证、限流等 |
|
||||
| **微服务** | 将单一应用程序划分成一组小的服务 |
|
||||
| **容器化** | 将应用程序及其依赖打包到容器中,实现环境一致性 |
|
||||
| **CI/CD** | 持续集成/持续部署,自动化软件交付流程 |
|
||||
| **P95响应时间** | 95%的请求响应时间低于该值 |
|
||||
| **RTO/RPO** | 恢复时间目标/恢复点目标,灾难恢复指标 |
|
||||
|
||||
### B. 参考资料
|
||||
|
||||
1. 《阿里巴巴Java开发手册》
|
||||
2. 《微服务架构设计模式》
|
||||
3. 《Spring Boot实战》
|
||||
4. 《React设计模式与最佳实践》
|
||||
5. 《数据库系统概念》
|
||||
6. 《企业级应用架构设计》
|
||||
7. 《网络安全法》和《个人信息保护法》
|
||||
|
||||
### C. 修订历史
|
||||
|
||||
| 版本 | 日期 | 作者 | 说明 |
|
||||
|------|------|------|------|
|
||||
| v1.0 | 2026-01-28 | 架构设计团队 | 初始版本创建 |
|
||||
| v1.1 | 待更新 | 待更新 | 根据评审意见更新 |
|
||||
|
||||
---
|
||||
|
||||
## 文档审批
|
||||
|
||||
| 角色 | 姓名 | 部门 | 审批意见 | 签字 | 日期 |
|
||||
|------|------|------|----------|------|------|
|
||||
| 产品经理 | | 产品部 | | | |
|
||||
| 技术负责人 | | 技术部 | | | |
|
||||
| 项目经理 | | 项目部 | | | |
|
||||
| 客户代表 | | 客户方 | | | |
|
||||
|
||||
**文档状态**:✅ 已完成
|
||||
**下一步行动**:组织需求评审会议,确认需求细节和优先级
|
||||
5
scripts/create_user_check.ps1
Normal file
5
scripts/create_user_check.ps1
Normal file
@@ -0,0 +1,5 @@
|
||||
$login = Invoke-RestMethod -Method Post -Uri http://localhost:8080/api/v1/auth/login -Body (@{username='admin';password='admin123'}|ConvertTo-Json) -ContentType 'application/json'
|
||||
$token = $login.data.token
|
||||
$payload = @{username='u1';password='u1123';fullName='Test User';email='u1@example.com';phone='123456';role='EMPLOYEE'} | ConvertTo-Json
|
||||
$response = Invoke-RestMethod -Method Post -Uri http://localhost:8080/api/v1/users -Headers @{Authorization='Bearer '+$token} -Body $payload -ContentType 'application/json'
|
||||
$response | ConvertTo-Json -Compress
|
||||
324
scripts/mysql_init.sql
Normal file
324
scripts/mysql_init.sql
Normal file
@@ -0,0 +1,324 @@
|
||||
-- MySQL数据库初始化脚本
|
||||
-- 适用于智能办公管理系统
|
||||
-- 版本: 1.0
|
||||
-- 作者: 数据库迁移团队
|
||||
|
||||
-- 创建数据库
|
||||
CREATE DATABASE IF NOT EXISTS smart_office_auth CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE DATABASE IF NOT EXISTS smart_office_attendance CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE DATABASE IF NOT EXISTS smart_office_approval CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 创建用户并授权(根据实际情况调整)
|
||||
-- CREATE USER 'smartoffice'@'%' IDENTIFIED BY 'SmartOffice123';
|
||||
-- GRANT ALL PRIVILEGES ON smart_office_auth.* TO 'smartoffice'@'%';
|
||||
-- GRANT ALL PRIVILEGES ON smart_office_attendance.* TO 'smartoffice'@'%';
|
||||
-- GRANT ALL PRIVILEGES ON smart_office_approval.* TO 'smartoffice'@'%';
|
||||
-- FLUSH PRIVILEGES;
|
||||
|
||||
-- 使用认证数据库
|
||||
USE smart_office_auth;
|
||||
|
||||
-- 用户表 (sys_user)
|
||||
CREATE TABLE IF NOT EXISTS sys_user (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password VARCHAR(200) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
phone VARCHAR(20),
|
||||
nickname VARCHAR(50),
|
||||
real_name VARCHAR(50),
|
||||
avatar VARCHAR(500),
|
||||
gender INT DEFAULT 0,
|
||||
birthday DATETIME,
|
||||
dept_id BIGINT,
|
||||
position VARCHAR(100),
|
||||
status INT DEFAULT 1,
|
||||
last_login_time DATETIME,
|
||||
last_login_ip VARCHAR(50),
|
||||
login_count INT DEFAULT 0,
|
||||
failed_login_count INT DEFAULT 0,
|
||||
lock_time DATETIME,
|
||||
password_reset_time DATETIME,
|
||||
is_super_admin TINYINT(1) DEFAULT FALSE,
|
||||
remark VARCHAR(500),
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
create_by BIGINT,
|
||||
update_by BIGINT,
|
||||
deleted TINYINT(1) DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_username ON sys_user(username);
|
||||
CREATE INDEX idx_email ON sys_user(email);
|
||||
CREATE INDEX idx_phone ON sys_user(phone);
|
||||
CREATE INDEX idx_dept_id ON sys_user(dept_id);
|
||||
|
||||
-- 角色表 (sys_role)
|
||||
CREATE TABLE IF NOT EXISTS sys_role (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
role_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
role_name VARCHAR(50) NOT NULL,
|
||||
description VARCHAR(200),
|
||||
data_scope INT DEFAULT 1,
|
||||
status INT DEFAULT 1,
|
||||
sort INT DEFAULT 0,
|
||||
is_system TINYINT(1) DEFAULT FALSE,
|
||||
remark VARCHAR(500),
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
create_by BIGINT,
|
||||
update_by BIGINT,
|
||||
deleted TINYINT(1) DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_role_code ON sys_role(role_code);
|
||||
CREATE INDEX idx_role_name ON sys_role(role_name);
|
||||
|
||||
-- 权限表 (sys_permission)
|
||||
CREATE TABLE IF NOT EXISTS sys_permission (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
permission_code VARCHAR(100) NOT NULL UNIQUE,
|
||||
permission_name VARCHAR(50) NOT NULL,
|
||||
permission_type INT NOT NULL,
|
||||
parent_id BIGINT DEFAULT 0,
|
||||
path VARCHAR(200),
|
||||
component VARCHAR(200),
|
||||
icon VARCHAR(100),
|
||||
sort INT DEFAULT 0,
|
||||
status INT DEFAULT 1,
|
||||
is_visible TINYINT(1) DEFAULT TRUE,
|
||||
is_cache TINYINT(1) DEFAULT FALSE,
|
||||
is_external TINYINT(1) DEFAULT FALSE,
|
||||
external_url VARCHAR(500),
|
||||
request_method VARCHAR(20),
|
||||
api_path VARCHAR(500),
|
||||
remark VARCHAR(500),
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
create_by BIGINT,
|
||||
update_by BIGINT,
|
||||
deleted TINYINT(1) DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_permission_code ON sys_permission(permission_code);
|
||||
CREATE INDEX idx_parent_id ON sys_permission(parent_id);
|
||||
CREATE INDEX idx_sort ON sys_permission(sort);
|
||||
|
||||
-- 用户角色关联表
|
||||
CREATE TABLE IF NOT EXISTS sys_user_role (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
role_id BIGINT NOT NULL,
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_role (user_id, role_id)
|
||||
);
|
||||
|
||||
-- 角色权限关联表
|
||||
CREATE TABLE IF NOT EXISTS sys_role_permission (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
role_id BIGINT NOT NULL,
|
||||
permission_id BIGINT NOT NULL,
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_role_permission (role_id, permission_id)
|
||||
);
|
||||
|
||||
-- 部门表 (sys_department)
|
||||
CREATE TABLE IF NOT EXISTS sys_department (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
dept_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
dept_name VARCHAR(100) NOT NULL,
|
||||
parent_id BIGINT DEFAULT 0,
|
||||
ancestors VARCHAR(500),
|
||||
leader_id BIGINT,
|
||||
sort INT DEFAULT 0,
|
||||
status INT DEFAULT 1,
|
||||
phone VARCHAR(20),
|
||||
email VARCHAR(100),
|
||||
address VARCHAR(200),
|
||||
remark VARCHAR(500),
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
create_by BIGINT,
|
||||
update_by BIGINT,
|
||||
deleted TINYINT(1) DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dept_code ON sys_department(dept_code);
|
||||
CREATE INDEX idx_parent_id ON sys_department(parent_id);
|
||||
|
||||
-- 操作日志表 (sys_operation_log)
|
||||
CREATE TABLE IF NOT EXISTS sys_operation_log (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
username VARCHAR(50),
|
||||
real_name VARCHAR(50),
|
||||
operation_type VARCHAR(50),
|
||||
operation_module VARCHAR(100),
|
||||
operation_description VARCHAR(500),
|
||||
request_method VARCHAR(10),
|
||||
request_url VARCHAR(500),
|
||||
request_params TEXT,
|
||||
request_ip VARCHAR(50),
|
||||
request_location VARCHAR(100),
|
||||
user_agent VARCHAR(500),
|
||||
execute_time BIGINT,
|
||||
status INT DEFAULT 1,
|
||||
error_message TEXT,
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_time ON sys_operation_log(user_id, create_time);
|
||||
CREATE INDEX idx_module_time ON sys_operation_log(operation_module, create_time);
|
||||
|
||||
-- 使用考勤数据库
|
||||
USE smart_office_attendance;
|
||||
|
||||
-- 考勤记录表 (attendance_record)
|
||||
CREATE TABLE IF NOT EXISTS attendance_record (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
dept_id BIGINT,
|
||||
attendance_date DATE NOT NULL,
|
||||
check_in_time DATETIME,
|
||||
check_out_time DATETIME,
|
||||
work_hours DECIMAL(5,2),
|
||||
late_minutes INT DEFAULT 0,
|
||||
early_minutes INT DEFAULT 0,
|
||||
overtime_hours DECIMAL(5,2) DEFAULT 0,
|
||||
attendance_status INT DEFAULT 0,
|
||||
location VARCHAR(200),
|
||||
latitude DECIMAL(10,8),
|
||||
longitude DECIMAL(11,8),
|
||||
device_info VARCHAR(200),
|
||||
ip_address VARCHAR(50),
|
||||
remark VARCHAR(500),
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_date ON attendance_record(user_id, attendance_date);
|
||||
CREATE INDEX idx_dept_date ON attendance_record(dept_id, attendance_date);
|
||||
|
||||
-- 请假申请表 (leave_request)
|
||||
CREATE TABLE IF NOT EXISTS leave_request (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
leave_type INT NOT NULL,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
duration_days DECIMAL(5,2),
|
||||
reason VARCHAR(500),
|
||||
attachment_url VARCHAR(500),
|
||||
status INT DEFAULT 0,
|
||||
approval_flow_id BIGINT,
|
||||
current_approver_id BIGINT,
|
||||
remark VARCHAR(500),
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_status ON leave_request(user_id, status);
|
||||
CREATE INDEX idx_approval_flow ON leave_request(approval_flow_id);
|
||||
|
||||
-- 加班申请表 (overtime_request)
|
||||
CREATE TABLE IF NOT EXISTS overtime_request (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
overtime_date DATE NOT NULL,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
duration_hours DECIMAL(5,2),
|
||||
reason VARCHAR(500),
|
||||
compensation_type INT DEFAULT 1,
|
||||
status INT DEFAULT 0,
|
||||
approval_flow_id BIGINT,
|
||||
current_approver_id BIGINT,
|
||||
remark VARCHAR(500),
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_status_ot ON overtime_request(user_id, status);
|
||||
CREATE INDEX idx_overtime_date ON overtime_request(overtime_date);
|
||||
|
||||
-- 使用审批数据库
|
||||
USE smart_office_approval;
|
||||
|
||||
-- 工作流定义表 (workflow_definition)
|
||||
CREATE TABLE IF NOT EXISTS workflow_definition (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
workflow_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
workflow_name VARCHAR(100) NOT NULL,
|
||||
workflow_type INT NOT NULL,
|
||||
description VARCHAR(500),
|
||||
version INT DEFAULT 1,
|
||||
status INT DEFAULT 1,
|
||||
form_schema JSON,
|
||||
process_definition JSON,
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
create_by BIGINT,
|
||||
update_by BIGINT
|
||||
);
|
||||
|
||||
-- 工作流实例表 (workflow_instance)
|
||||
CREATE TABLE IF NOT EXISTS workflow_instance (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
workflow_definition_id BIGINT NOT NULL,
|
||||
business_key VARCHAR(100),
|
||||
business_type VARCHAR(50),
|
||||
business_data JSON,
|
||||
initiator_id BIGINT NOT NULL,
|
||||
current_node_id BIGINT,
|
||||
current_assignee_id BIGINT,
|
||||
status INT DEFAULT 0,
|
||||
start_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
end_time DATETIME,
|
||||
duration_days INT,
|
||||
remark VARCHAR(500),
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_business_key ON workflow_instance(business_key);
|
||||
CREATE INDEX idx_initiator_status ON workflow_instance(initiator_id, status);
|
||||
|
||||
-- 审批节点表 (approval_node)
|
||||
CREATE TABLE IF NOT EXISTS approval_node (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
workflow_instance_id BIGINT NOT NULL,
|
||||
node_code VARCHAR(50) NOT NULL,
|
||||
node_name VARCHAR(100) NOT NULL,
|
||||
node_type INT NOT NULL,
|
||||
assignee_type INT NOT NULL,
|
||||
assignee_ids JSON,
|
||||
status INT DEFAULT 0,
|
||||
start_time DATETIME,
|
||||
end_time DATETIME,
|
||||
duration_hours DECIMAL(5,2),
|
||||
comment VARCHAR(500),
|
||||
attachment_url VARCHAR(500),
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 插入初始数据(可选)
|
||||
-- 插入超级管理员角色
|
||||
USE smart_office_auth;
|
||||
INSERT IGNORE INTO sys_role (role_code, role_name, description, data_scope, status, sort, is_system, remark)
|
||||
VALUES ('SUPER_ADMIN', '超级管理员', '系统超级管理员', 1, 1, 0, TRUE, '系统内置角色');
|
||||
|
||||
-- 插入默认权限(示例)
|
||||
INSERT IGNORE INTO sys_permission (permission_code, permission_name, permission_type, parent_id, path, component, icon, sort, status, is_visible, is_cache, is_external, external_url, request_method, api_path, remark)
|
||||
VALUES ('SYSTEM_MANAGE', '系统管理', 0, 0, '/system', 'Layout', 'system', 0, 1, TRUE, FALSE, FALSE, NULL, NULL, NULL, '系统管理目录');
|
||||
|
||||
-- 插入默认用户(密码为加密后的"admin123")
|
||||
INSERT IGNORE INTO sys_user (username, password, email, phone, nickname, real_name, avatar, gender, dept_id, position, status, is_super_admin, remark)
|
||||
VALUES ('admin', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', 'admin@smartoffice.com', '13800138000', '管理员', '系统管理员', '', 1, NULL, '系统管理员', 1, TRUE, '默认超级管理员');
|
||||
|
||||
-- 关联用户角色
|
||||
INSERT IGNORE INTO sys_user_role (user_id, role_id)
|
||||
SELECT u.id, r.id FROM sys_user u, sys_role r WHERE u.username = 'admin' AND r.role_code = 'SUPER_ADMIN';
|
||||
|
||||
-- 完成
|
||||
SELECT 'MySQL数据库初始化完成' AS message;
|
||||
Reference in New Issue
Block a user