Compare commits
45 Commits
eb856b1d73
...
ff352a4c24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff352a4c24 | ||
|
|
7e8fabb095 | ||
|
|
d8a0ac7519 | ||
|
|
e44d6063be | ||
|
|
ee65b6e3c9 | ||
|
|
9568dd7875 | ||
|
|
e16c1a85d7 | ||
|
|
88f6516fa9 | ||
|
|
9557c9ca16 | ||
|
|
3b41e73f82 | ||
|
|
14f431efff | ||
|
|
685df5e458 | ||
|
|
529d90ff46 | ||
|
|
db2cd24259 | ||
|
|
5d6e1dc394 | ||
|
|
1fec5e2cf2 | ||
|
|
6a08b99ed8 | ||
|
|
96a4287272 | ||
|
|
d8c3aba5d6 | ||
|
|
c02fcecbfc | ||
|
|
4bfd9573db | ||
|
|
0d7d3af0a8 | ||
|
|
f29f6d76ee | ||
|
|
97d3c9026b | ||
|
|
184034ff6b | ||
|
|
82986e988d | ||
|
|
b3c7f76b7f | ||
|
|
3a05523d23 | ||
|
|
5c899e6f4a | ||
|
|
bddd33ac2f | ||
|
|
c0523e19b4 | ||
|
|
5ceed71e62 | ||
|
|
91ecaa3ed7 | ||
|
|
0cbd08eb78 | ||
|
|
0baaf5f7ee | ||
|
|
8a012f6c6a | ||
|
|
6fd0288e7c | ||
|
|
4a03a639a6 | ||
|
|
a7cdf67d17 | ||
|
|
3afd732de8 | ||
|
|
edc41a1500 | ||
|
|
411a07caa1 | ||
|
|
d98e0d383c | ||
|
|
810eef769f | ||
|
|
5901ee82f0 |
49
CLAUDE.md
49
CLAUDE.md
@@ -1,3 +1,6 @@
|
||||
@wiki/index.md
|
||||
整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。
|
||||
|
||||
# ERP 平台底座 — 协作与实现规则
|
||||
|
||||
> **ERP Platform Base** 是一个模块化的商业 SaaS ERP 底座,目标是提供核心基础设施(身份权限、工作流、消息、配置),使行业业务模块(进销存、生产、财务等)可以快速插接。
|
||||
@@ -369,6 +372,17 @@ cd apps/web && pnpm build # 构建生产版本
|
||||
|
||||
# === 数据库 ===
|
||||
docker exec -it erp-postgres psql -U erp # 连接数据库
|
||||
|
||||
# === WASM 插件 ===
|
||||
cargo build -p erp-plugin-test-sample --target wasm32-unknown-unknown --release # 编译测试插件
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_test_sample.wasm -o target/erp_plugin_test_sample.component.wasm # 转为 Component
|
||||
cargo test -p erp-plugin-prototype # 运行插件集成测试
|
||||
|
||||
# === 一键启动 (PowerShell) ===
|
||||
.\dev.ps1 # 启动前后端(自动清理端口占用)
|
||||
.\dev.ps1 -Stop # 停止前后端
|
||||
.\dev.ps1 -Restart # 重启前后端
|
||||
.\dev.ps1 -Status # 查看端口状态
|
||||
```
|
||||
|
||||
---
|
||||
@@ -399,6 +413,7 @@ docker exec -it erp-postgres psql -U erp # 连接数据库
|
||||
| `message` | erp-message |
|
||||
| `config` | erp-config |
|
||||
| `server` | erp-server |
|
||||
| `plugin` | erp-plugin-prototype / erp-plugin-test-sample |
|
||||
| `web` | Web 前端 |
|
||||
| `ui` | React 组件 |
|
||||
| `db` | 数据库迁移 |
|
||||
@@ -422,6 +437,8 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
|------|------|
|
||||
| `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` | 平台底座设计规格 |
|
||||
| `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` | 平台底座实施计划 |
|
||||
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | WASM 插件系统设计规格 |
|
||||
| `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` | WASM 插件原型验证计划 |
|
||||
|
||||
所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。
|
||||
|
||||
@@ -436,24 +453,29 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
|
||||
| Phase | 内容 | 状态 |
|
||||
|-------|------|------|
|
||||
| Phase 1 | 基础设施 (workspace + core + Docker + 桌面端) | 🚧 进行中 |
|
||||
| Phase 2 | 身份与权限 (Auth) | ⏳ 待开始 |
|
||||
| Phase 3 | 系统配置 (Config) | ⏳ 待开始 |
|
||||
| Phase 4 | 工作流引擎 (Workflow) | ⏳ 待开始 |
|
||||
| Phase 5 | 消息中心 (Message) | ⏳ 待开始 |
|
||||
| Phase 6 | 整合与打磨 | ⏳ 待开始 |
|
||||
| Phase 1 | 基础设施 (workspace + core + Docker + 桌面端) | ✅ 完成 |
|
||||
| Phase 2 | 身份与权限 (Auth) | ✅ 完成 |
|
||||
| Phase 3 | 系统配置 (Config) | ✅ 完成 |
|
||||
| Phase 4 | 工作流引擎 (Workflow) | ✅ 完成 |
|
||||
| Phase 5 | 消息中心 (Message) | ✅ 完成 |
|
||||
| Phase 6 | 整合与打磨 | ✅ 完成 |
|
||||
| - | WASM 插件原型 (V1-V6) | ✅ 验证通过 |
|
||||
| - | 插件系统集成到主服务 | ✅ 已集成 |
|
||||
|
||||
### 已实现模块
|
||||
|
||||
| Crate | 功能 | 状态 |
|
||||
|-------|------|------|
|
||||
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait | 🚧 进行中 |
|
||||
| erp-common | 共享工具 | 🚧 进行中 |
|
||||
| erp-server | Axum 服务入口、配置、数据库连接 | 🚧 进行中 |
|
||||
| erp-auth | 身份与权限 | ⏳ 待开始 |
|
||||
| erp-workflow | 工作流引擎 | ⏳ 待开始 |
|
||||
| erp-message | 消息中心 | ⏳ 待开始 |
|
||||
| erp-config | 系统配置 | ⏳ 待开始 |
|
||||
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait、审计日志 | ✅ 完成 |
|
||||
| erp-common | 共享工具 | ✅ 完成 |
|
||||
| erp-server | Axum 服务入口、配置、数据库连接、CORS | ✅ 完成 |
|
||||
| erp-auth | 身份与权限 (用户/角色/权限/组织/部门/岗位) | ✅ 完成 |
|
||||
| erp-workflow | 工作流引擎 (BPMN 解析/Token 驱动/任务分配) | ✅ 完成 |
|
||||
| erp-message | 消息中心 (CRUD/模板/订阅/通知面板) | ✅ 完成 |
|
||||
| erp-config | 系统配置 (字典/菜单/设置/编号规则/主题) | ✅ 完成 |
|
||||
| erp-plugin | 插件管理 (WASM 运行时/生命周期/动态表/数据CRUD) | ✅ 已集成 |
|
||||
| erp-plugin-prototype | WASM 插件 Host 运行时 (Wasmtime + bindgen + Host API) | ✅ 原型验证 |
|
||||
| erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 |
|
||||
|
||||
<!-- ARCH-SNAPSHOT-END -->
|
||||
|
||||
@@ -481,5 +503,6 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- 当遇到**新增 API** → 添加 utoipa 注解,确保 OpenAPI 文档同步
|
||||
- 当遇到**新增表** → 创建 SeaORM migration + Entity,包含所有标准字段
|
||||
- 当遇到**新增页面** → 使用 Ant Design 组件,i18n key 引用文案
|
||||
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component
|
||||
|
||||
<!-- ANTI-PATTERN-END -->
|
||||
|
||||
2213
Cargo.lock
generated
2213
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@@ -2,12 +2,15 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/erp-core",
|
||||
"crates/erp-common",
|
||||
"crates/erp-server",
|
||||
"crates/erp-auth",
|
||||
"crates/erp-workflow",
|
||||
"crates/erp-message",
|
||||
"crates/erp-config",
|
||||
"crates/erp-server/migration",
|
||||
"crates/erp-plugin-prototype",
|
||||
"crates/erp-plugin-test-sample",
|
||||
"crates/erp-plugin",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -20,7 +23,7 @@ license = "MIT"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Web
|
||||
axum = "0.8"
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
|
||||
|
||||
@@ -58,17 +61,24 @@ jsonwebtoken = "9"
|
||||
# Password hashing
|
||||
argon2 = "0.5"
|
||||
|
||||
# Cryptographic hashing (token storage)
|
||||
sha2 = "0.10"
|
||||
|
||||
# API docs
|
||||
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-swagger-ui = { version = "8", features = ["axum"] }
|
||||
# utoipa-swagger-ui 需要下载 GitHub 资源,网络受限时暂不使用
|
||||
# utoipa-swagger-ui = { version = "8", features = ["axum"] }
|
||||
|
||||
# Validation
|
||||
validator = { version = "0.19", features = ["derive"] }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
|
||||
# Internal crates
|
||||
erp-core = { path = "crates/erp-core" }
|
||||
erp-common = { path = "crates/erp-common" }
|
||||
erp-auth = { path = "crates/erp-auth" }
|
||||
erp-workflow = { path = "crates/erp-workflow" }
|
||||
erp-message = { path = "crates/erp-message" }
|
||||
erp-config = { path = "crates/erp-config" }
|
||||
erp-plugin = { path = "crates/erp-plugin" }
|
||||
|
||||
64
README.md
Normal file
64
README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# ERP Platform Base
|
||||
|
||||
模块化商业 SaaS ERP 平台底座。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | Rust (Axum 0.8 + SeaORM + Tokio) |
|
||||
| 数据库 | PostgreSQL 16+ |
|
||||
| 缓存 | Redis 7+ |
|
||||
| 前端 | Vite + React 18 + TypeScript + Ant Design 5 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
erp/
|
||||
├── crates/
|
||||
│ ├── erp-core/ # 基础类型、错误、事件总线、模块 trait
|
||||
│ ├── erp-common/ # 共享工具
|
||||
│ ├── erp-auth/ # 身份与权限 (Phase 2)
|
||||
│ ├── erp-workflow/ # 工作流引擎 (Phase 4)
|
||||
│ ├── erp-message/ # 消息中心 (Phase 5)
|
||||
│ ├── erp-config/ # 系统配置 (Phase 3)
|
||||
│ └── erp-server/ # Axum 服务入口
|
||||
│ └── migration/ # SeaORM 数据库迁移
|
||||
├── apps/web/ # React SPA 前端
|
||||
├── docker/ # Docker 开发环境
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启动基础设施
|
||||
|
||||
```bash
|
||||
cd docker && docker compose up -d
|
||||
```
|
||||
|
||||
### 2. 启动后端
|
||||
|
||||
```bash
|
||||
cargo run -p erp-server
|
||||
```
|
||||
|
||||
### 3. 启动前端
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm install && pnpm dev
|
||||
```
|
||||
|
||||
### 4. 访问
|
||||
|
||||
- 前端: http://localhost:5173
|
||||
- 后端 API: http://localhost:3000
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
cargo check # 编译检查
|
||||
cargo test --workspace # 运行测试
|
||||
cargo run -p erp-server # 启动后端
|
||||
cd apps/web && pnpm dev # 启动前端
|
||||
```
|
||||
24
apps/web/.gitignore
vendored
Normal file
24
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
apps/web/README.md
Normal file
73
apps/web/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
apps/web/eslint.config.js
Normal file
23
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
16
apps/web/index.html
Normal file
16
apps/web/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="ERP 平台底座 — 模块化 SaaS 企业资源管理系统,提供身份权限、工作流引擎、消息中心、系统配置等核心基础设施" />
|
||||
<meta name="theme-color" content="#4F46E5" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>ERP Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
apps/web/package.json
Normal file
38
apps/web/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"antd": "^6.3.5",
|
||||
"axios": "^1.15.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
3362
apps/web/pnpm-lock.yaml
generated
Normal file
3362
apps/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/web/public/favicon.svg
Normal file
1
apps/web/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
apps/web/public/icons.svg
Normal file
24
apps/web/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
2
apps/web/public/robots.txt
Normal file
2
apps/web/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
153
apps/web/src/App.tsx
Normal file
153
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, lazy, Suspense } from 'react';
|
||||
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ConfigProvider, theme as antdTheme, Spin } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import Login from './pages/Login';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useAppStore } from './stores/app';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Users = lazy(() => import('./pages/Users'));
|
||||
const Roles = lazy(() => import('./pages/Roles'));
|
||||
const Organizations = lazy(() => import('./pages/Organizations'));
|
||||
const Workflow = lazy(() => import('./pages/Workflow'));
|
||||
const Messages = lazy(() => import('./pages/Messages'));
|
||||
const Settings = lazy(() => import('./pages/Settings'));
|
||||
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
|
||||
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const themeConfig = {
|
||||
token: {
|
||||
colorPrimary: '#4F46E5',
|
||||
colorSuccess: '#059669',
|
||||
colorWarning: '#D97706',
|
||||
colorError: '#DC2626',
|
||||
colorInfo: '#2563EB',
|
||||
colorBgLayout: '#F1F5F9',
|
||||
colorBgContainer: '#FFFFFF',
|
||||
colorBgElevated: '#FFFFFF',
|
||||
colorBorder: '#E2E8F0',
|
||||
colorBorderSecondary: '#F1F5F9',
|
||||
borderRadius: 8,
|
||||
borderRadiusLG: 12,
|
||||
borderRadiusSM: 6,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', 'Hiragino Sans GB', 'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontSize: 14,
|
||||
fontSizeHeading4: 20,
|
||||
controlHeight: 36,
|
||||
controlHeightLG: 40,
|
||||
controlHeightSM: 28,
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06)',
|
||||
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.07)',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
primaryShadow: '0 1px 2px 0 rgba(79, 70, 229, 0.3)',
|
||||
fontWeight: 500,
|
||||
},
|
||||
Card: {
|
||||
paddingLG: 20,
|
||||
},
|
||||
Table: {
|
||||
headerBg: '#F8FAFC',
|
||||
headerColor: '#475569',
|
||||
rowHoverBg: '#F5F3FF',
|
||||
fontSize: 14,
|
||||
},
|
||||
Menu: {
|
||||
itemBorderRadius: 8,
|
||||
itemMarginInline: 8,
|
||||
itemHeight: 40,
|
||||
},
|
||||
Modal: {
|
||||
borderRadiusLG: 16,
|
||||
},
|
||||
Tag: {
|
||||
borderRadiusSM: 6,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const darkThemeConfig = {
|
||||
...themeConfig,
|
||||
token: {
|
||||
...themeConfig.token,
|
||||
colorBgLayout: '#0B0F1A',
|
||||
colorBgContainer: '#111827',
|
||||
colorBgElevated: '#1E293B',
|
||||
colorBorder: '#1E293B',
|
||||
colorBorderSecondary: '#1E293B',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.3)',
|
||||
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
components: {
|
||||
...themeConfig.components,
|
||||
Table: {
|
||||
headerBg: '#1E293B',
|
||||
headerColor: '#94A3B8',
|
||||
rowHoverBg: '#1E293B',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
|
||||
const themeMode = useAppStore((s) => s.theme);
|
||||
|
||||
useEffect(() => {
|
||||
loadFromStorage();
|
||||
}, [loadFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', themeMode);
|
||||
}, [themeMode]);
|
||||
|
||||
const isDark = themeMode === 'dark';
|
||||
|
||||
return (
|
||||
<>
|
||||
<a href="#root" className="erp-skip-link">跳转到主要内容</a>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
...isDark ? darkThemeConfig : themeConfig,
|
||||
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
}}
|
||||
>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}><Spin size="large" /></div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/roles" element={<Roles />} />
|
||||
<Route path="/organizations" element={<Organizations />} />
|
||||
<Route path="/workflow" element={<Workflow />} />
|
||||
<Route path="/messages" element={<Messages />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/plugins/admin" element={<PluginAdmin />} />
|
||||
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</ConfigProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
apps/web/src/api/auditLogs.ts
Normal file
31
apps/web/src/api/auditLogs.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface AuditLogItem {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
user_id: string;
|
||||
old_value?: string;
|
||||
new_value?: string;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLogQuery {
|
||||
resource_type?: string;
|
||||
user_id?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export async function listAuditLogs(query: AuditLogQuery = {}) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<AuditLogItem> }>(
|
||||
'/audit-logs',
|
||||
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
63
apps/web/src/api/auth.ts
Normal file
63
apps/web/src/api/auth.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import client from './client';
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
status: string;
|
||||
roles: RoleInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface RoleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
export async function login(req: LoginRequest): Promise<LoginResponse> {
|
||||
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
|
||||
'/auth/login',
|
||||
req
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function refresh(refreshToken: string): Promise<LoginResponse> {
|
||||
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
|
||||
'/auth/refresh',
|
||||
{ refresh_token: refreshToken }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await client.post('/auth/logout');
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<void> {
|
||||
await client.post('/auth/change-password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
}
|
||||
129
apps/web/src/api/client.ts
Normal file
129
apps/web/src/api/client.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// 请求缓存:短时间内相同请求复用结果
|
||||
interface CacheEntry {
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const requestCache = new Map<string, CacheEntry>();
|
||||
const CACHE_TTL = 5000; // 5 秒缓存
|
||||
|
||||
function getCacheKey(config: { url?: string; params?: unknown; method?: string }): string {
|
||||
return `${config.method || 'get'}:${config.url || ''}:${JSON.stringify(config.params || {})}`;
|
||||
}
|
||||
|
||||
// Request interceptor: attach access token + cache
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// GET 请求检查缓存
|
||||
if (config.method === 'get' && config.url) {
|
||||
const key = getCacheKey(config);
|
||||
const entry = requestCache.get(key);
|
||||
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
|
||||
const source = axios.CancelToken.source();
|
||||
config.cancelToken = source.token;
|
||||
// 通过适配器返回缓存数据
|
||||
source.cancel(JSON.stringify({ __cached: true, data: entry.data }));
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器:缓存 GET 响应 + 自动刷新 token
|
||||
client.interceptors.response.use(
|
||||
(response) => {
|
||||
// 缓存 GET 响应
|
||||
if (response.config.method === 'get' && response.config.url) {
|
||||
const key = getCacheKey(response.config);
|
||||
requestCache.set(key, { data: response.data, timestamp: Date.now() });
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
// 处理缓存命中
|
||||
if (axios.isCancel(error)) {
|
||||
const cached = JSON.parse(error.message || '{}');
|
||||
if (cached.__cached) {
|
||||
return { data: cached.data, status: 200, statusText: 'OK (cached)', headers: {}, config: {} };
|
||||
}
|
||||
}
|
||||
|
||||
const originalRequest = error.config;
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return client(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!refreshToken) throw new Error('No refresh token');
|
||||
|
||||
const { data } = await axios.post('/api/v1/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const newAccessToken = data.data.access_token;
|
||||
const newRefreshToken = data.data.refresh_token;
|
||||
|
||||
localStorage.setItem('access_token', newAccessToken);
|
||||
localStorage.setItem('refresh_token', newRefreshToken);
|
||||
|
||||
processQueue(null, newAccessToken);
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return client(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.hash = '#/login';
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
function processQueue(error: unknown, token: string | null) {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (token) resolve(token);
|
||||
else reject(error);
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
// 清除缓存(登录/登出时调用)
|
||||
export function clearApiCache() {
|
||||
requestCache.clear();
|
||||
}
|
||||
|
||||
export default client;
|
||||
107
apps/web/src/api/dictionaries.ts
Normal file
107
apps/web/src/api/dictionaries.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface DictionaryItemInfo {
|
||||
id: string;
|
||||
dictionary_id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
sort_order: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface DictionaryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
items: DictionaryItemInfo[];
|
||||
}
|
||||
|
||||
export interface CreateDictionaryRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDictionaryRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function listDictionaries(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<DictionaryInfo> }>(
|
||||
'/config/dictionaries',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createDictionary(req: CreateDictionaryRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: DictionaryInfo }>(
|
||||
'/config/dictionaries',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateDictionary(id: string, req: UpdateDictionaryRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: DictionaryInfo }>(
|
||||
`/config/dictionaries/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteDictionary(id: string) {
|
||||
await client.delete(`/config/dictionaries/${id}`);
|
||||
}
|
||||
|
||||
export async function listItemsByCode(code: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: DictionaryItemInfo[] }>(
|
||||
'/config/dictionaries/items',
|
||||
{ params: { code } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface CreateDictionaryItemRequest {
|
||||
label: string;
|
||||
value: string;
|
||||
sort_order?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDictionaryItemRequest {
|
||||
label?: string;
|
||||
value?: string;
|
||||
sort_order?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export async function createDictionaryItem(
|
||||
dictionaryId: string,
|
||||
req: CreateDictionaryItemRequest,
|
||||
) {
|
||||
const { data } = await client.post<{ success: boolean; data: DictionaryItemInfo }>(
|
||||
`/config/dictionaries/${dictionaryId}/items`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateDictionaryItem(
|
||||
dictionaryId: string,
|
||||
itemId: string,
|
||||
req: UpdateDictionaryItemRequest,
|
||||
) {
|
||||
const { data } = await client.put<{ success: boolean; data: DictionaryItemInfo }>(
|
||||
`/config/dictionaries/${dictionaryId}/items/${itemId}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteDictionaryItem(dictionaryId: string, itemId: string) {
|
||||
await client.delete(`/config/dictionaries/${dictionaryId}/items/${itemId}`);
|
||||
}
|
||||
36
apps/web/src/api/languages.ts
Normal file
36
apps/web/src/api/languages.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import client from './client';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface LanguageInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
translations?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UpdateLanguageRequest {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
translations?: Record<string, string>;
|
||||
}
|
||||
|
||||
// --- API Functions ---
|
||||
|
||||
export async function listLanguages(): Promise<LanguageInfo[]> {
|
||||
const { data } = await client.get<{ success: boolean; data: LanguageInfo[] }>(
|
||||
'/config/languages',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateLanguage(
|
||||
code: string,
|
||||
req: UpdateLanguageRequest,
|
||||
): Promise<LanguageInfo> {
|
||||
const { data } = await client.put<{ success: boolean; data: LanguageInfo }>(
|
||||
`/config/languages/${code}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
56
apps/web/src/api/menus.ts
Normal file
56
apps/web/src/api/menus.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import client from './client';
|
||||
|
||||
export interface MenuInfo {
|
||||
id: string;
|
||||
parent_id?: string;
|
||||
title: string;
|
||||
path?: string;
|
||||
icon?: string;
|
||||
sort_order: number;
|
||||
visible: boolean;
|
||||
menu_type: string;
|
||||
permission?: string;
|
||||
children: MenuInfo[];
|
||||
}
|
||||
|
||||
export interface MenuItemReq {
|
||||
id?: string;
|
||||
parent_id?: string;
|
||||
title: string;
|
||||
path?: string;
|
||||
icon?: string;
|
||||
sort_order?: number;
|
||||
visible?: boolean;
|
||||
menu_type?: string;
|
||||
permission?: string;
|
||||
role_ids?: string[];
|
||||
}
|
||||
|
||||
export async function getMenus() {
|
||||
const { data } = await client.get<{ success: boolean; data: MenuInfo[] }>('/config/menus');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function batchSaveMenus(menus: MenuItemReq[]) {
|
||||
await client.put('/config/menus', { menus });
|
||||
}
|
||||
|
||||
export async function createMenu(req: MenuItemReq) {
|
||||
const { data } = await client.post<{ success: boolean; data: MenuInfo }>(
|
||||
'/config/menus',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateMenu(id: string, req: MenuItemReq) {
|
||||
const { data } = await client.put<{ success: boolean; data: MenuInfo }>(
|
||||
`/config/menus/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteMenu(id: string) {
|
||||
await client.delete(`/config/menus/${id}`);
|
||||
}
|
||||
40
apps/web/src/api/messageTemplates.ts
Normal file
40
apps/web/src/api/messageTemplates.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface MessageTemplateInfo {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
channel: string;
|
||||
title_template: string;
|
||||
body_template: string;
|
||||
language: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTemplateRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
channel?: string;
|
||||
title_template: string;
|
||||
body_template: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export async function listTemplates(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MessageTemplateInfo> }>(
|
||||
'/message-templates',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createTemplate(req: CreateTemplateRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: MessageTemplateInfo }>(
|
||||
'/message-templates',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
88
apps/web/src/api/messages.ts
Normal file
88
apps/web/src/api/messages.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface MessageInfo {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
template_id?: string;
|
||||
sender_id?: string;
|
||||
sender_type: string;
|
||||
recipient_id: string;
|
||||
recipient_type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
priority: string;
|
||||
business_type?: string;
|
||||
business_id?: string;
|
||||
is_read: boolean;
|
||||
read_at?: string;
|
||||
is_archived: boolean;
|
||||
status: string;
|
||||
sent_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
title: string;
|
||||
body: string;
|
||||
recipient_id: string;
|
||||
recipient_type?: string;
|
||||
priority?: string;
|
||||
template_id?: string;
|
||||
business_type?: string;
|
||||
business_id?: string;
|
||||
}
|
||||
|
||||
export interface MessageQuery {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
is_read?: boolean;
|
||||
priority?: string;
|
||||
business_type?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export async function listMessages(query: MessageQuery = {}) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MessageInfo> }>(
|
||||
'/messages',
|
||||
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getUnreadCount() {
|
||||
const { data } = await client.get<{ success: boolean; data: { count: number } }>(
|
||||
'/messages/unread-count',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function markRead(id: string) {
|
||||
const { data } = await client.put<{ success: boolean }>(
|
||||
`/messages/${id}/read`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function markAllRead() {
|
||||
const { data } = await client.put<{ success: boolean }>(
|
||||
'/messages/read-all',
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteMessage(id: string) {
|
||||
const { data } = await client.delete<{ success: boolean }>(
|
||||
`/messages/${id}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendMessage(req: SendMessageRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: MessageInfo }>(
|
||||
'/messages',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
71
apps/web/src/api/numberingRules.ts
Normal file
71
apps/web/src/api/numberingRules.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface NumberingRuleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
prefix: string;
|
||||
date_format?: string;
|
||||
seq_length: number;
|
||||
seq_start: number;
|
||||
seq_current: number;
|
||||
separator: string;
|
||||
reset_cycle: string;
|
||||
last_reset_date?: string;
|
||||
}
|
||||
|
||||
export interface CreateNumberingRuleRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
prefix?: string;
|
||||
date_format?: string;
|
||||
seq_length?: number;
|
||||
seq_start?: number;
|
||||
separator?: string;
|
||||
reset_cycle?: string;
|
||||
}
|
||||
|
||||
export interface UpdateNumberingRuleRequest {
|
||||
name?: string;
|
||||
prefix?: string;
|
||||
date_format?: string;
|
||||
seq_length?: number;
|
||||
separator?: string;
|
||||
reset_cycle?: string;
|
||||
}
|
||||
|
||||
export async function listNumberingRules(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<NumberingRuleInfo> }>(
|
||||
'/config/numbering-rules',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createNumberingRule(req: CreateNumberingRuleRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: NumberingRuleInfo }>(
|
||||
'/config/numbering-rules',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateNumberingRule(id: string, req: UpdateNumberingRuleRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: NumberingRuleInfo }>(
|
||||
`/config/numbering-rules/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function generateNumber(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: { number: string } }>(
|
||||
`/config/numbering-rules/${id}/generate`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteNumberingRule(id: string) {
|
||||
await client.delete(`/config/numbering-rules/${id}`);
|
||||
}
|
||||
174
apps/web/src/api/orgs.ts
Normal file
174
apps/web/src/api/orgs.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import client from './client';
|
||||
|
||||
// --- Organization types ---
|
||||
|
||||
export interface OrganizationInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
path?: string;
|
||||
level: number;
|
||||
sort_order: number;
|
||||
children: OrganizationInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateOrganizationRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateOrganizationRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
sort_order?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- Department types ---
|
||||
|
||||
export interface DepartmentInfo {
|
||||
id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
manager_id?: string;
|
||||
path?: string;
|
||||
sort_order: number;
|
||||
children: DepartmentInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateDepartmentRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
manager_id?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateDepartmentRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
manager_id?: string;
|
||||
sort_order?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- Position types ---
|
||||
|
||||
export interface PositionInfo {
|
||||
id: string;
|
||||
dept_id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
level: number;
|
||||
sort_order: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreatePositionRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
level?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdatePositionRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
level?: number;
|
||||
sort_order?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- Organization API ---
|
||||
|
||||
export async function listOrgTree() {
|
||||
const { data } = await client.get<{ success: boolean; data: OrganizationInfo[] }>(
|
||||
'/organizations',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createOrg(req: CreateOrganizationRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: OrganizationInfo }>(
|
||||
'/organizations',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateOrg(id: string, req: UpdateOrganizationRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: OrganizationInfo }>(
|
||||
`/organizations/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteOrg(id: string) {
|
||||
await client.delete(`/organizations/${id}`);
|
||||
}
|
||||
|
||||
// --- Department API ---
|
||||
|
||||
export async function listDeptTree(orgId: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: DepartmentInfo[] }>(
|
||||
`/organizations/${orgId}/departments`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createDept(orgId: string, req: CreateDepartmentRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: DepartmentInfo }>(
|
||||
`/organizations/${orgId}/departments`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteDept(id: string) {
|
||||
await client.delete(`/departments/${id}`);
|
||||
}
|
||||
|
||||
export async function updateDept(id: string, req: UpdateDepartmentRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: DepartmentInfo }>(
|
||||
`/departments/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// --- Position API ---
|
||||
|
||||
export async function listPositions(deptId: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PositionInfo[] }>(
|
||||
`/departments/${deptId}/positions`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createPosition(deptId: string, req: CreatePositionRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: PositionInfo }>(
|
||||
`/departments/${deptId}/positions`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deletePosition(id: string) {
|
||||
await client.delete(`/positions/${id}`);
|
||||
}
|
||||
|
||||
export async function updatePosition(id: string, req: UpdatePositionRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: PositionInfo }>(
|
||||
`/positions/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
71
apps/web/src/api/pluginData.ts
Normal file
71
apps/web/src/api/pluginData.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import client from './client';
|
||||
|
||||
export interface PluginDataRecord {
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
interface PaginatedDataResponse {
|
||||
data: PluginDataRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export async function listPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedDataResponse }>(
|
||||
`/plugins/${pluginId}/${entity}`,
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getPluginData(pluginId: string, entity: string, id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PluginDataRecord }>(
|
||||
`/plugins/${pluginId}/${entity}/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
recordData: Record<string, unknown>,
|
||||
) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginDataRecord }>(
|
||||
`/plugins/${pluginId}/${entity}`,
|
||||
{ data: recordData },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updatePluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
id: string,
|
||||
recordData: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
const { data } = await client.put<{ success: boolean; data: PluginDataRecord }>(
|
||||
`/plugins/${pluginId}/${entity}/${id}`,
|
||||
{ data: recordData, version },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deletePluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
id: string,
|
||||
) {
|
||||
await client.delete(`/plugins/${pluginId}/${entity}/${id}`);
|
||||
}
|
||||
121
apps/web/src/api/plugins.ts
Normal file
121
apps/web/src/api/plugins.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import client from './client';
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface PluginEntityInfo {
|
||||
name: string;
|
||||
display_name: string;
|
||||
table_name: string;
|
||||
}
|
||||
|
||||
export interface PluginPermissionInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type PluginStatus = 'uploaded' | 'installed' | 'enabled' | 'running' | 'disabled' | 'uninstalled';
|
||||
|
||||
export interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
status: PluginStatus;
|
||||
config: Record<string, unknown>;
|
||||
installed_at?: string;
|
||||
enabled_at?: string;
|
||||
entities: PluginEntityInfo[];
|
||||
permissions?: PluginPermissionInfo[];
|
||||
record_version: number;
|
||||
}
|
||||
|
||||
export async function listPlugins(page = 1, pageSize = 20, status?: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<PluginInfo> }>(
|
||||
'/admin/plugins',
|
||||
{ params: { page, page_size: pageSize, status: status || undefined } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getPlugin(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function uploadPlugin(wasmFile: File, manifestToml: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('wasm', wasmFile);
|
||||
formData.append('manifest', manifestToml);
|
||||
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
'/admin/plugins/upload',
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000 },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function installPlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/install`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function enablePlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/enable`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function disablePlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/disable`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function uninstallPlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/uninstall`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function purgePlugin(id: string) {
|
||||
await client.delete(`/admin/plugins/${id}`);
|
||||
}
|
||||
|
||||
export async function getPluginHealth(id: string) {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: { plugin_id: string; status: string; details: Record<string, unknown> };
|
||||
}>(`/admin/plugins/${id}/health`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updatePluginConfig(id: string, config: Record<string, unknown>, version: number) {
|
||||
const { data } = await client.put<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/config`,
|
||||
{ config, version },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getPluginSchema(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: Record<string, unknown> }>(
|
||||
`/admin/plugins/${id}/schema`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
75
apps/web/src/api/roles.ts
Normal file
75
apps/web/src/api/roles.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface RoleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_system: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface PermissionInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listRoles(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<RoleInfo> }>(
|
||||
'/roles',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getRole(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: RoleInfo }>(`/roles/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createRole(req: CreateRoleRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: RoleInfo }>('/roles', req);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateRole(id: string, req: UpdateRoleRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: RoleInfo }>(`/roles/${id}`, req);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteRole(id: string) {
|
||||
await client.delete(`/roles/${id}`);
|
||||
}
|
||||
|
||||
export async function assignPermissions(roleId: string, permissionIds: string[]) {
|
||||
await client.post(`/roles/${roleId}/permissions`, { permission_ids: permissionIds });
|
||||
}
|
||||
|
||||
export async function getRolePermissions(roleId: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>(
|
||||
`/roles/${roleId}/permissions`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listPermissions() {
|
||||
const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>('/permissions');
|
||||
return data.data;
|
||||
}
|
||||
29
apps/web/src/api/settings.ts
Normal file
29
apps/web/src/api/settings.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import client from './client';
|
||||
|
||||
export interface SettingInfo {
|
||||
id: string;
|
||||
scope: string;
|
||||
scope_id?: string;
|
||||
setting_key: string;
|
||||
setting_value: unknown;
|
||||
}
|
||||
|
||||
export async function getSetting(key: string, scope?: string, scopeId?: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: SettingInfo }>(
|
||||
`/config/settings/${key}`,
|
||||
{ params: { scope, scope_id: scopeId } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateSetting(key: string, settingValue: unknown) {
|
||||
const { data } = await client.put<{ success: boolean; data: SettingInfo }>(
|
||||
`/config/settings/${key}`,
|
||||
{ setting_value: settingValue },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteSetting(key: string) {
|
||||
await client.delete(`/config/settings/${encodeURIComponent(key)}`);
|
||||
}
|
||||
22
apps/web/src/api/themes.ts
Normal file
22
apps/web/src/api/themes.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import client from './client';
|
||||
|
||||
export interface ThemeConfig {
|
||||
primary_color?: string;
|
||||
logo_url?: string;
|
||||
sidebar_style?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
export async function getTheme() {
|
||||
const { data } = await client.get<{ success: boolean; data: ThemeConfig }>(
|
||||
'/config/themes',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateTheme(theme: ThemeConfig) {
|
||||
const { data } = await client.put<{ success: boolean; data: ThemeConfig }>(
|
||||
'/config/themes',
|
||||
theme,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
57
apps/web/src/api/users.ts
Normal file
57
apps/web/src/api/users.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import client from './client';
|
||||
import type { UserInfo } from './auth';
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
display_name?: string;
|
||||
status?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listUsers(page = 1, pageSize = 20, search = '') {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<UserInfo> }>(
|
||||
'/users',
|
||||
{ params: { page, page_size: pageSize, search: search || undefined } }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getUser(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: UserInfo }>(`/users/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createUser(req: CreateUserRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: UserInfo }>('/users', req);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, req: UpdateUserRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: UserInfo }>(`/users/${id}`, req);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string) {
|
||||
await client.delete(`/users/${id}`);
|
||||
}
|
||||
|
||||
export async function assignRoles(userId: string, roleIds: string[]) {
|
||||
await client.post(`/users/${userId}/roles`, { role_ids: roleIds });
|
||||
}
|
||||
89
apps/web/src/api/workflowDefinitions.ts
Normal file
89
apps/web/src/api/workflowDefinitions.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface NodeDef {
|
||||
id: string;
|
||||
type: 'StartEvent' | 'EndEvent' | 'UserTask' | 'ServiceTask' | 'ExclusiveGateway' | 'ParallelGateway';
|
||||
name: string;
|
||||
assignee_id?: string;
|
||||
candidate_groups?: string[];
|
||||
service_type?: string;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface EdgeDef {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
condition?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ProcessDefinitionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
version: number;
|
||||
category?: string;
|
||||
description?: string;
|
||||
nodes: NodeDef[];
|
||||
edges: EdgeDef[];
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateProcessDefinitionRequest {
|
||||
name: string;
|
||||
key: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
nodes: NodeDef[];
|
||||
edges: EdgeDef[];
|
||||
}
|
||||
|
||||
export interface UpdateProcessDefinitionRequest {
|
||||
name?: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
nodes?: NodeDef[];
|
||||
edges?: EdgeDef[];
|
||||
}
|
||||
|
||||
export async function listProcessDefinitions(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<ProcessDefinitionInfo> }>(
|
||||
'/workflow/definitions',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getProcessDefinition(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: ProcessDefinitionInfo }>(
|
||||
`/workflow/definitions/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createProcessDefinition(req: CreateProcessDefinitionRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: ProcessDefinitionInfo }>(
|
||||
'/workflow/definitions',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateProcessDefinition(id: string, req: UpdateProcessDefinitionRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: ProcessDefinitionInfo }>(
|
||||
`/workflow/definitions/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function publishProcessDefinition(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: ProcessDefinitionInfo }>(
|
||||
`/workflow/definitions/${id}/publish`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
72
apps/web/src/api/workflowInstances.ts
Normal file
72
apps/web/src/api/workflowInstances.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface TokenInfo {
|
||||
id: string;
|
||||
node_id: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProcessInstanceInfo {
|
||||
id: string;
|
||||
definition_id: string;
|
||||
definition_name?: string;
|
||||
business_key?: string;
|
||||
status: string;
|
||||
started_by: string;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
active_tokens: TokenInfo[];
|
||||
}
|
||||
|
||||
export interface StartInstanceRequest {
|
||||
definition_id: string;
|
||||
business_key?: string;
|
||||
variables?: Array<{ name: string; var_type?: string; value: unknown }>;
|
||||
}
|
||||
|
||||
export async function startInstance(req: StartInstanceRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: ProcessInstanceInfo }>(
|
||||
'/workflow/instances',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listInstances(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<ProcessInstanceInfo> }>(
|
||||
'/workflow/instances',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getInstance(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: ProcessInstanceInfo }>(
|
||||
`/workflow/instances/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function suspendInstance(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: null }>(
|
||||
`/workflow/instances/${id}/suspend`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function resumeInstance(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: null }>(
|
||||
`/workflow/instances/${id}/resume`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function terminateInstance(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: null }>(
|
||||
`/workflow/instances/${id}/terminate`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
61
apps/web/src/api/workflowTasks.ts
Normal file
61
apps/web/src/api/workflowTasks.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface TaskInfo {
|
||||
id: string;
|
||||
instance_id: string;
|
||||
token_id: string;
|
||||
node_id: string;
|
||||
node_name?: string;
|
||||
assignee_id?: string;
|
||||
candidate_groups?: unknown;
|
||||
status: string;
|
||||
outcome?: string;
|
||||
form_data?: unknown;
|
||||
due_date?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
definition_name?: string;
|
||||
business_key?: string;
|
||||
}
|
||||
|
||||
export interface CompleteTaskRequest {
|
||||
outcome: string;
|
||||
form_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DelegateTaskRequest {
|
||||
delegate_to: string;
|
||||
}
|
||||
|
||||
export async function listPendingTasks(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<TaskInfo> }>(
|
||||
'/workflow/tasks/pending',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listCompletedTasks(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<TaskInfo> }>(
|
||||
'/workflow/tasks/completed',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function completeTask(id: string, req: CompleteTaskRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: TaskInfo }>(
|
||||
`/workflow/tasks/${id}/complete`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function delegateTask(id: string, req: DelegateTaskRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: TaskInfo }>(
|
||||
`/workflow/tasks/${id}/delegate`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
184
apps/web/src/components/NotificationPanel.tsx
Normal file
184
apps/web/src/components/NotificationPanel.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Badge, List, Popover, Button, Empty, Typography, theme } from 'antd';
|
||||
import { BellOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMessageStore } from '../stores/message';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function NotificationPanel() {
|
||||
const navigate = useNavigate();
|
||||
// 使用独立 selector:数据订阅和函数引用分离,避免 effect 重复触发
|
||||
const unreadCount = useMessageStore((s) => s.unreadCount);
|
||||
const recentMessages = useMessageStore((s) => s.recentMessages);
|
||||
const markAsRead = useMessageStore((s) => s.markAsRead);
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 防止 StrictMode 双重 mount 和路由切换导致的重复初始化
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
const { fetchUnreadCount, fetchRecentMessages } = useMessageStore.getState();
|
||||
fetchUnreadCount();
|
||||
fetchRecentMessages();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchUnreadCount();
|
||||
fetchRecentMessages();
|
||||
}, 60000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
initializedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const content = (
|
||||
<div style={{ width: 360 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
padding: '4px 0',
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>通知</span>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 12, color: '#4F46E5' }}
|
||||
onClick={() => navigate('/messages')}
|
||||
>
|
||||
查看全部
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recentMessages.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无消息"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
style={{ padding: '24px 0' }}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
dataSource={recentMessages.slice(0, 5)}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
margin: '2px 0',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
border: 'none',
|
||||
background: !item.is_read ? (isDark ? '#1E293B' : '#F5F3FF') : 'transparent',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!item.is_read) {
|
||||
markAsRead(item.id);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (item.is_read) {
|
||||
e.currentTarget.style.background = isDark ? '#1E293B' : '#F8FAFC';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (item.is_read) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Text
|
||||
strong={!item.is_read}
|
||||
ellipsis
|
||||
style={{ maxWidth: 260, fontSize: 13 }}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
{!item.is_read && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: '#4F46E5',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
<Text
|
||||
type="secondary"
|
||||
ellipsis
|
||||
style={{ maxWidth: 300, fontSize: 12, display: 'block', marginTop: 2 }}
|
||||
>
|
||||
{item.body}
|
||||
</Text>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{recentMessages.length > 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
paddingTop: 8,
|
||||
marginTop: 4,
|
||||
borderTop: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
}}>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => navigate('/messages')}
|
||||
style={{ fontSize: 13, color: '#4F46E5', fontWeight: 500 }}
|
||||
>
|
||||
查看全部消息
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
overlayStyle={{ padding: 0 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDark ? '#1E293B' : '#F1F5F9';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Badge count={unreadCount} size="small" offset={[4, -4]}>
|
||||
<BellOutlined style={{
|
||||
fontSize: 16,
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
}} />
|
||||
</Badge>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
1139
apps/web/src/index.css
Normal file
1139
apps/web/src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
276
apps/web/src/layouts/MainLayout.tsx
Normal file
276
apps/web/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useCallback, memo, useEffect } from 'react';
|
||||
import { Layout, Avatar, Space, Dropdown, Tooltip, theme } from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
UserOutlined,
|
||||
SafetyOutlined,
|
||||
ApartmentOutlined,
|
||||
SettingOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PartitionOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
SearchOutlined,
|
||||
BulbOutlined,
|
||||
BulbFilled,
|
||||
AppstoreOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores/app';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePluginStore } from '../stores/plugin';
|
||||
import NotificationPanel from '../components/NotificationPanel';
|
||||
|
||||
const { Header, Sider, Content, Footer } = Layout;
|
||||
|
||||
interface MenuItem {
|
||||
key: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const mainMenuItems: MenuItem[] = [
|
||||
{ key: '/', icon: <HomeOutlined />, label: '工作台' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/roles', icon: <SafetyOutlined />, label: '权限管理' },
|
||||
{ key: '/organizations', icon: <ApartmentOutlined />, label: '组织架构' },
|
||||
];
|
||||
|
||||
const bizMenuItems: MenuItem[] = [
|
||||
{ key: '/workflow', icon: <PartitionOutlined />, label: '工作流' },
|
||||
{ key: '/messages', icon: <MessageOutlined />, label: '消息中心' },
|
||||
];
|
||||
|
||||
const sysMenuItems: MenuItem[] = [
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
{ key: '/plugins/admin', icon: <AppstoreOutlined />, label: '插件管理' },
|
||||
];
|
||||
|
||||
const routeTitleMap: Record<string, string> = {
|
||||
'/': '工作台',
|
||||
'/users': '用户管理',
|
||||
'/roles': '权限管理',
|
||||
'/organizations': '组织架构',
|
||||
'/workflow': '工作流',
|
||||
'/messages': '消息中心',
|
||||
'/settings': '系统设置',
|
||||
'/plugins/admin': '插件管理',
|
||||
};
|
||||
|
||||
// 侧边栏菜单项 - 提取为独立组件避免重复渲染
|
||||
const SidebarMenuItem = memo(function SidebarMenuItem({
|
||||
item,
|
||||
isActive,
|
||||
collapsed,
|
||||
onClick,
|
||||
}: {
|
||||
item: MenuItem;
|
||||
isActive: boolean;
|
||||
collapsed: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip title={collapsed ? item.label : ''} placement="right">
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`erp-sidebar-item ${isActive ? 'erp-sidebar-item-active' : ''}`}
|
||||
>
|
||||
<span className="erp-sidebar-item-icon">{item.icon}</span>
|
||||
{!collapsed && <span className="erp-sidebar-item-label">{item.label}</span>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { pluginMenuItems, fetchPlugins } = usePluginStore();
|
||||
theme.useToken();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname || '/';
|
||||
|
||||
// 加载插件菜单
|
||||
useEffect(() => {
|
||||
fetchPlugins(1, 'running');
|
||||
}, [fetchPlugins]);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
}, [logout, navigate]);
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: user?.display_name || user?.username || '用户',
|
||||
disabled: true,
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
danger: true,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
const sidebarWidth = sidebarCollapsed ? 72 : 240;
|
||||
const isDark = themeMode === 'dark';
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
{/* 现代深色侧边栏 */}
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={sidebarCollapsed}
|
||||
width={240}
|
||||
collapsedWidth={72}
|
||||
className={isDark ? 'erp-sider-dark' : 'erp-sider-dark erp-sider-default'}
|
||||
>
|
||||
{/* Logo 区域 */}
|
||||
<div className="erp-sidebar-logo" onClick={() => navigate('/')}>
|
||||
<div className="erp-sidebar-logo-icon">E</div>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="erp-sidebar-logo-text">ERP Platform</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 菜单组:基础模块 */}
|
||||
{!sidebarCollapsed && <div className="erp-sidebar-group">基础模块</div>}
|
||||
<div className="erp-sidebar-menu">
|
||||
{mainMenuItems.map((item) => (
|
||||
<SidebarMenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isActive={currentPath === item.key}
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={() => navigate(item.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 菜单组:业务模块 */}
|
||||
{!sidebarCollapsed && <div className="erp-sidebar-group">业务模块</div>}
|
||||
<div className="erp-sidebar-menu">
|
||||
{bizMenuItems.map((item) => (
|
||||
<SidebarMenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isActive={currentPath === item.key}
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={() => navigate(item.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 菜单组:插件 */}
|
||||
{pluginMenuItems.length > 0 && (
|
||||
<>
|
||||
{!sidebarCollapsed && <div className="erp-sidebar-group">插件</div>}
|
||||
<div className="erp-sidebar-menu">
|
||||
{pluginMenuItems.map((item) => (
|
||||
<SidebarMenuItem
|
||||
key={item.key}
|
||||
item={{
|
||||
key: item.key,
|
||||
icon: <AppstoreOutlined />,
|
||||
label: item.label,
|
||||
}}
|
||||
isActive={currentPath === item.key}
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={() => navigate(item.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 菜单组:系统 */}
|
||||
{!sidebarCollapsed && <div className="erp-sidebar-group">系统</div>}
|
||||
<div className="erp-sidebar-menu">
|
||||
{sysMenuItems.map((item) => (
|
||||
<SidebarMenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isActive={currentPath === item.key}
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={() => navigate(item.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
{/* 右侧主区域 */}
|
||||
<Layout
|
||||
className={`erp-main-layout ${isDark ? 'erp-main-layout-dark' : 'erp-main-layout-light'}`}
|
||||
style={{ marginLeft: sidebarWidth }}
|
||||
>
|
||||
{/* 顶部导航栏 */}
|
||||
<Header className={`erp-header ${isDark ? 'erp-header-dark' : 'erp-header-light'}`}>
|
||||
{/* 左侧:折叠按钮 + 标题 */}
|
||||
<Space size="middle" style={{ alignItems: 'center' }}>
|
||||
<div className="erp-header-btn" onClick={toggleSidebar}>
|
||||
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
<span className={`erp-header-title ${isDark ? 'erp-text-dark' : 'erp-text-light'}`}>
|
||||
{routeTitleMap[currentPath] ||
|
||||
pluginMenuItems.find((p) => p.key === currentPath)?.label ||
|
||||
'页面'}
|
||||
</span>
|
||||
</Space>
|
||||
|
||||
{/* 右侧:搜索 + 通知 + 主题切换 + 用户 */}
|
||||
<Space size={4} style={{ alignItems: 'center' }}>
|
||||
<Tooltip title="搜索">
|
||||
<div className="erp-header-btn">
|
||||
<SearchOutlined style={{ fontSize: 16 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={isDark ? '切换亮色模式' : '切换暗色模式'}>
|
||||
<div className="erp-header-btn" onClick={() => setTheme(isDark ? 'light' : 'dark')}>
|
||||
{isDark ? <BulbFilled style={{ fontSize: 16 }} /> : <BulbOutlined style={{ fontSize: 16 }} />}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<NotificationPanel />
|
||||
|
||||
<div className={`erp-header-divider ${isDark ? 'erp-header-divider-dark' : 'erp-header-divider-light'}`} />
|
||||
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" trigger={['click']}>
|
||||
<div className="erp-header-user">
|
||||
<Avatar
|
||||
size={30}
|
||||
className="erp-user-avatar"
|
||||
>
|
||||
{(user?.display_name?.[0] || user?.username?.[0] || 'U').toUpperCase()}
|
||||
</Avatar>
|
||||
{!sidebarCollapsed && (
|
||||
<span className={`erp-user-name ${isDark ? 'erp-text-dark-secondary' : 'erp-text-light-secondary'}`}>
|
||||
{user?.display_name || user?.username || 'User'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<Content style={{ padding: 20, minHeight: 'calc(100vh - 56px - 48px)' }}>
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
{/* 底部 */}
|
||||
<Footer className={`erp-footer ${isDark ? 'erp-footer-dark' : 'erp-footer-light'}`}>
|
||||
ERP Platform v0.1.0
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/main.tsx
Normal file
10
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
418
apps/web/src/pages/Home.tsx
Normal file
418
apps/web/src/pages/Home.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Row, Col, Spin, theme } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
FileTextOutlined,
|
||||
BellOutlined,
|
||||
ThunderboltOutlined,
|
||||
SettingOutlined,
|
||||
PartitionOutlined,
|
||||
ClockCircleOutlined,
|
||||
ApartmentOutlined,
|
||||
CheckCircleOutlined,
|
||||
TeamOutlined,
|
||||
FileProtectOutlined,
|
||||
RiseOutlined,
|
||||
FallOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import client from '../api/client';
|
||||
import { useMessageStore } from '../stores/message';
|
||||
|
||||
interface DashboardStats {
|
||||
userCount: number;
|
||||
roleCount: number;
|
||||
processInstanceCount: number;
|
||||
unreadMessages: number;
|
||||
}
|
||||
|
||||
interface TrendData {
|
||||
value: string;
|
||||
direction: 'up' | 'down' | 'neutral';
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface StatCardConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
gradient: string;
|
||||
iconBg: string;
|
||||
delay: string;
|
||||
trend: TrendData;
|
||||
sparkline: number[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface TaskItem {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
assignee: string;
|
||||
dueText: string;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
text: string;
|
||||
time: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
function useCountUp(end: number, duration = 800) {
|
||||
const [count, setCount] = useState(0);
|
||||
const prevEnd = useRef(end);
|
||||
|
||||
useEffect(() => {
|
||||
if (end === prevEnd.current && count > 0) return;
|
||||
prevEnd.current = end;
|
||||
|
||||
if (end === 0) { setCount(0); return; }
|
||||
|
||||
const startTime = performance.now();
|
||||
const startVal = 0;
|
||||
|
||||
function tick(now: number) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCount(Math.round(startVal + (end - startVal) * eased));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}, [end, duration]);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function StatValue({ value, loading }: { value: number; loading: boolean }) {
|
||||
const animatedValue = useCountUp(value);
|
||||
if (loading) return <Spin size="small" />;
|
||||
return <span className="erp-count-up">{animatedValue.toLocaleString()}</span>;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
userCount: 0,
|
||||
roleCount: 0,
|
||||
processInstanceCount: 0,
|
||||
unreadMessages: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const unreadCount = useMessageStore((s) => s.unreadCount);
|
||||
const fetchUnreadCount = useMessageStore((s) => s.fetchUnreadCount);
|
||||
const { token } = theme.useToken();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadStats() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [usersRes, rolesRes, instancesRes] = await Promise.allSettled([
|
||||
client.get('/users', { params: { page: 1, page_size: 1 } }),
|
||||
client.get('/roles', { params: { page: 1, page_size: 1 } }),
|
||||
client.get('/workflow/instances', { params: { page: 1, page_size: 1 } }),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const extractTotal = (res: PromiseSettledResult<{ data: { data?: { total?: number } } }>) =>
|
||||
res.status === 'fulfilled' ? (res.value.data?.data?.total ?? 0) : 0;
|
||||
|
||||
setStats({
|
||||
userCount: extractTotal(usersRes),
|
||||
roleCount: extractTotal(rolesRes),
|
||||
processInstanceCount: extractTotal(instancesRes),
|
||||
unreadMessages: unreadCount,
|
||||
});
|
||||
} catch {
|
||||
// 静默处理
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchUnreadCount();
|
||||
loadStats();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [fetchUnreadCount, unreadCount]);
|
||||
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
navigate(path);
|
||||
}, [navigate]);
|
||||
|
||||
const statCards: StatCardConfig[] = [
|
||||
{
|
||||
key: 'users',
|
||||
title: '用户总数',
|
||||
value: stats.userCount,
|
||||
icon: <UserOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
|
||||
iconBg: 'rgba(79, 70, 229, 0.12)',
|
||||
delay: 'erp-fade-in erp-fade-in-delay-1',
|
||||
trend: { value: '+2', direction: 'up', label: '较上周' },
|
||||
sparkline: [30, 45, 35, 50, 40, 55, 60, 50, 65, 70],
|
||||
onClick: () => handleNavigate('/users'),
|
||||
},
|
||||
{
|
||||
key: 'roles',
|
||||
title: '角色数量',
|
||||
value: stats.roleCount,
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #059669, #10B981)',
|
||||
iconBg: 'rgba(5, 150, 105, 0.12)',
|
||||
delay: 'erp-fade-in erp-fade-in-delay-2',
|
||||
trend: { value: '+1', direction: 'up', label: '较上月' },
|
||||
sparkline: [20, 25, 30, 28, 35, 40, 38, 42, 45, 50],
|
||||
onClick: () => handleNavigate('/roles'),
|
||||
},
|
||||
{
|
||||
key: 'processes',
|
||||
title: '流程实例',
|
||||
value: stats.processInstanceCount,
|
||||
icon: <FileTextOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)',
|
||||
iconBg: 'rgba(217, 119, 6, 0.12)',
|
||||
delay: 'erp-fade-in erp-fade-in-delay-3',
|
||||
trend: { value: '0', direction: 'neutral', label: '较昨日' },
|
||||
sparkline: [10, 15, 12, 20, 18, 25, 22, 28, 24, 20],
|
||||
onClick: () => handleNavigate('/workflow'),
|
||||
},
|
||||
{
|
||||
key: 'messages',
|
||||
title: '未读消息',
|
||||
value: stats.unreadMessages,
|
||||
icon: <BellOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)',
|
||||
iconBg: 'rgba(225, 29, 72, 0.12)',
|
||||
delay: 'erp-fade-in erp-fade-in-delay-4',
|
||||
trend: { value: '0', direction: 'neutral', label: '全部已读' },
|
||||
sparkline: [5, 8, 3, 10, 6, 12, 8, 4, 7, 5],
|
||||
onClick: () => handleNavigate('/messages'),
|
||||
},
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#4F46E5' },
|
||||
{ icon: <SafetyCertificateOutlined />, label: '权限管理', path: '/roles', color: '#059669' },
|
||||
{ icon: <ApartmentOutlined />, label: '组织架构', path: '/organizations', color: '#D97706' },
|
||||
{ icon: <PartitionOutlined />, label: '工作流', path: '/workflow', color: '#7C3AED' },
|
||||
{ icon: <BellOutlined />, label: '消息中心', path: '/messages', color: '#E11D48' },
|
||||
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#64748B' },
|
||||
];
|
||||
|
||||
const pendingTasks: TaskItem[] = [
|
||||
{ id: '1', title: '审核新用户注册申请', priority: 'high', assignee: '系统', dueText: '待处理', color: '#DC2626', icon: <UserOutlined />, path: '/users' },
|
||||
{ id: '2', title: '配置工作流审批节点', priority: 'medium', assignee: '管理员', dueText: '进行中', color: '#D97706', icon: <PartitionOutlined />, path: '/workflow' },
|
||||
{ id: '3', title: '更新角色权限策略', priority: 'low', assignee: '管理员', dueText: '计划中', color: '#059669', icon: <SafetyCertificateOutlined />, path: '/roles' },
|
||||
];
|
||||
|
||||
const recentActivities: ActivityItem[] = [
|
||||
{ id: '1', text: '系统管理员 创建了 <strong>管理员角色</strong>', time: '刚刚', icon: <TeamOutlined /> },
|
||||
{ id: '2', text: '系统管理员 配置了 <strong>工作流模板</strong>', time: '5 分钟前', icon: <FileProtectOutlined /> },
|
||||
{ id: '3', text: '系统管理员 更新了 <strong>组织架构</strong>', time: '10 分钟前', icon: <ApartmentOutlined /> },
|
||||
{ id: '4', text: '系统管理员 设置了 <strong>消息通知偏好</strong>', time: '30 分钟前', icon: <BellOutlined /> },
|
||||
];
|
||||
|
||||
const priorityLabel: Record<string, string> = { high: '紧急', medium: '一般', low: '低' };
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 欢迎语 */}
|
||||
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
|
||||
<h2 style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: isDark ? '#F1F5F9' : '#0F172A',
|
||||
margin: '0 0 4px',
|
||||
letterSpacing: '-0.5px',
|
||||
}}>
|
||||
工作台
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: isDark ? '#94A3B8' : '#475569', margin: 0 }}>
|
||||
欢迎回来,这是您的系统概览
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片行 */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
{statCards.map((card) => {
|
||||
const maxSpark = Math.max(...card.sparkline, 1);
|
||||
return (
|
||||
<Col xs={24} sm={12} lg={6} key={card.key}>
|
||||
<div
|
||||
className={`erp-stat-card ${card.delay}`}
|
||||
style={{ '--card-gradient': card.gradient, '--card-icon-bg': card.iconBg } as React.CSSProperties}
|
||||
onClick={card.onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') card.onClick?.(); }}
|
||||
>
|
||||
<div className="erp-stat-card-bar" />
|
||||
<div className="erp-stat-card-body">
|
||||
<div className="erp-stat-card-info">
|
||||
<div className="erp-stat-card-title">{card.title}</div>
|
||||
<div className="erp-stat-card-value">
|
||||
<StatValue value={card.value} loading={loading} />
|
||||
</div>
|
||||
<div className={`erp-stat-card-trend erp-stat-card-trend-${card.trend.direction}`}>
|
||||
{card.trend.direction === 'up' && <RiseOutlined />}
|
||||
{card.trend.direction === 'down' && <FallOutlined />}
|
||||
<span>{card.trend.value}</span>
|
||||
<span className="erp-stat-card-trend-label">{card.trend.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="erp-stat-card-icon">{card.icon}</div>
|
||||
</div>
|
||||
<div className="erp-stat-card-sparkline">
|
||||
{card.sparkline.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="erp-stat-card-sparkline-bar"
|
||||
style={{
|
||||
height: `${(v / maxSpark) * 100}%`,
|
||||
background: card.gradient,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* 待办任务 + 最近活动 */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
{/* 待办任务 */}
|
||||
<Col xs={24} lg={14}>
|
||||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-2">
|
||||
<div className="erp-section-header">
|
||||
<CheckCircleOutlined className="erp-section-icon" style={{ color: '#E11D48' }} />
|
||||
<span className="erp-section-title">待办任务</span>
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 12,
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
}}>
|
||||
{pendingTasks.length} 项待处理
|
||||
</span>
|
||||
</div>
|
||||
<div className="erp-task-list">
|
||||
{pendingTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="erp-task-item"
|
||||
style={{ '--task-color': task.color } as React.CSSProperties}
|
||||
onClick={() => handleNavigate(task.path)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(task.path); }}
|
||||
>
|
||||
<div className="erp-task-item-icon">{task.icon}</div>
|
||||
<div className="erp-task-item-content">
|
||||
<div className="erp-task-item-title">{task.title}</div>
|
||||
<div className="erp-task-item-meta">
|
||||
<span>{task.assignee}</span>
|
||||
<span>{task.dueText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`erp-task-priority erp-task-priority-${task.priority}`}>
|
||||
{priorityLabel[task.priority]}
|
||||
</span>
|
||||
<RightOutlined style={{ color: isDark ? '#475569' : '#CBD5E1', fontSize: 12 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
{/* 最近活动 */}
|
||||
<Col xs={24} lg={10}>
|
||||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3" style={{ height: '100%' }}>
|
||||
<div className="erp-section-header">
|
||||
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#6366F1' }} />
|
||||
<span className="erp-section-title">最近动态</span>
|
||||
</div>
|
||||
<div className="erp-activity-list">
|
||||
{recentActivities.map((activity) => (
|
||||
<div key={activity.id} className="erp-activity-item">
|
||||
<div className="erp-activity-dot">{activity.icon}</div>
|
||||
<div className="erp-activity-content">
|
||||
<div className="erp-activity-text" dangerouslySetInnerHTML={{ __html: activity.text }} />
|
||||
<div className="erp-activity-time">{activity.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 快捷入口 + 系统信息 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={16}>
|
||||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3">
|
||||
<div className="erp-section-header">
|
||||
<ThunderboltOutlined className="erp-section-icon" />
|
||||
<span className="erp-section-title">快捷入口</span>
|
||||
</div>
|
||||
<Row gutter={[12, 12]}>
|
||||
{quickActions.map((action) => (
|
||||
<Col xs={12} sm={8} md={8} key={action.path}>
|
||||
<div
|
||||
className="erp-quick-action"
|
||||
style={{ '--action-color': action.color } as React.CSSProperties}
|
||||
onClick={() => handleNavigate(action.path)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(action.path); }}
|
||||
>
|
||||
<div className="erp-quick-action-icon">{action.icon}</div>
|
||||
<span className="erp-quick-action-label">{action.label}</span>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={8}>
|
||||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-4" style={{ height: '100%' }}>
|
||||
<div className="erp-section-header">
|
||||
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#6366F1' }} />
|
||||
<span className="erp-section-title">系统信息</span>
|
||||
</div>
|
||||
<div className="erp-system-info-list">
|
||||
{[
|
||||
{ label: '系统版本', value: 'v0.1.0' },
|
||||
{ label: '后端框架', value: 'Axum 0.8 + Tokio' },
|
||||
{ label: '数据库', value: 'PostgreSQL 16' },
|
||||
{ label: '缓存', value: 'Redis 7' },
|
||||
{ label: '前端框架', value: 'React 19 + Ant Design 6' },
|
||||
{ label: '模块数量', value: '5 个业务模块' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="erp-system-info-item">
|
||||
<span className="erp-system-info-label">{item.label}</span>
|
||||
<span className="erp-system-info-value">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
apps/web/src/pages/Login.tsx
Normal file
208
apps/web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Form, Input, Button, message, Divider } from 'antd';
|
||||
import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const loading = useAuthStore((s) => s.loading);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
await login(values.username, values.password);
|
||||
messageApi.success('登录成功');
|
||||
navigate('/');
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
'登录失败,请检查用户名和密码';
|
||||
messageApi.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
{contextHolder}
|
||||
|
||||
{/* 左侧品牌展示区 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'linear-gradient(135deg, #312E81 0%, #4F46E5 50%, #6366F1 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '60px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-20%',
|
||||
right: '-10%',
|
||||
width: '500px',
|
||||
height: '500px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-15%',
|
||||
left: '-8%',
|
||||
width: '400px',
|
||||
height: '400px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 品牌内容 */}
|
||||
<div style={{ position: 'relative', zIndex: 1, textAlign: 'center', maxWidth: '480px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 32px',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined style={{ fontSize: 32, color: '#fff' }} />
|
||||
</div>
|
||||
|
||||
<h1
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontSize: 36,
|
||||
fontWeight: 800,
|
||||
margin: '0 0 16px',
|
||||
letterSpacing: '-1px',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
ERP Platform
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontSize: 16,
|
||||
lineHeight: 1.6,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
新一代模块化企业资源管理平台
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.6,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
身份权限 · 工作流引擎 · 消息中心 · 系统配置
|
||||
</p>
|
||||
|
||||
{/* 底部特性点 */}
|
||||
<div style={{ marginTop: 48, display: 'flex', gap: 32, justifyContent: 'center' }}>
|
||||
{[
|
||||
{ label: '多租户架构', value: 'SaaS' },
|
||||
{ label: '模块化设计', value: '可插拔' },
|
||||
{ label: '事件驱动', value: '可扩展' },
|
||||
].map((item) => (
|
||||
<div key={item.label} style={{ textAlign: 'center' }}>
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.9)', fontSize: 18, fontWeight: 700 }}>
|
||||
{item.value}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 12, marginTop: 4 }}>
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧登录表单区 */}
|
||||
<main
|
||||
style={{
|
||||
width: 480,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '60px',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 360, width: '100%', margin: '0 auto' }}>
|
||||
<h2 style={{ marginBottom: 4, fontWeight: 700, fontSize: 24 }}>
|
||||
欢迎回来
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: '#64748B' }}>
|
||||
请登录您的账户以继续
|
||||
</p>
|
||||
|
||||
<Divider style={{ margin: '24px 0' }} />
|
||||
|
||||
<Form name="login" onFinish={onFinish} autoComplete="off" size="large" layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: '#94A3B8' }} />}
|
||||
placeholder="用户名"
|
||||
style={{ height: 44, borderRadius: 10 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: '#94A3B8' }} />}
|
||||
placeholder="密码"
|
||||
style={{ height: 44, borderRadius: 10 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
style={{
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ marginTop: 32, textAlign: 'center' }}>
|
||||
<p style={{ fontSize: 12, color: '#64748B', margin: 0 }}>
|
||||
ERP Platform v0.1.0 · Powered by Rust + React
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/pages/Messages.tsx
Normal file
72
apps/web/src/pages/Messages.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { Tabs } from 'antd';
|
||||
import { BellOutlined, MailOutlined, FileTextOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import NotificationList from './messages/NotificationList';
|
||||
import MessageTemplates from './messages/MessageTemplates';
|
||||
import NotificationPreferences from './messages/NotificationPreferences';
|
||||
import type { MessageQuery } from '../api/messages';
|
||||
|
||||
const UNREAD_FILTER: MessageQuery = { is_read: false };
|
||||
|
||||
export default function Messages() {
|
||||
const [activeKey, setActiveKey] = useState('all');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="erp-page-header" style={{ borderBottom: 'none', marginBottom: 0, paddingBottom: 8 }}>
|
||||
<div>
|
||||
<h4>消息中心</h4>
|
||||
<div className="erp-page-subtitle">管理站内消息、模板和通知偏好</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
style={{ marginTop: 8 }}
|
||||
items={[
|
||||
{
|
||||
key: 'all',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<MailOutlined style={{ fontSize: 14 }} />
|
||||
全部消息
|
||||
</span>
|
||||
),
|
||||
children: <NotificationList />,
|
||||
},
|
||||
{
|
||||
key: 'unread',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<BellOutlined style={{ fontSize: 14 }} />
|
||||
未读消息
|
||||
</span>
|
||||
),
|
||||
children: <NotificationList queryFilter={UNREAD_FILTER} />,
|
||||
},
|
||||
{
|
||||
key: 'templates',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<FileTextOutlined style={{ fontSize: 14 }} />
|
||||
消息模板
|
||||
</span>
|
||||
),
|
||||
children: <MessageTemplates />,
|
||||
},
|
||||
{
|
||||
key: 'preferences',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<SettingOutlined style={{ fontSize: 14 }} />
|
||||
通知设置
|
||||
</span>
|
||||
),
|
||||
children: <NotificationPreferences />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
623
apps/web/src/pages/Organizations.tsx
Normal file
623
apps/web/src/pages/Organizations.tsx
Normal file
@@ -0,0 +1,623 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Tree,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Table,
|
||||
Popconfirm,
|
||||
message,
|
||||
Empty,
|
||||
Tag,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ApartmentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import {
|
||||
listOrgTree,
|
||||
createOrg,
|
||||
updateOrg,
|
||||
deleteOrg,
|
||||
listDeptTree,
|
||||
createDept,
|
||||
deleteDept,
|
||||
listPositions,
|
||||
createPosition,
|
||||
deletePosition,
|
||||
type OrganizationInfo,
|
||||
type DepartmentInfo,
|
||||
type PositionInfo,
|
||||
} from '../api/orgs';
|
||||
|
||||
export default function Organizations() {
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
const cardStyle = {
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
};
|
||||
|
||||
// --- Org tree state ---
|
||||
const [orgTree, setOrgTree] = useState<OrganizationInfo[]>([]);
|
||||
const [selectedOrg, setSelectedOrg] = useState<OrganizationInfo | null>(null);
|
||||
const [, setLoading] = useState(false);
|
||||
|
||||
// --- Department tree state ---
|
||||
const [deptTree, setDeptTree] = useState<DepartmentInfo[]>([]);
|
||||
const [selectedDept, setSelectedDept] = useState<DepartmentInfo | null>(null);
|
||||
|
||||
// --- Position list state ---
|
||||
const [positions, setPositions] = useState<PositionInfo[]>([]);
|
||||
|
||||
// --- Modal state ---
|
||||
const [orgModalOpen, setOrgModalOpen] = useState(false);
|
||||
const [deptModalOpen, setDeptModalOpen] = useState(false);
|
||||
const [positionModalOpen, setPositionModalOpen] = useState(false);
|
||||
const [editOrg, setEditOrg] = useState<OrganizationInfo | null>(null);
|
||||
|
||||
const [orgForm] = Form.useForm();
|
||||
const [deptForm] = Form.useForm();
|
||||
const [positionForm] = Form.useForm();
|
||||
|
||||
// --- Fetch org tree ---
|
||||
const fetchOrgTree = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const tree = await listOrgTree();
|
||||
setOrgTree(tree);
|
||||
if (selectedOrg) {
|
||||
const stillExists = findOrgInTree(tree, selectedOrg.id);
|
||||
if (!stillExists) {
|
||||
setSelectedOrg(null);
|
||||
setDeptTree([]);
|
||||
setPositions([]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.error('加载组织树失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [selectedOrg]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrgTree();
|
||||
}, [fetchOrgTree]);
|
||||
|
||||
// --- Fetch dept tree when org selected ---
|
||||
const fetchDeptTree = useCallback(async () => {
|
||||
if (!selectedOrg) return;
|
||||
try {
|
||||
const tree = await listDeptTree(selectedOrg.id);
|
||||
setDeptTree(tree);
|
||||
if (selectedDept) {
|
||||
const stillExists = findDeptInTree(tree, selectedDept.id);
|
||||
if (!stillExists) {
|
||||
setSelectedDept(null);
|
||||
setPositions([]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.error('加载部门树失败');
|
||||
}
|
||||
}, [selectedOrg, selectedDept]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeptTree();
|
||||
}, [fetchDeptTree]);
|
||||
|
||||
// --- Fetch positions when dept selected ---
|
||||
const fetchPositions = useCallback(async () => {
|
||||
if (!selectedDept) return;
|
||||
try {
|
||||
const list = await listPositions(selectedDept.id);
|
||||
setPositions(list);
|
||||
} catch {
|
||||
message.error('加载岗位列表失败');
|
||||
}
|
||||
}, [selectedDept]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPositions();
|
||||
}, [fetchPositions]);
|
||||
|
||||
// --- Org handlers ---
|
||||
const handleCreateOrg = async (values: {
|
||||
name: string;
|
||||
code?: string;
|
||||
sort_order?: number;
|
||||
}) => {
|
||||
try {
|
||||
if (editOrg) {
|
||||
await updateOrg(editOrg.id, {
|
||||
name: values.name,
|
||||
code: values.code,
|
||||
sort_order: values.sort_order,
|
||||
version: editOrg.version,
|
||||
});
|
||||
message.success('组织更新成功');
|
||||
} else {
|
||||
await createOrg({
|
||||
name: values.name,
|
||||
code: values.code,
|
||||
parent_id: selectedOrg?.id,
|
||||
sort_order: values.sort_order,
|
||||
});
|
||||
message.success('组织创建成功');
|
||||
}
|
||||
setOrgModalOpen(false);
|
||||
setEditOrg(null);
|
||||
orgForm.resetFields();
|
||||
fetchOrgTree();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOrg = async (id: string) => {
|
||||
try {
|
||||
await deleteOrg(id);
|
||||
message.success('组织已删除');
|
||||
setSelectedOrg(null);
|
||||
setDeptTree([]);
|
||||
setPositions([]);
|
||||
fetchOrgTree();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '删除失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Dept handlers ---
|
||||
const handleCreateDept = async (values: {
|
||||
name: string;
|
||||
code?: string;
|
||||
sort_order?: number;
|
||||
}) => {
|
||||
if (!selectedOrg) return;
|
||||
try {
|
||||
await createDept(selectedOrg.id, {
|
||||
name: values.name,
|
||||
code: values.code,
|
||||
parent_id: selectedDept?.id,
|
||||
sort_order: values.sort_order,
|
||||
});
|
||||
message.success('部门创建成功');
|
||||
setDeptModalOpen(false);
|
||||
deptForm.resetFields();
|
||||
fetchDeptTree();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDept = async (id: string) => {
|
||||
try {
|
||||
await deleteDept(id);
|
||||
message.success('部门已删除');
|
||||
setSelectedDept(null);
|
||||
setPositions([]);
|
||||
fetchDeptTree();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '删除失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Position handlers ---
|
||||
const handleCreatePosition = async (values: {
|
||||
name: string;
|
||||
code?: string;
|
||||
level?: number;
|
||||
sort_order?: number;
|
||||
}) => {
|
||||
if (!selectedDept) return;
|
||||
try {
|
||||
await createPosition(selectedDept.id, {
|
||||
name: values.name,
|
||||
code: values.code,
|
||||
level: values.level,
|
||||
sort_order: values.sort_order,
|
||||
});
|
||||
message.success('岗位创建成功');
|
||||
setPositionModalOpen(false);
|
||||
positionForm.resetFields();
|
||||
fetchPositions();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePosition = async (id: string) => {
|
||||
try {
|
||||
await deletePosition(id);
|
||||
message.success('岗位已删除');
|
||||
fetchPositions();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Tree node converters ---
|
||||
const convertOrgTree = (items: OrganizationInfo[]): DataNode[] =>
|
||||
items.map((item) => ({
|
||||
key: item.id,
|
||||
title: (
|
||||
<span>
|
||||
{item.name}{' '}
|
||||
{item.code && <Tag style={{
|
||||
marginLeft: 4,
|
||||
background: isDark ? '#1E293B' : '#EEF2FF',
|
||||
border: 'none',
|
||||
color: '#4F46E5',
|
||||
fontSize: 11,
|
||||
}}>{item.code}</Tag>}
|
||||
</span>
|
||||
),
|
||||
children: convertOrgTree(item.children),
|
||||
}));
|
||||
|
||||
const convertDeptTree = (items: DepartmentInfo[]): DataNode[] =>
|
||||
items.map((item) => ({
|
||||
key: item.id,
|
||||
title: (
|
||||
<span>
|
||||
{item.name}{' '}
|
||||
{item.code && <Tag style={{
|
||||
marginLeft: 4,
|
||||
background: isDark ? '#1E293B' : '#ECFDF5',
|
||||
border: 'none',
|
||||
color: '#059669',
|
||||
fontSize: 11,
|
||||
}}>{item.code}</Tag>}
|
||||
</span>
|
||||
),
|
||||
children: convertDeptTree(item.children),
|
||||
}));
|
||||
|
||||
const onSelectOrg = (selectedKeys: React.Key[]) => {
|
||||
if (selectedKeys.length === 0) {
|
||||
setSelectedOrg(null);
|
||||
setDeptTree([]);
|
||||
setSelectedDept(null);
|
||||
setPositions([]);
|
||||
return;
|
||||
}
|
||||
const org = findOrgInTree(orgTree, selectedKeys[0] as string);
|
||||
setSelectedOrg(org);
|
||||
setSelectedDept(null);
|
||||
setPositions([]);
|
||||
};
|
||||
|
||||
const onSelectDept = (selectedKeys: React.Key[]) => {
|
||||
if (selectedKeys.length === 0) {
|
||||
setSelectedDept(null);
|
||||
setPositions([]);
|
||||
return;
|
||||
}
|
||||
const dept = findDeptInTree(deptTree, selectedKeys[0] as string);
|
||||
setSelectedDept(dept);
|
||||
};
|
||||
|
||||
const positionColumns = [
|
||||
{ title: '岗位名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '编码', dataIndex: 'code', key: 'code', render: (v?: string) => v || '-' },
|
||||
{ title: '级别', dataIndex: 'level', key: 'level' },
|
||||
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: PositionInfo) => (
|
||||
<Popconfirm
|
||||
title="确定删除此岗位?"
|
||||
onConfirm={() => handleDeletePosition(record.id)}
|
||||
>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题 */}
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>
|
||||
<ApartmentOutlined style={{ marginRight: 8, color: '#4F46E5' }} />
|
||||
组织架构管理
|
||||
</h4>
|
||||
<div className="erp-page-subtitle">管理组织、部门和岗位的层级结构</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 三栏布局 */}
|
||||
<div style={{ display: 'flex', gap: 16, minHeight: 500 }}>
|
||||
{/* 左栏:组织树 */}
|
||||
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '14px 20px',
|
||||
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>组织</span>
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditOrg(null);
|
||||
orgForm.resetFields();
|
||||
setOrgModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
{selectedOrg && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditOrg(selectedOrg);
|
||||
orgForm.setFieldsValue({
|
||||
name: selectedOrg.name,
|
||||
code: selectedOrg.code,
|
||||
sort_order: selectedOrg.sort_order,
|
||||
});
|
||||
setOrgModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此组织?"
|
||||
onConfirm={() => handleDeleteOrg(selectedOrg.id)}
|
||||
>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ padding: 12 }}>
|
||||
{orgTree.length > 0 ? (
|
||||
<Tree
|
||||
showLine
|
||||
defaultExpandAll
|
||||
treeData={convertOrgTree(orgTree)}
|
||||
onSelect={onSelectOrg}
|
||||
selectedKeys={selectedOrg ? [selectedOrg.id] : []}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无组织" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中栏:部门树 */}
|
||||
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '14px 20px',
|
||||
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{selectedOrg ? `${selectedOrg.name} · 部门` : '部门'}
|
||||
</span>
|
||||
{selectedOrg && (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
deptForm.resetFields();
|
||||
setDeptModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
{selectedDept && (
|
||||
<Popconfirm
|
||||
title="确定删除此部门?"
|
||||
onConfirm={() => handleDeleteDept(selectedDept.id)}
|
||||
>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 12 }}>
|
||||
{selectedOrg ? (
|
||||
deptTree.length > 0 ? (
|
||||
<Tree
|
||||
showLine
|
||||
defaultExpandAll
|
||||
treeData={convertDeptTree(deptTree)}
|
||||
onSelect={onSelectDept}
|
||||
selectedKeys={selectedDept ? [selectedDept.id] : []}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无部门" />
|
||||
)
|
||||
) : (
|
||||
<Empty description="请先选择组织" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右栏:岗位表 */}
|
||||
<div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '14px 20px',
|
||||
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{selectedDept ? `${selectedDept.name} · 岗位` : '岗位'}
|
||||
</span>
|
||||
{selectedDept && (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
positionForm.resetFields();
|
||||
setPositionModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建岗位
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '0 4px' }}>
|
||||
{selectedDept ? (
|
||||
<Table
|
||||
columns={positionColumns}
|
||||
dataSource={positions}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty description="请先选择部门" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Org Modal */}
|
||||
<Modal
|
||||
title={editOrg ? '编辑组织' : selectedOrg ? `在 ${selectedOrg.name} 下新建子组织` : '新建根组织'}
|
||||
open={orgModalOpen}
|
||||
onCancel={() => {
|
||||
setOrgModalOpen(false);
|
||||
setEditOrg(null);
|
||||
}}
|
||||
onOk={() => orgForm.submit()}
|
||||
>
|
||||
<Form form={orgForm} onFinish={handleCreateOrg} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入组织名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="编码">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Dept Modal */}
|
||||
<Modal
|
||||
title={
|
||||
selectedDept
|
||||
? `在 ${selectedDept.name} 下新建子部门`
|
||||
: `在 ${selectedOrg?.name} 下新建部门`
|
||||
}
|
||||
open={deptModalOpen}
|
||||
onCancel={() => setDeptModalOpen(false)}
|
||||
onOk={() => deptForm.submit()}
|
||||
>
|
||||
<Form form={deptForm} onFinish={handleCreateDept} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入部门名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="编码">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Position Modal */}
|
||||
<Modal
|
||||
title={`在 ${selectedDept?.name} 下新建岗位`}
|
||||
open={positionModalOpen}
|
||||
onCancel={() => setPositionModalOpen(false)}
|
||||
onOk={() => positionForm.submit()}
|
||||
>
|
||||
<Form form={positionForm} onFinish={handleCreatePosition} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="岗位名称"
|
||||
rules={[{ required: true, message: '请输入岗位名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="编码">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="level" label="级别" initialValue={1}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function findOrgInTree(
|
||||
tree: OrganizationInfo[],
|
||||
id: string,
|
||||
): OrganizationInfo | null {
|
||||
for (const item of tree) {
|
||||
if (item.id === id) return item;
|
||||
const found = findOrgInTree(item.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findDeptInTree(
|
||||
tree: DepartmentInfo[],
|
||||
id: string,
|
||||
): DepartmentInfo | null {
|
||||
for (const item of tree) {
|
||||
if (item.id === id) return item;
|
||||
const found = findDeptInTree(item.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
342
apps/web/src/pages/PluginAdmin.tsx
Normal file
342
apps/web/src/pages/PluginAdmin.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
Upload,
|
||||
Modal,
|
||||
Input,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Popconfirm,
|
||||
Form,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import {
|
||||
UploadOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
CloudDownloadOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
AppstoreOutlined,
|
||||
HeartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { PluginInfo, PluginStatus } from '../api/plugins';
|
||||
import {
|
||||
listPlugins,
|
||||
uploadPlugin,
|
||||
installPlugin,
|
||||
enablePlugin,
|
||||
disablePlugin,
|
||||
uninstallPlugin,
|
||||
purgePlugin,
|
||||
getPluginHealth,
|
||||
} from '../api/plugins';
|
||||
|
||||
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
|
||||
uploaded: { color: '#64748B', label: '已上传' },
|
||||
installed: { color: '#2563EB', label: '已安装' },
|
||||
enabled: { color: '#059669', label: '已启用' },
|
||||
running: { color: '#059669', label: '运行中' },
|
||||
disabled: { color: '#DC2626', label: '已禁用' },
|
||||
uninstalled: { color: '#9333EA', label: '已卸载' },
|
||||
};
|
||||
|
||||
export default function PluginAdmin() {
|
||||
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [manifestText, setManifestText] = useState('');
|
||||
const [wasmFile, setWasmFile] = useState<File | null>(null);
|
||||
const [detailPlugin, setDetailPlugin] = useState<PluginInfo | null>(null);
|
||||
const [healthDetail, setHealthDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const fetchPlugins = useCallback(async (p = page) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listPlugins(p);
|
||||
setPlugins(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载插件列表失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlugins();
|
||||
}, [fetchPlugins]);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!wasmFile || !manifestText.trim()) {
|
||||
message.warning('请选择 WASM 文件并填写 Manifest');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await uploadPlugin(wasmFile, manifestText);
|
||||
message.success('插件上传成功');
|
||||
setUploadModalOpen(false);
|
||||
setWasmFile(null);
|
||||
setManifestText('');
|
||||
fetchPlugins();
|
||||
} catch {
|
||||
message.error('插件上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (id: string, action: () => Promise<PluginInfo>, label: string) => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
await action();
|
||||
message.success(`${label}成功`);
|
||||
fetchPlugins();
|
||||
if (detailPlugin?.id === id) {
|
||||
setDetailPlugin(null);
|
||||
}
|
||||
} catch {
|
||||
message.error(`${label}失败`);
|
||||
}
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
const handleHealthCheck = async (id: string) => {
|
||||
try {
|
||||
const result = await getPluginHealth(id);
|
||||
setHealthDetail(result.details);
|
||||
} catch {
|
||||
message.error('健康检查失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getActions = (record: PluginInfo) => {
|
||||
const id = record.id;
|
||||
const btns: React.ReactNode[] = [];
|
||||
|
||||
switch (record.status) {
|
||||
case 'uploaded':
|
||||
btns.push(
|
||||
<Button
|
||||
key="install"
|
||||
size="small"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
loading={actionLoading === id}
|
||||
onClick={() => handleAction(id, () => installPlugin(id), '安装')}
|
||||
>
|
||||
安装
|
||||
</Button>,
|
||||
);
|
||||
break;
|
||||
case 'installed':
|
||||
btns.push(
|
||||
<Button
|
||||
key="enable"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={actionLoading === id}
|
||||
onClick={() => handleAction(id, () => enablePlugin(id), '启用')}
|
||||
>
|
||||
启用
|
||||
</Button>,
|
||||
);
|
||||
break;
|
||||
case 'enabled':
|
||||
case 'running':
|
||||
btns.push(
|
||||
<Button
|
||||
key="disable"
|
||||
size="small"
|
||||
danger
|
||||
icon={<PauseCircleOutlined />}
|
||||
loading={actionLoading === id}
|
||||
onClick={() => handleAction(id, () => disablePlugin(id), '停用')}
|
||||
>
|
||||
停用
|
||||
</Button>,
|
||||
);
|
||||
break;
|
||||
case 'disabled':
|
||||
btns.push(
|
||||
<Button
|
||||
key="uninstall"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
loading={actionLoading === id}
|
||||
onClick={() => handleAction(id, () => uninstallPlugin(id), '卸载')}
|
||||
>
|
||||
卸载
|
||||
</Button>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return btns;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: '版本', dataIndex: 'version', key: 'version', width: 80 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: PluginStatus) => {
|
||||
const cfg = STATUS_CONFIG[status] || { color: '#64748B', label: status };
|
||||
return <Tag color={cfg.color}>{cfg.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '作者', dataIndex: 'author', key: 'author', width: 120 },
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 320,
|
||||
render: (_: unknown, record: PluginInfo) => (
|
||||
<Space size="small">
|
||||
{getActions(record)}
|
||||
<Button size="small" onClick={() => setDetailPlugin(record)}>
|
||||
详情
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要清除该插件记录吗?"
|
||||
onConfirm={() => handleAction(record.id, async () => { await purgePlugin(record.id); return record; }, '清除')}
|
||||
>
|
||||
<Button size="small" danger disabled={record.status !== 'uninstalled'}>
|
||||
清除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Button icon={<UploadOutlined />} type="primary" onClick={() => setUploadModalOpen(true)}>
|
||||
上传插件
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchPlugins()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={plugins}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => setPage(p),
|
||||
showTotal: (t) => `共 ${t} 个插件`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="上传插件"
|
||||
open={uploadModalOpen}
|
||||
onOk={handleUpload}
|
||||
onCancel={() => setUploadModalOpen(false)}
|
||||
okText="上传"
|
||||
width={600}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="WASM 文件" required>
|
||||
<Upload
|
||||
beforeUpload={(file) => {
|
||||
setWasmFile(file);
|
||||
return false;
|
||||
}}
|
||||
maxCount={1}
|
||||
accept=".wasm"
|
||||
fileList={wasmFile ? [wasmFile as unknown as Parameters<typeof Upload>[0]] : []}
|
||||
onRemove={() => setWasmFile(null)}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择 WASM 文件</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
<Form.Item label="Manifest (TOML)" required>
|
||||
<Input.TextArea
|
||||
rows={12}
|
||||
value={manifestText}
|
||||
onChange={(e) => setManifestText(e.target.value)}
|
||||
placeholder="[metadata]
|
||||
id = "my-plugin"
|
||||
name = "我的插件"
|
||||
version = "0.1.0""
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={detailPlugin ? `插件详情: ${detailPlugin.name}` : '插件详情'}
|
||||
open={!!detailPlugin}
|
||||
onClose={() => {
|
||||
setDetailPlugin(null);
|
||||
setHealthDetail(null);
|
||||
}}
|
||||
width={500}
|
||||
>
|
||||
{detailPlugin && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="ID">{detailPlugin.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="名称">{detailPlugin.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">{detailPlugin.version}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_CONFIG[detailPlugin.status]?.color}>
|
||||
{STATUS_CONFIG[detailPlugin.status]?.label || detailPlugin.status}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="作者">{detailPlugin.author || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="描述">{detailPlugin.description || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="安装时间">{detailPlugin.installed_at || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="启用时间">{detailPlugin.enabled_at || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="实体数量">{detailPlugin.entities.length}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
icon={<HeartOutlined />}
|
||||
onClick={() => detailPlugin && handleHealthCheck(detailPlugin.id)}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
健康检查
|
||||
</Button>
|
||||
{healthDetail && (
|
||||
<pre
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(healthDetail, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
apps/web/src/pages/PluginCRUDPage.tsx
Normal file
256
apps/web/src/pages/PluginCRUDPage.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
DatePicker,
|
||||
Switch,
|
||||
Select,
|
||||
Tag,
|
||||
message,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
listPluginData,
|
||||
createPluginData,
|
||||
updatePluginData,
|
||||
deletePluginData,
|
||||
} from '../api/pluginData';
|
||||
import { getPluginSchema } from '../api/plugins';
|
||||
|
||||
interface FieldDef {
|
||||
name: string;
|
||||
field_type: string;
|
||||
required: boolean;
|
||||
display_name?: string;
|
||||
ui_widget?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
interface EntitySchema {
|
||||
name: string;
|
||||
display_name: string;
|
||||
fields: FieldDef[];
|
||||
}
|
||||
|
||||
export default function PluginCRUDPage() {
|
||||
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
|
||||
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fields, setFields] = useState<FieldDef[]>([]);
|
||||
const [displayName, setDisplayName] = useState(entityName || '');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 加载 schema
|
||||
useEffect(() => {
|
||||
if (!pluginId) return;
|
||||
getPluginSchema(pluginId)
|
||||
.then((schema) => {
|
||||
const entities = (schema as { entities?: EntitySchema[] }).entities || [];
|
||||
const entity = entities.find((e) => e.name === entityName);
|
||||
if (entity) {
|
||||
setFields(entity.fields);
|
||||
setDisplayName(entity.display_name || entityName || '');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// schema 加载失败时仍可使用
|
||||
});
|
||||
}, [pluginId, entityName]);
|
||||
|
||||
const fetchData = useCallback(async (p = page) => {
|
||||
if (!pluginId || !entityName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listPluginData(pluginId, entityName, p);
|
||||
setRecords(result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })));
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载数据失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [pluginId, entityName, page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||||
if (!pluginId || !entityName) return;
|
||||
// 去除内部字段
|
||||
const { _id, _version, ...data } = values as Record<string, unknown> & { _id?: string; _version?: number };
|
||||
|
||||
try {
|
||||
if (editRecord) {
|
||||
await updatePluginData(
|
||||
pluginId,
|
||||
entityName,
|
||||
editRecord._id as string,
|
||||
data,
|
||||
editRecord._version as number,
|
||||
);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createPluginData(pluginId, entityName, data);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditRecord(null);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (record: Record<string, unknown>) => {
|
||||
if (!pluginId || !entityName) return;
|
||||
try {
|
||||
await deletePluginData(pluginId, entityName, record._id as string);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 动态生成列
|
||||
const columns = [
|
||||
...fields.slice(0, 5).map((f) => ({
|
||||
title: f.display_name || f.name,
|
||||
dataIndex: f.name,
|
||||
key: f.name,
|
||||
ellipsis: true,
|
||||
render: (val: unknown) => {
|
||||
if (typeof val === 'boolean') return val ? <Tag color="green">是</Tag> : <Tag>否</Tag>;
|
||||
return String(val ?? '-');
|
||||
},
|
||||
})),
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_: unknown, record: Record<string, unknown>) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditRecord(record);
|
||||
form.setFieldsValue(record);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 动态生成表单字段
|
||||
const renderFormField = (field: FieldDef) => {
|
||||
const widget = field.ui_widget || field.field_type;
|
||||
switch (widget) {
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'float':
|
||||
case 'decimal':
|
||||
return <InputNumber style={{ width: '100%' }} />;
|
||||
case 'boolean':
|
||||
return <Switch />;
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
return <DatePicker showTime={widget === 'datetime'} style={{ width: '100%' }} />;
|
||||
case 'select':
|
||||
return (
|
||||
<Select>
|
||||
{(field.options || []).map((opt) => (
|
||||
<Select.Option key={String(opt.value)} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
default:
|
||||
return <Input />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ margin: 0 }}>{displayName}</h2>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setEditRecord(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
rowKey="_id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => setPage(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editRecord ? '编辑' : '新增'}
|
||||
open={modalOpen}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditRecord(null);
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
{fields.map((field) => (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
label={field.display_name || field.name}
|
||||
rules={field.required ? [{ required: true, message: `请输入${field.display_name || field.name}` }] : []}
|
||||
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
|
||||
>
|
||||
{renderFormField(field)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
373
apps/web/src/pages/Roles.tsx
Normal file
373
apps/web/src/pages/Roles.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Checkbox,
|
||||
message,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
listRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
assignPermissions,
|
||||
getRolePermissions,
|
||||
listPermissions,
|
||||
type RoleInfo,
|
||||
type PermissionInfo,
|
||||
} from '../api/roles';
|
||||
|
||||
export default function Roles() {
|
||||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editRole, setEditRole] = useState<RoleInfo | null>(null);
|
||||
const [permModalOpen, setPermModalOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<RoleInfo | null>(null);
|
||||
const [selectedPermIds, setSelectedPermIds] = useState<string[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listRoles();
|
||||
setRoles(result.data);
|
||||
} catch {
|
||||
message.error('加载角色失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
setPermissions(await listPermissions());
|
||||
} catch {
|
||||
// 静默处理
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
fetchPermissions();
|
||||
}, [fetchRoles, fetchPermissions]);
|
||||
|
||||
const handleCreate = async (values: {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (editRole) {
|
||||
await updateRole(editRole.id, { ...values, version: editRole.version });
|
||||
message.success('角色更新成功');
|
||||
} else {
|
||||
await createRole(values);
|
||||
message.success('角色创建成功');
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setEditRole(null);
|
||||
form.resetFields();
|
||||
fetchRoles();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteRole(id);
|
||||
message.success('角色已删除');
|
||||
fetchRoles();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openPermModal = async (role: RoleInfo) => {
|
||||
setSelectedRole(role);
|
||||
try {
|
||||
const rolePerms = await getRolePermissions(role.id);
|
||||
setSelectedPermIds(rolePerms.map((p) => p.id));
|
||||
} catch {
|
||||
setSelectedPermIds([]);
|
||||
}
|
||||
setPermModalOpen(true);
|
||||
};
|
||||
|
||||
const savePermissions = async () => {
|
||||
if (!selectedRole) return;
|
||||
try {
|
||||
await assignPermissions(selectedRole.id, selectedPermIds);
|
||||
message.success('权限分配成功');
|
||||
setPermModalOpen(false);
|
||||
} catch {
|
||||
message.error('权限分配失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (role: RoleInfo) => {
|
||||
setEditRole(role);
|
||||
form.setFieldsValue({
|
||||
name: role.name,
|
||||
code: role.code,
|
||||
description: role.description,
|
||||
});
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditRole(null);
|
||||
form.resetFields();
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
setCreateModalOpen(false);
|
||||
setEditRole(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '角色名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (v: string, record: RoleInfo) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: record.is_system
|
||||
? 'linear-gradient(135deg, #4F46E5, #818CF8)'
|
||||
: isDark ? '#1E293B' : '#F1F5F9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: record.is_system ? '#fff' : isDark ? '#94A3B8' : '#64748B',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<span style={{ fontWeight: 500 }}>{v}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '编码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
render: (v: string) => (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
border: 'none',
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
}}>
|
||||
{v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
render: (v: string | undefined) => (
|
||||
<span style={{ color: isDark ? '#64748B' : '#94A3B8' }}>{v || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'is_system',
|
||||
key: 'is_system',
|
||||
width: 100,
|
||||
render: (v: boolean) => (
|
||||
<Tag
|
||||
style={{
|
||||
color: v ? '#4F46E5' : (isDark ? '#94A3B8' : '#64748B'),
|
||||
background: v ? '#EEF2FF' : (isDark ? '#1E293B' : '#F1F5F9'),
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{v ? '系统' : '自定义'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (_: unknown, record: RoleInfo) => (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
onClick={() => openPermModal(record)}
|
||||
style={{ color: '#4F46E5' }}
|
||||
>
|
||||
权限
|
||||
</Button>
|
||||
{!record.is_system && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditModal(record)}
|
||||
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此角色?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
/>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const groupedPermissions = permissions.reduce<Record<string, PermissionInfo[]>>(
|
||||
(acc, p) => {
|
||||
if (!acc[p.resource]) acc[p.resource] = [];
|
||||
acc[p.resource].push(p);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题和工具栏 */}
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>角色管理</h4>
|
||||
<div className="erp-page-subtitle">管理系统角色和权限分配</div>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
新建角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 表格容器 */}
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={roles}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条记录` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 新建/编辑角色弹窗 */}
|
||||
<Modal
|
||||
title={editRole ? '编辑角色' : '新建角色'}
|
||||
open={createModalOpen}
|
||||
onCancel={closeCreateModal}
|
||||
onOk={() => form.submit()}
|
||||
width={480}
|
||||
>
|
||||
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入角色名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="编码"
|
||||
rules={[{ required: true, message: '请输入角色编码' }]}
|
||||
>
|
||||
<Input disabled={!!editRole} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 权限分配弹窗 */}
|
||||
<Modal
|
||||
title={`权限分配 - ${selectedRole?.name || ''}`}
|
||||
open={permModalOpen}
|
||||
onCancel={() => setPermModalOpen(false)}
|
||||
onOk={savePermissions}
|
||||
width={600}
|
||||
>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{Object.entries(groupedPermissions).map(([resource, perms]) => (
|
||||
<div
|
||||
key={resource}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`,
|
||||
background: isDark ? '#0B0F1A' : '#F8FAFC',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontWeight: 600,
|
||||
marginBottom: 12,
|
||||
textTransform: 'capitalize',
|
||||
color: isDark ? '#E2E8F0' : '#334155',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
{resource}
|
||||
</div>
|
||||
<Checkbox.Group
|
||||
value={selectedPermIds}
|
||||
onChange={(values) => setSelectedPermIds(values as string[])}
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}
|
||||
>
|
||||
{perms.map((p) => (
|
||||
<Checkbox
|
||||
key={p.id}
|
||||
value={p.id}
|
||||
style={{ marginRight: 0 }}
|
||||
>
|
||||
{p.name}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
apps/web/src/pages/Settings.tsx
Normal file
121
apps/web/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Tabs } from 'antd';
|
||||
import {
|
||||
BookOutlined,
|
||||
GlobalOutlined,
|
||||
MenuOutlined,
|
||||
NumberOutlined,
|
||||
SettingOutlined,
|
||||
BgColorsOutlined,
|
||||
AuditOutlined,
|
||||
LockOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import DictionaryManager from './settings/DictionaryManager';
|
||||
import LanguageManager from './settings/LanguageManager';
|
||||
import MenuConfig from './settings/MenuConfig';
|
||||
import NumberingRules from './settings/NumberingRules';
|
||||
import SystemSettings from './settings/SystemSettings';
|
||||
import ThemeSettings from './settings/ThemeSettings';
|
||||
import AuditLogViewer from './settings/AuditLogViewer';
|
||||
import ChangePassword from './settings/ChangePassword';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="erp-page-header" style={{ borderBottom: 'none', marginBottom: 0, paddingBottom: 8 }}>
|
||||
<div>
|
||||
<h4>系统设置</h4>
|
||||
<div className="erp-page-subtitle">管理系统参数、字典、菜单、主题等配置</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="dictionaries"
|
||||
style={{ marginTop: 8 }}
|
||||
items={[
|
||||
{
|
||||
key: 'dictionaries',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<BookOutlined style={{ fontSize: 14 }} />
|
||||
数据字典
|
||||
</span>
|
||||
),
|
||||
children: <DictionaryManager />,
|
||||
},
|
||||
{
|
||||
key: 'languages',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<GlobalOutlined style={{ fontSize: 14 }} />
|
||||
语言管理
|
||||
</span>
|
||||
),
|
||||
children: <LanguageManager />,
|
||||
},
|
||||
{
|
||||
key: 'menus',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<MenuOutlined style={{ fontSize: 14 }} />
|
||||
菜单配置
|
||||
</span>
|
||||
),
|
||||
children: <MenuConfig />,
|
||||
},
|
||||
{
|
||||
key: 'numbering',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<NumberOutlined style={{ fontSize: 14 }} />
|
||||
编号规则
|
||||
</span>
|
||||
),
|
||||
children: <NumberingRules />,
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<SettingOutlined style={{ fontSize: 14 }} />
|
||||
系统参数
|
||||
</span>
|
||||
),
|
||||
children: <SystemSettings />,
|
||||
},
|
||||
{
|
||||
key: 'theme',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<BgColorsOutlined style={{ fontSize: 14 }} />
|
||||
主题设置
|
||||
</span>
|
||||
),
|
||||
children: <ThemeSettings />,
|
||||
},
|
||||
{
|
||||
key: 'audit-log',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<AuditOutlined style={{ fontSize: 14 }} />
|
||||
审计日志
|
||||
</span>
|
||||
),
|
||||
children: <AuditLogViewer />,
|
||||
},
|
||||
{
|
||||
key: 'change-password',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<LockOutlined style={{ fontSize: 14 }} />
|
||||
修改密码
|
||||
</span>
|
||||
),
|
||||
children: <ChangePassword />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
473
apps/web/src/pages/Users.tsx
Normal file
473
apps/web/src/pages/Users.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Checkbox,
|
||||
message,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
UserOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
StopOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
assignRoles,
|
||||
type CreateUserRequest,
|
||||
type UpdateUserRequest,
|
||||
} from '../api/users';
|
||||
import { listRoles, type RoleInfo } from '../api/roles';
|
||||
import type { UserInfo } from '../api/auth';
|
||||
|
||||
const STATUS_COLOR_MAP: Record<string, string> = {
|
||||
active: '#059669',
|
||||
disabled: '#DC2626',
|
||||
locked: '#D97706',
|
||||
};
|
||||
|
||||
const STATUS_BG_MAP: Record<string, string> = {
|
||||
active: '#ECFDF5',
|
||||
disabled: '#FEF2F2',
|
||||
locked: '#FFFBEB',
|
||||
};
|
||||
|
||||
const STATUS_LABEL_MAP: Record<string, string> = {
|
||||
active: '正常',
|
||||
disabled: '禁用',
|
||||
locked: '锁定',
|
||||
};
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState<UserInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editUser, setEditUser] = useState<UserInfo | null>(null);
|
||||
const [roleModalOpen, setRoleModalOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null);
|
||||
const [allRoles, setAllRoles] = useState<RoleInfo[]>([]);
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
const fetchUsers = useCallback(async (p = page) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listUsers(p, 20, searchText);
|
||||
setUsers(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载用户列表失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [page, searchText]);
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
try {
|
||||
const result = await listRoles();
|
||||
setAllRoles(result.data);
|
||||
} catch {
|
||||
// 静默处理
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchRoles();
|
||||
}, [fetchUsers, fetchRoles]);
|
||||
|
||||
const handleCreateOrEdit = async (values: {
|
||||
username: string;
|
||||
password?: string;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (editUser) {
|
||||
const req: UpdateUserRequest = {
|
||||
display_name: values.display_name,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
version: editUser.version,
|
||||
};
|
||||
await updateUser(editUser.id, req);
|
||||
message.success('用户更新成功');
|
||||
} else {
|
||||
const req: CreateUserRequest = {
|
||||
username: values.username,
|
||||
password: values.password ?? '',
|
||||
display_name: values.display_name,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
};
|
||||
await createUser(req);
|
||||
message.success('用户创建成功');
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setEditUser(null);
|
||||
form.resetFields();
|
||||
fetchUsers();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteUser(id);
|
||||
message.success('用户已删除');
|
||||
fetchUsers();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (id: string, status: string) => {
|
||||
try {
|
||||
const user = users.find(u => u.id === id);
|
||||
if (!user) return;
|
||||
await updateUser(id, { status, version: user.version });
|
||||
message.success(status === 'disabled' ? '用户已禁用' : '用户已启用');
|
||||
fetchUsers();
|
||||
} catch {
|
||||
message.error('状态更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignRoles = async () => {
|
||||
if (!selectedUser) return;
|
||||
try {
|
||||
await assignRoles(selectedUser.id, selectedRoleIds);
|
||||
message.success('角色分配成功');
|
||||
setRoleModalOpen(false);
|
||||
fetchUsers();
|
||||
} catch {
|
||||
message.error('角色分配失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditUser(null);
|
||||
form.resetFields();
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (user: UserInfo) => {
|
||||
setEditUser(user);
|
||||
form.setFieldsValue({
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
});
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
setCreateModalOpen(false);
|
||||
setEditUser(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const openRoleModal = (user: UserInfo) => {
|
||||
setSelectedUser(user);
|
||||
setSelectedRoleIds(user.roles.map((r) => r.id));
|
||||
setRoleModalOpen(true);
|
||||
};
|
||||
|
||||
const filteredUsers = users;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
render: (v: string, record: UserInfo) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #4F46E5, #818CF8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{(record.display_name?.[0] || v?.[0] || 'U').toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 14 }}>{v}</div>
|
||||
{record.display_name && (
|
||||
<div style={{ fontSize: 12, color: isDark ? '#64748B' : '#94A3B8' }}>
|
||||
{record.display_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
render: (v: string | undefined) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
render: (v: string | undefined) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => (
|
||||
<Tag
|
||||
style={{
|
||||
color: STATUS_COLOR_MAP[status] || '#64748B',
|
||||
background: STATUS_BG_MAP[status] || '#F1F5F9',
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL_MAP[status] || status}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'roles',
|
||||
key: 'roles',
|
||||
render: (roles: RoleInfo[]) =>
|
||||
roles.length > 0
|
||||
? roles.map((r) => (
|
||||
<Tag key={r.id} style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
border: 'none',
|
||||
color: isDark ? '#CBD5E1' : '#475569',
|
||||
}}>
|
||||
{r.name}
|
||||
</Tag>
|
||||
))
|
||||
: <span style={{ color: isDark ? '#475569' : '#CBD5E1' }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 240,
|
||||
render: (_: unknown, record: UserInfo) => (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditModal(record)}
|
||||
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
onClick={() => openRoleModal(record)}
|
||||
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
|
||||
/>
|
||||
{record.status === 'active' ? (
|
||||
<Popconfirm
|
||||
title="确定禁用此用户?"
|
||||
onConfirm={() => handleToggleStatus(record.id, 'disabled')}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<StopOutlined />}
|
||||
danger
|
||||
/>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() => handleToggleStatus(record.id, 'active')}
|
||||
style={{ color: '#059669' }}
|
||||
/>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定删除此用户?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题和工具栏 */}
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>用户管理</h4>
|
||||
<div className="erp-page-subtitle">管理系统用户账户、角色分配和状态</div>
|
||||
</div>
|
||||
<Space size={8}>
|
||||
<Input
|
||||
placeholder="搜索用户名..."
|
||||
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220, borderRadius: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
新建用户
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 表格容器 */}
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredUsers}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => {
|
||||
setPage(p);
|
||||
fetchUsers(p);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
style: { padding: '12px 16px', margin: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 新建/编辑用户弹窗 */}
|
||||
<Modal
|
||||
title={editUser ? '编辑用户' : '新建用户'}
|
||||
open={createModalOpen}
|
||||
onCancel={closeCreateModal}
|
||||
onOk={() => form.submit()}
|
||||
width={480}
|
||||
>
|
||||
<Form form={form} onFinish={handleCreateOrEdit} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined style={{ color: '#94A3B8' }} />} disabled={!!editUser} />
|
||||
</Form.Item>
|
||||
{!editUser && (
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="display_name" label="显示名">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
||||
>
|
||||
<Input type="email" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 角色分配弹窗 */}
|
||||
<Modal
|
||||
title={`分配角色 - ${selectedUser?.username || ''}`}
|
||||
open={roleModalOpen}
|
||||
onCancel={() => setRoleModalOpen(false)}
|
||||
onOk={handleAssignRoles}
|
||||
width={480}
|
||||
>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Checkbox.Group
|
||||
value={selectedRoleIds}
|
||||
onChange={(values) => setSelectedRoleIds(values as string[])}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
>
|
||||
{allRoles.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`,
|
||||
background: isDark ? '#0B0F1A' : '#F8FAFC',
|
||||
}}
|
||||
>
|
||||
<Checkbox value={r.id}>
|
||||
<span style={{ fontWeight: 500 }}>{r.name}</span>
|
||||
<span style={{ color: isDark ? '#475569' : '#94A3B8', marginLeft: 8, fontSize: 12 }}>
|
||||
{r.code}
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
apps/web/src/pages/Workflow.tsx
Normal file
70
apps/web/src/pages/Workflow.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import { Tabs } from 'antd';
|
||||
import { PartitionOutlined, FileSearchOutlined, CheckSquareOutlined, MonitorOutlined } from '@ant-design/icons';
|
||||
import ProcessDefinitions from './workflow/ProcessDefinitions';
|
||||
import PendingTasks from './workflow/PendingTasks';
|
||||
import CompletedTasks from './workflow/CompletedTasks';
|
||||
import InstanceMonitor from './workflow/InstanceMonitor';
|
||||
|
||||
export default function Workflow() {
|
||||
const [activeKey, setActiveKey] = useState('definitions');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="erp-page-header" style={{ borderBottom: 'none', marginBottom: 0, paddingBottom: 8 }}>
|
||||
<div>
|
||||
<h4>工作流引擎</h4>
|
||||
<div className="erp-page-subtitle">管理流程定义、审批任务和流程监控</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
style={{ marginTop: 8 }}
|
||||
items={[
|
||||
{
|
||||
key: 'definitions',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<PartitionOutlined style={{ fontSize: 14 }} />
|
||||
流程定义
|
||||
</span>
|
||||
),
|
||||
children: <ProcessDefinitions />,
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<FileSearchOutlined style={{ fontSize: 14 }} />
|
||||
我的待办
|
||||
</span>
|
||||
),
|
||||
children: <PendingTasks />,
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<CheckSquareOutlined style={{ fontSize: 14 }} />
|
||||
我的已办
|
||||
</span>
|
||||
),
|
||||
children: <CompletedTasks />,
|
||||
},
|
||||
{
|
||||
key: 'instances',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<MonitorOutlined style={{ fontSize: 14 }} />
|
||||
流程监控
|
||||
</span>
|
||||
),
|
||||
children: <InstanceMonitor />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
194
apps/web/src/pages/messages/MessageTemplates.tsx
Normal file
194
apps/web/src/pages/messages/MessageTemplates.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, message, theme, Tag } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { listTemplates, createTemplate, type MessageTemplateInfo } from '../../api/messageTemplates';
|
||||
|
||||
const channelMap: Record<string, { label: string; color: string }> = {
|
||||
in_app: { label: '站内', color: '#4F46E5' },
|
||||
email: { label: '邮件', color: '#059669' },
|
||||
sms: { label: '短信', color: '#D97706' },
|
||||
wechat: { label: '微信', color: '#7C3AED' },
|
||||
};
|
||||
|
||||
export default function MessageTemplates() {
|
||||
const [data, setData] = useState<MessageTemplateInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
const fetchData = useCallback(async (p = page) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listTemplates(p, 20);
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载模板列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(1);
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await createTemplate(values);
|
||||
message.success('模板创建成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<MessageTemplateInfo> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '编码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
render: (v: string) => (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
border: 'none',
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
}}>
|
||||
{v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '通道',
|
||||
dataIndex: 'channel',
|
||||
key: 'channel',
|
||||
width: 90,
|
||||
render: (c: string) => {
|
||||
const info = channelMap[c] || { label: c, color: '#64748B' };
|
||||
return (
|
||||
<Tag style={{
|
||||
background: info.color + '15',
|
||||
border: 'none',
|
||||
color: info.color,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{info.label}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '标题模板',
|
||||
dataIndex: 'title_template',
|
||||
key: 'title_template',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '语言',
|
||||
dataIndex: 'language',
|
||||
key: 'language',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (v: string) => (
|
||||
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>{v}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
|
||||
共 {total} 个模板
|
||||
</span>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
||||
新建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => { setPage(p); fetchData(p); },
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="新建消息模板"
|
||||
open={modalOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => { setModalOpen(false); form.resetFields(); }}
|
||||
width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请输入编码' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="channel" label="通道" initialValue="in_app">
|
||||
<Select options={[
|
||||
{ value: 'in_app', label: '站内' },
|
||||
{ value: 'email', label: '邮件' },
|
||||
{ value: 'sms', label: '短信' },
|
||||
{ value: 'wechat', label: '微信' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="title_template" label="标题模板" rules={[{ required: true, message: '请输入标题模板' }]}>
|
||||
<Input placeholder="支持 {{variable}} 变量插值" />
|
||||
</Form.Item>
|
||||
<Form.Item name="body_template" label="内容模板" rules={[{ required: true, message: '请输入内容模板' }]}>
|
||||
<Input.TextArea rows={4} placeholder="支持 {{variable}} 变量插值" />
|
||||
</Form.Item>
|
||||
<Form.Item name="language" label="语言" initialValue="zh-CN">
|
||||
<Select options={[
|
||||
{ value: 'zh-CN', label: '中文' },
|
||||
{ value: 'en-US', label: '英文' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
apps/web/src/pages/messages/NotificationList.tsx
Normal file
248
apps/web/src/pages/messages/NotificationList.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Table, Button, Tag, Space, Modal, Typography, message, theme } from 'antd';
|
||||
import { CheckOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { listMessages, markRead, markAllRead, deleteMessage, type MessageInfo, type MessageQuery } from '../../api/messages';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
interface Props {
|
||||
queryFilter?: MessageQuery;
|
||||
}
|
||||
|
||||
const priorityStyles: Record<string, { bg: string; color: string; text: string }> = {
|
||||
urgent: { bg: '#FEF2F2', color: '#DC2626', text: '紧急' },
|
||||
important: { bg: '#FFFBEB', color: '#D97706', text: '重要' },
|
||||
normal: { bg: '#EEF2FF', color: '#4F46E5', text: '普通' },
|
||||
};
|
||||
|
||||
export default function NotificationList({ queryFilter }: Props) {
|
||||
const [data, setData] = useState<MessageInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
const fetchData = useCallback(async (p = page, filter?: MessageQuery) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listMessages({ page: p, page_size: 20, ...filter });
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载消息列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
const filterKey = useMemo(() => JSON.stringify(queryFilter), [queryFilter]);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
fetchData(1, queryFilter);
|
||||
}
|
||||
}, [filterKey, fetchData, queryFilter]);
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
try {
|
||||
await markRead(id);
|
||||
fetchData(page, queryFilter);
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await markAllRead();
|
||||
fetchData(page, queryFilter);
|
||||
message.success('已全部标记为已读');
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteMessage(id);
|
||||
fetchData(page, queryFilter);
|
||||
message.success('已删除');
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const showDetail = (record: MessageInfo) => {
|
||||
Modal.info({
|
||||
title: record.title,
|
||||
width: 520,
|
||||
content: (
|
||||
<div>
|
||||
<Paragraph>{record.body}</Paragraph>
|
||||
<div style={{ marginTop: 8, color: isDark ? '#475569' : '#94A3B8', fontSize: 12 }}>
|
||||
{record.created_at}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
if (!record.is_read) {
|
||||
handleMarkRead(record.id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<MessageInfo> = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (text: string, record) => (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: record.is_read ? 400 : 600,
|
||||
cursor: 'pointer',
|
||||
color: record.is_read ? (isDark ? '#94A3B8' : '#64748B') : 'inherit',
|
||||
}}
|
||||
onClick={() => showDetail(record)}
|
||||
>
|
||||
{!record.is_read && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: '#4F46E5',
|
||||
marginRight: 8,
|
||||
}} />
|
||||
)}
|
||||
{text}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '优先级',
|
||||
dataIndex: 'priority',
|
||||
key: 'priority',
|
||||
width: 90,
|
||||
render: (p: string) => {
|
||||
const info = priorityStyles[p] || { bg: '#F1F5F9', color: '#64748B', text: p };
|
||||
return (
|
||||
<Tag style={{
|
||||
background: info.bg,
|
||||
border: 'none',
|
||||
color: info.color,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{info.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '发送者',
|
||||
dataIndex: 'sender_type',
|
||||
key: 'sender_type',
|
||||
width: 80,
|
||||
render: (s: string) => <span style={{ color: isDark ? '#64748B' : '#94A3B8' }}>{s === 'system' ? '系统' : '用户'}</span>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_read',
|
||||
key: 'is_read',
|
||||
width: 80,
|
||||
render: (r: boolean) => (
|
||||
<Tag style={{
|
||||
background: r ? (isDark ? '#1E293B' : '#F1F5F9') : '#EEF2FF',
|
||||
border: 'none',
|
||||
color: r ? (isDark ? '#64748B' : '#94A3B8') : '#4F46E5',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{r ? '已读' : '未读'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (v: string) => (
|
||||
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>{v}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_: unknown, record) => (
|
||||
<Space size={4}>
|
||||
{!record.is_read && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => handleMarkRead(record.id)}
|
||||
style={{ color: '#4F46E5' }}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => showDetail(record)}
|
||||
style={{ color: isDark ? '#64748B' : '#94A3B8' }}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.id)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
|
||||
共 {total} 条消息
|
||||
</span>
|
||||
<Button icon={<CheckOutlined />} onClick={handleMarkAllRead}>
|
||||
全部标记已读
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => { setPage(p); fetchData(p, queryFilter); },
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
apps/web/src/pages/messages/NotificationPreferences.tsx
Normal file
79
apps/web/src/pages/messages/NotificationPreferences.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Form, Switch, TimePicker, Button, message, theme } from 'antd';
|
||||
import { BellOutlined } from '@ant-design/icons';
|
||||
import client from '../../api/client';
|
||||
|
||||
interface PreferencesData {
|
||||
dnd_enabled: boolean;
|
||||
dnd_start?: string;
|
||||
dnd_end?: string;
|
||||
}
|
||||
|
||||
export default function NotificationPreferences() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dndEnabled, setDndEnabled] = useState(false);
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({ dnd_enabled: false });
|
||||
}, [form]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const req: PreferencesData = {
|
||||
dnd_enabled: values.dnd_enabled || false,
|
||||
dnd_start: values.dnd_range?.[0]?.format('HH:mm'),
|
||||
dnd_end: values.dnd_range?.[1]?.format('HH:mm'),
|
||||
};
|
||||
|
||||
await client.put('/message-subscriptions', {
|
||||
dnd_enabled: req.dnd_enabled,
|
||||
dnd_start: req.dnd_start,
|
||||
dnd_end: req.dnd_end,
|
||||
});
|
||||
|
||||
message.success('偏好设置已保存');
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
padding: 24,
|
||||
maxWidth: 600,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 20 }}>
|
||||
<BellOutlined style={{ fontSize: 16, color: '#4F46E5' }} />
|
||||
<span style={{ fontSize: 15, fontWeight: 600 }}>通知偏好设置</span>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="dnd_enabled" label="免打扰模式" valuePropName="checked">
|
||||
<Switch onChange={setDndEnabled} />
|
||||
</Form.Item>
|
||||
|
||||
{dndEnabled && (
|
||||
<Form.Item name="dnd_range" label="免打扰时段">
|
||||
<TimePicker.RangePicker format="HH:mm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSave} loading={loading}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
apps/web/src/pages/settings/AuditLogViewer.tsx
Normal file
206
apps/web/src/pages/settings/AuditLogViewer.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Select, Input, Tag, message, theme } from 'antd';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import { listAuditLogs, type AuditLogItem, type AuditLogQuery } from '../../api/auditLogs';
|
||||
|
||||
const RESOURCE_TYPE_OPTIONS = [
|
||||
{ value: 'user', label: '用户' },
|
||||
{ value: 'role', label: '角色' },
|
||||
{ value: 'organization', label: '组织' },
|
||||
{ value: 'department', label: '部门' },
|
||||
{ value: 'position', label: '岗位' },
|
||||
{ value: 'process_instance', label: '流程实例' },
|
||||
{ value: 'dictionary', label: '字典' },
|
||||
{ value: 'menu', label: '菜单' },
|
||||
{ value: 'setting', label: '设置' },
|
||||
{ value: 'numbering_rule', label: '编号规则' },
|
||||
];
|
||||
|
||||
const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = {
|
||||
create: { bg: '#ECFDF5', color: '#059669', text: '创建' },
|
||||
update: { bg: '#EEF2FF', color: '#4F46E5', text: '更新' },
|
||||
delete: { bg: '#FEF2F2', color: '#DC2626', text: '删除' },
|
||||
};
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function AuditLogViewer() {
|
||||
const [logs, setLogs] = useState<AuditLogItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState<AuditLogQuery>({ page: 1, page_size: 20 });
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
const fetchLogs = useCallback(async (params: AuditLogQuery) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listAuditLogs(params);
|
||||
setLogs(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载审计日志失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(query);
|
||||
}, [query, fetchLogs]);
|
||||
|
||||
const handleFilterChange = (field: keyof AuditLogQuery, value: string | undefined) => {
|
||||
setQuery((prev) => ({
|
||||
...prev,
|
||||
[field]: value || undefined,
|
||||
page: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination: TablePaginationConfig) => {
|
||||
setQuery((prev) => ({
|
||||
...prev,
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
}));
|
||||
};
|
||||
|
||||
const columns: ColumnsType<AuditLogItem> = [
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (action: string) => {
|
||||
const info = ACTION_STYLES[action] || { bg: '#F1F5F9', color: '#64748B', text: action };
|
||||
return (
|
||||
<Tag style={{
|
||||
background: info.bg,
|
||||
border: 'none',
|
||||
color: info.color,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{info.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '资源类型',
|
||||
dataIndex: 'resource_type',
|
||||
key: 'resource_type',
|
||||
width: 120,
|
||||
render: (v: string) => (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
border: 'none',
|
||||
color: isDark ? '#CBD5E1' : '#475569',
|
||||
}}>
|
||||
{v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '资源 ID',
|
||||
dataIndex: 'resource_id',
|
||||
key: 'resource_id',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}>
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作用户',
|
||||
dataIndex: 'user_id',
|
||||
key: 'user_id',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}>
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (value: string) => (
|
||||
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
|
||||
{formatDateTime(value)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 筛选工具栏 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
}}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="资源类型"
|
||||
style={{ width: 160 }}
|
||||
options={RESOURCE_TYPE_OPTIONS}
|
||||
value={query.resource_type}
|
||||
onChange={(value) => handleFilterChange('resource_type', value)}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="操作用户 ID"
|
||||
style={{ width: 240 }}
|
||||
value={query.user_id ?? ''}
|
||||
onChange={(e) => handleFilterChange('user_id', e.target.value)}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8', marginLeft: 'auto' }}>
|
||||
共 {total} 条日志
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={logs}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: query.page,
|
||||
pageSize: query.page_size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/pages/settings/ChangePassword.tsx
Normal file
95
apps/web/src/pages/settings/ChangePassword.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Form, Input, Button, message, Card, Typography } from 'antd';
|
||||
import { LockOutlined } from '@ant-design/icons';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { changePassword } from '../../api/auth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function ChangePassword() {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = async (values: {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
}) => {
|
||||
if (values.new_password !== values.confirm_password) {
|
||||
messageApi.error('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await changePassword(values.current_password, values.new_password);
|
||||
messageApi.success('密码修改成功,请重新登录');
|
||||
await logout();
|
||||
navigate('/login');
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '密码修改失败';
|
||||
messageApi.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ maxWidth: 480, margin: '0 auto' }}>
|
||||
{contextHolder}
|
||||
<Title level={4} style={{ marginBottom: 24 }}>
|
||||
修改密码
|
||||
</Title>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
name="current_password"
|
||||
label="当前密码"
|
||||
rules={[{ required: true, message: '请输入当前密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请输入当前密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="new_password"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码长度不能少于6位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm_password"
|
||||
label="确认新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('new_password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请再次输入新密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
确认修改
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
346
apps/web/src/pages/settings/DictionaryManager.tsx
Normal file
346
apps/web/src/pages/settings/DictionaryManager.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Popconfirm,
|
||||
message,
|
||||
Typography,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
listDictionaries,
|
||||
createDictionary,
|
||||
updateDictionary,
|
||||
deleteDictionary,
|
||||
createDictionaryItem,
|
||||
updateDictionaryItem,
|
||||
deleteDictionaryItem,
|
||||
type DictionaryInfo,
|
||||
type DictionaryItemInfo,
|
||||
type CreateDictionaryRequest,
|
||||
type CreateDictionaryItemRequest,
|
||||
type UpdateDictionaryItemRequest,
|
||||
} from '../../api/dictionaries';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type DictItem = DictionaryItemInfo;
|
||||
type Dictionary = DictionaryInfo;
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export default function DictionaryManager() {
|
||||
const [dictionaries, setDictionaries] = useState<Dictionary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dictModalOpen, setDictModalOpen] = useState(false);
|
||||
const [editDict, setEditDict] = useState<Dictionary | null>(null);
|
||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||
const [activeDictId, setActiveDictId] = useState<string | null>(null);
|
||||
const [editItem, setEditItem] = useState<DictItem | null>(null);
|
||||
const [dictForm] = Form.useForm();
|
||||
const [itemForm] = Form.useForm();
|
||||
|
||||
const fetchDictionaries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listDictionaries();
|
||||
setDictionaries(Array.isArray(result) ? result : result.data ?? []);
|
||||
} catch {
|
||||
message.error('加载字典列表失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDictionaries();
|
||||
}, [fetchDictionaries]);
|
||||
|
||||
// --- Dictionary CRUD ---
|
||||
|
||||
const handleDictSubmit = async (values: CreateDictionaryRequest) => {
|
||||
try {
|
||||
if (editDict) {
|
||||
await updateDictionary(editDict.id, values);
|
||||
message.success('字典更新成功');
|
||||
} else {
|
||||
await createDictionary(values);
|
||||
message.success('字典创建成功');
|
||||
}
|
||||
closeDictModal();
|
||||
fetchDictionaries();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDict = async (id: string) => {
|
||||
try {
|
||||
await deleteDictionary(id);
|
||||
message.success('字典已删除');
|
||||
fetchDictionaries();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDict = (dict: Dictionary) => {
|
||||
setEditDict(dict);
|
||||
dictForm.setFieldsValue({
|
||||
name: dict.name,
|
||||
code: dict.code,
|
||||
description: dict.description,
|
||||
});
|
||||
setDictModalOpen(true);
|
||||
};
|
||||
|
||||
const openCreateDict = () => {
|
||||
setEditDict(null);
|
||||
dictForm.resetFields();
|
||||
setDictModalOpen(true);
|
||||
};
|
||||
|
||||
const closeDictModal = () => {
|
||||
setDictModalOpen(false);
|
||||
setEditDict(null);
|
||||
dictForm.resetFields();
|
||||
};
|
||||
|
||||
// --- Dictionary Item CRUD ---
|
||||
|
||||
const openAddItem = (dictId: string) => {
|
||||
setActiveDictId(dictId);
|
||||
setEditItem(null);
|
||||
itemForm.resetFields();
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditItem = (dictId: string, item: DictItem) => {
|
||||
setActiveDictId(dictId);
|
||||
setEditItem(item);
|
||||
itemForm.setFieldsValue({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
sort_order: item.sort_order,
|
||||
color: item.color,
|
||||
});
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
const handleItemSubmit = async (values: CreateDictionaryItemRequest & { sort_order: number }) => {
|
||||
if (!activeDictId) return;
|
||||
try {
|
||||
if (editItem) {
|
||||
await updateDictionaryItem(activeDictId, editItem.id, values as UpdateDictionaryItemRequest);
|
||||
message.success('字典项更新成功');
|
||||
} else {
|
||||
await createDictionaryItem(activeDictId, values);
|
||||
message.success('字典项添加成功');
|
||||
}
|
||||
closeItemModal();
|
||||
fetchDictionaries();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (dictId: string, itemId: string) => {
|
||||
try {
|
||||
await deleteDictionaryItem(dictId, itemId);
|
||||
message.success('字典项已删除');
|
||||
fetchDictionaries();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const closeItemModal = () => {
|
||||
setItemModalOpen(false);
|
||||
setActiveDictId(null);
|
||||
setEditItem(null);
|
||||
itemForm.resetFields();
|
||||
};
|
||||
|
||||
// --- Columns ---
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '编码', dataIndex: 'code', key: 'code' },
|
||||
{
|
||||
title: '说明',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: Dictionary) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => openAddItem(record.id)}>
|
||||
添加项
|
||||
</Button>
|
||||
<Button size="small" onClick={() => openEditDict(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除此字典?"
|
||||
onConfirm={() => handleDeleteDict(record.id)}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const itemColumns = (dictId: string) => [
|
||||
{ title: '标签', dataIndex: 'label', key: 'label' },
|
||||
{ title: '值', dataIndex: 'value', key: 'value' },
|
||||
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order', width: 80 },
|
||||
{
|
||||
title: '颜色',
|
||||
dataIndex: 'color',
|
||||
key: 'color',
|
||||
width: 80,
|
||||
render: (color?: string) =>
|
||||
color ? <Tag color={color}>{color}</Tag> : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: DictItem) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => openEditItem(dictId, record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除此字典项?"
|
||||
onConfirm={() => handleDeleteItem(dictId, record.id)}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
数据字典管理
|
||||
</Typography.Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateDict}>
|
||||
新建字典
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dictionaries}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => (
|
||||
<Table
|
||||
columns={itemColumns(record.id)}
|
||||
dataSource={record.items}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dictionary Modal */}
|
||||
<Modal
|
||||
title={editDict ? '编辑字典' : '新建字典'}
|
||||
open={dictModalOpen}
|
||||
onCancel={closeDictModal}
|
||||
onOk={() => dictForm.submit()}
|
||||
>
|
||||
<Form form={dictForm} onFinish={handleDictSubmit} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入字典名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="编码"
|
||||
rules={[{ required: true, message: '请输入字典编码' }]}
|
||||
>
|
||||
<Input disabled={!!editDict} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Dictionary Item Modal */}
|
||||
<Modal
|
||||
title={editItem ? '编辑字典项' : '添加字典项'}
|
||||
open={itemModalOpen}
|
||||
onCancel={closeItemModal}
|
||||
onOk={() => itemForm.submit()}
|
||||
>
|
||||
<Form form={itemForm} onFinish={handleItemSubmit} layout="vertical">
|
||||
<Form.Item
|
||||
name="label"
|
||||
label="标签"
|
||||
rules={[{ required: true, message: '请输入标签' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="value"
|
||||
label="值"
|
||||
rules={[{ required: true, message: '请输入值' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sort_order"
|
||||
label="排序"
|
||||
initialValue={0}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="color" label="颜色">
|
||||
<Input placeholder="如:blue, red, green 或十六进制色值" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
apps/web/src/pages/settings/LanguageManager.tsx
Normal file
210
apps/web/src/pages/settings/LanguageManager.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Switch,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
Card,
|
||||
} from 'antd';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
listLanguages,
|
||||
updateLanguage,
|
||||
type LanguageInfo,
|
||||
} from '../../api/languages';
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export default function LanguageManager() {
|
||||
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editingLang, setEditingLang] = useState<LanguageInfo | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
const fetchLanguages = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listLanguages();
|
||||
setLanguages(result);
|
||||
} catch {
|
||||
message.error('加载语言列表失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLanguages();
|
||||
}, [fetchLanguages]);
|
||||
|
||||
// --- Enable / Disable Toggle ---
|
||||
|
||||
const handleToggle = async (record: LanguageInfo, enabled: boolean) => {
|
||||
try {
|
||||
await updateLanguage(record.code, { enabled });
|
||||
setLanguages((prev) =>
|
||||
prev.map((lang) =>
|
||||
lang.code === record.code ? { ...lang, enabled } : lang,
|
||||
),
|
||||
);
|
||||
message.success(enabled ? '已启用' : '已禁用');
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Edit Modal ---
|
||||
|
||||
const openEdit = (lang: LanguageInfo) => {
|
||||
setEditingLang(lang);
|
||||
editForm.setFieldsValue({
|
||||
name: lang.name,
|
||||
translations: lang.translations
|
||||
? Object.entries(lang.translations)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
: '',
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const closeEdit = () => {
|
||||
setEditModalOpen(false);
|
||||
setEditingLang(null);
|
||||
editForm.resetFields();
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (values: { name: string; translations: string }) => {
|
||||
if (!editingLang) return;
|
||||
|
||||
const translations: Record<string, string> = {};
|
||||
if (values.translations?.trim()) {
|
||||
for (const line of values.translations.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
const val = trimmed.slice(eqIndex + 1).trim();
|
||||
if (key) {
|
||||
translations[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateLanguage(editingLang.code, {
|
||||
name: values.name,
|
||||
translations,
|
||||
});
|
||||
setLanguages((prev) =>
|
||||
prev.map((lang) =>
|
||||
lang.code === editingLang.code ? updated : lang,
|
||||
),
|
||||
);
|
||||
message.success('语言更新成功');
|
||||
closeEdit();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '更新失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Columns ---
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '语言代码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '语言名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 120,
|
||||
render: (enabled: boolean, record: LanguageInfo) => (
|
||||
<Switch checked={enabled} onChange={(checked) => handleToggle(record, checked)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '翻译条目数',
|
||||
key: 'translationCount',
|
||||
width: 140,
|
||||
render: (_: unknown, record: LanguageInfo) =>
|
||||
record.translations ? Object.keys(record.translations).length : 0,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: LanguageInfo) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginBottom: 16 }}>
|
||||
语言管理
|
||||
</Typography.Title>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={languages}
|
||||
rowKey="code"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
title={`编辑语言 - ${editingLang?.name ?? ''}`}
|
||||
open={editModalOpen}
|
||||
onCancel={closeEdit}
|
||||
onOk={() => editForm.submit()}
|
||||
>
|
||||
<Form form={editForm} onFinish={handleEditSubmit} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="语言名称"
|
||||
rules={[{ required: true, message: '请输入语言名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="translations"
|
||||
label="翻译内容"
|
||||
extra="每行一条,格式:key=value"
|
||||
>
|
||||
<Input.TextArea rows={10} placeholder={'common.save=保存\ncommon.cancel=取消'} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
apps/web/src/pages/settings/MenuConfig.tsx
Normal file
298
apps/web/src/pages/settings/MenuConfig.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Switch,
|
||||
TreeSelect,
|
||||
Popconfirm,
|
||||
message,
|
||||
Typography,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
getMenus,
|
||||
createMenu,
|
||||
updateMenu,
|
||||
deleteMenu,
|
||||
type MenuInfo,
|
||||
type MenuItemReq,
|
||||
} from '../../api/menus';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type MenuItem = MenuInfo;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** Convert nested menu tree back to flat list */
|
||||
function flattenMenuTree(tree: MenuItem[]): MenuItem[] {
|
||||
const result: MenuItem[] = [];
|
||||
const walk = (items: MenuItem[]) => {
|
||||
for (const item of items) {
|
||||
const { children, ...rest } = item;
|
||||
result.push(rest as MenuItem);
|
||||
if (children?.length) walk(children);
|
||||
}
|
||||
};
|
||||
walk(tree);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Convert menu tree to TreeSelect data nodes */
|
||||
function toTreeSelectData(
|
||||
items: MenuItem[],
|
||||
): Array<{ title: string; value: string; children?: Array<{ title: string; value: string }> }> {
|
||||
return items.map((item) => ({
|
||||
title: item.title,
|
||||
value: item.id,
|
||||
children:
|
||||
item.children && item.children.length > 0
|
||||
? toTreeSelectData(item.children)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
const menuTypeLabels: Record<string, { text: string; color: string }> = {
|
||||
directory: { text: '目录', color: 'blue' },
|
||||
menu: { text: '菜单', color: 'green' },
|
||||
button: { text: '按钮', color: 'orange' },
|
||||
};
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export default function MenuConfig() {
|
||||
const [_menus, setMenus] = useState<MenuItem[]>([]);
|
||||
const [menuTree, setMenuTree] = useState<MenuItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editMenu, setEditMenu] = useState<MenuItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchMenus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const tree = await getMenus();
|
||||
setMenus(flattenMenuTree(tree));
|
||||
setMenuTree(tree);
|
||||
} catch {
|
||||
message.error('加载菜单失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMenus();
|
||||
}, [fetchMenus]);
|
||||
|
||||
const handleSubmit = async (values: MenuItemReq) => {
|
||||
try {
|
||||
if (editMenu) {
|
||||
await updateMenu(editMenu.id, values);
|
||||
message.success('菜单更新成功');
|
||||
} else {
|
||||
await createMenu(values);
|
||||
message.success('菜单创建成功');
|
||||
}
|
||||
closeModal();
|
||||
fetchMenus();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteMenu(id);
|
||||
message.success('菜单已删除');
|
||||
fetchMenus();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditMenu(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
menu_type: 'menu',
|
||||
sort_order: 0,
|
||||
visible: true,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (menu: MenuItem) => {
|
||||
setEditMenu(menu);
|
||||
form.setFieldsValue({
|
||||
parent_id: menu.parent_id || undefined,
|
||||
title: menu.title,
|
||||
path: menu.path,
|
||||
icon: menu.icon,
|
||||
menu_type: menu.menu_type,
|
||||
sort_order: menu.sort_order,
|
||||
visible: menu.visible,
|
||||
permission: menu.permission,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditMenu(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', width: 200 },
|
||||
{
|
||||
title: '路径',
|
||||
dataIndex: 'path',
|
||||
key: 'path',
|
||||
ellipsis: true,
|
||||
render: (v?: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '图标',
|
||||
dataIndex: 'icon',
|
||||
key: 'icon',
|
||||
width: 100,
|
||||
render: (v?: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'menu_type',
|
||||
key: 'menu_type',
|
||||
width: 90,
|
||||
render: (v: string) => {
|
||||
const info = menuTypeLabels[v] ?? { text: v, color: 'default' };
|
||||
return <Tag color={info.color}>{info.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort_order',
|
||||
key: 'sort_order',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '可见',
|
||||
dataIndex: 'visible',
|
||||
key: 'visible',
|
||||
width: 80,
|
||||
render: (v: boolean) =>
|
||||
v ? <Tag color="green">是</Tag> : <Tag color="default">否</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
render: (_: unknown, record: MenuItem) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除此菜单?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
菜单配置
|
||||
</Typography.Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
添加菜单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={menuTree}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
indentSize={20}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editMenu ? '编辑菜单' : '添加菜单'}
|
||||
open={modalOpen}
|
||||
onCancel={closeModal}
|
||||
onOk={() => form.submit()}
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} onFinish={handleSubmit} layout="vertical">
|
||||
<Form.Item name="parent_id" label="上级菜单">
|
||||
<TreeSelect
|
||||
treeData={toTreeSelectData(menuTree)}
|
||||
placeholder="无(顶级菜单)"
|
||||
allowClear
|
||||
treeDefaultExpandAll
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="标题"
|
||||
rules={[{ required: true, message: '请输入菜单标题' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="path" label="路径">
|
||||
<Input placeholder="/example/path" />
|
||||
</Form.Item>
|
||||
<Form.Item name="icon" label="图标">
|
||||
<Input placeholder="图标名称,如 HomeOutlined" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="menu_type"
|
||||
label="类型"
|
||||
rules={[{ required: true, message: '请选择菜单类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '目录', value: 'directory' },
|
||||
{ label: '菜单', value: 'menu' },
|
||||
{ label: '按钮', value: 'button' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="visible" label="可见" valuePropName="checked" initialValue>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="permission" label="权限标识">
|
||||
<Input placeholder="如 system:user:list" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
apps/web/src/pages/settings/NumberingRules.tsx
Normal file
284
apps/web/src/pages/settings/NumberingRules.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Popconfirm,
|
||||
message,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, NumberOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
listNumberingRules,
|
||||
createNumberingRule,
|
||||
updateNumberingRule,
|
||||
deleteNumberingRule,
|
||||
generateNumber,
|
||||
type NumberingRuleInfo,
|
||||
type CreateNumberingRuleRequest,
|
||||
type UpdateNumberingRuleRequest,
|
||||
} from '../../api/numberingRules';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type NumberingRule = NumberingRuleInfo;
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const resetCycleOptions = [
|
||||
{ label: '不重置', value: 'never' },
|
||||
{ label: '每天', value: 'daily' },
|
||||
{ label: '每月', value: 'monthly' },
|
||||
{ label: '每年', value: 'yearly' },
|
||||
];
|
||||
|
||||
const resetCycleLabels: Record<string, string> = {
|
||||
never: '不重置',
|
||||
daily: '每天',
|
||||
monthly: '每月',
|
||||
yearly: '每年',
|
||||
};
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export default function NumberingRules() {
|
||||
const [rules, setRules] = useState<NumberingRule[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editRule, setEditRule] = useState<NumberingRule | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchRules = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listNumberingRules();
|
||||
setRules(Array.isArray(result) ? result : result.data ?? []);
|
||||
} catch {
|
||||
message.error('加载编号规则失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRules();
|
||||
}, [fetchRules]);
|
||||
|
||||
const handleSubmit = async (values: CreateNumberingRuleRequest) => {
|
||||
try {
|
||||
if (editRule) {
|
||||
await updateNumberingRule(editRule.id, values as UpdateNumberingRuleRequest);
|
||||
message.success('编号规则更新成功');
|
||||
} else {
|
||||
await createNumberingRule(values);
|
||||
message.success('编号规则创建成功');
|
||||
}
|
||||
closeModal();
|
||||
fetchRules();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteNumberingRule(id);
|
||||
message.success('编号规则已删除');
|
||||
fetchRules();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (rule: NumberingRule) => {
|
||||
try {
|
||||
const result = await generateNumber(rule.id);
|
||||
message.success(`生成编号: ${result.number}`);
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '生成编号失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditRule(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
seq_length: 4,
|
||||
seq_start: 1,
|
||||
separator: '-',
|
||||
reset_cycle: 'never',
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (rule: NumberingRule) => {
|
||||
setEditRule(rule);
|
||||
form.setFieldsValue({
|
||||
name: rule.name,
|
||||
code: rule.code,
|
||||
prefix: rule.prefix,
|
||||
date_format: rule.date_format,
|
||||
seq_length: rule.seq_length,
|
||||
seq_start: rule.seq_start,
|
||||
separator: rule.separator,
|
||||
reset_cycle: rule.reset_cycle,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditRule(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '编码', dataIndex: 'code', key: 'code' },
|
||||
{
|
||||
title: '前缀',
|
||||
dataIndex: 'prefix',
|
||||
key: 'prefix',
|
||||
render: (v?: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '日期格式',
|
||||
dataIndex: 'date_format',
|
||||
key: 'date_format',
|
||||
render: (v?: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '序列长度',
|
||||
dataIndex: 'seq_length',
|
||||
key: 'seq_length',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '当前值',
|
||||
dataIndex: 'current_value',
|
||||
key: 'current_value',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '重置周期',
|
||||
dataIndex: 'reset_cycle',
|
||||
key: 'reset_cycle',
|
||||
width: 100,
|
||||
render: (v: string) => resetCycleLabels[v] ?? v,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: NumberingRule) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<NumberOutlined />}
|
||||
onClick={() => handleGenerate(record)}
|
||||
>
|
||||
生成编号
|
||||
</Button>
|
||||
<Button size="small" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除此编号规则?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
编号规则管理
|
||||
</Typography.Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={rules}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editRule ? '编辑编号规则' : '新建编号规则'}
|
||||
open={modalOpen}
|
||||
onCancel={closeModal}
|
||||
onOk={() => form.submit()}
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} onFinish={handleSubmit} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入规则名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="编码"
|
||||
rules={[{ required: true, message: '请输入规则编码' }]}
|
||||
>
|
||||
<Input disabled={!!editRule} />
|
||||
</Form.Item>
|
||||
<Form.Item name="prefix" label="前缀">
|
||||
<Input placeholder="如 PO、SO" />
|
||||
</Form.Item>
|
||||
<Form.Item name="date_format" label="日期格式">
|
||||
<Input placeholder="如 YYYYMMDD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="separator" label="分隔符">
|
||||
<Input placeholder="默认 -" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="seq_length"
|
||||
label="序列长度"
|
||||
rules={[{ required: true, message: '请输入序列长度' }]}
|
||||
>
|
||||
<InputNumber min={1} max={20} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="seq_start" label="起始值" initialValue={1}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="reset_cycle"
|
||||
label="重置周期"
|
||||
rules={[{ required: true, message: '请选择重置周期' }]}
|
||||
>
|
||||
<Select options={resetCycleOptions} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
apps/web/src/pages/settings/SystemSettings.tsx
Normal file
249
apps/web/src/pages/settings/SystemSettings.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Space,
|
||||
Popconfirm,
|
||||
message,
|
||||
Table,
|
||||
Modal,
|
||||
Tag,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, SearchOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
getSetting,
|
||||
updateSetting,
|
||||
deleteSetting,
|
||||
} from '../../api/settings';
|
||||
|
||||
interface SettingEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default function SystemSettings() {
|
||||
const [entries, setEntries] = useState<SettingEntry[]>([]);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editEntry, setEditEntry] = useState<SettingEntry | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchKey.trim()) {
|
||||
message.warning('请输入设置键名');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await getSetting(searchKey.trim());
|
||||
const value = String(result.setting_value ?? '');
|
||||
|
||||
setEntries((prev) => {
|
||||
const exists = prev.findIndex((e) => e.key === searchKey.trim());
|
||||
if (exists >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[exists] = { ...updated[exists], value };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, { key: searchKey.trim(), value }];
|
||||
});
|
||||
message.success('查询成功');
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 404) {
|
||||
message.info('该设置键不存在,可点击"添加设置"创建');
|
||||
} else {
|
||||
message.error('查询失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (values: { setting_key: string; setting_value: string }) => {
|
||||
const key = values.setting_key.trim();
|
||||
const value = values.setting_value;
|
||||
try {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch {
|
||||
message.error('设置值必须是有效的 JSON 格式');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSetting(key, value);
|
||||
|
||||
setEntries((prev) => {
|
||||
const exists = prev.findIndex((e) => e.key === key);
|
||||
if (exists >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[exists] = { key, value };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, { key, value }];
|
||||
});
|
||||
|
||||
message.success('设置已保存');
|
||||
closeModal();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '保存失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
try {
|
||||
await deleteSetting(key);
|
||||
setEntries((prev) => prev.filter((e) => e.key !== key));
|
||||
message.success('设置已删除');
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditEntry(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (entry: SettingEntry) => {
|
||||
setEditEntry(entry);
|
||||
form.setFieldsValue({
|
||||
setting_key: entry.key,
|
||||
setting_value: entry.value,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditEntry(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '键',
|
||||
dataIndex: 'key',
|
||||
key: 'key',
|
||||
width: 250,
|
||||
render: (v: string) => (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
border: 'none',
|
||||
color: isDark ? '#CBD5E1' : '#475569',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
}}>
|
||||
{v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '值 (JSON)',
|
||||
dataIndex: 'value',
|
||||
key: 'value',
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_: unknown, record: SettingEntry) => (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此设置?"
|
||||
onConfirm={() => handleDelete(record.key)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="输入设置键名查询"
|
||||
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
style={{ width: 300, borderRadius: 8 }}
|
||||
/>
|
||||
<Button onClick={handleSearch}>查询</Button>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
添加设置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={entries}
|
||||
rowKey="key"
|
||||
size="small"
|
||||
pagination={false}
|
||||
locale={{ emptyText: '暂无设置项,请通过搜索查询或添加新设置' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editEntry ? '编辑设置' : '添加设置'}
|
||||
open={modalOpen}
|
||||
onCancel={closeModal}
|
||||
onOk={() => form.submit()}
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} onFinish={handleSave} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="setting_key"
|
||||
label="键名"
|
||||
rules={[{ required: true, message: '请输入设置键名' }]}
|
||||
>
|
||||
<Input disabled={!!editEntry} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="setting_value"
|
||||
label="值 (JSON)"
|
||||
rules={[{ required: true, message: '请输入设置值' }]}
|
||||
>
|
||||
<Input.TextArea rows={6} placeholder='{"key": "value"}' style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
apps/web/src/pages/settings/ThemeSettings.tsx
Normal file
98
apps/web/src/pages/settings/ThemeSettings.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Form, Input, Select, Button, ColorPicker, message, Typography } from 'antd';
|
||||
import {
|
||||
getTheme,
|
||||
updateTheme,
|
||||
} from '../../api/themes';
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export default function ThemeSettings() {
|
||||
const [form] = Form.useForm();
|
||||
const [, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchTheme = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const theme = await getTheme();
|
||||
form.setFieldsValue({
|
||||
primary_color: theme.primary_color || '#1677ff',
|
||||
logo_url: theme.logo_url || '',
|
||||
sidebar_style: theme.sidebar_style || 'light',
|
||||
});
|
||||
} catch {
|
||||
// Theme may not exist yet; use defaults
|
||||
form.setFieldsValue({
|
||||
primary_color: '#1677ff',
|
||||
logo_url: '',
|
||||
sidebar_style: 'light',
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTheme();
|
||||
}, [fetchTheme]);
|
||||
|
||||
const handleSave = async (values: {
|
||||
primary_color: string;
|
||||
logo_url: string;
|
||||
sidebar_style: 'light' | 'dark';
|
||||
}) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateTheme({
|
||||
primary_color:
|
||||
typeof values.primary_color === 'string'
|
||||
? values.primary_color
|
||||
: (values.primary_color as { toHexString?: () => string }).toHexString?.() ?? String(values.primary_color),
|
||||
logo_url: values.logo_url,
|
||||
sidebar_style: values.sidebar_style,
|
||||
});
|
||||
message.success('主题设置已保存');
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '保存失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginBottom: 16 }}>
|
||||
主题设置
|
||||
</Typography.Title>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleSave}
|
||||
layout="vertical"
|
||||
style={{ maxWidth: 480 }}
|
||||
>
|
||||
<Form.Item name="primary_color" label="主色调">
|
||||
<ColorPicker format="hex" />
|
||||
</Form.Item>
|
||||
<Form.Item name="logo_url" label="Logo URL">
|
||||
<Input placeholder="https://example.com/logo.png" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sidebar_style" label="侧边栏风格">
|
||||
<Select
|
||||
options={[
|
||||
{ label: '亮色', value: 'light' },
|
||||
{ label: '暗色', value: 'dark' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
apps/web/src/pages/workflow/CompletedTasks.tsx
Normal file
101
apps/web/src/pages/workflow/CompletedTasks.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import { Table, Tag, theme } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { listCompletedTasks, type TaskInfo } from '../../api/workflowTasks';
|
||||
|
||||
const outcomeStyles: Record<string, { bg: string; color: string; text: string }> = {
|
||||
approved: { bg: '#ECFDF5', color: '#059669', text: '同意' },
|
||||
rejected: { bg: '#FEF2F2', color: '#DC2626', text: '拒绝' },
|
||||
delegated: { bg: '#EEF2FF', color: '#4F46E5', text: '已委派' },
|
||||
};
|
||||
|
||||
export default function CompletedTasks() {
|
||||
const [data, setData] = useState<TaskInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listCompletedTasks(page, 20);
|
||||
setData(res.data);
|
||||
setTotal(res.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const columns: ColumnsType<TaskInfo> = [
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'node_name',
|
||||
key: 'node_name',
|
||||
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
|
||||
},
|
||||
{ title: '流程', dataIndex: 'definition_name', key: 'definition_name' },
|
||||
{
|
||||
title: '业务键',
|
||||
dataIndex: 'business_key',
|
||||
key: 'business_key',
|
||||
render: (v: string | undefined) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'outcome',
|
||||
key: 'outcome',
|
||||
width: 100,
|
||||
render: (o: string) => {
|
||||
const info = outcomeStyles[o] || { bg: '#F1F5F9', color: '#64748B', text: o };
|
||||
return (
|
||||
<Tag style={{
|
||||
background: info.bg,
|
||||
border: 'none',
|
||||
color: info.color,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{info.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '完成时间',
|
||||
dataIndex: 'completed_at',
|
||||
key: 'completed_at',
|
||||
width: 180,
|
||||
render: (v: string) => (
|
||||
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
|
||||
{v ? new Date(v).toLocaleString() : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: setPage,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
apps/web/src/pages/workflow/InstanceMonitor.tsx
Normal file
247
apps/web/src/pages/workflow/InstanceMonitor.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import { Button, message, Modal, Table, Tag, theme } from 'antd';
|
||||
import { EyeOutlined, PauseCircleOutlined, PlayCircleOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
listInstances,
|
||||
resumeInstance,
|
||||
suspendInstance,
|
||||
terminateInstance,
|
||||
type ProcessInstanceInfo,
|
||||
} from '../../api/workflowInstances';
|
||||
import { getProcessDefinition, type NodeDef, type EdgeDef } from '../../api/workflowDefinitions';
|
||||
import ProcessViewer from './ProcessViewer';
|
||||
|
||||
const statusStyles: Record<string, { bg: string; color: string; text: string }> = {
|
||||
running: { bg: '#EEF2FF', color: '#4F46E5', text: '运行中' },
|
||||
suspended: { bg: '#FFFBEB', color: '#D97706', text: '已挂起' },
|
||||
completed: { bg: '#ECFDF5', color: '#059669', text: '已完成' },
|
||||
terminated: { bg: '#FEF2F2', color: '#DC2626', text: '已终止' },
|
||||
};
|
||||
|
||||
export default function InstanceMonitor() {
|
||||
const [data, setData] = useState<ProcessInstanceInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const [viewerNodes, setViewerNodes] = useState<NodeDef[]>([]);
|
||||
const [viewerEdges, setViewerEdges] = useState<EdgeDef[]>([]);
|
||||
const [activeNodeIds, setActiveNodeIds] = useState<string[]>([]);
|
||||
const [viewerLoading, setViewerLoading] = useState(false);
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listInstances(page, 20);
|
||||
setData(res.data);
|
||||
setTotal(res.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleViewFlow = async (record: ProcessInstanceInfo) => {
|
||||
setViewerLoading(true);
|
||||
setViewerOpen(true);
|
||||
try {
|
||||
const def = await getProcessDefinition(record.definition_id);
|
||||
setViewerNodes(def.nodes);
|
||||
setViewerEdges(def.edges);
|
||||
setActiveNodeIds(record.active_tokens.map((t) => t.node_id));
|
||||
} catch {
|
||||
message.error('加载流程图失败');
|
||||
setViewerOpen(false);
|
||||
} finally {
|
||||
setViewerLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminate = async (id: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认终止',
|
||||
content: '确定要终止该流程实例吗?此操作不可撤销。',
|
||||
okText: '确定终止',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await terminateInstance(id);
|
||||
message.success('已终止');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSuspend = async (id: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认挂起',
|
||||
content: '确定要挂起该流程实例吗?挂起后可通过"恢复"按钮继续执行。',
|
||||
okText: '确定挂起',
|
||||
okType: 'default',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await suspendInstance(id);
|
||||
message.success('已挂起');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleResume = async (id: string) => {
|
||||
try {
|
||||
await resumeInstance(id);
|
||||
message.success('已恢复');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ProcessInstanceInfo> = [
|
||||
{
|
||||
title: '流程',
|
||||
dataIndex: 'definition_name',
|
||||
key: 'definition_name',
|
||||
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '业务键',
|
||||
dataIndex: 'business_key',
|
||||
key: 'business_key',
|
||||
render: (v: string | undefined) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (s: string) => {
|
||||
const info = statusStyles[s] || { bg: '#F1F5F9', color: '#64748B', text: s };
|
||||
return (
|
||||
<Tag style={{
|
||||
background: info.bg,
|
||||
border: 'none',
|
||||
color: info.color,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{info.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '当前节点',
|
||||
key: 'current_nodes',
|
||||
width: 150,
|
||||
render: (_, record) => record.active_tokens.map(t => t.node_id).join(', ') || '-',
|
||||
},
|
||||
{
|
||||
title: '发起时间',
|
||||
dataIndex: 'started_at',
|
||||
key: 'started_at',
|
||||
width: 180,
|
||||
render: (v: string) => (
|
||||
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
|
||||
{new Date(v).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 240,
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleViewFlow(record)}
|
||||
>
|
||||
流程图
|
||||
</Button>
|
||||
{record.status === 'running' && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<PauseCircleOutlined />}
|
||||
onClick={() => handleSuspend(record.id)}
|
||||
>
|
||||
挂起
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={() => handleTerminate(record.id)}
|
||||
>
|
||||
终止
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{record.status === 'suspended' && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => handleResume(record.id)}
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: setPage,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="流程图查看"
|
||||
open={viewerOpen}
|
||||
onCancel={() => setViewerOpen(false)}
|
||||
footer={null}
|
||||
width={720}
|
||||
loading={viewerLoading}
|
||||
>
|
||||
<ProcessViewer nodes={viewerNodes} edges={viewerEdges} activeNodeIds={activeNodeIds} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
217
apps/web/src/pages/workflow/PendingTasks.tsx
Normal file
217
apps/web/src/pages/workflow/PendingTasks.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import { Button, Input, message, Modal, Space, Table, Tag, theme } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined, SendOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
listPendingTasks,
|
||||
completeTask,
|
||||
delegateTask,
|
||||
type TaskInfo,
|
||||
} from '../../api/workflowTasks';
|
||||
|
||||
export default function PendingTasks() {
|
||||
const [data, setData] = useState<TaskInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [completeModal, setCompleteModal] = useState<TaskInfo | null>(null);
|
||||
const [outcome, setOutcome] = useState('approved');
|
||||
const [delegateModal, setDelegateModal] = useState<TaskInfo | null>(null);
|
||||
const [delegateTo, setDelegateTo] = useState('');
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listPendingTasks(page, 20);
|
||||
setData(res.data);
|
||||
setTotal(res.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!completeModal) return;
|
||||
try {
|
||||
await completeTask(completeModal.id, { outcome });
|
||||
message.success('审批完成');
|
||||
setCompleteModal(null);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('审批失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelegate = async () => {
|
||||
if (!delegateModal || !delegateTo.trim()) {
|
||||
message.warning('请输入委派目标用户 ID');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await delegateTask(delegateModal.id, { delegate_to: delegateTo.trim() });
|
||||
message.success('委派成功');
|
||||
setDelegateModal(null);
|
||||
setDelegateTo('');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('委派失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TaskInfo> = [
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'node_name',
|
||||
key: 'node_name',
|
||||
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
|
||||
},
|
||||
{ title: '流程', dataIndex: 'definition_name', key: 'definition_name' },
|
||||
{
|
||||
title: '业务键',
|
||||
dataIndex: 'business_key',
|
||||
key: 'business_key',
|
||||
render: (v: string | undefined) => v ? (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
border: 'none',
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
}}>
|
||||
{v}
|
||||
</Tag>
|
||||
) : '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (s: string) => (
|
||||
<Tag style={{
|
||||
background: '#EEF2FF',
|
||||
border: 'none',
|
||||
color: '#4F46E5',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{s}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (v: string) => (
|
||||
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
|
||||
{new Date(v).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => { setCompleteModal(record); setOutcome('approved'); }}
|
||||
>
|
||||
审批
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => { setDelegateModal(record); setDelegateTo(''); }}
|
||||
>
|
||||
委派
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: setPage,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="审批任务"
|
||||
open={!!completeModal}
|
||||
onOk={handleComplete}
|
||||
onCancel={() => setCompleteModal(null)}
|
||||
>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<p style={{ fontWeight: 500, marginBottom: 16 }}>
|
||||
任务: {completeModal?.node_name}
|
||||
</p>
|
||||
<Space size={12}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => setOutcome('approved')}
|
||||
ghost={outcome !== 'approved'}
|
||||
>
|
||||
同意
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => setOutcome('rejected')}
|
||||
ghost={outcome !== 'rejected'}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="委派任务"
|
||||
open={!!delegateModal}
|
||||
onOk={handleDelegate}
|
||||
onCancel={() => { setDelegateModal(null); setDelegateTo(''); }}
|
||||
okText="确认委派"
|
||||
>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<p style={{ fontWeight: 500, marginBottom: 16 }}>
|
||||
任务: {delegateModal?.node_name}
|
||||
</p>
|
||||
<Input
|
||||
placeholder="输入目标用户 ID (UUID)"
|
||||
value={delegateTo}
|
||||
onChange={(e) => setDelegateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
199
apps/web/src/pages/workflow/ProcessDefinitions.tsx
Normal file
199
apps/web/src/pages/workflow/ProcessDefinitions.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Button, message, Modal, Space, Table, Tag, theme } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
listProcessDefinitions,
|
||||
createProcessDefinition,
|
||||
updateProcessDefinition,
|
||||
publishProcessDefinition,
|
||||
type ProcessDefinitionInfo,
|
||||
type CreateProcessDefinitionRequest,
|
||||
} from '../../api/workflowDefinitions';
|
||||
import ProcessDesigner from './ProcessDesigner';
|
||||
|
||||
const statusColors: Record<string, { bg: string; color: string; text: string }> = {
|
||||
draft: { bg: '#F1F5F9', color: '#64748B', text: '草稿' },
|
||||
published: { bg: '#ECFDF5', color: '#059669', text: '已发布' },
|
||||
deprecated: { bg: '#FEF2F2', color: '#DC2626', text: '已弃用' },
|
||||
};
|
||||
|
||||
export default function ProcessDefinitions() {
|
||||
const [data, setData] = useState<ProcessDefinitionInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [designerOpen, setDesignerOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
const fetchData = useCallback(async (p = page) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listProcessDefinitions(p, 20);
|
||||
setData(res.data);
|
||||
setTotal(res.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
setDesignerOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
setEditingId(id);
|
||||
setDesignerOpen(true);
|
||||
};
|
||||
|
||||
const handlePublish = async (id: string) => {
|
||||
try {
|
||||
await publishProcessDefinition(id);
|
||||
message.success('发布成功');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('发布失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (req: CreateProcessDefinitionRequest, id?: string) => {
|
||||
try {
|
||||
if (id) {
|
||||
await updateProcessDefinition(id, req);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createProcessDefinition(req);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setDesignerOpen(false);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error(id ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ProcessDefinitionInfo> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '编码',
|
||||
dataIndex: 'key',
|
||||
key: 'key',
|
||||
render: (v: string) => (
|
||||
<Tag style={{
|
||||
background: isDark ? '#1E293B' : '#F1F5F9',
|
||||
border: 'none',
|
||||
color: isDark ? '#94A3B8' : '#64748B',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
}}>
|
||||
{v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '版本', dataIndex: 'version', key: 'version', width: 80 },
|
||||
{ title: '分类', dataIndex: 'category', key: 'category', width: 120 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (s: string) => {
|
||||
const info = statusColors[s] || { bg: '#F1F5F9', color: '#64748B', text: s };
|
||||
return (
|
||||
<Tag style={{
|
||||
background: info.bg,
|
||||
border: 'none',
|
||||
color: info.color,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{info.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
{record.status === 'draft' && (
|
||||
<>
|
||||
<Button size="small" type="text" onClick={() => handleEdit(record.id)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={() => handlePublish(record.id)}>
|
||||
发布
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
|
||||
共 {total} 个流程定义
|
||||
</span>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新建流程
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: setPage,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑流程' : '新建流程'}
|
||||
open={designerOpen}
|
||||
onCancel={() => setDesignerOpen(false)}
|
||||
footer={null}
|
||||
width={1200}
|
||||
destroyOnHidden
|
||||
>
|
||||
<ProcessDesigner
|
||||
definitionId={editingId}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
272
apps/web/src/pages/workflow/ProcessDesigner.tsx
Normal file
272
apps/web/src/pages/workflow/ProcessDesigner.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Form, Input, message, Spin } from 'antd';
|
||||
import {
|
||||
ReactFlow,
|
||||
Controls,
|
||||
Background,
|
||||
addEdge,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
type Connection,
|
||||
type Node,
|
||||
type Edge,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {
|
||||
type CreateProcessDefinitionRequest,
|
||||
type NodeDef,
|
||||
type EdgeDef,
|
||||
getProcessDefinition,
|
||||
} from '../../api/workflowDefinitions';
|
||||
|
||||
const NODE_TYPES_MAP: Record<string, { label: string; color: string }> = {
|
||||
StartEvent: { label: '开始', color: '#52c41a' },
|
||||
EndEvent: { label: '结束', color: '#ff4d4f' },
|
||||
UserTask: { label: '用户任务', color: '#1890ff' },
|
||||
ServiceTask: { label: '服务任务', color: '#722ed1' },
|
||||
ExclusiveGateway: { label: '排他网关', color: '#fa8c16' },
|
||||
ParallelGateway: { label: '并行网关', color: '#13c2c2' },
|
||||
};
|
||||
|
||||
const PALETTE_ITEMS = Object.entries(NODE_TYPES_MAP).map(([type, info]) => ({
|
||||
type,
|
||||
label: info.label,
|
||||
color: info.color,
|
||||
}));
|
||||
|
||||
function createFlowNode(type: string, label: string, position: { x: number; y: number }, id?: string): Node {
|
||||
return {
|
||||
id: id || `node_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
type: 'default',
|
||||
position,
|
||||
data: { label: `${label}`, nodeType: type, name: label },
|
||||
style: {
|
||||
background: NODE_TYPES_MAP[type]?.color || '#f0f0f0',
|
||||
color: '#fff',
|
||||
padding: '8px 16px',
|
||||
borderRadius: type.includes('Gateway') ? 0 : type === 'StartEvent' || type === 'EndEvent' ? 50 : 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
border: '2px solid rgba(255,255,255,0.3)',
|
||||
width: type.includes('Gateway') ? 80 : 140,
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface ProcessDesignerProps {
|
||||
definitionId: string | null;
|
||||
onSave: (req: CreateProcessDefinitionRequest, id?: string) => void;
|
||||
}
|
||||
|
||||
export default function ProcessDesigner({ definitionId, onSave }: ProcessDesignerProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
const isEditing = definitionId !== null;
|
||||
|
||||
// 加载流程定义(编辑模式)或初始化默认节点(新建模式)
|
||||
useEffect(() => {
|
||||
if (!definitionId) {
|
||||
const startNode = createFlowNode('StartEvent', '开始', { x: 250, y: 50 });
|
||||
const userNode = createFlowNode('UserTask', '审批', { x: 250, y: 200 });
|
||||
const endNode = createFlowNode('EndEvent', '结束', { x: 250, y: 400 });
|
||||
setNodes([startNode, userNode, endNode]);
|
||||
setEdges([
|
||||
{ id: 'e_start_approve', source: startNode.id, target: userNode.id, markerEnd: { type: MarkerType.ArrowClosed } },
|
||||
{ id: 'e_approve_end', source: userNode.id, target: endNode.id, markerEnd: { type: MarkerType.ArrowClosed } },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
getProcessDefinition(definitionId)
|
||||
.then((def) => {
|
||||
form.setFieldsValue({
|
||||
name: def.name,
|
||||
key: def.key,
|
||||
category: def.category,
|
||||
description: def.description,
|
||||
});
|
||||
const flowNodes = def.nodes.map((n, i) =>
|
||||
createFlowNode(n.type, n.name, n.position || { x: 200, y: i * 120 + 50 }, n.id)
|
||||
);
|
||||
const flowEdges: Edge[] = def.edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
label: e.label || e.condition,
|
||||
}));
|
||||
setNodes(flowNodes);
|
||||
setEdges(flowEdges);
|
||||
})
|
||||
.catch(() => message.error('加载流程定义失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [definitionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
setEdges((eds) =>
|
||||
addEdge(
|
||||
{ ...connection, markerEnd: { type: MarkerType.ArrowClosed } },
|
||||
eds,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setEdges],
|
||||
);
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
setSelectedNode(node);
|
||||
}, []);
|
||||
|
||||
const handleAddNode = (type: string) => {
|
||||
const info = NODE_TYPES_MAP[type];
|
||||
if (!info) return;
|
||||
const newNode = createFlowNode(type, info.label, {
|
||||
x: 100 + Math.random() * 400,
|
||||
y: 100 + Math.random() * 300,
|
||||
});
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
};
|
||||
|
||||
const handleDeleteNode = () => {
|
||||
if (!selectedNode) return;
|
||||
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
|
||||
setEdges((eds) =>
|
||||
eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id),
|
||||
);
|
||||
setSelectedNode(null);
|
||||
};
|
||||
|
||||
const handleUpdateNodeName = (name: string) => {
|
||||
if (!selectedNode) return;
|
||||
setNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === selectedNode.id
|
||||
? { ...n, data: { ...n.data, label: name, name } }
|
||||
: n,
|
||||
),
|
||||
);
|
||||
setSelectedNode((prev) => (prev ? { ...prev, data: { ...prev.data, label: name, name } } : null));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
form.validateFields().then((values) => {
|
||||
const flowNodes: NodeDef[] = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: (n.data.nodeType as NodeDef['type']) || 'UserTask',
|
||||
name: String(n.data.name || n.data.label || ''),
|
||||
position: { x: Math.round(n.position.x), y: Math.round(n.position.y) },
|
||||
}));
|
||||
const flowEdges: EdgeDef[] = edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label ? String(e.label) : undefined,
|
||||
}));
|
||||
onSave(
|
||||
{ ...values, nodes: flowNodes, edges: flowEdges },
|
||||
definitionId || undefined,
|
||||
);
|
||||
}).catch(() => {
|
||||
message.error('请填写必要字段');
|
||||
});
|
||||
};
|
||||
|
||||
const defaultEdgeOptions = useMemo(
|
||||
() => ({
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}><Spin /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 16, height: 500 }}>
|
||||
{/* 左侧工具面板 */}
|
||||
<div style={{ width: 180, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<p style={{ fontWeight: 500, margin: '0 0 4px' }}>添加节点</p>
|
||||
{PALETTE_ITEMS.map((item) => (
|
||||
<Button
|
||||
key={item.type}
|
||||
size="small"
|
||||
style={{ textAlign: 'left' }}
|
||||
onClick={() => handleAddNode(item.type)}
|
||||
>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 2,
|
||||
background: item.color,
|
||||
marginRight: 6,
|
||||
}} />
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{selectedNode && (
|
||||
<div style={{ marginTop: 16, padding: 8, background: '#f5f5f5', borderRadius: 6 }}>
|
||||
<p style={{ fontWeight: 500, margin: '0 0 8px', fontSize: 12 }}>节点属性</p>
|
||||
<Input
|
||||
size="small"
|
||||
value={String(selectedNode.data.name || '')}
|
||||
onChange={(e) => handleUpdateNodeName(e.target.value)}
|
||||
placeholder="节点名称"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Button size="small" danger onClick={handleDeleteNode} block>
|
||||
删除节点
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间画布 */}
|
||||
<div style={{ flex: 1, border: '1px solid #d9d9d9', borderRadius: 6 }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
fitView
|
||||
>
|
||||
<Controls />
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* 右侧表单 */}
|
||||
<div style={{ width: 220, overflow: 'auto' }}>
|
||||
<Form form={form} layout="vertical" size="small">
|
||||
<Form.Item name="name" label="流程名称" rules={[{ required: true, message: '请输入' }]}>
|
||||
<Input placeholder="请假审批" />
|
||||
</Form.Item>
|
||||
<Form.Item name="key" label="流程编码" rules={[{ required: true, message: '请输入' }]}>
|
||||
<Input placeholder="leave_approval" disabled={isEditing} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Input placeholder="leave" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Button type="primary" onClick={handleSave}>{isEditing ? '更新' : '保存'}</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
apps/web/src/pages/workflow/ProcessViewer.tsx
Normal file
84
apps/web/src/pages/workflow/ProcessViewer.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Controls,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import type { NodeDef, EdgeDef } from '../../api/workflowDefinitions';
|
||||
|
||||
const NODE_TYPE_STYLES: Record<string, { color: string; radius: number; width: number }> = {
|
||||
StartEvent: { color: '#52c41a', radius: 50, width: 100 },
|
||||
EndEvent: { color: '#ff4d4f', radius: 50, width: 100 },
|
||||
UserTask: { color: '#1890ff', radius: 6, width: 160 },
|
||||
ServiceTask: { color: '#722ed1', radius: 6, width: 160 },
|
||||
ExclusiveGateway: { color: '#fa8c16', radius: 0, width: 100 },
|
||||
ParallelGateway: { color: '#13c2c2', radius: 0, width: 100 },
|
||||
};
|
||||
|
||||
interface ProcessViewerProps {
|
||||
nodes: NodeDef[];
|
||||
edges: EdgeDef[];
|
||||
activeNodeIds?: string[];
|
||||
}
|
||||
|
||||
export default function ProcessViewer({ nodes, edges, activeNodeIds = [] }: ProcessViewerProps) {
|
||||
const flowNodes: Node[] = useMemo(() =>
|
||||
nodes.map((n, i) => {
|
||||
const style = NODE_TYPE_STYLES[n.type] || NODE_TYPE_STYLES.UserTask;
|
||||
const isActive = activeNodeIds.includes(n.id);
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'default',
|
||||
position: n.position || { x: 200, y: i * 120 + 50 },
|
||||
data: { label: n.name },
|
||||
style: {
|
||||
background: isActive ? '#fff3cd' : style.color,
|
||||
color: isActive ? '#856404' : '#fff',
|
||||
padding: '8px 16px',
|
||||
borderRadius: style.radius,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
border: isActive ? '3px solid #ffc107' : '2px solid rgba(255,255,255,0.3)',
|
||||
width: style.width,
|
||||
textAlign: 'center' as const,
|
||||
boxShadow: isActive ? '0 0 8px rgba(255,193,7,0.5)' : 'none',
|
||||
},
|
||||
};
|
||||
}),
|
||||
[nodes, activeNodeIds],
|
||||
);
|
||||
|
||||
const flowEdges: Edge[] = useMemo(() =>
|
||||
edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label || e.condition,
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
style: { stroke: '#999' },
|
||||
})),
|
||||
[edges],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: 400, border: '1px solid #d9d9d9', borderRadius: 6 }}>
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
>
|
||||
<Controls showInteractive={false} />
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
apps/web/src/stores/app.ts
Normal file
15
apps/web/src/stores/app.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AppState {
|
||||
theme: 'light' | 'dark';
|
||||
sidebarCollapsed: boolean;
|
||||
toggleSidebar: () => void;
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
theme: 'light',
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
}));
|
||||
69
apps/web/src/stores/auth.ts
Normal file
69
apps/web/src/stores/auth.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { create } from 'zustand';
|
||||
import { login as apiLogin, logout as apiLogout, type UserInfo } from '../api/auth';
|
||||
|
||||
// Synchronously restore auth state from localStorage at store creation time.
|
||||
// This eliminates the flash-of-login-page on refresh because isAuthenticated
|
||||
// is already `true` before the first render.
|
||||
function restoreInitialState(): { user: UserInfo | null; isAuthenticated: boolean } {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr) as UserInfo;
|
||||
return { user, isAuthenticated: true };
|
||||
} catch {
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
return { user: null, isAuthenticated: false };
|
||||
}
|
||||
|
||||
const initial = restoreInitialState();
|
||||
|
||||
interface AuthState {
|
||||
user: UserInfo | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
loadFromStorage: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: initial.user,
|
||||
isAuthenticated: initial.isAuthenticated,
|
||||
loading: false,
|
||||
|
||||
login: async (username, password) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const resp = await apiLogin({ username, password });
|
||||
localStorage.setItem('access_token', resp.access_token);
|
||||
localStorage.setItem('refresh_token', resp.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(resp.user));
|
||||
set({ user: resp.user, isAuthenticated: true, loading: false });
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch {
|
||||
// Ignore logout API errors
|
||||
}
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
set({ user: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
// Kept for backward compatibility but no longer needed since
|
||||
// initial state is restored synchronously at store creation.
|
||||
loadFromStorage: () => {
|
||||
const state = restoreInitialState();
|
||||
set({ user: state.user, isAuthenticated: state.isAuthenticated });
|
||||
},
|
||||
}));
|
||||
70
apps/web/src/stores/message.ts
Normal file
70
apps/web/src/stores/message.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
import { getUnreadCount, listMessages, markRead, type MessageInfo } from '../api/messages';
|
||||
|
||||
interface MessageState {
|
||||
unreadCount: number;
|
||||
recentMessages: MessageInfo[];
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
fetchRecentMessages: () => Promise<void>;
|
||||
markAsRead: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// 请求去重:记录正在进行的请求,防止并发重复调用
|
||||
let unreadCountPromise: Promise<void> | null = null;
|
||||
let recentMessagesPromise: Promise<void> | null = null;
|
||||
|
||||
export const useMessageStore = create<MessageState>((set) => ({
|
||||
unreadCount: 0,
|
||||
recentMessages: [],
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
// 如果已有进行中的请求,复用该 Promise
|
||||
if (unreadCountPromise) {
|
||||
await unreadCountPromise;
|
||||
return;
|
||||
}
|
||||
unreadCountPromise = (async () => {
|
||||
try {
|
||||
const result = await getUnreadCount();
|
||||
set({ unreadCount: result.count });
|
||||
} catch {
|
||||
// 静默失败,不影响用户体验
|
||||
} finally {
|
||||
unreadCountPromise = null;
|
||||
}
|
||||
})();
|
||||
await unreadCountPromise;
|
||||
},
|
||||
|
||||
fetchRecentMessages: async () => {
|
||||
if (recentMessagesPromise) {
|
||||
await recentMessagesPromise;
|
||||
return;
|
||||
}
|
||||
recentMessagesPromise = (async () => {
|
||||
try {
|
||||
const result = await listMessages({ page: 1, page_size: 5 });
|
||||
set({ recentMessages: result.data });
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
recentMessagesPromise = null;
|
||||
}
|
||||
})();
|
||||
await recentMessagesPromise;
|
||||
},
|
||||
|
||||
markAsRead: async (id: string) => {
|
||||
try {
|
||||
await markRead(id);
|
||||
set((state) => ({
|
||||
unreadCount: Math.max(0, state.unreadCount - 1),
|
||||
recentMessages: state.recentMessages.map((m) =>
|
||||
m.id === id ? { ...m, is_read: true } : m,
|
||||
),
|
||||
}));
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
},
|
||||
}));
|
||||
59
apps/web/src/stores/plugin.ts
Normal file
59
apps/web/src/stores/plugin.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { create } from 'zustand';
|
||||
import type { PluginInfo, PluginStatus } from '../api/plugins';
|
||||
import { listPlugins } from '../api/plugins';
|
||||
|
||||
export interface PluginMenuItem {
|
||||
key: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
pluginId: string;
|
||||
entity: string;
|
||||
menuGroup?: string;
|
||||
}
|
||||
|
||||
interface PluginStore {
|
||||
plugins: PluginInfo[];
|
||||
loading: boolean;
|
||||
pluginMenuItems: PluginMenuItem[];
|
||||
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
|
||||
refreshMenuItems: () => void;
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||
plugins: [],
|
||||
loading: false,
|
||||
pluginMenuItems: [],
|
||||
|
||||
fetchPlugins: async (page = 1, status?: PluginStatus) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const result = await listPlugins(page, 100, status);
|
||||
set({ plugins: result.data });
|
||||
get().refreshMenuItems();
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
refreshMenuItems: () => {
|
||||
const { plugins } = get();
|
||||
const items: PluginMenuItem[] = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.status !== 'running' && plugin.status !== 'enabled') continue;
|
||||
|
||||
for (const entity of plugin.entities) {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/${entity.name}`,
|
||||
icon: 'AppstoreOutlined',
|
||||
label: entity.display_name || entity.name,
|
||||
pluginId: plugin.id,
|
||||
entity: entity.name,
|
||||
menuGroup: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
set({ pluginMenuItems: items });
|
||||
},
|
||||
}));
|
||||
25
apps/web/tsconfig.app.json
Normal file
25
apps/web/tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
apps/web/tsconfig.json
Normal file
7
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
apps/web/tsconfig.node.json
Normal file
24
apps/web/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
53
apps/web/vite.config.ts
Normal file
53
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), ...tailwindcss()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/ws": {
|
||||
target: "ws://localhost:3000",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: "es2023",
|
||||
cssTarget: "chrome120",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/") || id.includes("node_modules/react-router-dom")) {
|
||||
return "vendor-react";
|
||||
}
|
||||
if (id.includes("node_modules/antd") || id.includes("node_modules/@ant-design")) {
|
||||
return "vendor-antd";
|
||||
}
|
||||
if (id.includes("node_modules/axios") || id.includes("node_modules/zustand")) {
|
||||
return "vendor-utils";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcemap: false,
|
||||
reportCompressedSize: false,
|
||||
chunkSizeWarningLimit: 600,
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-router-dom",
|
||||
"antd",
|
||||
"@ant-design/icons",
|
||||
"axios",
|
||||
"zustand",
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -14,3 +14,10 @@ axum.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
argon2.workspace = true
|
||||
sha2.workspace = true
|
||||
validator.workspace = true
|
||||
utoipa.workspace = true
|
||||
async-trait.workspace = true
|
||||
|
||||
77
crates/erp-auth/src/auth_state.rs
Normal file
77
crates/erp-auth/src/auth_state.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use erp_core::events::EventBus;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Auth-specific state extracted from the server's AppState via `FromRef`.
|
||||
///
|
||||
/// This avoids a circular dependency between erp-auth and erp-server.
|
||||
/// The server crate implements `FromRef<AppState> for AuthState` so that
|
||||
/// Axum handlers in erp-auth can extract `State<AuthState>` directly.
|
||||
///
|
||||
/// Contains everything the auth handlers need:
|
||||
/// - Database connection for user/credential lookups
|
||||
/// - EventBus for publishing domain events
|
||||
/// - JWT configuration for token signing and validation
|
||||
/// - Default tenant ID for the bootstrap phase
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub jwt_secret: String,
|
||||
pub access_ttl_secs: i64,
|
||||
pub refresh_ttl_secs: i64,
|
||||
pub default_tenant_id: Uuid,
|
||||
}
|
||||
|
||||
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.
|
||||
///
|
||||
/// Falls back to parsing the raw string as seconds if no unit suffix is recognized.
|
||||
pub fn parse_ttl(ttl: &str) -> i64 {
|
||||
let ttl = ttl.trim();
|
||||
if let Some(num) = ttl.strip_suffix('s') {
|
||||
num.parse::<i64>().unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('m') {
|
||||
num.parse::<i64>().map(|n| n * 60).unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('h') {
|
||||
num.parse::<i64>().map(|n| n * 3600).unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('d') {
|
||||
num.parse::<i64>().map(|n| n * 86400).unwrap_or(900)
|
||||
} else {
|
||||
ttl.parse::<i64>().unwrap_or(900)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_seconds() {
|
||||
assert_eq!(parse_ttl("900s"), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_minutes() {
|
||||
assert_eq!(parse_ttl("15m"), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_hours() {
|
||||
assert_eq!(parse_ttl("1h"), 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_days() {
|
||||
assert_eq!(parse_ttl("7d"), 604800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_raw_number() {
|
||||
assert_eq!(parse_ttl("300"), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_fallback_on_invalid() {
|
||||
assert_eq!(parse_ttl("invalid"), 900);
|
||||
}
|
||||
}
|
||||
436
crates/erp-auth/src/dto.rs
Normal file
436
crates/erp-auth/src/dto.rs
Normal file
@@ -0,0 +1,436 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
// --- Auth DTOs ---
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct LoginReq {
|
||||
#[validate(length(min = 1, message = "用户名不能为空"))]
|
||||
pub username: String,
|
||||
#[validate(length(min = 1, message = "密码不能为空"))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct LoginResp {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires_in: u64,
|
||||
pub user: UserResp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct RefreshReq {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// 修改密码请求
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct ChangePasswordReq {
|
||||
#[validate(length(min = 1, message = "当前密码不能为空"))]
|
||||
pub current_password: String,
|
||||
#[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))]
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
// --- User DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UserResp {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub status: String,
|
||||
pub roles: Vec<RoleResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateUserReq {
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub username: String,
|
||||
#[validate(length(min = 6, max = 128))]
|
||||
pub password: String,
|
||||
#[validate(email)]
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateUserReq {
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// --- Role DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct RoleResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
pub description: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateRoleReq {
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub name: String,
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub code: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateRoleReq {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct AssignRolesReq {
|
||||
pub role_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
// --- Permission DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PermissionResp {
|
||||
pub id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub resource: String,
|
||||
pub action: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct AssignPermissionsReq {
|
||||
pub permission_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
// --- Organization DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct OrganizationResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub path: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub children: Vec<OrganizationResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateOrganizationReq {
|
||||
#[validate(length(min = 1))]
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateOrganizationReq {
|
||||
pub name: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// --- Department DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DepartmentResp {
|
||||
pub id: Uuid,
|
||||
pub org_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub manager_id: Option<Uuid>,
|
||||
pub path: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub children: Vec<DepartmentResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateDepartmentReq {
|
||||
#[validate(length(min = 1))]
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub manager_id: Option<Uuid>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateDepartmentReq {
|
||||
pub name: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub manager_id: Option<Uuid>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// --- Position DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PositionResp {
|
||||
pub id: Uuid,
|
||||
pub dept_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreatePositionReq {
|
||||
#[validate(length(min = 1))]
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub level: Option<i32>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdatePositionReq {
|
||||
pub name: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub level: Option<i32>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use validator::Validate;
|
||||
|
||||
#[test]
|
||||
fn login_req_valid() {
|
||||
let req = LoginReq {
|
||||
username: "admin".to_string(),
|
||||
password: "password123".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_req_empty_username_fails() {
|
||||
let req = LoginReq {
|
||||
username: "".to_string(),
|
||||
password: "password123".to_string(),
|
||||
};
|
||||
let result = req.validate();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_valid() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "oldPassword123".to_string(),
|
||||
new_password: "newPassword456".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_empty_current_fails() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "".to_string(),
|
||||
new_password: "newPassword456".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_short_new_fails() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "oldPassword123".to_string(),
|
||||
new_password: "12345".to_string(), // min 6
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_long_new_fails() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "oldPassword123".to_string(),
|
||||
new_password: "a".repeat(129), // max 128
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_req_empty_password_fails() {
|
||||
let req = LoginReq {
|
||||
username: "admin".to_string(),
|
||||
password: "".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_valid() {
|
||||
let req = CreateUserReq {
|
||||
username: "alice".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
email: Some("alice@example.com".to_string()),
|
||||
phone: None,
|
||||
display_name: Some("Alice".to_string()),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_short_password_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "bob".to_string(),
|
||||
password: "12345".to_string(), // min 6
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_empty_username_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_invalid_email_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "charlie".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
email: Some("not-an-email".to_string()),
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_long_username_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "a".repeat(51), // max 50
|
||||
password: "secret123".to_string(),
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_role_req_valid() {
|
||||
let req = CreateRoleReq {
|
||||
name: "管理员".to_string(),
|
||||
code: "admin".to_string(),
|
||||
description: Some("系统管理员".to_string()),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_role_req_empty_name_fails() {
|
||||
let req = CreateRoleReq {
|
||||
name: "".to_string(),
|
||||
code: "admin".to_string(),
|
||||
description: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_role_req_empty_code_fails() {
|
||||
let req = CreateRoleReq {
|
||||
name: "管理员".to_string(),
|
||||
code: "".to_string(),
|
||||
description: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_org_req_valid() {
|
||||
let req = CreateOrganizationReq {
|
||||
name: "总部".to_string(),
|
||||
code: Some("HQ".to_string()),
|
||||
parent_id: None,
|
||||
sort_order: Some(0),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_org_req_empty_name_fails() {
|
||||
let req = CreateOrganizationReq {
|
||||
name: "".to_string(),
|
||||
code: None,
|
||||
parent_id: None,
|
||||
sort_order: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dept_req_valid() {
|
||||
let req = CreateDepartmentReq {
|
||||
name: "技术部".to_string(),
|
||||
code: Some("TECH".to_string()),
|
||||
parent_id: None,
|
||||
manager_id: None,
|
||||
sort_order: Some(1),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_position_req_valid() {
|
||||
let req = CreatePositionReq {
|
||||
name: "高级工程师".to_string(),
|
||||
code: Some("SENIOR".to_string()),
|
||||
level: Some(3),
|
||||
sort_order: None,
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_position_req_empty_name_fails() {
|
||||
let req = CreatePositionReq {
|
||||
name: "".to_string(),
|
||||
code: None,
|
||||
level: None,
|
||||
sort_order: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
}
|
||||
68
crates/erp-auth/src/entity/department.rs
Normal file
68
crates/erp-auth/src/entity/department.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "departments")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub org_id: Uuid,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manager_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::organization::Entity",
|
||||
from = "Column::OrgId",
|
||||
to = "super::organization::Column::Id",
|
||||
on_delete = "Restrict"
|
||||
)]
|
||||
Organization,
|
||||
#[sea_orm(has_many = "super::position::Entity")]
|
||||
Position,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::ManagerId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "SetNull"
|
||||
)]
|
||||
Manager,
|
||||
}
|
||||
|
||||
impl Related<super::organization::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Organization.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::position::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Position.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Manager.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
10
crates/erp-auth/src/entity/mod.rs
Normal file
10
crates/erp-auth/src/entity/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod department;
|
||||
pub mod organization;
|
||||
pub mod permission;
|
||||
pub mod position;
|
||||
pub mod role;
|
||||
pub mod role_permission;
|
||||
pub mod user;
|
||||
pub mod user_credential;
|
||||
pub mod user_role;
|
||||
pub mod user_token;
|
||||
40
crates/erp-auth/src/entity/organization.rs
Normal file
40
crates/erp-auth/src/entity/organization.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "organizations")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::department::Entity")]
|
||||
Department,
|
||||
}
|
||||
|
||||
impl Related<super::department::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Department.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
37
crates/erp-auth/src/entity/permission.rs
Normal file
37
crates/erp-auth/src/entity/permission.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "permissions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub resource: String,
|
||||
pub action: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::role_permission::Entity")]
|
||||
RolePermission,
|
||||
}
|
||||
|
||||
impl Related<super::role_permission::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::RolePermission.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
42
crates/erp-auth/src/entity/position.rs
Normal file
42
crates/erp-auth/src/entity/position.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "positions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub dept_id: Uuid,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::department::Entity",
|
||||
from = "Column::DeptId",
|
||||
to = "super::department::Column::Id",
|
||||
on_delete = "Restrict"
|
||||
)]
|
||||
Department,
|
||||
}
|
||||
|
||||
impl Related<super::department::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Department.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
44
crates/erp-auth/src/entity/role.rs
Normal file
44
crates/erp-auth/src/entity/role.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "roles")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::role_permission::Entity")]
|
||||
RolePermission,
|
||||
#[sea_orm(has_many = "super::user_role::Entity")]
|
||||
UserRole,
|
||||
}
|
||||
|
||||
impl Related<super::role_permission::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::RolePermission.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user_role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserRole.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
51
crates/erp-auth/src/entity/role_permission.rs
Normal file
51
crates/erp-auth/src/entity/role_permission.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "role_permissions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub role_id: Uuid,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub permission_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::role::Entity",
|
||||
from = "Column::RoleId",
|
||||
to = "super::role::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Role,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::permission::Entity",
|
||||
from = "Column::PermissionId",
|
||||
to = "super::permission::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Permission,
|
||||
}
|
||||
|
||||
impl Related<super::role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Role.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::permission::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Permission.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
59
crates/erp-auth/src/entity/user.rs
Normal file
59
crates/erp-auth/src/entity/user.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub username: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_login_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::user_credential::Entity")]
|
||||
UserCredential,
|
||||
#[sea_orm(has_many = "super::user_token::Entity")]
|
||||
UserToken,
|
||||
#[sea_orm(has_many = "super::user_role::Entity")]
|
||||
UserRole,
|
||||
}
|
||||
|
||||
impl Related<super::user_credential::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserCredential.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user_token::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserToken.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user_role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserRole.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
41
crates/erp-auth/src/entity/user_credential.rs
Normal file
41
crates/erp-auth/src/entity/user_credential.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_credentials")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub credential_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_data: Option<serde_json::Value>,
|
||||
pub verified: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
51
crates/erp-auth/src/entity/user_role.rs
Normal file
51
crates/erp-auth/src/entity/user_role.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_roles")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: Uuid,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub role_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::role::Entity",
|
||||
from = "Column::RoleId",
|
||||
to = "super::role::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Role,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Role.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
44
crates/erp-auth/src/entity/user_token.rs
Normal file
44
crates/erp-auth/src/entity/user_token.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_tokens")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub token_hash: String,
|
||||
pub token_type: String,
|
||||
pub expires_at: DateTimeUtc,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub revoked_at: Option<DateTimeUtc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_info: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
131
crates/erp-auth/src/error.rs
Normal file
131
crates/erp-auth/src/error.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use erp_core::error::AppError;
|
||||
|
||||
/// Auth module error types
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("用户名或密码错误")]
|
||||
InvalidCredentials,
|
||||
|
||||
#[error("Token 已过期")]
|
||||
TokenExpired,
|
||||
|
||||
#[error("Token 已被吊销")]
|
||||
TokenRevoked,
|
||||
|
||||
#[error("用户已被{0}")]
|
||||
UserDisabled(String),
|
||||
|
||||
#[error("密码哈希错误")]
|
||||
HashError(String),
|
||||
|
||||
#[error("JWT 错误: {0}")]
|
||||
JwtError(#[from] jsonwebtoken::errors::Error),
|
||||
|
||||
#[error("{0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||
VersionMismatch,
|
||||
}
|
||||
|
||||
impl From<AuthError> for AppError {
|
||||
fn from(err: AuthError) -> Self {
|
||||
match err {
|
||||
AuthError::InvalidCredentials => AppError::Unauthorized,
|
||||
AuthError::TokenExpired => AppError::Unauthorized,
|
||||
AuthError::TokenRevoked => AppError::Unauthorized,
|
||||
AuthError::UserDisabled(s) => AppError::Forbidden(s),
|
||||
AuthError::Validation(s) => AppError::Validation(s),
|
||||
AuthError::HashError(_) => AppError::Internal(err.to_string()),
|
||||
AuthError::JwtError(_) => AppError::Unauthorized,
|
||||
AuthError::VersionMismatch => AppError::VersionMismatch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppError> for AuthError {
|
||||
fn from(err: AppError) -> Self {
|
||||
match err {
|
||||
AppError::VersionMismatch => AuthError::VersionMismatch,
|
||||
other => AuthError::Validation(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type AuthResult<T> = Result<T, AuthError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use erp_core::error::AppError;
|
||||
|
||||
#[test]
|
||||
fn auth_error_invalid_credentials_maps_to_unauthorized() {
|
||||
let app: AppError = AuthError::InvalidCredentials.into();
|
||||
match app {
|
||||
AppError::Unauthorized => {}
|
||||
other => panic!("Expected Unauthorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_token_expired_maps_to_unauthorized() {
|
||||
let app: AppError = AuthError::TokenExpired.into();
|
||||
match app {
|
||||
AppError::Unauthorized => {}
|
||||
other => panic!("Expected Unauthorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_user_disabled_maps_to_forbidden() {
|
||||
let app: AppError = AuthError::UserDisabled("已禁用".to_string()).into();
|
||||
match app {
|
||||
AppError::Forbidden(msg) => assert_eq!(msg, "已禁用"),
|
||||
other => panic!("Expected Forbidden, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_hash_error_maps_to_internal() {
|
||||
let app: AppError = AuthError::HashError("argon2 failed".to_string()).into();
|
||||
match app {
|
||||
AppError::Internal(_) => {}
|
||||
other => panic!("Expected Internal, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_validation_maps_to_validation() {
|
||||
let app: AppError = AuthError::Validation("用户名已存在".to_string()).into();
|
||||
match app {
|
||||
AppError::Validation(msg) => assert_eq!(msg, "用户名已存在"),
|
||||
other => panic!("Expected Validation, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_version_mismatch_roundtrip() {
|
||||
// AuthError::VersionMismatch -> AppError::VersionMismatch -> AuthError::VersionMismatch
|
||||
let app: AppError = AuthError::VersionMismatch.into();
|
||||
match app {
|
||||
AppError::VersionMismatch => {}
|
||||
other => panic!("Expected VersionMismatch, got {:?}", other),
|
||||
}
|
||||
// And back
|
||||
let auth: AuthError = AppError::VersionMismatch.into();
|
||||
match auth {
|
||||
AuthError::VersionMismatch => {}
|
||||
other => panic!("Expected VersionMismatch, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_error_other_maps_to_auth_validation() {
|
||||
let auth: AuthError = AppError::NotFound("not found".to_string()).into();
|
||||
match auth {
|
||||
AuthError::Validation(msg) => assert!(msg.contains("not found")),
|
||||
other => panic!("Expected Validation, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
168
crates/erp-auth/src/handler/auth_handler.rs
Normal file
168
crates/erp-auth/src/handler/auth_handler.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{ChangePasswordReq, LoginReq, LoginResp, RefreshReq};
|
||||
use crate::service::auth_service::{AuthService, JwtConfig};
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/login",
|
||||
request_body = LoginReq,
|
||||
responses(
|
||||
(status = 200, description = "登录成功", body = ApiResponse<LoginResp>),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "用户名或密码错误"),
|
||||
),
|
||||
tag = "认证"
|
||||
)]
|
||||
/// POST /api/v1/auth/login
|
||||
///
|
||||
/// Authenticates a user with username and password, returning access and refresh tokens.
|
||||
///
|
||||
/// During the bootstrap phase, the tenant_id is taken from `AuthState::default_tenant_id`.
|
||||
/// In production, this will come from a tenant-resolution middleware.
|
||||
pub async fn login<S>(
|
||||
State(state): State<AuthState>,
|
||||
Json(req): Json<LoginReq>,
|
||||
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let tenant_id = state.default_tenant_id;
|
||||
|
||||
let jwt_config = JwtConfig {
|
||||
secret: &state.jwt_secret,
|
||||
access_ttl_secs: state.access_ttl_secs,
|
||||
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||
};
|
||||
|
||||
let resp = AuthService::login(
|
||||
tenant_id,
|
||||
&req.username,
|
||||
&req.password,
|
||||
&state.db,
|
||||
&jwt_config,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/refresh",
|
||||
request_body = RefreshReq,
|
||||
responses(
|
||||
(status = 200, description = "刷新成功", body = ApiResponse<LoginResp>),
|
||||
(status = 401, description = "刷新令牌无效或已过期"),
|
||||
),
|
||||
tag = "认证"
|
||||
)]
|
||||
/// POST /api/v1/auth/refresh
|
||||
///
|
||||
/// Validates an existing refresh token, revokes it (rotation), and issues
|
||||
/// a new access + refresh token pair.
|
||||
pub async fn refresh<S>(
|
||||
State(state): State<AuthState>,
|
||||
Json(req): Json<RefreshReq>,
|
||||
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let jwt_config = JwtConfig {
|
||||
secret: &state.jwt_secret,
|
||||
access_ttl_secs: state.access_ttl_secs,
|
||||
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||
};
|
||||
|
||||
let resp = AuthService::refresh(&req.refresh_token, &state.db, &jwt_config).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/logout",
|
||||
responses(
|
||||
(status = 200, description = "已成功登出"),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "认证"
|
||||
)]
|
||||
/// POST /api/v1/auth/logout
|
||||
///
|
||||
/// Revokes all refresh tokens for the authenticated user, effectively
|
||||
/// logging them out on all devices.
|
||||
pub async fn logout<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
AuthService::logout(ctx.user_id, ctx.tenant_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("已成功登出".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/change-password",
|
||||
request_body = ChangePasswordReq,
|
||||
responses(
|
||||
(status = 200, description = "密码修改成功,需重新登录"),
|
||||
(status = 400, description = "当前密码不正确"),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "认证"
|
||||
)]
|
||||
/// POST /api/v1/auth/change-password
|
||||
///
|
||||
/// 修改当前登录用户的密码。修改成功后所有已签发的 refresh token 将被吊销,
|
||||
/// 用户需要在所有设备上重新登录。
|
||||
pub async fn change_password<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<ChangePasswordReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
AuthService::change_password(
|
||||
ctx.user_id,
|
||||
ctx.tenant_id,
|
||||
&req.current_password,
|
||||
&req.new_password,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("密码修改成功,请重新登录".to_string()),
|
||||
}))
|
||||
}
|
||||
4
crates/erp-auth/src/handler/mod.rs
Normal file
4
crates/erp-auth/src/handler/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod auth_handler;
|
||||
pub mod org_handler;
|
||||
pub mod role_handler;
|
||||
pub mod user_handler;
|
||||
460
crates/erp-auth/src/handler/org_handler.rs
Normal file
460
crates/erp-auth/src/handler/org_handler.rs
Normal file
@@ -0,0 +1,460 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{
|
||||
CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp,
|
||||
OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq,
|
||||
};
|
||||
use crate::service::dept_service::DeptService;
|
||||
use crate::service::org_service::OrgService;
|
||||
use crate::service::position_service::PositionService;
|
||||
use erp_core::rbac::require_permission;
|
||||
|
||||
// --- Organization handlers ---
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/organizations",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<OrganizationResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// GET /api/v1/organizations
|
||||
///
|
||||
/// List all organizations within the current tenant as a nested tree.
|
||||
/// Requires the `organization.list` permission.
|
||||
pub async fn list_organizations<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<OrganizationResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.list")?;
|
||||
|
||||
let tree = OrgService::get_tree(ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(tree)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/organizations",
|
||||
request_body = CreateOrganizationReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<OrganizationResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// POST /api/v1/organizations
|
||||
///
|
||||
/// Create a new organization within the current tenant.
|
||||
/// Requires the `organization.create` permission.
|
||||
pub async fn create_organization<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateOrganizationReq>,
|
||||
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let org = OrgService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(org)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/organizations/{id}",
|
||||
params(("id" = Uuid, Path, description = "组织ID")),
|
||||
request_body = UpdateOrganizationReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<OrganizationResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "组织不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// PUT /api/v1/organizations/{id}
|
||||
///
|
||||
/// Update editable organization fields (name, code, sort_order).
|
||||
/// Requires the `organization.update` permission.
|
||||
pub async fn update_organization<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateOrganizationReq>,
|
||||
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.update")?;
|
||||
|
||||
let org = OrgService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(org)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/organizations/{id}",
|
||||
params(("id" = Uuid, Path, description = "组织ID")),
|
||||
responses(
|
||||
(status = 200, description = "组织已删除"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "组织不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// DELETE /api/v1/organizations/{id}
|
||||
///
|
||||
/// Soft-delete an organization by ID.
|
||||
/// Requires the `organization.delete` permission.
|
||||
pub async fn delete_organization<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.delete")?;
|
||||
|
||||
OrgService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("组织已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Department handlers ---
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/organizations/{org_id}/departments",
|
||||
params(("org_id" = Uuid, Path, description = "组织ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<DepartmentResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// GET /api/v1/organizations/{org_id}/departments
|
||||
///
|
||||
/// List all departments for an organization as a nested tree.
|
||||
/// Requires the `department.list` permission.
|
||||
pub async fn list_departments<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(org_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<DepartmentResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.list")?;
|
||||
|
||||
let tree = DeptService::list_tree(org_id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(tree)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/organizations/{org_id}/departments",
|
||||
params(("org_id" = Uuid, Path, description = "组织ID")),
|
||||
request_body = CreateDepartmentReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<DepartmentResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// POST /api/v1/organizations/{org_id}/departments
|
||||
///
|
||||
/// Create a new department under the specified organization.
|
||||
/// Requires the `department.create` permission.
|
||||
pub async fn create_department<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(org_id): Path<Uuid>,
|
||||
Json(req): Json<CreateDepartmentReq>,
|
||||
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let dept = DeptService::create(
|
||||
org_id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(dept)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/departments/{id}",
|
||||
params(("id" = Uuid, Path, description = "部门ID")),
|
||||
request_body = UpdateDepartmentReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<DepartmentResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "部门不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// PUT /api/v1/departments/{id}
|
||||
///
|
||||
/// Update editable department fields (name, code, manager_id, sort_order).
|
||||
/// Requires the `department.update` permission.
|
||||
pub async fn update_department<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateDepartmentReq>,
|
||||
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.update")?;
|
||||
|
||||
let dept = DeptService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(dept)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/departments/{id}",
|
||||
params(("id" = Uuid, Path, description = "部门ID")),
|
||||
responses(
|
||||
(status = 200, description = "部门已删除"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "部门不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// DELETE /api/v1/departments/{id}
|
||||
///
|
||||
/// Soft-delete a department by ID.
|
||||
/// Requires the `department.delete` permission.
|
||||
pub async fn delete_department<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.delete")?;
|
||||
|
||||
DeptService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("部门已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Position handlers ---
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/departments/{dept_id}/positions",
|
||||
params(("dept_id" = Uuid, Path, description = "部门ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<PositionResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// GET /api/v1/departments/{dept_id}/positions
|
||||
///
|
||||
/// List all positions for a department.
|
||||
/// Requires the `position.list` permission.
|
||||
pub async fn list_positions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(dept_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<PositionResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.list")?;
|
||||
|
||||
let positions = PositionService::list(dept_id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(positions)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/departments/{dept_id}/positions",
|
||||
params(("dept_id" = Uuid, Path, description = "部门ID")),
|
||||
request_body = CreatePositionReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<PositionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// POST /api/v1/departments/{dept_id}/positions
|
||||
///
|
||||
/// Create a new position under the specified department.
|
||||
/// Requires the `position.create` permission.
|
||||
pub async fn create_position<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(dept_id): Path<Uuid>,
|
||||
Json(req): Json<CreatePositionReq>,
|
||||
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let pos = PositionService::create(
|
||||
dept_id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(pos)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/positions/{id}",
|
||||
params(("id" = Uuid, Path, description = "岗位ID")),
|
||||
request_body = UpdatePositionReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<PositionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "岗位不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// PUT /api/v1/positions/{id}
|
||||
///
|
||||
/// Update editable position fields (name, code, level, sort_order).
|
||||
/// Requires the `position.update` permission.
|
||||
pub async fn update_position<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdatePositionReq>,
|
||||
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.update")?;
|
||||
|
||||
let pos = PositionService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(pos)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/positions/{id}",
|
||||
params(("id" = Uuid, Path, description = "岗位ID")),
|
||||
responses(
|
||||
(status = 200, description = "岗位已删除"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "岗位不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// DELETE /api/v1/positions/{id}
|
||||
///
|
||||
/// Soft-delete a position by ID.
|
||||
/// Requires the `position.delete` permission.
|
||||
pub async fn delete_position<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.delete")?;
|
||||
|
||||
PositionService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("岗位已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
320
crates/erp-auth/src/handler/role_handler.rs
Normal file
320
crates/erp-auth/src/handler/role_handler.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq};
|
||||
use crate::service::permission_service::PermissionService;
|
||||
use crate::service::role_service::RoleService;
|
||||
use erp_core::rbac::require_permission;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/roles",
|
||||
params(Pagination),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<RoleResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// GET /api/v1/roles
|
||||
///
|
||||
/// List roles within the current tenant with pagination.
|
||||
/// Requires the `role.list` permission.
|
||||
pub async fn list_roles<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<RoleResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.list")?;
|
||||
|
||||
let (roles, total) = RoleService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: roles,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/roles",
|
||||
request_body = CreateRoleReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<RoleResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// POST /api/v1/roles
|
||||
///
|
||||
/// Create a new role within the current tenant.
|
||||
/// Requires the `role.create` permission.
|
||||
pub async fn create_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateRoleReq>,
|
||||
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let role = RoleService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.name,
|
||||
&req.code,
|
||||
&req.description,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(role)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/roles/{id}",
|
||||
params(("id" = Uuid, Path, description = "角色ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<RoleResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "角色不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// GET /api/v1/roles/:id
|
||||
///
|
||||
/// Fetch a single role by ID within the current tenant.
|
||||
/// Requires the `role.read` permission.
|
||||
pub async fn get_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.read")?;
|
||||
|
||||
let role = RoleService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(role)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/roles/{id}",
|
||||
params(("id" = Uuid, Path, description = "角色ID")),
|
||||
request_body = UpdateRoleReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<RoleResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "角色不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// PUT /api/v1/roles/:id
|
||||
///
|
||||
/// Update editable role fields (name, description).
|
||||
/// Requires the `role.update` permission.
|
||||
pub async fn update_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateRoleReq>,
|
||||
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.update")?;
|
||||
|
||||
let role = RoleService::update(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.name,
|
||||
&req.description,
|
||||
req.version,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(role)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/roles/{id}",
|
||||
params(("id" = Uuid, Path, description = "角色ID")),
|
||||
responses(
|
||||
(status = 200, description = "角色已删除"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "角色不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// DELETE /api/v1/roles/:id
|
||||
///
|
||||
/// Soft-delete a role by ID within the current tenant.
|
||||
/// System roles cannot be deleted.
|
||||
/// Requires the `role.delete` permission.
|
||||
pub async fn delete_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.delete")?;
|
||||
|
||||
RoleService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("角色已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/roles/{id}/permissions",
|
||||
params(("id" = Uuid, Path, description = "角色ID")),
|
||||
request_body = AssignPermissionsReq,
|
||||
responses(
|
||||
(status = 200, description = "权限分配成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "角色不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// POST /api/v1/roles/:id/permissions
|
||||
///
|
||||
/// Replace all permission assignments for a role.
|
||||
/// Requires the `role.update` permission.
|
||||
pub async fn assign_permissions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<AssignPermissionsReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.update")?;
|
||||
|
||||
RoleService::assign_permissions(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.permission_ids,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("权限分配成功".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/roles/{id}/permissions",
|
||||
params(("id" = Uuid, Path, description = "角色ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<PermissionResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "角色不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// GET /api/v1/roles/:id/permissions
|
||||
///
|
||||
/// Fetch all permissions assigned to a role.
|
||||
/// Requires the `role.read` permission.
|
||||
pub async fn get_role_permissions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.read")?;
|
||||
|
||||
let perms = RoleService::get_role_permissions(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(perms)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/permissions",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<PermissionResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "权限管理"
|
||||
)]
|
||||
/// GET /api/v1/permissions
|
||||
///
|
||||
/// List all permissions within the current tenant.
|
||||
/// Requires the `permission.list` permission.
|
||||
pub async fn list_permissions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "permission.list")?;
|
||||
|
||||
let perms = PermissionService::list(ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(perms)))
|
||||
}
|
||||
269
crates/erp-auth/src/handler/user_handler.rs
Normal file
269
crates/erp-auth/src/handler/user_handler.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
|
||||
use crate::service::user_service::UserService;
|
||||
use erp_core::rbac::require_permission;
|
||||
|
||||
/// Query parameters for user list endpoint.
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct UserListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
/// Optional search term — filters by username (case-insensitive contains).
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/users",
|
||||
params(UserListParams),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<UserResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// GET /api/v1/users
|
||||
///
|
||||
/// List users within the current tenant with pagination and optional search.
|
||||
/// Requires the `user.list` permission.
|
||||
pub async fn list_users<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<UserListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<UserResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.list")?;
|
||||
|
||||
let pagination = Pagination {
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let (users, total) = UserService::list(
|
||||
ctx.tenant_id,
|
||||
&pagination,
|
||||
params.search.as_deref(),
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: users,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/users",
|
||||
request_body = CreateUserReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<UserResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// POST /api/v1/users
|
||||
///
|
||||
/// Create a new user within the current tenant.
|
||||
/// Requires the `user.create` permission.
|
||||
pub async fn create_user<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateUserReq>,
|
||||
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let user = UserService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(user)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/users/{id}",
|
||||
params(("id" = Uuid, Path, description = "用户ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<UserResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "用户不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// GET /api/v1/users/:id
|
||||
///
|
||||
/// Fetch a single user by ID within the current tenant.
|
||||
/// Requires the `user.read` permission.
|
||||
pub async fn get_user<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.read")?;
|
||||
|
||||
let user = UserService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(user)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/users/{id}",
|
||||
params(("id" = Uuid, Path, description = "用户ID")),
|
||||
request_body = UpdateUserReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<UserResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "用户不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// PUT /api/v1/users/:id
|
||||
///
|
||||
/// Update editable user fields.
|
||||
/// Requires the `user.update` permission.
|
||||
pub async fn update_user<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateUserReq>,
|
||||
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.update")?;
|
||||
|
||||
let user = UserService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(user)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/users/{id}",
|
||||
params(("id" = Uuid, Path, description = "用户ID")),
|
||||
responses(
|
||||
(status = 200, description = "用户已删除"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "用户不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// DELETE /api/v1/users/:id
|
||||
///
|
||||
/// Soft-delete a user by ID within the current tenant.
|
||||
/// Requires the `user.delete` permission.
|
||||
pub async fn delete_user<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.delete")?;
|
||||
|
||||
UserService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("用户已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Assign roles request body.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct AssignRolesReq {
|
||||
pub role_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
/// Assign roles response.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AssignRolesResp {
|
||||
pub roles: Vec<RoleResp>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/users/{id}/roles",
|
||||
params(("id" = Uuid, Path, description = "用户ID")),
|
||||
request_body = AssignRolesReq,
|
||||
responses(
|
||||
(status = 200, description = "角色分配成功", body = ApiResponse<AssignRolesResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "用户不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// POST /api/v1/users/:id/roles
|
||||
///
|
||||
/// Replace all role assignments for a user within the current tenant.
|
||||
/// Requires the `user.update` permission.
|
||||
pub async fn assign_roles<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<AssignRolesReq>,
|
||||
) -> Result<Json<ApiResponse<AssignRolesResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.update")?;
|
||||
|
||||
let roles =
|
||||
UserService::assign_roles(id, ctx.tenant_id, ctx.user_id, &req.role_ids, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(AssignRolesResp { roles })))
|
||||
}
|
||||
@@ -1 +1,11 @@
|
||||
// erp-auth: 身份与权限模块 (Phase 2)
|
||||
pub mod auth_state;
|
||||
pub mod dto;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod handler;
|
||||
pub mod middleware;
|
||||
pub mod module;
|
||||
pub mod service;
|
||||
|
||||
pub use auth_state::AuthState;
|
||||
pub use module::AuthModule;
|
||||
|
||||
64
crates/erp-auth/src/middleware/jwt_auth.rs
Normal file
64
crates/erp-auth/src/middleware/jwt_auth.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::TenantContext;
|
||||
|
||||
use crate::service::token_service::TokenService;
|
||||
|
||||
/// JWT authentication middleware function.
|
||||
///
|
||||
/// Extracts the `Bearer` token from the `Authorization` header, validates it
|
||||
/// using `TokenService::decode_token`, and injects a `TenantContext` into the
|
||||
/// request extensions so downstream handlers can access tenant/user identity.
|
||||
///
|
||||
/// The `jwt_secret` parameter is passed explicitly by the server crate at
|
||||
/// middleware construction time, avoiding any circular dependency between
|
||||
/// erp-auth and erp-server.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `AppError::Unauthorized` if:
|
||||
/// - The `Authorization` header is missing
|
||||
/// - The header value does not start with `"Bearer "`
|
||||
/// - The token cannot be decoded or has expired
|
||||
/// - The token type is not "access"
|
||||
pub async fn jwt_auth_middleware_fn(
|
||||
jwt_secret: String,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let token = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let claims =
|
||||
TokenService::decode_token(token, &jwt_secret).map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
// Verify this is an access token, not a refresh token
|
||||
if claims.token_type != "access" {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let ctx = TenantContext {
|
||||
tenant_id: claims.tid,
|
||||
user_id: claims.sub,
|
||||
roles: claims.roles,
|
||||
permissions: claims.permissions,
|
||||
};
|
||||
|
||||
// Reconstruct the request with the TenantContext injected into extensions.
|
||||
// We cannot borrow `req` mutably after reading headers, so we rebuild.
|
||||
let (parts, body) = req.into_parts();
|
||||
let mut req = Request::from_parts(parts, body);
|
||||
req.extensions_mut().insert(ctx);
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
4
crates/erp-auth/src/middleware/mod.rs
Normal file
4
crates/erp-auth/src/middleware/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod jwt_auth;
|
||||
|
||||
pub use erp_core::rbac::{require_any_permission, require_permission, require_role};
|
||||
pub use jwt_auth::jwt_auth_middleware_fn;
|
||||
196
crates/erp-auth/src/module.rs
Normal file
196
crates/erp-auth/src/module.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use axum::Router;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::ErpModule;
|
||||
|
||||
use crate::handler::{auth_handler, org_handler, role_handler, user_handler};
|
||||
|
||||
/// Auth module implementing the `ErpModule` trait.
|
||||
///
|
||||
/// Manages identity, authentication, and user CRUD within the ERP platform.
|
||||
/// This module has no dependencies on other business modules.
|
||||
pub struct AuthModule;
|
||||
|
||||
impl AuthModule {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Build public (unauthenticated) routes for the auth module.
|
||||
///
|
||||
/// These routes do not require a valid JWT token.
|
||||
/// The caller wraps this into whatever state type the application uses.
|
||||
pub fn public_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/auth/login", axum::routing::post(auth_handler::login))
|
||||
.route("/auth/refresh", axum::routing::post(auth_handler::refresh))
|
||||
}
|
||||
|
||||
/// Build protected (authenticated) routes for the auth module.
|
||||
///
|
||||
/// These routes require a valid JWT token, verified by the middleware layer.
|
||||
/// The caller wraps this into whatever state type the application uses.
|
||||
pub fn protected_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/auth/logout", axum::routing::post(auth_handler::logout))
|
||||
.route(
|
||||
"/auth/change-password",
|
||||
axum::routing::post(auth_handler::change_password),
|
||||
)
|
||||
.route(
|
||||
"/users",
|
||||
axum::routing::get(user_handler::list_users).post(user_handler::create_user),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}",
|
||||
axum::routing::get(user_handler::get_user)
|
||||
.put(user_handler::update_user)
|
||||
.delete(user_handler::delete_user),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/roles",
|
||||
axum::routing::post(user_handler::assign_roles),
|
||||
)
|
||||
.route(
|
||||
"/roles",
|
||||
axum::routing::get(role_handler::list_roles).post(role_handler::create_role),
|
||||
)
|
||||
.route(
|
||||
"/roles/{id}",
|
||||
axum::routing::get(role_handler::get_role)
|
||||
.put(role_handler::update_role)
|
||||
.delete(role_handler::delete_role),
|
||||
)
|
||||
.route(
|
||||
"/roles/{id}/permissions",
|
||||
axum::routing::get(role_handler::get_role_permissions)
|
||||
.post(role_handler::assign_permissions),
|
||||
)
|
||||
.route(
|
||||
"/permissions",
|
||||
axum::routing::get(role_handler::list_permissions),
|
||||
)
|
||||
// Organization routes
|
||||
.route(
|
||||
"/organizations",
|
||||
axum::routing::get(org_handler::list_organizations)
|
||||
.post(org_handler::create_organization),
|
||||
)
|
||||
.route(
|
||||
"/organizations/{id}",
|
||||
axum::routing::put(org_handler::update_organization)
|
||||
.delete(org_handler::delete_organization),
|
||||
)
|
||||
// Department routes (nested under organization)
|
||||
.route(
|
||||
"/organizations/{org_id}/departments",
|
||||
axum::routing::get(org_handler::list_departments)
|
||||
.post(org_handler::create_department),
|
||||
)
|
||||
.route(
|
||||
"/departments/{id}",
|
||||
axum::routing::put(org_handler::update_department)
|
||||
.delete(org_handler::delete_department),
|
||||
)
|
||||
// Position routes (nested under department)
|
||||
.route(
|
||||
"/departments/{dept_id}/positions",
|
||||
axum::routing::get(org_handler::list_positions).post(org_handler::create_position),
|
||||
)
|
||||
.route(
|
||||
"/positions/{id}",
|
||||
axum::routing::put(org_handler::update_position)
|
||||
.delete(org_handler::delete_position),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuthModule {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ErpModule for AuthModule {
|
||||
fn name(&self) -> &str {
|
||||
"auth"
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
// Auth is a foundational module with no business-module dependencies.
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn register_event_handlers(&self, _bus: &EventBus) {
|
||||
// Auth 模块暂无跨模块事件订阅需求
|
||||
}
|
||||
|
||||
async fn on_tenant_created(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<()> {
|
||||
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
|
||||
.unwrap_or_else(|_| "Admin@2026".to_string());
|
||||
crate::service::seed::seed_tenant_auth(db, tenant_id, &password)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
tracing::info!(tenant_id = %tenant_id, "Tenant auth initialized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_tenant_deleted(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use chrono::Utc;
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
// 软删除该租户下所有用户
|
||||
let users = crate::entity::user::Entity::find()
|
||||
.filter(crate::entity::user::Column::TenantId.eq(tenant_id))
|
||||
.filter(crate::entity::user::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
|
||||
for user_model in users {
|
||||
let current_version = user_model.version;
|
||||
let active: crate::entity::user::ActiveModel = user_model.into();
|
||||
let mut to_update: crate::entity::user::ActiveModel = active;
|
||||
to_update.deleted_at = Set(Some(now));
|
||||
to_update.updated_at = Set(now);
|
||||
to_update.version = Set(current_version + 1);
|
||||
let _ = to_update
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
}
|
||||
|
||||
tracing::info!(tenant_id = %tenant_id, "Tenant users soft-deleted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
304
crates/erp-auth/src/service/auth_service.rs
Normal file
304
crates/erp-auth/src/service/auth_service.rs
Normal file
@@ -0,0 +1,304 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{LoginResp, RoleResp, UserResp};
|
||||
use crate::entity::{role, user, user_credential, user_role};
|
||||
use crate::error::AuthError;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::error::AuthResult;
|
||||
|
||||
use super::password;
|
||||
use super::token_service::TokenService;
|
||||
|
||||
/// JWT configuration needed for token signing.
|
||||
pub struct JwtConfig<'a> {
|
||||
pub secret: &'a str,
|
||||
pub access_ttl_secs: i64,
|
||||
pub refresh_ttl_secs: i64,
|
||||
}
|
||||
|
||||
/// Authentication service handling login, token refresh, and logout.
|
||||
pub struct AuthService;
|
||||
|
||||
impl AuthService {
|
||||
/// Authenticate a user and issue access + refresh tokens.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Look up user by tenant + username (soft-delete aware)
|
||||
/// 2. Verify user status is "active"
|
||||
/// 3. Fetch the stored password credential
|
||||
/// 4. Verify password hash
|
||||
/// 5. Collect roles and permissions
|
||||
/// 6. Sign JWT tokens
|
||||
/// 7. Update last_login_at
|
||||
/// 8. Publish login event
|
||||
pub async fn login(
|
||||
tenant_id: Uuid,
|
||||
username: &str,
|
||||
password_plain: &str,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
jwt: &JwtConfig<'_>,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<LoginResp> {
|
||||
// 1. Find user by tenant_id + username
|
||||
let user_model = user::Entity::find()
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::Username.eq(username))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
|
||||
// 2. Check user status
|
||||
if user_model.status != "active" {
|
||||
return Err(AuthError::UserDisabled(user_model.status.clone()));
|
||||
}
|
||||
|
||||
// 3. Find password credential
|
||||
let cred = user_credential::Entity::find()
|
||||
.filter(user_credential::Column::UserId.eq(user_model.id))
|
||||
.filter(user_credential::Column::CredentialType.eq("password"))
|
||||
.filter(user_credential::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
|
||||
// 4. Verify password
|
||||
let stored_hash = cred
|
||||
.credential_data
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
|
||||
if !password::verify_password(password_plain, stored_hash)? {
|
||||
return Err(AuthError::InvalidCredentials);
|
||||
}
|
||||
|
||||
// 5. Get roles and permissions
|
||||
let roles: Vec<String> = TokenService::get_user_roles(user_model.id, tenant_id, db).await?;
|
||||
let permissions = TokenService::get_user_permissions(user_model.id, tenant_id, db).await?;
|
||||
|
||||
// 6. Sign tokens
|
||||
let access_token = TokenService::sign_access_token(
|
||||
user_model.id,
|
||||
tenant_id,
|
||||
roles.clone(),
|
||||
permissions,
|
||||
jwt.secret,
|
||||
jwt.access_ttl_secs,
|
||||
)?;
|
||||
let (refresh_token, _) = TokenService::sign_refresh_token(
|
||||
user_model.id,
|
||||
tenant_id,
|
||||
db,
|
||||
jwt.secret,
|
||||
jwt.refresh_ttl_secs,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 7. Update last_login_at
|
||||
let mut user_active: user::ActiveModel = user_model.clone().into();
|
||||
user_active.last_login_at = Set(Some(Utc::now()));
|
||||
user_active.updated_at = Set(Utc::now());
|
||||
user_active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// 8. Build response
|
||||
let role_resps = Self::get_user_role_resps(user_model.id, tenant_id, db).await?;
|
||||
let user_resp = UserResp {
|
||||
id: user_model.id,
|
||||
username: user_model.username.clone(),
|
||||
email: user_model.email,
|
||||
phone: user_model.phone,
|
||||
display_name: user_model.display_name,
|
||||
avatar_url: user_model.avatar_url,
|
||||
status: user_model.status,
|
||||
roles: role_resps,
|
||||
version: user_model.version,
|
||||
};
|
||||
|
||||
// 9. Publish event
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"user.login",
|
||||
tenant_id,
|
||||
serde_json::json!({ "user_id": user_model.id, "username": user_model.username }),
|
||||
), db).await;
|
||||
|
||||
Ok(LoginResp {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in: jwt.access_ttl_secs as u64,
|
||||
user: user_resp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh the token pair: validate the old refresh token, revoke it, issue a new pair.
|
||||
pub async fn refresh(
|
||||
refresh_token_str: &str,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
jwt: &JwtConfig<'_>,
|
||||
) -> AuthResult<LoginResp> {
|
||||
// Validate existing refresh token
|
||||
let (old_token_id, claims) =
|
||||
TokenService::validate_refresh_token(refresh_token_str, db, jwt.secret).await?;
|
||||
|
||||
// Revoke the old token (rotation)
|
||||
TokenService::revoke_token(old_token_id, db).await?;
|
||||
|
||||
// Fetch fresh roles and permissions
|
||||
let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?;
|
||||
let permissions = TokenService::get_user_permissions(claims.sub, claims.tid, db).await?;
|
||||
|
||||
// Sign new token pair
|
||||
let access_token = TokenService::sign_access_token(
|
||||
claims.sub,
|
||||
claims.tid,
|
||||
roles.clone(),
|
||||
permissions,
|
||||
jwt.secret,
|
||||
jwt.access_ttl_secs,
|
||||
)?;
|
||||
let (new_refresh_token, _) = TokenService::sign_refresh_token(
|
||||
claims.sub,
|
||||
claims.tid,
|
||||
db,
|
||||
jwt.secret,
|
||||
jwt.refresh_ttl_secs,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Fetch user for the response
|
||||
let user_model = user::Entity::find_by_id(claims.sub)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::TokenRevoked)?;
|
||||
|
||||
let role_resps = Self::get_user_role_resps(claims.sub, claims.tid, db).await?;
|
||||
let user_resp = UserResp {
|
||||
id: user_model.id,
|
||||
username: user_model.username,
|
||||
email: user_model.email,
|
||||
phone: user_model.phone,
|
||||
display_name: user_model.display_name,
|
||||
avatar_url: user_model.avatar_url,
|
||||
status: user_model.status,
|
||||
roles: role_resps,
|
||||
version: user_model.version,
|
||||
};
|
||||
|
||||
Ok(LoginResp {
|
||||
access_token,
|
||||
refresh_token: new_refresh_token,
|
||||
expires_in: jwt.access_ttl_secs as u64,
|
||||
user: user_resp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Revoke all refresh tokens for a user, effectively logging them out everywhere.
|
||||
pub async fn logout(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<()> {
|
||||
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await
|
||||
}
|
||||
|
||||
/// Change password for the authenticated user.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Verify current password
|
||||
/// 2. Hash the new password
|
||||
/// 3. Update the credential record
|
||||
/// 4. Revoke all existing refresh tokens (force re-login)
|
||||
pub async fn change_password(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
current_password: &str,
|
||||
new_password: &str,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<()> {
|
||||
// 1. Find the user's password credential
|
||||
let cred = user_credential::Entity::find()
|
||||
.filter(user_credential::Column::UserId.eq(user_id))
|
||||
.filter(user_credential::Column::TenantId.eq(tenant_id))
|
||||
.filter(user_credential::Column::CredentialType.eq("password"))
|
||||
.filter(user_credential::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?;
|
||||
|
||||
// 2. Verify current password
|
||||
let stored_hash = cred
|
||||
.credential_data
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
|
||||
.ok_or_else(|| AuthError::Validation("用户凭证异常".to_string()))?;
|
||||
|
||||
if !password::verify_password(current_password, stored_hash)? {
|
||||
return Err(AuthError::Validation("当前密码不正确".to_string()));
|
||||
}
|
||||
|
||||
// 3. Hash new password and update credential
|
||||
let new_hash = password::hash_password(new_password)?;
|
||||
let mut cred_active: user_credential::ActiveModel = cred.into();
|
||||
cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash })));
|
||||
cred_active.updated_at = Set(Utc::now());
|
||||
cred_active.version = Set(2);
|
||||
cred_active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// 4. Revoke all refresh tokens — force re-login on all devices
|
||||
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
|
||||
|
||||
tracing::info!(user_id = %user_id, "Password changed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch role details for a user, returning RoleResp DTOs.
|
||||
async fn get_user_role_resps(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<RoleResp>> {
|
||||
let user_roles = user_role::Entity::find()
|
||||
.filter(user_role::Column::UserId.eq(user_id))
|
||||
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
|
||||
if role_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let roles = role::Entity::find()
|
||||
.filter(role::Column::Id.is_in(role_ids))
|
||||
.filter(role::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(roles
|
||||
.iter()
|
||||
.map(|r| RoleResp {
|
||||
id: r.id,
|
||||
name: r.name.clone(),
|
||||
code: r.code.clone(),
|
||||
description: r.description.clone(),
|
||||
is_system: r.is_system,
|
||||
version: r.version,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
352
crates/erp-auth/src/service/dept_service.rs
Normal file
352
crates/erp-auth/src/service/dept_service.rs
Normal file
@@ -0,0 +1,352 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq};
|
||||
use crate::entity::department;
|
||||
use crate::entity::organization;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// Department CRUD service -- create, read, update, soft-delete departments
|
||||
/// within an organization, supporting tree-structured hierarchy.
|
||||
pub struct DeptService;
|
||||
|
||||
impl DeptService {
|
||||
/// Fetch all departments for an organization as a nested tree.
|
||||
///
|
||||
/// Root departments (parent_id = None) form the top level.
|
||||
pub async fn list_tree(
|
||||
org_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<DepartmentResp>> {
|
||||
// Verify the organization exists
|
||||
let _org = organization::Entity::find_by_id(org_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
let items = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::OrgId.eq(org_id))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(build_dept_tree(&items))
|
||||
}
|
||||
|
||||
/// Create a new department under the specified organization.
|
||||
///
|
||||
/// If `parent_id` is provided, computes `path` from the parent department.
|
||||
/// Otherwise, path is computed from the organization root.
|
||||
pub async fn create(
|
||||
org_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreateDepartmentReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<DepartmentResp> {
|
||||
// Verify the organization exists
|
||||
let org = organization::Entity::find_by_id(org_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
// Check code uniqueness within tenant if code is provided
|
||||
if let Some(ref code) = req.code {
|
||||
let existing = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::Code.eq(code.as_str()))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("部门编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute path from parent department or organization root
|
||||
let path = if let Some(parent_id) = req.parent_id {
|
||||
let parent = department::Entity::find_by_id(parent_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| {
|
||||
d.tenant_id == tenant_id && d.org_id == org_id && d.deleted_at.is_none()
|
||||
})
|
||||
.ok_or_else(|| AuthError::Validation("父级部门不存在".to_string()))?;
|
||||
|
||||
let parent_path = parent.path.clone().unwrap_or_default();
|
||||
Some(format!("{}{}/", parent_path, parent.id))
|
||||
} else {
|
||||
// Root department under the organization
|
||||
let org_path = org.path.clone().unwrap_or_default();
|
||||
Some(format!("{}{}/", org_path, org.id))
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = department::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
org_id: Set(org_id),
|
||||
name: Set(req.name.clone()),
|
||||
code: Set(req.code.clone()),
|
||||
parent_id: Set(req.parent_id),
|
||||
manager_id: Set(req.manager_id),
|
||||
path: Set(path),
|
||||
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"department.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dept_id": id, "org_id": org_id, "name": req.name }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"department.create",
|
||||
"department",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(DepartmentResp {
|
||||
id,
|
||||
org_id,
|
||||
name: req.name.clone(),
|
||||
code: req.code.clone(),
|
||||
parent_id: req.parent_id,
|
||||
manager_id: req.manager_id,
|
||||
path: None,
|
||||
sort_order: req.sort_order.unwrap_or(0),
|
||||
children: vec![],
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable department fields (name, code, manager_id, sort_order).
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &UpdateDepartmentReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<DepartmentResp> {
|
||||
let model = department::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||
|
||||
// If code is being changed, check uniqueness
|
||||
if let Some(new_code) = &req.code
|
||||
&& Some(new_code) != model.code.as_ref()
|
||||
{
|
||||
let existing = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::Code.eq(new_code.as_str()))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("部门编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let next_ver = check_version(req.version, model.version)?;
|
||||
|
||||
let mut active: department::ActiveModel = model.into();
|
||||
|
||||
if let Some(n) = &req.name {
|
||||
active.name = Set(n.clone());
|
||||
}
|
||||
if let Some(c) = &req.code {
|
||||
active.code = Set(Some(c.clone()));
|
||||
}
|
||||
if let Some(mgr_id) = &req.manager_id {
|
||||
active.manager_id = Set(Some(*mgr_id));
|
||||
}
|
||||
if let Some(so) = &req.sort_order {
|
||||
active.sort_order = Set(*so);
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"department.update",
|
||||
"department",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(DepartmentResp {
|
||||
id: updated.id,
|
||||
org_id: updated.org_id,
|
||||
name: updated.name.clone(),
|
||||
code: updated.code.clone(),
|
||||
parent_id: updated.parent_id,
|
||||
manager_id: updated.manager_id,
|
||||
path: updated.path.clone(),
|
||||
sort_order: updated.sort_order,
|
||||
children: vec![],
|
||||
version: updated.version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete a department by setting the `deleted_at` timestamp.
|
||||
///
|
||||
/// Will not delete if child departments exist.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<()> {
|
||||
let model = department::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||
|
||||
// Check for child departments
|
||||
let children = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::ParentId.eq(id))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if children.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该部门下存在子部门,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_version = model.version;
|
||||
let mut active: department::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(current_version + 1);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"department.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dept_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"department.delete",
|
||||
"department",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a nested tree of `DepartmentResp` from a flat list of models.
|
||||
fn build_dept_tree(items: &[department::Model]) -> Vec<DepartmentResp> {
|
||||
let mut children_map: HashMap<Option<Uuid>, Vec<&department::Model>> = HashMap::new();
|
||||
for item in items {
|
||||
children_map.entry(item.parent_id).or_default().push(item);
|
||||
}
|
||||
|
||||
fn build_node(
|
||||
item: &department::Model,
|
||||
map: &HashMap<Option<Uuid>, Vec<&department::Model>>,
|
||||
) -> DepartmentResp {
|
||||
let children = map
|
||||
.get(&Some(item.id))
|
||||
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
|
||||
.unwrap_or_default();
|
||||
DepartmentResp {
|
||||
id: item.id,
|
||||
org_id: item.org_id,
|
||||
name: item.name.clone(),
|
||||
code: item.code.clone(),
|
||||
parent_id: item.parent_id,
|
||||
manager_id: item.manager_id,
|
||||
path: item.path.clone(),
|
||||
sort_order: item.sort_order,
|
||||
children,
|
||||
version: item.version,
|
||||
}
|
||||
}
|
||||
|
||||
children_map
|
||||
.get(&None)
|
||||
.map(|root_items| {
|
||||
root_items
|
||||
.iter()
|
||||
.map(|item| build_node(item, &children_map))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
10
crates/erp-auth/src/service/mod.rs
Normal file
10
crates/erp-auth/src/service/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod auth_service;
|
||||
pub mod dept_service;
|
||||
pub mod org_service;
|
||||
pub mod password;
|
||||
pub mod permission_service;
|
||||
pub mod position_service;
|
||||
pub mod role_service;
|
||||
pub mod seed;
|
||||
pub mod token_service;
|
||||
pub mod user_service;
|
||||
449
crates/erp-auth/src/service/org_service.rs
Normal file
449
crates/erp-auth/src/service/org_service.rs
Normal file
@@ -0,0 +1,449 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
|
||||
use crate::entity::organization;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// Organization CRUD service -- create, read, update, soft-delete organizations
|
||||
/// within a tenant, supporting tree-structured hierarchy with path and level.
|
||||
pub struct OrgService;
|
||||
|
||||
impl OrgService {
|
||||
/// Fetch all organizations for a tenant as a flat list (not deleted).
|
||||
pub async fn list_flat(
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<organization::Model>> {
|
||||
let items = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Fetch all organizations for a tenant as a nested tree.
|
||||
///
|
||||
/// Root nodes have `parent_id = None`. Children are grouped by `parent_id`.
|
||||
pub async fn get_tree(
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<OrganizationResp>> {
|
||||
let items = Self::list_flat(tenant_id, db).await?;
|
||||
Ok(build_org_tree(&items))
|
||||
}
|
||||
|
||||
/// Create a new organization within the current tenant.
|
||||
///
|
||||
/// If `parent_id` is provided, computes `path` from the parent's path and id,
|
||||
/// and sets `level = parent.level + 1`. Otherwise, level defaults to 1.
|
||||
pub async fn create(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreateOrganizationReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<OrganizationResp> {
|
||||
// Check code uniqueness within tenant if code is provided
|
||||
if let Some(ref code) = req.code {
|
||||
let existing = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::Code.eq(code.as_str()))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let (path, level) = if let Some(parent_id) = req.parent_id {
|
||||
let parent = organization::Entity::find_by_id(parent_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("父级组织不存在".to_string()))?;
|
||||
|
||||
let parent_path = parent.path.clone().unwrap_or_default();
|
||||
let computed_path = format!("{}{}/", parent_path, parent.id);
|
||||
(Some(computed_path), parent.level + 1)
|
||||
} else {
|
||||
(None, 1)
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = organization::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(req.name.clone()),
|
||||
code: Set(req.code.clone()),
|
||||
parent_id: Set(req.parent_id),
|
||||
path: Set(path),
|
||||
level: Set(level),
|
||||
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"organization.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "org_id": id, "name": req.name }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"organization.create",
|
||||
"organization",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(OrganizationResp {
|
||||
id,
|
||||
name: req.name.clone(),
|
||||
code: req.code.clone(),
|
||||
parent_id: req.parent_id,
|
||||
path: None,
|
||||
level,
|
||||
sort_order: req.sort_order.unwrap_or(0),
|
||||
children: vec![],
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable organization fields (name, code, sort_order).
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &UpdateOrganizationReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<OrganizationResp> {
|
||||
let model = organization::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
// If code is being changed, check uniqueness
|
||||
if let Some(ref new_code) = req.code
|
||||
&& Some(new_code) != model.code.as_ref()
|
||||
{
|
||||
let existing = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::Code.eq(new_code.as_str()))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let next_ver = check_version(req.version, model.version)?;
|
||||
|
||||
let mut active: organization::ActiveModel = model.into();
|
||||
|
||||
if let Some(ref name) = req.name {
|
||||
active.name = Set(name.clone());
|
||||
}
|
||||
if let Some(ref code) = req.code {
|
||||
active.code = Set(Some(code.clone()));
|
||||
}
|
||||
if let Some(sort_order) = req.sort_order {
|
||||
active.sort_order = Set(sort_order);
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"organization.update",
|
||||
"organization",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(OrganizationResp {
|
||||
id: updated.id,
|
||||
name: updated.name.clone(),
|
||||
code: updated.code.clone(),
|
||||
parent_id: updated.parent_id,
|
||||
path: updated.path.clone(),
|
||||
level: updated.level,
|
||||
sort_order: updated.sort_order,
|
||||
children: vec![],
|
||||
version: updated.version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete an organization by setting the `deleted_at` timestamp.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<()> {
|
||||
let model = organization::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
// Check for child organizations
|
||||
let children = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::ParentId.eq(id))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if children.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该组织下存在子组织,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_version = model.version;
|
||||
let mut active: organization::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(current_version + 1);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"organization.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "org_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"organization.delete",
|
||||
"organization",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a nested tree of `OrganizationResp` from a flat list of models.
|
||||
///
|
||||
/// Root nodes (parent_id = None) form the top level. Each node recursively
|
||||
/// includes its children grouped by parent_id.
|
||||
pub(crate) fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
|
||||
let mut children_map: HashMap<Option<Uuid>, Vec<&organization::Model>> = HashMap::new();
|
||||
for item in items {
|
||||
children_map.entry(item.parent_id).or_default().push(item);
|
||||
}
|
||||
|
||||
fn build_node(
|
||||
item: &organization::Model,
|
||||
map: &HashMap<Option<Uuid>, Vec<&organization::Model>>,
|
||||
) -> OrganizationResp {
|
||||
let children = map
|
||||
.get(&Some(item.id))
|
||||
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
|
||||
.unwrap_or_default();
|
||||
OrganizationResp {
|
||||
id: item.id,
|
||||
name: item.name.clone(),
|
||||
code: item.code.clone(),
|
||||
parent_id: item.parent_id,
|
||||
path: item.path.clone(),
|
||||
level: item.level,
|
||||
sort_order: item.sort_order,
|
||||
children,
|
||||
version: item.version,
|
||||
}
|
||||
}
|
||||
|
||||
children_map
|
||||
.get(&None)
|
||||
.map(|root_items| {
|
||||
root_items
|
||||
.iter()
|
||||
.map(|item| build_node(item, &children_map))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::organization;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn make_org(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
name: &str,
|
||||
parent_id: Option<Uuid>,
|
||||
level: i32,
|
||||
version: i32,
|
||||
) -> organization::Model {
|
||||
organization::Model {
|
||||
id,
|
||||
tenant_id,
|
||||
name: name.to_string(),
|
||||
code: None,
|
||||
parent_id,
|
||||
path: None,
|
||||
level,
|
||||
sort_order: 0,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
created_by: Uuid::now_v7(),
|
||||
updated_by: Uuid::now_v7(),
|
||||
deleted_at: None,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_empty() {
|
||||
let tree = build_org_tree(&[]);
|
||||
assert!(tree.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_single_root() {
|
||||
let tid = Uuid::now_v7();
|
||||
let root_id = Uuid::now_v7();
|
||||
let items = vec![make_org(root_id, tid, "总公司", None, 1, 1)];
|
||||
|
||||
let tree = build_org_tree(&items);
|
||||
assert_eq!(tree.len(), 1);
|
||||
assert_eq!(tree[0].name, "总公司");
|
||||
assert!(tree[0].children.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_multiple_roots() {
|
||||
let tid = Uuid::now_v7();
|
||||
let items = vec![
|
||||
make_org(Uuid::now_v7(), tid, "公司A", None, 1, 1),
|
||||
make_org(Uuid::now_v7(), tid, "公司B", None, 1, 1),
|
||||
];
|
||||
|
||||
let tree = build_org_tree(&items);
|
||||
assert_eq!(tree.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_nested_children() {
|
||||
let tid = Uuid::now_v7();
|
||||
let root_id = Uuid::now_v7();
|
||||
let child1_id = Uuid::now_v7();
|
||||
let child2_id = Uuid::now_v7();
|
||||
let grandchild_id = Uuid::now_v7();
|
||||
|
||||
let items = vec![
|
||||
make_org(root_id, tid, "总公司", None, 1, 1),
|
||||
make_org(child1_id, tid, "分公司A", Some(root_id), 2, 1),
|
||||
make_org(child2_id, tid, "分公司B", Some(root_id), 2, 1),
|
||||
make_org(grandchild_id, tid, "部门A1", Some(child1_id), 3, 1),
|
||||
];
|
||||
|
||||
let tree = build_org_tree(&items);
|
||||
assert_eq!(tree.len(), 1); // one root
|
||||
assert_eq!(tree[0].children.len(), 2); // two children
|
||||
assert_eq!(tree[0].children[0].children.len(), 1); // one grandchild
|
||||
assert_eq!(tree[0].children[0].children[0].name, "部门A1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_deep_nesting() {
|
||||
let tid = Uuid::now_v7();
|
||||
let l1 = Uuid::now_v7();
|
||||
let l2 = Uuid::now_v7();
|
||||
let l3 = Uuid::now_v7();
|
||||
let l4 = Uuid::now_v7();
|
||||
|
||||
let items = vec![
|
||||
make_org(l1, tid, "L1", None, 1, 1),
|
||||
make_org(l2, tid, "L2", Some(l1), 2, 1),
|
||||
make_org(l3, tid, "L3", Some(l2), 3, 1),
|
||||
make_org(l4, tid, "L4", Some(l3), 4, 1),
|
||||
];
|
||||
|
||||
let tree = build_org_tree(&items);
|
||||
assert_eq!(tree.len(), 1);
|
||||
assert_eq!(tree[0].children[0].children[0].children[0].name, "L4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_preserves_version() {
|
||||
let tid = Uuid::now_v7();
|
||||
let root_id = Uuid::now_v7();
|
||||
let items = vec![make_org(root_id, tid, "测试", None, 1, 5)];
|
||||
|
||||
let tree = build_org_tree(&items);
|
||||
assert_eq!(tree[0].version, 5);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user