Compare commits
105 Commits
eb856b1d73
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd274b39a | ||
|
|
d6dc47ab6a | ||
|
|
5e89aef99f | ||
|
|
9f85188886 | ||
|
|
b6c4e14b58 | ||
|
|
432eb2f9f5 | ||
|
|
9fb73788f7 | ||
|
|
0a57cd7030 | ||
|
|
b96978b588 | ||
|
|
fb809f124c | ||
|
|
60799176ca | ||
|
|
4ea9bccba6 | ||
|
|
9549f896b6 | ||
|
|
a333b3673f | ||
|
|
c9a58e9d34 | ||
|
|
c487a94f19 | ||
|
|
022ac951c9 | ||
|
|
b0ee3e495d | ||
|
|
e2e58d3a00 | ||
|
|
5b2ae16ffb | ||
|
|
8bef5e2401 | ||
|
|
a7342f83e9 | ||
|
|
41a0dc8bd6 | ||
|
|
89684313d9 | ||
|
|
e24b820d80 | ||
|
|
e6aaa18ceb | ||
|
|
314580243e | ||
|
|
dadb826804 | ||
|
|
649334e862 | ||
|
|
f4b1a06d53 | ||
|
|
527a57df9e | ||
|
|
62f17d13ad | ||
|
|
6f286acbeb | ||
|
|
f697b5fd6d | ||
|
|
abc3086571 | ||
|
|
16b7a36bfb | ||
|
|
28c7126518 | ||
|
|
091d517af6 | ||
|
|
3b0b78c4cb | ||
|
|
2616e83ec6 | ||
|
|
20734330a6 | ||
|
|
a897cd7a87 | ||
|
|
32dd0f72c1 | ||
|
|
67bdf9e942 | ||
|
|
a7cf44cd46 | ||
|
|
d07e476898 | ||
|
|
2866ffb634 | ||
|
|
b08e8b5ab5 | ||
|
|
f4dd228a67 | ||
|
|
ae62e2ecb2 | ||
|
|
3483395f5e | ||
|
|
b482230a07 | ||
|
|
9effa9f942 | ||
|
|
169e6d1fe5 | ||
|
|
a6d3a0efcc | ||
|
|
92789e6713 | ||
|
|
e68fe8c1b1 | ||
|
|
0ad77693f4 | ||
|
|
472bf244d8 | ||
|
|
52c8821ffa | ||
|
|
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 |
185
.claude/skills/plugin-development/SKILL.md
Normal file
185
.claude/skills/plugin-development/SKILL.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# 插件开发 Skill
|
||||||
|
|
||||||
|
基于 CRM 客户管理插件的开发经验,提炼可复用的插件开发流程和模式。
|
||||||
|
|
||||||
|
## 触发场景
|
||||||
|
|
||||||
|
- 用户说"开发一个新插件"、"新建行业模块"、"创建插件"
|
||||||
|
- 用户提到需要在 ERP 平台上扩展新的业务模块
|
||||||
|
|
||||||
|
## 插件开发流程
|
||||||
|
|
||||||
|
### 第一步:需求分析 → 数据模型
|
||||||
|
|
||||||
|
1. 确定插件 ID(如 `erp-crm`、`erp-inventory`)
|
||||||
|
2. 列出实体及其字段,为每个字段标注:
|
||||||
|
- `field_type`: String/Integer/Float/Boolean/Date/DateTime/Uuid/Decimal/Json
|
||||||
|
- `required` / `unique` / `searchable` / `filterable` / `sortable`
|
||||||
|
- `visible_when`: 条件显示表达式(如 `type == 'enterprise'`)
|
||||||
|
- `ui_widget`: 表单控件(input/select/textarea/datepicker)
|
||||||
|
- `options`: select 类型的选项列表
|
||||||
|
|
||||||
|
### 第二步:编写 plugin.toml manifest
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[metadata]
|
||||||
|
id = "erp-xxx"
|
||||||
|
name = "模块名称"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "描述"
|
||||||
|
author = "ERP Team"
|
||||||
|
min_platform_version = "0.1.0"
|
||||||
|
|
||||||
|
# 权限:{entity}.{list|manage}
|
||||||
|
[[permissions]]
|
||||||
|
code = "entity.list"
|
||||||
|
name = "查看 XX"
|
||||||
|
|
||||||
|
[[permissions]]
|
||||||
|
code = "entity.manage"
|
||||||
|
name = "管理 XX"
|
||||||
|
|
||||||
|
# 实体定义
|
||||||
|
[[schema.entities]]
|
||||||
|
name = "entity"
|
||||||
|
display_name = "实体名"
|
||||||
|
|
||||||
|
[[schema.entities.fields]]
|
||||||
|
name = "field_name"
|
||||||
|
field_type = "String"
|
||||||
|
required = true
|
||||||
|
display_name = "字段名"
|
||||||
|
searchable = true
|
||||||
|
|
||||||
|
# 页面声明
|
||||||
|
[[ui.pages]]
|
||||||
|
type = "crud"
|
||||||
|
entity = "entity"
|
||||||
|
label = "页面标题"
|
||||||
|
icon = "icon-name"
|
||||||
|
enable_search = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第三步:创建 Rust crate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p crates/erp-plugin-xxx/src
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cargo.toml**:
|
||||||
|
```toml
|
||||||
|
[package]
|
||||||
|
name = "erp-plugin-xxx"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wit-bindgen = "0.55"
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
```
|
||||||
|
|
||||||
|
**src/lib.rs**:
|
||||||
|
```rust
|
||||||
|
wit_bindgen::generate!({
|
||||||
|
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||||
|
world: "plugin-world",
|
||||||
|
});
|
||||||
|
|
||||||
|
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||||
|
|
||||||
|
struct XxxPlugin;
|
||||||
|
|
||||||
|
impl Guest for XxxPlugin {
|
||||||
|
fn init() -> Result<(), String> { Ok(()) }
|
||||||
|
fn on_tenant_created(_tenant_id: String) -> Result<(), String> { Ok(()) }
|
||||||
|
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> { Ok(()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export!(XxxPlugin);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第四步:注册到 workspace
|
||||||
|
|
||||||
|
根 `Cargo.toml` 的 `[workspace] members` 添加 `"crates/erp-plugin-xxx"`。
|
||||||
|
|
||||||
|
### 第五步:编译和转换
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p erp-plugin-xxx --target wasm32-unknown-unknown --release
|
||||||
|
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_xxx.wasm -o target/erp_plugin_xxx.component.wasm
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第六步:上传和测试
|
||||||
|
|
||||||
|
PluginAdmin 页面上传 `.component.wasm` + `plugin.toml`:上传 → 安装 → 启用。
|
||||||
|
|
||||||
|
## 可用页面类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | 必填配置 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `crud` | 增删改查表格 | `entity`, `label` |
|
||||||
|
| `tree` | 树形展示 | `entity`, `label`, `id_field`, `parent_field`, `label_field` |
|
||||||
|
| `detail` | 详情 Drawer | `entity`, `label`, `sections` |
|
||||||
|
| `tabs` | 标签页容器 | `label`, `tabs`(子页面列表) |
|
||||||
|
|
||||||
|
## detail section 类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | 配置 |
|
||||||
|
|------|------|------|
|
||||||
|
| `fields` | 字段描述列表 | `label`, `fields`(字段名数组) |
|
||||||
|
| `crud` | 嵌套 CRUD 表格 | `label`, `entity`, `filter_field` |
|
||||||
|
|
||||||
|
## 字段属性速查
|
||||||
|
|
||||||
|
| 属性 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `searchable` | 可搜索,自动创建 B-tree 索引 |
|
||||||
|
| `filterable` | 可筛选,前端渲染 Select |
|
||||||
|
| `sortable` | 可排序,表格列头排序图标 |
|
||||||
|
| `visible_when` | 条件显示,格式 `field == 'value'` |
|
||||||
|
| `unique` | 唯一约束,CREATE UNIQUE INDEX |
|
||||||
|
| `ui_widget` | 控件:select / textarea |
|
||||||
|
| `options` | select 选项 `[{label, value}]` |
|
||||||
|
|
||||||
|
## 权限规则
|
||||||
|
|
||||||
|
- 格式:`{entity}.{list|manage}`
|
||||||
|
- 安装时自动加 manifest_id 前缀
|
||||||
|
- REST API 动态检查,无精细权限时回退 `plugin.list` / `plugin.admin`
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/v1/plugins/{id}/{entity}` | 列表(filter/search/sort) |
|
||||||
|
| POST | `/api/v1/plugins/{id}/{entity}` | 创建(required 校验) |
|
||||||
|
| GET | `/api/v1/plugins/{id}/{entity}/{rid}` | 详情 |
|
||||||
|
| PUT | `/api/v1/plugins/{id}/{entity}/{rid}` | 更新(乐观锁) |
|
||||||
|
| DELETE | `/api/v1/plugins/{id}/{entity}/{rid}` | 软删除 |
|
||||||
|
| GET | `/api/v1/plugins/{id}/{entity}/count` | 统计 |
|
||||||
|
| GET | `/api/v1/plugins/{id}/{entity}/aggregate` | 聚合 |
|
||||||
|
|
||||||
|
## 测试检查清单
|
||||||
|
|
||||||
|
- [ ] `cargo check --workspace` 通过
|
||||||
|
- [ ] `cargo test --workspace` 通过
|
||||||
|
- [ ] WASM 编译 + Component 转换成功
|
||||||
|
- [ ] 上传 → 安装 → 启用流程正常
|
||||||
|
- [ ] CRUD 完整可用
|
||||||
|
- [ ] 唯一字段重复插入返回冲突
|
||||||
|
- [ ] filter/search/sort 查询正常
|
||||||
|
- [ ] visible_when 条件字段动态显示
|
||||||
|
- [ ] 侧边栏菜单正确生成
|
||||||
|
|
||||||
|
## 常见陷阱
|
||||||
|
|
||||||
|
1. 表名格式:`plugin_{sanitized_id}_{sanitized_entity}`,连字符变下划线
|
||||||
|
2. edition 必须是 "2024"
|
||||||
|
3. WIT 路径:`../erp-plugin-prototype/wit/plugin.wit`,不是 `erp-plugin`
|
||||||
|
4. JSONB 无外键约束,Uuid 字段不自动校验引用完整性
|
||||||
|
5. Fuel 限制 1000 万,简单逻辑足够,避免重计算循环
|
||||||
|
6. manifest 中只写 `entity.action`,安装时自动加 manifest_id 前缀
|
||||||
74
CLAUDE.md
74
CLAUDE.md
@@ -1,3 +1,6 @@
|
|||||||
|
@wiki/index.md
|
||||||
|
整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。
|
||||||
|
|
||||||
# ERP 平台底座 — 协作与实现规则
|
# ERP 平台底座 — 协作与实现规则
|
||||||
|
|
||||||
> **ERP Platform Base** 是一个模块化的商业 SaaS ERP 底座,目标是提供核心基础设施(身份权限、工作流、消息、配置),使行业业务模块(进销存、生产、财务等)可以快速插接。
|
> **ERP Platform Base** 是一个模块化的商业 SaaS ERP 底座,目标是提供核心基础设施(身份权限、工作流、消息、配置),使行业业务模块(进销存、生产、财务等)可以快速插接。
|
||||||
@@ -126,11 +129,18 @@ erp-server (→ 所有 crate,组装入口)
|
|||||||
|
|
||||||
1. **理解需求** — 确认改动的目标模块和影响范围
|
1. **理解需求** — 确认改动的目标模块和影响范围
|
||||||
2. **最小实现** — 只改必要的代码,保持模块边界
|
2. **最小实现** — 只改必要的代码,保持模块边界
|
||||||
3. **自动验证** — `cargo check` / `cargo test` / `pnpm dev` 必须通过
|
3. **验证通过** — 必须全部通过才可继续:
|
||||||
4. **提交** — 按 §10 规范提交
|
- `cargo check` — 编译无错误
|
||||||
5. **文档同步** — 更新相关文档(如果涉及架构变化)
|
- `cargo test --workspace` — 所有测试通过(有相关测试时)
|
||||||
|
- `pnpm dev` — 前端页面可正常渲染(涉及前端时)
|
||||||
|
- 功能验证 — 启动服务实际测试改动是否生效(涉及 API 或 UI 时)
|
||||||
|
4. **提交** — 验证通过后按 §10 规范提交
|
||||||
|
5. **文档同步** — 更新相关文档(如果涉及架构、接口、模块变化)
|
||||||
|
6. **推送到仓库** — 提交后立即 `git push`,确保远程仓库同步
|
||||||
|
|
||||||
**铁律:步骤 4 是任务完成的硬性条件。不允许"等一下再提交"。**
|
**铁律:**
|
||||||
|
- **步骤 3 验证不通过 = 任务未完成**,不允许跳过验证直接提交。
|
||||||
|
- **步骤 6 推送是强制环节**,不推送就等于没完成。不允许"等一下再推"。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -369,6 +379,17 @@ cd apps/web && pnpm build # 构建生产版本
|
|||||||
|
|
||||||
# === 数据库 ===
|
# === 数据库 ===
|
||||||
docker exec -it erp-postgres psql -U erp # 连接数据库
|
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 +420,8 @@ docker exec -it erp-postgres psql -U erp # 连接数据库
|
|||||||
| `message` | erp-message |
|
| `message` | erp-message |
|
||||||
| `config` | erp-config |
|
| `config` | erp-config |
|
||||||
| `server` | erp-server |
|
| `server` | erp-server |
|
||||||
|
| `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample |
|
||||||
|
| `crm` | erp-plugin-crm |
|
||||||
| `web` | Web 前端 |
|
| `web` | Web 前端 |
|
||||||
| `ui` | React 组件 |
|
| `ui` | React 组件 |
|
||||||
| `db` | 数据库迁移 |
|
| `db` | 数据库迁移 |
|
||||||
@@ -422,6 +445,10 @@ chore(docker): 添加 PostgreSQL 健康检查
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` | 平台底座设计规格 |
|
| `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` | 平台底座设计规格 |
|
||||||
| `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.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 插件原型验证计划 |
|
||||||
|
| `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` | CRM 客户管理插件设计规格 |
|
||||||
|
| `docs/superpowers/plans/2026-04-16-crm-plugin-plan.md` | CRM 插件实施计划 |
|
||||||
|
|
||||||
所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。
|
所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。
|
||||||
|
|
||||||
@@ -436,24 +463,31 @@ chore(docker): 添加 PostgreSQL 健康检查
|
|||||||
|
|
||||||
| Phase | 内容 | 状态 |
|
| Phase | 内容 | 状态 |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| Phase 1 | 基础设施 (workspace + core + Docker + 桌面端) | 🚧 进行中 |
|
| Phase 1 | 基础设施 (workspace + core + Docker + 桌面端) | ✅ 完成 |
|
||||||
| Phase 2 | 身份与权限 (Auth) | ⏳ 待开始 |
|
| Phase 2 | 身份与权限 (Auth) | ✅ 完成 |
|
||||||
| Phase 3 | 系统配置 (Config) | ⏳ 待开始 |
|
| Phase 3 | 系统配置 (Config) | ✅ 完成 |
|
||||||
| Phase 4 | 工作流引擎 (Workflow) | ⏳ 待开始 |
|
| Phase 4 | 工作流引擎 (Workflow) | ✅ 完成 |
|
||||||
| Phase 5 | 消息中心 (Message) | ⏳ 待开始 |
|
| Phase 5 | 消息中心 (Message) | ✅ 完成 |
|
||||||
| Phase 6 | 整合与打磨 | ⏳ 待开始 |
|
| Phase 6 | 整合与打磨 | ✅ 完成 |
|
||||||
|
| - | WASM 插件原型 (V1-V6) | ✅ 验证通过 |
|
||||||
|
| - | 插件系统集成到主服务 | ✅ 已集成 |
|
||||||
|
| - | CRM 插件 (Phase 1-3) | ✅ 完成 |
|
||||||
|
|
||||||
### 已实现模块
|
### 已实现模块
|
||||||
|
|
||||||
| Crate | 功能 | 状态 |
|
| Crate | 功能 | 状态 |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait | 🚧 进行中 |
|
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait、审计日志 | ✅ 完成 |
|
||||||
| erp-common | 共享工具 | 🚧 进行中 |
|
| erp-common | 共享工具 | ✅ 完成 |
|
||||||
| erp-server | Axum 服务入口、配置、数据库连接 | 🚧 进行中 |
|
| erp-server | Axum 服务入口、配置、数据库连接、CORS | ✅ 完成 |
|
||||||
| erp-auth | 身份与权限 | ⏳ 待开始 |
|
| erp-auth | 身份与权限 (用户/角色/权限/组织/部门/岗位) | ✅ 完成 |
|
||||||
| erp-workflow | 工作流引擎 | ⏳ 待开始 |
|
| erp-workflow | 工作流引擎 (BPMN 解析/Token 驱动/任务分配) | ✅ 完成 |
|
||||||
| erp-message | 消息中心 | ⏳ 待开始 |
|
| erp-message | 消息中心 (CRUD/模板/订阅/通知面板) | ✅ 完成 |
|
||||||
| erp-config | 系统配置 | ⏳ 待开始 |
|
| erp-config | 系统配置 (字典/菜单/设置/编号规则/主题) | ✅ 完成 |
|
||||||
|
| erp-plugin | 插件管理 (WASM 运行时/生命周期/动态表/数据CRUD) | ✅ 已集成 |
|
||||||
|
| erp-plugin-prototype | WASM 插件 Host 运行时 (Wasmtime + bindgen + Host API) | ✅ 原型验证 |
|
||||||
|
| erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 |
|
||||||
|
| erp-plugin-crm | CRM 客户管理插件 (5 实体/9 权限/6 页面) | ✅ 完成 |
|
||||||
|
|
||||||
<!-- ARCH-SNAPSHOT-END -->
|
<!-- ARCH-SNAPSHOT-END -->
|
||||||
|
|
||||||
@@ -472,6 +506,11 @@ chore(docker): 添加 PostgreSQL 健康检查
|
|||||||
- ❌ **不要**假设只有单租户 — 从第一天就按多租户设计
|
- ❌ **不要**假设只有单租户 — 从第一天就按多租户设计
|
||||||
- ❌ **不要**提前实现远期功能 — 严格按 Phase 计划推进
|
- ❌ **不要**提前实现远期功能 — 严格按 Phase 计划推进
|
||||||
- ❌ **不要**忽略 `version` 字段 — 所有更新操作必须检查乐观锁
|
- ❌ **不要**忽略 `version` 字段 — 所有更新操作必须检查乐观锁
|
||||||
|
- ❌ **不要**在动态表 SQL 中拼接用户输入 — 使用 `sanitize_identifier` 防注入
|
||||||
|
- ❌ **不要**在插件 crate 中直接依赖 erp-auth — 权限注册用 raw SQL,保持模块边界
|
||||||
|
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
|
||||||
|
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步
|
||||||
|
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
|
||||||
|
|
||||||
### 场景化指令
|
### 场景化指令
|
||||||
|
|
||||||
@@ -481,5 +520,6 @@ chore(docker): 添加 PostgreSQL 健康检查
|
|||||||
- 当遇到**新增 API** → 添加 utoipa 注解,确保 OpenAPI 文档同步
|
- 当遇到**新增 API** → 添加 utoipa 注解,确保 OpenAPI 文档同步
|
||||||
- 当遇到**新增表** → 创建 SeaORM migration + Entity,包含所有标准字段
|
- 当遇到**新增表** → 创建 SeaORM migration + Entity,包含所有标准字段
|
||||||
- 当遇到**新增页面** → 使用 Ant Design 组件,i18n key 引用文案
|
- 当遇到**新增页面** → 使用 Ant Design 组件,i18n key 引用文案
|
||||||
|
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component
|
||||||
|
|
||||||
<!-- ANTI-PATTERN-END -->
|
<!-- ANTI-PATTERN-END -->
|
||||||
|
|||||||
2263
Cargo.lock
generated
2263
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
19
Cargo.toml
@@ -2,12 +2,16 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/erp-core",
|
"crates/erp-core",
|
||||||
"crates/erp-common",
|
|
||||||
"crates/erp-server",
|
"crates/erp-server",
|
||||||
"crates/erp-auth",
|
"crates/erp-auth",
|
||||||
"crates/erp-workflow",
|
"crates/erp-workflow",
|
||||||
"crates/erp-message",
|
"crates/erp-message",
|
||||||
"crates/erp-config",
|
"crates/erp-config",
|
||||||
|
"crates/erp-server/migration",
|
||||||
|
"crates/erp-plugin-prototype",
|
||||||
|
"crates/erp-plugin-test-sample",
|
||||||
|
"crates/erp-plugin",
|
||||||
|
"crates/erp-plugin-crm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -20,7 +24,7 @@ license = "MIT"
|
|||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
# Web
|
# Web
|
||||||
axum = "0.8"
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
|
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
|
||||||
|
|
||||||
@@ -58,17 +62,24 @@ jsonwebtoken = "9"
|
|||||||
# Password hashing
|
# Password hashing
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
|
||||||
|
# Cryptographic hashing (token storage)
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
# API docs
|
# API docs
|
||||||
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
|
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
|
# Validation
|
||||||
validator = { version = "0.19", features = ["derive"] }
|
validator = { version = "0.19", features = ["derive"] }
|
||||||
|
|
||||||
|
# Async trait
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
erp-core = { path = "crates/erp-core" }
|
erp-core = { path = "crates/erp-core" }
|
||||||
erp-common = { path = "crates/erp-common" }
|
|
||||||
erp-auth = { path = "crates/erp-auth" }
|
erp-auth = { path = "crates/erp-auth" }
|
||||||
erp-workflow = { path = "crates/erp-workflow" }
|
erp-workflow = { path = "crates/erp-workflow" }
|
||||||
erp-message = { path = "crates/erp-message" }
|
erp-message = { path = "crates/erp-message" }
|
||||||
erp-config = { path = "crates/erp-config" }
|
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>
|
||||||
41
apps/web/package.json
Normal file
41
apps/web/package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"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/charts": "^2.6.7",
|
||||||
|
"@ant-design/icons": "^6.1.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@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"
|
||||||
|
}
|
||||||
|
}
|
||||||
4258
apps/web/pnpm-lock.yaml
generated
Normal file
4258
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: /
|
||||||
163
apps/web/src/App.tsx
Normal file
163
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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'));
|
||||||
|
const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage })));
|
||||||
|
const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage })));
|
||||||
|
const PluginGraphPage = lazy(() => import('./pages/PluginGraphPage').then((m) => ({ default: m.PluginGraphPage })));
|
||||||
|
const PluginDashboardPage = lazy(() => import('./pages/PluginDashboardPage').then((m) => ({ default: m.PluginDashboardPage })));
|
||||||
|
const PluginKanbanPage = lazy(() => import('./pages/PluginKanbanPage'));
|
||||||
|
|
||||||
|
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/tabs/:pageLabel" element={<PluginTabsPage />} />
|
||||||
|
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
|
||||||
|
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
|
||||||
|
<Route path="/plugins/:pluginId/dashboard" element={<PluginDashboardPage />} />
|
||||||
|
<Route path="/plugins/:pluginId/kanban/:entityName" element={<PluginKanbanPage />} />
|
||||||
|
<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;
|
||||||
|
}
|
||||||
173
apps/web/src/api/pluginData.ts
Normal file
173
apps/web/src/api/pluginData.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
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 interface PluginDataListOptions {
|
||||||
|
filter?: Record<string, string>;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_order?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPluginData(
|
||||||
|
pluginId: string,
|
||||||
|
entity: string,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
options?: PluginDataListOptions,
|
||||||
|
) {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
};
|
||||||
|
if (options?.filter) params.filter = JSON.stringify(options.filter);
|
||||||
|
if (options?.search) params.search = options.search;
|
||||||
|
if (options?.sort_by) params.sort_by = options.sort_by;
|
||||||
|
if (options?.sort_order) params.sort_order = options.sort_order;
|
||||||
|
|
||||||
|
const { data } = await client.get<{ success: boolean; data: PaginatedDataResponse }>(
|
||||||
|
`/plugins/${pluginId}/${entity}`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countPluginData(
|
||||||
|
pluginId: string,
|
||||||
|
entity: string,
|
||||||
|
options?: { filter?: Record<string, string>; search?: string },
|
||||||
|
) {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (options?.filter) params.filter = JSON.stringify(options.filter);
|
||||||
|
if (options?.search) params.search = options.search;
|
||||||
|
|
||||||
|
const { data } = await client.get<{ success: boolean; data: number }>(
|
||||||
|
`/plugins/${pluginId}/${entity}/count`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggregateItem {
|
||||||
|
key: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aggregatePluginData(
|
||||||
|
pluginId: string,
|
||||||
|
entity: string,
|
||||||
|
groupBy: string,
|
||||||
|
filter?: Record<string, string>,
|
||||||
|
) {
|
||||||
|
const params: Record<string, string> = { group_by: groupBy };
|
||||||
|
if (filter) params.filter = JSON.stringify(filter);
|
||||||
|
|
||||||
|
const { data } = await client.get<{ success: boolean; data: AggregateItem[] }>(
|
||||||
|
`/plugins/${pluginId}/${entity}/aggregate`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 批量操作 ──
|
||||||
|
|
||||||
|
export async function batchPluginData(
|
||||||
|
pluginId: string,
|
||||||
|
entity: string,
|
||||||
|
req: { action: string; ids: string[]; data?: Record<string, unknown> },
|
||||||
|
) {
|
||||||
|
const { data } = await client.post<{ success: boolean; data: unknown }>(
|
||||||
|
`/plugins/${pluginId}/${entity}/batch`,
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 部分更新 ──
|
||||||
|
|
||||||
|
export async function patchPluginData(
|
||||||
|
pluginId: string,
|
||||||
|
entity: string,
|
||||||
|
id: string,
|
||||||
|
req: { data: Record<string, unknown>; version: number },
|
||||||
|
) {
|
||||||
|
const { data } = await client.patch<{ success: boolean; data: PluginDataRecord }>(
|
||||||
|
`/plugins/${pluginId}/${entity}/${id}`,
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 时间序列 ──
|
||||||
|
|
||||||
|
export async function getPluginTimeseries(
|
||||||
|
pluginId: string,
|
||||||
|
entity: string,
|
||||||
|
params: {
|
||||||
|
time_field: string;
|
||||||
|
time_grain: string;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: unknown }>(
|
||||||
|
`/plugins/${pluginId}/${entity}/timeseries`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
192
apps/web/src/api/plugins.ts
Normal file
192
apps/web/src/api/plugins.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
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): Promise<PluginSchemaResponse> {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: PluginSchemaResponse }>(
|
||||||
|
`/admin/plugins/${id}/schema`,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schema 类型定义 ──
|
||||||
|
|
||||||
|
export interface PluginFieldSchema {
|
||||||
|
name: string;
|
||||||
|
field_type: string;
|
||||||
|
required: boolean;
|
||||||
|
display_name?: string;
|
||||||
|
ui_widget?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
searchable?: boolean;
|
||||||
|
filterable?: boolean;
|
||||||
|
sortable?: boolean;
|
||||||
|
visible_when?: string;
|
||||||
|
unique?: boolean;
|
||||||
|
ref_entity?: string;
|
||||||
|
ref_label_field?: string;
|
||||||
|
ref_search_fields?: string[];
|
||||||
|
cascade_from?: string;
|
||||||
|
cascade_filter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginEntitySchema {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
fields: PluginFieldSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSchemaResponse {
|
||||||
|
entities: PluginEntitySchema[];
|
||||||
|
ui?: PluginUiSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginUiSchema {
|
||||||
|
pages: PluginPageSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginPageSchema =
|
||||||
|
| { type: 'crud'; entity: string; label: string; icon?: string; enable_search?: boolean; enable_views?: string[] }
|
||||||
|
| { type: 'tree'; entity: string; label: string; icon?: string; id_field: string; parent_field: string; label_field: string }
|
||||||
|
| { type: 'detail'; entity: string; label: string; sections: PluginSectionSchema[] }
|
||||||
|
| { type: 'tabs'; label: string; icon?: string; tabs: PluginPageSchema[] }
|
||||||
|
| { type: 'graph'; entity: string; label: string; relationship_entity: string; source_field: string; target_field: string; edge_label_field: string; node_label_field: string }
|
||||||
|
| { type: 'dashboard'; label: string; widgets?: DashboardWidget[] }
|
||||||
|
| {
|
||||||
|
type: 'kanban';
|
||||||
|
entity: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
lane_field: string;
|
||||||
|
lane_order?: string[];
|
||||||
|
card_title_field: string;
|
||||||
|
card_subtitle_field?: string;
|
||||||
|
card_fields?: string[];
|
||||||
|
enable_drag?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DashboardWidget {
|
||||||
|
type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart';
|
||||||
|
entity: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
dimension_field?: string;
|
||||||
|
dimension_order?: string[];
|
||||||
|
metric?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginSectionSchema =
|
||||||
|
| { type: 'fields'; label: string; fields: string[] }
|
||||||
|
| { type: 'crud'; label: string; entity: string; filter_field?: string; enable_views?: string[] };
|
||||||
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;
|
||||||
|
}
|
||||||
80
apps/web/src/components/EntitySelect.tsx
Normal file
80
apps/web/src/components/EntitySelect.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Select, Spin } from 'antd';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { listPluginData } from '../api/pluginData';
|
||||||
|
|
||||||
|
interface EntitySelectProps {
|
||||||
|
pluginId: string;
|
||||||
|
entity: string;
|
||||||
|
labelField: string;
|
||||||
|
searchFields?: string[];
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string, label: string) => void;
|
||||||
|
cascadeFrom?: string;
|
||||||
|
cascadeFilter?: string;
|
||||||
|
cascadeValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntitySelect({
|
||||||
|
pluginId,
|
||||||
|
entity,
|
||||||
|
labelField,
|
||||||
|
searchFields: _searchFields,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
cascadeFrom,
|
||||||
|
cascadeFilter,
|
||||||
|
cascadeValue,
|
||||||
|
placeholder,
|
||||||
|
}: EntitySelectProps) {
|
||||||
|
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchData = useCallback(
|
||||||
|
async (keyword?: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filter: Record<string, string> | undefined =
|
||||||
|
cascadeFrom && cascadeFilter && cascadeValue
|
||||||
|
? { [cascadeFilter]: cascadeValue }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const result = await listPluginData(pluginId, entity, 1, 20, {
|
||||||
|
search: keyword,
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = (result.data || []).map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: String(item.data?.[labelField] ?? item.id),
|
||||||
|
}));
|
||||||
|
setOptions(items);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder || '请选择'}
|
||||||
|
loading={loading}
|
||||||
|
options={options}
|
||||||
|
onSearch={(v) => fetchData(v)}
|
||||||
|
onChange={(v) => {
|
||||||
|
const opt = options.find((o) => o.value === v);
|
||||||
|
onChange?.(v, opt?.label || '');
|
||||||
|
}}
|
||||||
|
filterOption={false}
|
||||||
|
notFoundContent={loading ? <Spin size="small" /> : '无数据'}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1183
apps/web/src/index.css
Normal file
1183
apps/web/src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
366
apps/web/src/layouts/MainLayout.tsx
Normal file
366
apps/web/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { useCallback, useState, 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,
|
||||||
|
TeamOutlined,
|
||||||
|
TableOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} 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 type { PluginMenuGroup } 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,
|
||||||
|
indented,
|
||||||
|
}: {
|
||||||
|
item: MenuItem;
|
||||||
|
isActive: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
indented?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={collapsed ? item.label : ''} placement="right">
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`erp-sidebar-item ${isActive ? 'erp-sidebar-item-active' : ''} ${indented ? 'erp-sidebar-item-indented' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="erp-sidebar-item-icon">{item.icon}</span>
|
||||||
|
{!collapsed && <span className="erp-sidebar-item-label">{item.label}</span>}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 动态图标映射
|
||||||
|
const pluginIconMap: Record<string, React.ReactNode> = {
|
||||||
|
AppstoreOutlined: <AppstoreOutlined />,
|
||||||
|
team: <TeamOutlined />,
|
||||||
|
TeamOutlined: <TeamOutlined />,
|
||||||
|
user: <UserOutlined />,
|
||||||
|
UserOutlined: <UserOutlined />,
|
||||||
|
message: <MessageOutlined />,
|
||||||
|
MessageOutlined: <MessageOutlined />,
|
||||||
|
tags: <TagsOutlined />,
|
||||||
|
TagsOutlined: <TagsOutlined />,
|
||||||
|
apartment: <ApartmentOutlined />,
|
||||||
|
ApartmentOutlined: <ApartmentOutlined />,
|
||||||
|
TableOutlined: <TableOutlined />,
|
||||||
|
DashboardOutlined: <AppstoreOutlined />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPluginIcon(iconName: string): React.ReactNode {
|
||||||
|
return pluginIconMap[iconName] || <AppstoreOutlined />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插件子菜单组 — 可折叠二级标题 + 三级菜单项
|
||||||
|
const SidebarSubMenu = memo(function SidebarSubMenu({
|
||||||
|
group,
|
||||||
|
collapsed,
|
||||||
|
currentPath,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
group: PluginMenuGroup;
|
||||||
|
collapsed: boolean;
|
||||||
|
currentPath: string;
|
||||||
|
onNavigate: (key: string) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const hasActive = group.items.some((item) => currentPath === item.key);
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
// 折叠模式:显示插件图标,Tooltip 列出所有子项
|
||||||
|
const tooltipContent = group.items.map((item) => item.label).join(' / ');
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltipContent} placement="right">
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
const first = group.items[0];
|
||||||
|
if (first) onNavigate(first.key);
|
||||||
|
}}
|
||||||
|
className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="erp-sidebar-item-icon"><AppstoreOutlined /></span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`erp-sidebar-submenu-title ${hasActive ? 'erp-sidebar-submenu-title-active' : ''}`}
|
||||||
|
onClick={() => setExpanded((e) => !e)}
|
||||||
|
>
|
||||||
|
<span className="erp-sidebar-submenu-arrow">
|
||||||
|
<RightOutlined
|
||||||
|
style={{ fontSize: 10, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="erp-sidebar-submenu-label">{group.pluginName}</span>
|
||||||
|
</div>
|
||||||
|
{expanded && group.items.map((item) => (
|
||||||
|
<SidebarMenuItem
|
||||||
|
key={item.key}
|
||||||
|
item={{
|
||||||
|
key: item.key,
|
||||||
|
icon: getPluginIcon(item.icon),
|
||||||
|
label: item.label,
|
||||||
|
}}
|
||||||
|
isActive={currentPath === item.key}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onClick={() => onNavigate(item.key)}
|
||||||
|
indented
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const pluginMenuItems = usePluginStore((s) => s.pluginMenuItems);
|
||||||
|
const pluginMenuGroups = usePluginStore((s) => s.pluginMenuGroups);
|
||||||
|
const fetchPlugins = usePluginStore((s) => s.fetchPlugins);
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 菜单组:插件 */}
|
||||||
|
{pluginMenuGroups.length > 0 && (
|
||||||
|
<>
|
||||||
|
{!sidebarCollapsed && <div className="erp-sidebar-group">插件</div>}
|
||||||
|
<div className="erp-sidebar-menu">
|
||||||
|
{pluginMenuGroups.map((group) => (
|
||||||
|
<SidebarSubMenu
|
||||||
|
key={group.pluginId}
|
||||||
|
group={group}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
currentPath={currentPath}
|
||||||
|
onNavigate={(key) => navigate(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]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
341
apps/web/src/pages/PluginAdmin.tsx
Normal file
341
apps/web/src/pages/PluginAdmin.tsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
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,
|
||||||
|
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={[]}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
674
apps/web/src/pages/PluginCRUDPage.tsx
Normal file
674
apps/web/src/pages/PluginCRUDPage.tsx
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
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,
|
||||||
|
Drawer,
|
||||||
|
Descriptions,
|
||||||
|
Segmented,
|
||||||
|
Timeline,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
listPluginData,
|
||||||
|
createPluginData,
|
||||||
|
updatePluginData,
|
||||||
|
deletePluginData,
|
||||||
|
batchPluginData,
|
||||||
|
type PluginDataListOptions,
|
||||||
|
} from '../api/pluginData';
|
||||||
|
import EntitySelect from '../components/EntitySelect';
|
||||||
|
import {
|
||||||
|
getPluginSchema,
|
||||||
|
type PluginFieldSchema,
|
||||||
|
type PluginEntitySchema,
|
||||||
|
type PluginPageSchema,
|
||||||
|
type PluginSectionSchema,
|
||||||
|
} from '../api/plugins';
|
||||||
|
import { evaluateVisibleWhen } from '../utils/exprEvaluator';
|
||||||
|
|
||||||
|
const { Search } = Input;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface PluginCRUDPageProps {
|
||||||
|
/** 如果从 tabs/detail 页面内嵌使用,通过 props 传入配置 */
|
||||||
|
pluginIdOverride?: string;
|
||||||
|
entityOverride?: string;
|
||||||
|
filterField?: string;
|
||||||
|
filterValue?: string;
|
||||||
|
enableViews?: string[];
|
||||||
|
/** detail 页面内嵌时使用 compact 模式 */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginCRUDPage({
|
||||||
|
pluginIdOverride,
|
||||||
|
entityOverride,
|
||||||
|
filterField,
|
||||||
|
filterValue,
|
||||||
|
enableViews: enableViewsProp,
|
||||||
|
compact,
|
||||||
|
}: PluginCRUDPageProps = {}) {
|
||||||
|
const routeParams = useParams<{ pluginId: string; entityName: string }>();
|
||||||
|
const pluginId = pluginIdOverride || routeParams.pluginId || '';
|
||||||
|
const entityName = entityOverride || routeParams.entityName || '';
|
||||||
|
|
||||||
|
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<PluginFieldSchema[]>([]);
|
||||||
|
const [displayName, setDisplayName] = useState(entityName || '');
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
|
// 筛选/搜索/排序 state
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [sortBy, setSortBy] = useState<string | undefined>();
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
|
// 视图切换
|
||||||
|
const [viewMode, setViewMode] = useState<string>('table');
|
||||||
|
|
||||||
|
// 批量选择
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 详情 Drawer
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [detailSections, setDetailSections] = useState<PluginSectionSchema[]>([]);
|
||||||
|
const [allEntities, setAllEntities] = useState<PluginEntitySchema[]>([]);
|
||||||
|
const [allPages, setAllPages] = useState<PluginPageSchema[]>([]);
|
||||||
|
|
||||||
|
// 从 fields 中提取 filterable 字段
|
||||||
|
const filterableFields = fields.filter((f) => f.filterable);
|
||||||
|
|
||||||
|
// 查找是否有 detail 页面
|
||||||
|
const hasDetailPage = allPages.some(
|
||||||
|
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 可用视图
|
||||||
|
const enableViews = enableViewsProp ||
|
||||||
|
(() => {
|
||||||
|
const page = allPages.find(
|
||||||
|
(p) => p.type === 'crud' && 'entity' in p && p.entity === entityName,
|
||||||
|
);
|
||||||
|
return (page as { enable_views?: string[] })?.enable_views || ['table'];
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 加载 schema
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
async function loadSchema() {
|
||||||
|
try {
|
||||||
|
const schema = await getPluginSchema(pluginId!);
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
const entities: PluginEntitySchema[] = (schema as { entities?: PluginEntitySchema[] }).entities || [];
|
||||||
|
setAllEntities(entities);
|
||||||
|
const entity = entities.find((e) => e.name === entityName);
|
||||||
|
if (entity) {
|
||||||
|
setFields(entity.fields);
|
||||||
|
setDisplayName(entity.display_name || entityName || '');
|
||||||
|
}
|
||||||
|
const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui;
|
||||||
|
if (ui?.pages) {
|
||||||
|
setAllPages(ui.pages);
|
||||||
|
const detailPage = ui.pages.find(
|
||||||
|
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
|
||||||
|
);
|
||||||
|
if (detailPage && 'sections' in detailPage) {
|
||||||
|
setDetailSections(detailPage.sections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.warning('Schema 加载失败,部分功能不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSchema();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, entityName]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(
|
||||||
|
async (p = page, overrides?: { search?: string; sort_by?: string; sort_order?: 'asc' | 'desc' }) => {
|
||||||
|
if (!pluginId || !entityName) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const options: PluginDataListOptions = {};
|
||||||
|
const mergedFilters = { ...filters };
|
||||||
|
if (filterField && filterValue) {
|
||||||
|
mergedFilters[filterField] = filterValue;
|
||||||
|
}
|
||||||
|
if (Object.keys(mergedFilters).length > 0) {
|
||||||
|
options.filter = mergedFilters;
|
||||||
|
}
|
||||||
|
const effectiveSearch = overrides?.search ?? searchText;
|
||||||
|
if (effectiveSearch) options.search = effectiveSearch;
|
||||||
|
const effectiveSortBy = overrides?.sort_by ?? sortBy;
|
||||||
|
const effectiveSortOrder = overrides?.sort_order ?? sortOrder;
|
||||||
|
if (effectiveSortBy) {
|
||||||
|
options.sort_by = effectiveSortBy;
|
||||||
|
options.sort_order = effectiveSortOrder;
|
||||||
|
}
|
||||||
|
const result = await listPluginData(pluginId, entityName, p, 20, options);
|
||||||
|
setRecords(
|
||||||
|
result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })),
|
||||||
|
);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载数据失败');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
[pluginId, entityName, page, filters, searchText, sortBy, sortOrder, filterField, filterValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// 筛选变化
|
||||||
|
const handleFilterChange = (fieldName: string, value: string | undefined) => {
|
||||||
|
const newFilters = { ...filters };
|
||||||
|
if (value) {
|
||||||
|
newFilters[fieldName] = value;
|
||||||
|
} else {
|
||||||
|
delete newFilters[fieldName];
|
||||||
|
}
|
||||||
|
setFilters(newFilters);
|
||||||
|
setPage(1);
|
||||||
|
// 直接触发重新查询
|
||||||
|
fetchData(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 handleBatchDelete = async () => {
|
||||||
|
if (!pluginId || !entityName || selectedRowKeys.length === 0) return;
|
||||||
|
try {
|
||||||
|
await batchPluginData(pluginId, entityName, {
|
||||||
|
action: 'delete',
|
||||||
|
ids: selectedRowKeys,
|
||||||
|
});
|
||||||
|
message.success(`已删除 ${selectedRowKeys.length} 条记录`);
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
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,
|
||||||
|
sorter: f.sortable ? true : undefined,
|
||||||
|
render: (val: unknown) => {
|
||||||
|
if (typeof val === 'boolean') return val ? <Tag color="green">是</Tag> : <Tag>否</Tag>;
|
||||||
|
return String(val ?? '-');
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: hasDetailPage ? 200 : 150,
|
||||||
|
render: (_: unknown, record: Record<string, unknown>) => (
|
||||||
|
<Space size="small">
|
||||||
|
{hasDetailPage && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setDetailRecord(record);
|
||||||
|
setDetailOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setEditRecord(record);
|
||||||
|
form.setFieldsValue(record);
|
||||||
|
setFormValues(record);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record)}>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 动态生成表单字段
|
||||||
|
const renderFormField = (field: PluginFieldSchema) => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
case 'textarea':
|
||||||
|
return <TextArea rows={3} />;
|
||||||
|
case 'entity_select':
|
||||||
|
return (
|
||||||
|
<EntitySelect
|
||||||
|
pluginId={pluginId}
|
||||||
|
entity={field.ref_entity!}
|
||||||
|
labelField={field.ref_label_field || 'name'}
|
||||||
|
searchFields={field.ref_search_fields}
|
||||||
|
value={formValues[field.name] as string | undefined}
|
||||||
|
onChange={(v) => form.setFieldValue(field.name, v)}
|
||||||
|
cascadeFrom={field.cascade_from}
|
||||||
|
cascadeFilter={field.cascade_filter}
|
||||||
|
cascadeValue={
|
||||||
|
field.cascade_from
|
||||||
|
? (formValues[field.cascade_from] as string | undefined)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
placeholder={field.display_name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <Input />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timeline 视图渲染
|
||||||
|
const renderTimeline = () => {
|
||||||
|
const dateField = fields.find((f) => f.field_type === 'DateTime' || f.field_type === 'date');
|
||||||
|
const titleField = fields.find((f) => f.searchable)?.name || fields[1]?.name;
|
||||||
|
const contentField = fields.find((f) => f.ui_widget === 'textarea')?.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
items={records.map((record) => ({
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
{titleField && (
|
||||||
|
<p>
|
||||||
|
<strong>{String(record[titleField] ?? '-')}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{contentField && <p>{String(record[contentField] ?? '-')}</p>}
|
||||||
|
{dateField && (
|
||||||
|
<p style={{ color: '#999', fontSize: 12 }}>
|
||||||
|
{String(record[dateField.name] ?? '-')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 详情 Drawer 渲染
|
||||||
|
const renderDetailDrawer = () => {
|
||||||
|
if (!detailRecord) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={displayName + ' 详情'}
|
||||||
|
open={detailOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDetailOpen(false);
|
||||||
|
setDetailRecord(null);
|
||||||
|
}}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
{detailSections.length > 0 ? (
|
||||||
|
detailSections.map((section, idx) => {
|
||||||
|
if (section.type === 'fields') {
|
||||||
|
return (
|
||||||
|
<div key={idx} style={{ marginBottom: 24 }}>
|
||||||
|
<h4>{section.label}</h4>
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
{section.fields.map((fieldName) => {
|
||||||
|
const fieldDef = fields.find((f) => f.name === fieldName);
|
||||||
|
const val = detailRecord[fieldName];
|
||||||
|
return (
|
||||||
|
<Descriptions.Item
|
||||||
|
key={fieldName}
|
||||||
|
label={fieldDef?.display_name || fieldName}
|
||||||
|
>
|
||||||
|
{typeof val === 'boolean' ? (
|
||||||
|
val ? (
|
||||||
|
<Tag color="green">是</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>否</Tag>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
String(val ?? '-')
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Descriptions>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.type === 'crud') {
|
||||||
|
const secEntity = allEntities.find((e) => e.name === section.entity);
|
||||||
|
return (
|
||||||
|
<div key={idx} style={{ marginBottom: 24 }}>
|
||||||
|
<h4>{section.label}</h4>
|
||||||
|
{secEntity && (
|
||||||
|
<PluginCRUDPage
|
||||||
|
pluginIdOverride={pluginId}
|
||||||
|
entityOverride={section.entity}
|
||||||
|
filterField={section.filter_field}
|
||||||
|
filterValue={String(detailRecord._id ?? '')}
|
||||||
|
enableViews={section.enable_views}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
// 没有 sections 配置时,默认展示所有字段
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
{fields.map((field) => {
|
||||||
|
const val = detailRecord[field.name];
|
||||||
|
return (
|
||||||
|
<Descriptions.Item key={field.name} label={field.display_name || field.name}>
|
||||||
|
{typeof val === 'boolean' ? (
|
||||||
|
val ? (
|
||||||
|
<Tag color="green">是</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>否</Tag>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
String(val ?? '-')
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={compact ? { padding: 0 } : { padding: 24 }}>
|
||||||
|
{!compact && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: 0 }}>{displayName}</h2>
|
||||||
|
<Space>
|
||||||
|
{enableViews.length > 1 && (
|
||||||
|
<Segmented
|
||||||
|
options={enableViews.map((v) => ({
|
||||||
|
label: v === 'table' ? '表格' : v === 'timeline' ? '时间线' : v,
|
||||||
|
value: v,
|
||||||
|
}))}
|
||||||
|
value={viewMode}
|
||||||
|
onChange={(val) => setViewMode(val as string)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditRecord(null);
|
||||||
|
form.resetFields();
|
||||||
|
setFormValues({});
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索和筛选栏 */}
|
||||||
|
{!compact && (
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
{fields.some((f) => f.searchable) && (
|
||||||
|
<Search
|
||||||
|
placeholder="搜索..."
|
||||||
|
allowClear
|
||||||
|
style={{ width: 240 }}
|
||||||
|
onSearch={(value) => {
|
||||||
|
setSearchText(value);
|
||||||
|
setPage(1);
|
||||||
|
fetchData(1, { search: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filterableFields.map((field) => (
|
||||||
|
<Select
|
||||||
|
key={field.name}
|
||||||
|
placeholder={field.display_name || field.name}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 150 }}
|
||||||
|
options={field.options || []}
|
||||||
|
onChange={(value) => handleFilterChange(field.name, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 批量操作栏 */}
|
||||||
|
{selectedRowKeys.length > 0 && !compact && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'var(--colorBgContainer, #fff)',
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>已选择 <strong>{selectedRowKeys.length}</strong> 项</span>
|
||||||
|
<Popconfirm
|
||||||
|
title={`确定删除选中的 ${selectedRowKeys.length} 条记录?`}
|
||||||
|
onConfirm={handleBatchDelete}
|
||||||
|
>
|
||||||
|
<Button danger icon={<DeleteOutlined />}>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Button onClick={() => setSelectedRowKeys([])}>
|
||||||
|
取消选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'table' || enableViews.length <= 1 ? (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={records}
|
||||||
|
rowKey="_id"
|
||||||
|
loading={loading}
|
||||||
|
size={compact ? 'small' : undefined}
|
||||||
|
rowSelection={
|
||||||
|
compact
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange={(_pagination, _filters, sorter) => {
|
||||||
|
if (!Array.isArray(sorter) && sorter.field) {
|
||||||
|
const newSortBy = String(sorter.field);
|
||||||
|
const newSortOrder = sorter.order === 'ascend' ? 'asc' as const : 'desc' as const;
|
||||||
|
setSortBy(newSortBy);
|
||||||
|
setSortOrder(newSortOrder);
|
||||||
|
setPage(1);
|
||||||
|
fetchData(1, { sort_by: newSortBy, sort_order: newSortOrder });
|
||||||
|
} else if (!sorter || (Array.isArray(sorter) && sorter.length === 0)) {
|
||||||
|
setSortBy(undefined);
|
||||||
|
setSortOrder('desc');
|
||||||
|
setPage(1);
|
||||||
|
fetchData(1, { sort_by: undefined, sort_order: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
pagination={
|
||||||
|
compact
|
||||||
|
? { pageSize: 5, showTotal: (t) => `共 ${t} 条` }
|
||||||
|
: {
|
||||||
|
current: page,
|
||||||
|
total,
|
||||||
|
pageSize: 20,
|
||||||
|
onChange: (p) => setPage(p),
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : viewMode === 'timeline' ? (
|
||||||
|
renderTimeline()
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 新增/编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={editRecord ? '编辑' : '新增'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditRecord(null);
|
||||||
|
setFormValues({});
|
||||||
|
}}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
onValuesChange={(_, allValues) => setFormValues(allValues)}
|
||||||
|
>
|
||||||
|
{fields.map((field) => {
|
||||||
|
// visible_when 条件显示
|
||||||
|
const visible = evaluateVisibleWhen(field.visible_when, formValues);
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 详情 Drawer */}
|
||||||
|
{renderDetailDrawer()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
397
apps/web/src/pages/PluginDashboardPage.tsx
Normal file
397
apps/web/src/pages/PluginDashboardPage.tsx
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Row, Col, Empty, Select, theme } from 'antd';
|
||||||
|
import { DashboardOutlined } from '@ant-design/icons';
|
||||||
|
import { countPluginData, aggregatePluginData } from '../api/pluginData';
|
||||||
|
import {
|
||||||
|
getPluginSchema,
|
||||||
|
type PluginEntitySchema,
|
||||||
|
type PluginSchemaResponse,
|
||||||
|
type PluginPageSchema,
|
||||||
|
type DashboardWidget,
|
||||||
|
} from '../api/plugins';
|
||||||
|
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboard/dashboardTypes';
|
||||||
|
import { ENTITY_PALETTE, DEFAULT_PALETTE, ENTITY_ICONS, getDelayClass } from './dashboard/dashboardConstants';
|
||||||
|
import {
|
||||||
|
StatCard,
|
||||||
|
SkeletonStatCard,
|
||||||
|
BreakdownCard,
|
||||||
|
SkeletonBreakdownCard,
|
||||||
|
WidgetRenderer,
|
||||||
|
} from './dashboard/DashboardWidgets';
|
||||||
|
|
||||||
|
// ── 主组件 ──
|
||||||
|
|
||||||
|
export function PluginDashboardPage() {
|
||||||
|
const { pluginId } = useParams<{ pluginId: string }>();
|
||||||
|
const { token: themeToken } = theme.useToken();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [schemaLoading, setSchemaLoading] = useState(false);
|
||||||
|
const [entities, setEntities] = useState<PluginEntitySchema[]>([]);
|
||||||
|
const [selectedEntity, setSelectedEntity] = useState<string>('');
|
||||||
|
const [entityStats, setEntityStats] = useState<EntityStat[]>([]);
|
||||||
|
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Widget-based dashboard state
|
||||||
|
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
|
||||||
|
const [widgetData, setWidgetData] = useState<WidgetData[]>([]);
|
||||||
|
const [widgetsLoading, setWidgetsLoading] = useState(false);
|
||||||
|
const isDark =
|
||||||
|
themeToken.colorBgContainer === '#111827' ||
|
||||||
|
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
||||||
|
|
||||||
|
// 加载 schema
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
async function loadSchema() {
|
||||||
|
setSchemaLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
const entityList = schema.entities || [];
|
||||||
|
setEntities(entityList);
|
||||||
|
if (entityList.length > 0) {
|
||||||
|
setSelectedEntity(entityList[0].name);
|
||||||
|
}
|
||||||
|
// 提取 dashboard widgets
|
||||||
|
const pages = schema.ui?.pages || [];
|
||||||
|
const dashboardPage = pages.find(
|
||||||
|
(p): p is PluginPageSchema & { type: 'dashboard'; widgets?: DashboardWidget[] } =>
|
||||||
|
p.type === 'dashboard',
|
||||||
|
);
|
||||||
|
if (dashboardPage?.widgets && dashboardPage.widgets.length > 0) {
|
||||||
|
setWidgets(dashboardPage.widgets);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Schema 加载失败,部分功能不可用');
|
||||||
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) setSchemaLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSchema();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId]);
|
||||||
|
const currentEntity = useMemo(
|
||||||
|
() => entities.find((e) => e.name === selectedEntity),
|
||||||
|
[entities, selectedEntity],
|
||||||
|
);
|
||||||
|
const filterableFields = useMemo(
|
||||||
|
() => currentEntity?.fields.filter((f) => f.filterable) || [],
|
||||||
|
[currentEntity],
|
||||||
|
);
|
||||||
|
// 加载所有实体的计数
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId || entities.length === 0) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
async function loadAllCounts() {
|
||||||
|
const results: EntityStat[] = [];
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const count = await countPluginData(pluginId!, entity.name);
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
|
||||||
|
results.push({
|
||||||
|
name: entity.name,
|
||||||
|
displayName: entity.display_name || entity.name,
|
||||||
|
count,
|
||||||
|
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
|
||||||
|
gradient: palette.gradient,
|
||||||
|
iconBg: palette.iconBg,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
|
||||||
|
results.push({
|
||||||
|
name: entity.name,
|
||||||
|
displayName: entity.display_name || entity.name,
|
||||||
|
count: 0,
|
||||||
|
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
|
||||||
|
gradient: palette.gradient,
|
||||||
|
iconBg: palette.iconBg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
setEntityStats(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAllCounts();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, entities]);
|
||||||
|
// Widget 数据并行加载
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId || widgets.length === 0) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
async function loadWidgetData() {
|
||||||
|
setWidgetsLoading(true);
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
widgets.map(async (widget) => {
|
||||||
|
try {
|
||||||
|
if (widget.type === 'stat_card') {
|
||||||
|
const count = await countPluginData(pluginId!, widget.entity);
|
||||||
|
return { widget, data: [], count };
|
||||||
|
}
|
||||||
|
if (widget.dimension_field) {
|
||||||
|
const data = await aggregatePluginData(
|
||||||
|
pluginId!,
|
||||||
|
widget.entity,
|
||||||
|
widget.dimension_field,
|
||||||
|
);
|
||||||
|
return { widget, data };
|
||||||
|
}
|
||||||
|
// 没有 dimension_field 时仅返回计数
|
||||||
|
const count = await countPluginData(pluginId!, widget.entity);
|
||||||
|
return { widget, data: [], count };
|
||||||
|
} catch {
|
||||||
|
return { widget, data: [], count: 0 };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
setWidgetData(results);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) setWidgetsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadWidgetData();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, widgets]);
|
||||||
|
// 当前实体的聚合数据
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const fieldResults: FieldBreakdown[] = [];
|
||||||
|
for (const field of filterableFields) {
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const items = await aggregatePluginData(pluginId!, selectedEntity!, field.name);
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
fieldResults.push({
|
||||||
|
fieldName: field.name,
|
||||||
|
displayName: field.display_name || field.name,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 单个字段聚合失败不影响其他字段
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!abortController.signal.aborted) setBreakdowns(fieldResults);
|
||||||
|
} catch {
|
||||||
|
setError('统计数据加载失败');
|
||||||
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, selectedEntity, filterableFields, entityStats]);
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = loadData();
|
||||||
|
return () => { cleanup?.then((fn) => fn?.()).catch(() => {}); };
|
||||||
|
}, [loadData]);
|
||||||
|
// 当前选中实体的总数
|
||||||
|
const currentTotal = useMemo(
|
||||||
|
() => entityStats.find((s) => s.name === selectedEntity)?.count ?? 0,
|
||||||
|
[entityStats, selectedEntity],
|
||||||
|
);
|
||||||
|
// 当前实体的色板
|
||||||
|
const currentPalette = useMemo(
|
||||||
|
() => ENTITY_PALETTE[selectedEntity] || DEFAULT_PALETTE,
|
||||||
|
[selectedEntity],
|
||||||
|
);
|
||||||
|
// ── 渲染 ──
|
||||||
|
if (schemaLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<SkeletonStatCard key={i} delay={getDelayClass(i)} />
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<SkeletonBreakdownCard key={i} index={i} />
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
CRM 数据全景视图,实时掌握业务动态
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={selectedEntity || undefined}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
options={entities.map((e) => ({
|
||||||
|
label: e.display_name || e.name,
|
||||||
|
value: e.name,
|
||||||
|
}))}
|
||||||
|
onChange={setSelectedEntity}
|
||||||
|
aria-label="选择实体类型"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 顶部统计卡片 */}
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
{loading && entityStats.length === 0
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<SkeletonStatCard key={i} delay={getDelayClass(i)} />
|
||||||
|
))
|
||||||
|
: entityStats.map((stat, i) => (
|
||||||
|
<StatCard
|
||||||
|
key={stat.name}
|
||||||
|
stat={stat}
|
||||||
|
loading={loading}
|
||||||
|
delay={getDelayClass(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Widget 图表区域 */}
|
||||||
|
{widgets.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div className="erp-section-header">
|
||||||
|
<DashboardOutlined
|
||||||
|
className="erp-section-icon"
|
||||||
|
style={{ color: '#4F46E5' }}
|
||||||
|
/>
|
||||||
|
<span className="erp-section-title">图表分析</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{widgetsLoading && widgetData.length === 0 ? (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{widgets.map((_, i) => (
|
||||||
|
<SkeletonBreakdownCard key={i} index={i} />
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
{widgetData.map((wd) => {
|
||||||
|
const colSpan = wd.widget.type === 'stat_card' ? 6
|
||||||
|
: wd.widget.type === 'pie_chart' || wd.widget.type === 'funnel_chart' ? 12
|
||||||
|
: 12;
|
||||||
|
return (
|
||||||
|
<Col key={`${wd.widget.type}-${wd.widget.entity}-${wd.widget.title}`} xs={24} sm={colSpan}>
|
||||||
|
<WidgetRenderer widgetData={wd} isDark={isDark} />
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分组统计区域 */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div className="erp-section-header">
|
||||||
|
<DashboardOutlined
|
||||||
|
className="erp-section-icon"
|
||||||
|
style={{ color: currentPalette.tagColor === 'purple' ? '#4F46E5' : '#3B82F6' }}
|
||||||
|
/>
|
||||||
|
<span className="erp-section-title">
|
||||||
|
{currentEntity?.display_name || selectedEntity} 数据分布
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--erp-text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
共 {currentTotal.toLocaleString()} 条记录
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && breakdowns.length === 0 ? (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<SkeletonBreakdownCard key={i} index={i} />
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
) : breakdowns.length > 0 ? (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{breakdowns.map((bd, i) => (
|
||||||
|
<BreakdownCard
|
||||||
|
key={bd.fieldName}
|
||||||
|
breakdown={bd}
|
||||||
|
totalCount={currentTotal}
|
||||||
|
index={i}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<div className="erp-content-card" style={{ textAlign: 'center', padding: '48px 24px' }}>
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={
|
||||||
|
filterableFields.length === 0
|
||||||
|
? '当前实体无可筛选项,暂无分布数据'
|
||||||
|
: '暂无数据'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: isDark ? 'rgba(220, 38, 38, 0.1)' : '#FEF2F2',
|
||||||
|
color: isDark ? '#FCA5A5' : '#991B1B',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
758
apps/web/src/pages/PluginGraphPage.tsx
Normal file
758
apps/web/src/pages/PluginGraphPage.tsx
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Empty,
|
||||||
|
Spin,
|
||||||
|
Statistic,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
Badge,
|
||||||
|
Flex,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ApartmentOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
NodeIndexOutlined,
|
||||||
|
AimOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { listPluginData } from '../api/pluginData';
|
||||||
|
import {
|
||||||
|
getPluginSchema,
|
||||||
|
type PluginFieldSchema,
|
||||||
|
type PluginSchemaResponse,
|
||||||
|
} from '../api/plugins';
|
||||||
|
|
||||||
|
import type { GraphNode, GraphEdge, GraphConfig, NodePosition, HoverState } from './graph/graphTypes';
|
||||||
|
import { computeCircularLayout } from './graph/graphLayout';
|
||||||
|
import {
|
||||||
|
RELATIONSHIP_COLORS,
|
||||||
|
NODE_HOVER_SCALE,
|
||||||
|
getRelColor,
|
||||||
|
getEdgeTypeLabel,
|
||||||
|
getNodeDegree,
|
||||||
|
degreeToRadius,
|
||||||
|
drawCurvedEdge,
|
||||||
|
drawNode,
|
||||||
|
drawEdgeLabel,
|
||||||
|
drawNodeLabel,
|
||||||
|
} from './graph/graphRenderer';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件关系图谱页面 — 通过路由参数自加载 schema
|
||||||
|
* 路由: /plugins/:pluginId/graph/:entityName
|
||||||
|
*/
|
||||||
|
export function PluginGraphPage() {
|
||||||
|
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const animFrameRef = useRef<number>(0);
|
||||||
|
const nodePositionsRef = useRef<Map<string, NodePosition>>(new Map());
|
||||||
|
const visibleNodesRef = useRef<GraphNode[]>([]);
|
||||||
|
const visibleEdgesRef = useRef<GraphEdge[]>([]);
|
||||||
|
|
||||||
|
const [customers, setCustomers] = useState<GraphNode[]>([]);
|
||||||
|
const [relationships, setRelationships] = useState<GraphEdge[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedCenter, setSelectedCenter] = useState<string | null>(null);
|
||||||
|
const [relTypes, setRelTypes] = useState<string[]>([]);
|
||||||
|
const [relFilter, setRelFilter] = useState<string | undefined>();
|
||||||
|
const [graphConfig, setGraphConfig] = useState<GraphConfig | null>(null);
|
||||||
|
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
|
||||||
|
const [hoverState, setHoverState] = useState<HoverState>({ nodeId: null, x: 0, y: 0 });
|
||||||
|
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 });
|
||||||
|
|
||||||
|
// ── Computed stats ──
|
||||||
|
|
||||||
|
const filteredRels = relFilter
|
||||||
|
? relationships.filter((r) => r.label === relFilter)
|
||||||
|
: relationships;
|
||||||
|
|
||||||
|
const visibleEdges = selectedCenter
|
||||||
|
? filteredRels.filter((r) => r.source === selectedCenter || r.target === selectedCenter)
|
||||||
|
: filteredRels;
|
||||||
|
|
||||||
|
const visibleNodeIds = new Set<string>();
|
||||||
|
if (selectedCenter) {
|
||||||
|
visibleNodeIds.add(selectedCenter);
|
||||||
|
for (const e of visibleEdges) {
|
||||||
|
visibleNodeIds.add(e.source);
|
||||||
|
visibleNodeIds.add(e.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const visibleNodes = selectedCenter
|
||||||
|
? customers.filter((n) => visibleNodeIds.has(n.id))
|
||||||
|
: customers;
|
||||||
|
|
||||||
|
const centerNode = customers.find((c) => c.id === selectedCenter);
|
||||||
|
const centerDegree = selectedCenter ? getNodeDegree(selectedCenter, visibleEdges) : 0;
|
||||||
|
|
||||||
|
// ── Schema loading ──
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId || !entityName) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
async function loadSchema() {
|
||||||
|
try {
|
||||||
|
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
|
||||||
|
const pages = schema.ui?.pages || [];
|
||||||
|
const graphPage = pages.find(
|
||||||
|
(p): p is typeof p & GraphConfig & { type: 'graph' } =>
|
||||||
|
p.type === 'graph' && p.entity === entityName,
|
||||||
|
);
|
||||||
|
if (graphPage) {
|
||||||
|
setGraphConfig({
|
||||||
|
entity: graphPage.entity,
|
||||||
|
relationshipEntity: graphPage.relationship_entity,
|
||||||
|
sourceField: graphPage.source_field,
|
||||||
|
targetField: graphPage.target_field,
|
||||||
|
edgeLabelField: graphPage.edge_label_field,
|
||||||
|
nodeLabelField: graphPage.node_label_field,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = schema.entities?.find((e) => e.name === entityName);
|
||||||
|
if (entity) setFields(entity.fields);
|
||||||
|
} catch {
|
||||||
|
message.warning('Schema 加载失败,部分功能不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSchema();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, entityName]);
|
||||||
|
|
||||||
|
// ── Data loading ──
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId || !graphConfig) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const gc = graphConfig;
|
||||||
|
const labelField = fields.find((f) => f.name === gc.nodeLabelField)?.name || fields[1]?.name || 'name';
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let allCustomers: GraphNode[] = [];
|
||||||
|
let page = 1;
|
||||||
|
let hasMore = true;
|
||||||
|
while (hasMore) {
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
const result = await listPluginData(pluginId!, gc.entity, page, 100);
|
||||||
|
allCustomers = [
|
||||||
|
...allCustomers,
|
||||||
|
...result.data.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
label: String(r.data[labelField] || '未命名'),
|
||||||
|
data: r.data,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
hasMore = result.data.length === 100 && allCustomers.length < result.total;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
setCustomers(allCustomers);
|
||||||
|
|
||||||
|
let allRels: GraphEdge[] = [];
|
||||||
|
page = 1;
|
||||||
|
hasMore = true;
|
||||||
|
const types = new Set<string>();
|
||||||
|
while (hasMore) {
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
const result = await listPluginData(pluginId!, gc.relationshipEntity, page, 100);
|
||||||
|
for (const r of result.data) {
|
||||||
|
const relType = String(r.data[gc.edgeLabelField] || '');
|
||||||
|
types.add(relType);
|
||||||
|
allRels.push({
|
||||||
|
source: String(r.data[gc.sourceField] || ''),
|
||||||
|
target: String(r.data[gc.targetField] || ''),
|
||||||
|
label: relType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
hasMore = result.data.length === 100 && allRels.length < result.total;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
setRelationships(allRels);
|
||||||
|
setRelTypes(Array.from(types));
|
||||||
|
} catch {
|
||||||
|
message.warning('数据加载失败');
|
||||||
|
}
|
||||||
|
if (!abortController.signal.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, graphConfig, fields]);
|
||||||
|
|
||||||
|
// ── Canvas resize observer ──
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { width } = entry.contentRect;
|
||||||
|
if (width > 0) {
|
||||||
|
setCanvasSize({ width, height: Math.max(500, Math.min(700, width * 0.65)) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Update refs for animation loop ──
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
visibleNodesRef.current = visibleNodes;
|
||||||
|
visibleEdgesRef.current = visibleEdges;
|
||||||
|
}, [visibleNodes, visibleEdges]);
|
||||||
|
|
||||||
|
// ── Main canvas drawing with requestAnimationFrame ──
|
||||||
|
|
||||||
|
const drawGraph = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const nodes = visibleNodesRef.current;
|
||||||
|
const edges = visibleEdgesRef.current;
|
||||||
|
|
||||||
|
const width = canvasSize.width;
|
||||||
|
const height = canvasSize.height;
|
||||||
|
|
||||||
|
// High DPI support
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = width * dpr;
|
||||||
|
canvas.height = height * dpr;
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// Theme-aware colors
|
||||||
|
const textColor = token.colorText;
|
||||||
|
const bgColor = token.colorBgContainer;
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = bgColor;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
|
||||||
|
// Compute layout
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
const radius = Math.min(width, height) * 0.36;
|
||||||
|
|
||||||
|
const positions = computeCircularLayout(nodes, centerX, centerY, radius);
|
||||||
|
nodePositionsRef.current = positions;
|
||||||
|
|
||||||
|
// Precompute degrees for node sizing
|
||||||
|
const degreeMap = new Map<string, number>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
degreeMap.set(node.id, getNodeDegree(node.id, edges));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw edges first (behind nodes) ──
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const from = positions.get(edge.source);
|
||||||
|
const to = positions.get(edge.target);
|
||||||
|
if (!from || !to) continue;
|
||||||
|
|
||||||
|
const colors = getRelColor(edge.label);
|
||||||
|
const isHighlighted =
|
||||||
|
hoverState.nodeId === edge.source || hoverState.nodeId === edge.target;
|
||||||
|
const alpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.15) : 0.7;
|
||||||
|
const lw = isHighlighted ? 2.5 : 1.5;
|
||||||
|
|
||||||
|
const labelPos = drawCurvedEdge(
|
||||||
|
ctx, from.x, from.y, to.x, to.y,
|
||||||
|
colors.base, lw, isHighlighted, alpha,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Edge label
|
||||||
|
if (edge.label && labelPos) {
|
||||||
|
const labelAlpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.1) : 0.9;
|
||||||
|
drawEdgeLabel(ctx, labelPos.labelX, labelPos.labelY - 10, edge.label, colors.base, labelAlpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw nodes ──
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const pos = positions.get(node.id);
|
||||||
|
if (!pos) continue;
|
||||||
|
|
||||||
|
const isCenter = node.id === selectedCenter;
|
||||||
|
const isHovered = node.id === hoverState.nodeId;
|
||||||
|
const degree = degreeMap.get(node.id) || 0;
|
||||||
|
const r = degreeToRadius(degree, isCenter);
|
||||||
|
|
||||||
|
// Determine node color from its most common edge type, or default palette
|
||||||
|
let nodeColorBase = '#4F46E5';
|
||||||
|
let nodeColorLight = '#818CF8';
|
||||||
|
let nodeColorGlow = 'rgba(79,70,229,0.3)';
|
||||||
|
|
||||||
|
if (isCenter) {
|
||||||
|
const firstEdge = edges.find((e) => e.source === node.id || e.target === node.id);
|
||||||
|
if (firstEdge) {
|
||||||
|
const rc = getRelColor(firstEdge.label);
|
||||||
|
nodeColorBase = rc.base;
|
||||||
|
nodeColorLight = rc.light;
|
||||||
|
nodeColorGlow = rc.glow;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const idx = nodes.indexOf(node);
|
||||||
|
const palette = Object.values(RELATIONSHIP_COLORS);
|
||||||
|
const pick = palette[idx % palette.length];
|
||||||
|
nodeColorBase = pick.base;
|
||||||
|
nodeColorLight = pick.light;
|
||||||
|
nodeColorGlow = pick.glow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeAlpha = hoverState.nodeId
|
||||||
|
? (isHovered || (hoverState.nodeId && edges.some(
|
||||||
|
(e) => (e.source === hoverState.nodeId && e.target === node.id) ||
|
||||||
|
(e.target === hoverState.nodeId && e.source === node.id),
|
||||||
|
)) ? 1 : 0.2)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
drawNode(ctx, pos.x, pos.y, r, nodeColorBase, nodeColorLight, nodeColorGlow, isCenter, isHovered, nodeAlpha);
|
||||||
|
drawNodeLabel(ctx, pos.x, pos.y, r, node.label, textColor, isCenter, isHovered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hover tooltip ──
|
||||||
|
|
||||||
|
if (hoverState.nodeId) {
|
||||||
|
const hoveredNode = nodes.find((n) => n.id === hoverState.nodeId);
|
||||||
|
if (hoveredNode) {
|
||||||
|
const degree = degreeMap.get(hoverState.nodeId) || 0;
|
||||||
|
const tooltipText = `${hoveredNode.label} (${degree} 条关系)`;
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||||
|
const metrics = ctx.measureText(tooltipText);
|
||||||
|
const tw = metrics.width + 16;
|
||||||
|
const th = 28;
|
||||||
|
const tx = hoverState.x - tw / 2;
|
||||||
|
const ty = hoverState.y - 40;
|
||||||
|
|
||||||
|
ctx.fillStyle = token.colorBgElevated;
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.15)';
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(tx, ty, tw, th, 6);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.fillStyle = token.colorText;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(tooltipText, hoverState.x, ty + th / 2);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [canvasSize, selectedCenter, hoverState, token]);
|
||||||
|
|
||||||
|
// ── Animation loop ──
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animate = () => {
|
||||||
|
drawGraph();
|
||||||
|
animFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
animFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => cancelAnimationFrame(animFrameRef.current);
|
||||||
|
}, [drawGraph]);
|
||||||
|
|
||||||
|
// ── Mouse interaction handlers ──
|
||||||
|
|
||||||
|
const handleCanvasMouseMove = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const positions = nodePositionsRef.current;
|
||||||
|
const nodes = visibleNodesRef.current;
|
||||||
|
const edges = visibleEdgesRef.current;
|
||||||
|
|
||||||
|
let foundId: string | null = null;
|
||||||
|
for (const node of nodes) {
|
||||||
|
const pos = positions.get(node.id);
|
||||||
|
if (!pos) continue;
|
||||||
|
const degree = getNodeDegree(node.id, edges);
|
||||||
|
const r = degreeToRadius(degree, node.id === selectedCenter) * NODE_HOVER_SCALE;
|
||||||
|
const dx = x - pos.x;
|
||||||
|
const dy = y - pos.y;
|
||||||
|
if (dx * dx + dy * dy < r * r) {
|
||||||
|
foundId = node.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.style.cursor = foundId ? 'pointer' : 'default';
|
||||||
|
setHoverState((prev) => {
|
||||||
|
if (prev.nodeId === foundId) return prev;
|
||||||
|
return { nodeId: foundId, x, y };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundId) {
|
||||||
|
setHoverState({ nodeId: foundId, x, y });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedCenter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCanvasMouseLeave = useCallback(() => {
|
||||||
|
setHoverState({ nodeId: null, x: 0, y: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCanvasClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const positions = nodePositionsRef.current;
|
||||||
|
const nodes = visibleNodesRef.current;
|
||||||
|
const edges = visibleEdgesRef.current;
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const pos = positions.get(node.id);
|
||||||
|
if (!pos) continue;
|
||||||
|
const degree = getNodeDegree(node.id, edges);
|
||||||
|
const r = degreeToRadius(degree, node.id === selectedCenter);
|
||||||
|
const dx = x - pos.x;
|
||||||
|
const dy = y - pos.y;
|
||||||
|
if (dx * dx + dy * dy < r * r) {
|
||||||
|
setSelectedCenter((prev) => (prev === node.id ? null : node.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedCenter],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Legend data ──
|
||||||
|
|
||||||
|
const legendItems = relTypes.map((type) => ({
|
||||||
|
label: getEdgeTypeLabel(type),
|
||||||
|
rawLabel: type,
|
||||||
|
color: getRelColor(type).base,
|
||||||
|
count: relationships.filter((r) => r.label === type).length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Render ──
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<Spin size="large" tip="加载图谱数据中..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
{/* Stats Row */}
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ borderLeft: `3px solid ${token.colorPrimary}` }}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
<TeamOutlined style={{ marginRight: 4 }} />
|
||||||
|
客户总数
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
value={customers.length}
|
||||||
|
valueStyle={{ color: token.colorPrimary, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ borderLeft: `3px solid ${token.colorSuccess}` }}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
<NodeIndexOutlined style={{ marginRight: 4 }} />
|
||||||
|
关系总数
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
value={relationships.length}
|
||||||
|
valueStyle={{ color: token.colorSuccess, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ borderLeft: `3px solid ${token.colorWarning}` }}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
<AimOutlined style={{ marginRight: 4 }} />
|
||||||
|
当前中心
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
value={centerNode?.label || '未选择'}
|
||||||
|
valueStyle={{
|
||||||
|
fontSize: 20,
|
||||||
|
color: centerNode ? token.colorWarning : token.colorTextDisabled,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedCenter && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{centerDegree} 条直接关系
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Main Graph Card */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<ApartmentOutlined />
|
||||||
|
<span>客户关系图谱</span>
|
||||||
|
{relFilter && (
|
||||||
|
<Tag
|
||||||
|
color="blue"
|
||||||
|
closable
|
||||||
|
onClose={() => setRelFilter(undefined)}
|
||||||
|
>
|
||||||
|
{getEdgeTypeLabel(relFilter)}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
extra={
|
||||||
|
<Space wrap>
|
||||||
|
<Select
|
||||||
|
placeholder="筛选关系类型"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 150 }}
|
||||||
|
value={relFilter}
|
||||||
|
options={relTypes.map((t) => ({
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: getRelColor(t).base,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{getEdgeTypeLabel(t)}
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
({relationships.filter((r) => r.label === t).length})
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
value: t,
|
||||||
|
}))}
|
||||||
|
onChange={(v) => setRelFilter(v)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="选择中心客户"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
style={{ width: 200 }}
|
||||||
|
optionFilterProp="label"
|
||||||
|
value={selectedCenter || undefined}
|
||||||
|
options={customers.map((c) => ({
|
||||||
|
label: c.label,
|
||||||
|
value: c.id,
|
||||||
|
}))}
|
||||||
|
onChange={(v) => setSelectedCenter(v || null)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{customers.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description="暂无客户数据"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div ref={containerRef} style={{ position: 'relative' }}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onMouseMove={handleCanvasMouseMove}
|
||||||
|
onMouseLeave={handleCanvasMouseLeave}
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: canvasSize.height,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Legend overlay */}
|
||||||
|
{legendItems.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 12,
|
||||||
|
left: 12,
|
||||||
|
background: token.colorBgElevated,
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
boxShadow: token.boxShadowSecondary,
|
||||||
|
maxWidth: 220,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ fontSize: 11, color: token.colorTextSecondary, display: 'block', marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
关系类型图例
|
||||||
|
</Text>
|
||||||
|
<Flex wrap="wrap" gap={6}>
|
||||||
|
{legendItems.map((item) => (
|
||||||
|
<Tag
|
||||||
|
key={item.rawLabel}
|
||||||
|
color={item.color}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 11,
|
||||||
|
cursor: relFilter === item.rawLabel ? 'default' : 'pointer',
|
||||||
|
opacity: relFilter && relFilter !== item.rawLabel ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setRelFilter((prev) =>
|
||||||
|
prev === item.rawLabel ? undefined : item.rawLabel,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label} ({item.count})
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info overlay */}
|
||||||
|
{hoverState.nodeId && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
background: token.colorBgElevated,
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
boxShadow: token.boxShadowSecondary,
|
||||||
|
maxWidth: 280,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={4}>
|
||||||
|
<Text strong>
|
||||||
|
{visibleNodes.find((n) => n.id === hoverState.nodeId)?.label}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
<InfoCircleOutlined style={{ marginRight: 4 }} />
|
||||||
|
点击节点设为中心 / 再次点击取消
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Selected node detail panel */}
|
||||||
|
{selectedCenter && centerNode && (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<Badge color={token.colorPrimary} />
|
||||||
|
<Text strong>{centerNode.label}</Text>
|
||||||
|
<Text type="secondary">— 详细信息</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Tooltip title="取消选中">
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ cursor: 'pointer', fontSize: 12 }}
|
||||||
|
onClick={() => setSelectedCenter(null)}
|
||||||
|
>
|
||||||
|
<ReloadOutlined style={{ marginRight: 4 }} />
|
||||||
|
重置视图
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 12]}>
|
||||||
|
{Object.entries(centerNode.data).map(([key, value]) => {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
const fieldSchema = fields.find((f) => f.name === key);
|
||||||
|
const displayName = fieldSchema?.display_name || key;
|
||||||
|
return (
|
||||||
|
<Col xs={12} sm={8} md={6} key={key}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>{String(value)}</Text>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
<Divider style={{ margin: '12px 0 8px' }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
直接关系: {centerDegree} 条 —
|
||||||
|
显示 {visibleNodes.length} 个节点、{visibleEdges.length} 条边
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
345
apps/web/src/pages/PluginKanbanPage.tsx
Normal file
345
apps/web/src/pages/PluginKanbanPage.tsx
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Card, Spin, Typography, Tag, message } from 'antd';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCorners,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
|
||||||
|
import { listPluginData, patchPluginData } from '../api/pluginData';
|
||||||
|
import { getPluginSchema, type PluginPageSchema } from '../api/plugins';
|
||||||
|
|
||||||
|
// ── 内部看板渲染组件 ──
|
||||||
|
|
||||||
|
interface KanbanInnerProps {
|
||||||
|
pluginId: string;
|
||||||
|
entity: string;
|
||||||
|
laneField: string;
|
||||||
|
laneOrder: string[];
|
||||||
|
cardTitleField: string;
|
||||||
|
cardSubtitleField?: string;
|
||||||
|
cardFields?: string[];
|
||||||
|
enableDrag?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KanbanInner({
|
||||||
|
pluginId,
|
||||||
|
entity,
|
||||||
|
laneField,
|
||||||
|
laneOrder,
|
||||||
|
cardTitleField,
|
||||||
|
cardSubtitleField,
|
||||||
|
cardFields,
|
||||||
|
enableDrag,
|
||||||
|
}: KanbanInnerProps) {
|
||||||
|
const [lanes, setLanes] = useState<Record<string, any[]>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const allData: Record<string, any[]> = {};
|
||||||
|
const results = await Promise.all(
|
||||||
|
laneOrder.map(async (lane) => {
|
||||||
|
const res = await listPluginData(pluginId, entity, 1, 100, {
|
||||||
|
filter: { [laneField]: lane },
|
||||||
|
});
|
||||||
|
return { lane, data: res.data || [] };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const { lane, data } of results) {
|
||||||
|
allData[lane] = data;
|
||||||
|
}
|
||||||
|
setLanes(allData);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [pluginId, entity]);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
setActiveId(null);
|
||||||
|
if (!enableDrag) return;
|
||||||
|
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const recordId = active.id as string;
|
||||||
|
const newLane = String(over.data.current?.lane || over.id);
|
||||||
|
if (!newLane) return;
|
||||||
|
|
||||||
|
let currentLane = '';
|
||||||
|
for (const [lane, items] of Object.entries(lanes)) {
|
||||||
|
if (items.some((item) => item.id === recordId)) {
|
||||||
|
currentLane = lane;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentLane === newLane) return;
|
||||||
|
|
||||||
|
// 乐观更新
|
||||||
|
setLanes((prev) => {
|
||||||
|
const next: Record<string, any[]> = {};
|
||||||
|
for (const [lane, items] of Object.entries(prev)) {
|
||||||
|
if (lane === currentLane) {
|
||||||
|
next[lane] = items.filter((item) => item.id !== recordId);
|
||||||
|
} else if (lane === newLane) {
|
||||||
|
const moved = prev[currentLane]?.find((item) => item.id === recordId);
|
||||||
|
next[lane] = moved ? [...items, moved] : [...items];
|
||||||
|
} else {
|
||||||
|
next[lane] = items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await patchPluginData(pluginId, entity, recordId, {
|
||||||
|
data: { [laneField]: newLane },
|
||||||
|
version: 0,
|
||||||
|
});
|
||||||
|
message.success('移动成功');
|
||||||
|
} catch {
|
||||||
|
message.error('移动失败');
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragCancel = () => {
|
||||||
|
setActiveId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeCard = activeId
|
||||||
|
? Object.values(lanes)
|
||||||
|
.flat()
|
||||||
|
.find((item) => item.id === activeId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCorners}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: 16, overflowX: 'auto', padding: 16 }}>
|
||||||
|
{laneOrder.map((lane) => {
|
||||||
|
const items = lanes[lane] || [];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lane}
|
||||||
|
id={`lane-${lane}`}
|
||||||
|
style={{
|
||||||
|
minWidth: 280,
|
||||||
|
flex: 1,
|
||||||
|
background: 'var(--colorBgLayout, #f5f5f5)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong>{lane}</Typography.Text>
|
||||||
|
<Tag>{items.length}</Tag>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
cursor: enableDrag ? 'grab' : 'default',
|
||||||
|
opacity: activeId === item.id ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong>
|
||||||
|
{item.data?.[cardTitleField] ?? '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
{cardSubtitleField && item.data?.[cardSubtitleField] && (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{item.data[cardSubtitleField]}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cardFields && (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
{cardFields.map(
|
||||||
|
(f) =>
|
||||||
|
item.data?.[f] ? (
|
||||||
|
<Tag key={f}>{String(item.data[f])}</Tag>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<DragOverlay>
|
||||||
|
{activeCard ? (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
cursor: 'grabbing',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
width: 260,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong>
|
||||||
|
{activeCard.data?.[cardTitleField] ?? '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
{cardSubtitleField && activeCard.data?.[cardSubtitleField] && (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{activeCard.data[cardSubtitleField]}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 路由入口:自加载 schema ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由入口组件
|
||||||
|
* 路由: /plugins/:pluginId/kanban/:entityName
|
||||||
|
* 自动加载 schema 并提取 kanban 页面配置
|
||||||
|
*/
|
||||||
|
export default function PluginKanbanPageRoute() {
|
||||||
|
const { pluginId, entityName } = useParams<{
|
||||||
|
pluginId: string;
|
||||||
|
entityName: string;
|
||||||
|
}>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pageConfig, setPageConfig] = useState<{
|
||||||
|
entity: string;
|
||||||
|
lane_field: string;
|
||||||
|
lane_order?: string[];
|
||||||
|
card_title_field: string;
|
||||||
|
card_subtitle_field?: string;
|
||||||
|
card_fields?: string[];
|
||||||
|
enable_drag?: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId || !entityName) return;
|
||||||
|
|
||||||
|
async function loadSchema() {
|
||||||
|
try {
|
||||||
|
const schema = await getPluginSchema(pluginId!);
|
||||||
|
const pages: PluginPageSchema[] = schema.ui?.pages || [];
|
||||||
|
const kanbanPage = pages.find(
|
||||||
|
(p): p is PluginPageSchema & { type: 'kanban' } =>
|
||||||
|
p.type === 'kanban' && p.entity === entityName,
|
||||||
|
);
|
||||||
|
if (kanbanPage) {
|
||||||
|
setPageConfig(kanbanPage);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.warning('Schema 加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSchema();
|
||||||
|
}, [pluginId, entityName]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pageConfig) {
|
||||||
|
return <div style={{ padding: 24 }}>未找到看板页面配置</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KanbanInner
|
||||||
|
pluginId={pluginId!}
|
||||||
|
entity={pageConfig.entity}
|
||||||
|
laneField={pageConfig.lane_field}
|
||||||
|
laneOrder={pageConfig.lane_order || []}
|
||||||
|
cardTitleField={pageConfig.card_title_field}
|
||||||
|
cardSubtitleField={pageConfig.card_subtitle_field}
|
||||||
|
cardFields={pageConfig.card_fields}
|
||||||
|
enableDrag={pageConfig.enable_drag}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs/Detail 内嵌使用 ──
|
||||||
|
|
||||||
|
export interface PluginKanbanPageFromConfigProps {
|
||||||
|
pluginId: string;
|
||||||
|
page: {
|
||||||
|
entity: string;
|
||||||
|
lane_field: string;
|
||||||
|
lane_order?: string[];
|
||||||
|
card_title_field: string;
|
||||||
|
card_subtitle_field?: string;
|
||||||
|
card_fields?: string[];
|
||||||
|
enable_drag?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginKanbanPageFromConfig({
|
||||||
|
pluginId,
|
||||||
|
page,
|
||||||
|
}: PluginKanbanPageFromConfigProps) {
|
||||||
|
return (
|
||||||
|
<KanbanInner
|
||||||
|
pluginId={pluginId}
|
||||||
|
entity={page.entity}
|
||||||
|
laneField={page.lane_field}
|
||||||
|
laneOrder={page.lane_order || []}
|
||||||
|
cardTitleField={page.card_title_field}
|
||||||
|
cardSubtitleField={page.card_subtitle_field}
|
||||||
|
cardFields={page.card_fields}
|
||||||
|
enableDrag={page.enable_drag}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/web/src/pages/PluginTabsPage.tsx
Normal file
91
apps/web/src/pages/PluginTabsPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Tabs, Spin, message } from 'antd';
|
||||||
|
import { getPluginSchema, type PluginPageSchema, type PluginSchemaResponse } from '../api/plugins';
|
||||||
|
import PluginCRUDPage from './PluginCRUDPage';
|
||||||
|
import { PluginTreePage } from './PluginTreePage';
|
||||||
|
import { PluginKanbanPageFromConfig } from './PluginKanbanPage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件 Tabs 页面 — 通过路由参数自加载 schema
|
||||||
|
* 路由: /plugins/:pluginId/tabs/:pageLabel
|
||||||
|
*/
|
||||||
|
export function PluginTabsPage() {
|
||||||
|
const { pluginId, pageLabel } = useParams<{ pluginId: string; pageLabel: string }>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [tabs, setTabs] = useState<PluginPageSchema[]>([]);
|
||||||
|
const [activeKey, setActiveKey] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId || !pageLabel) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
async function loadSchema() {
|
||||||
|
try {
|
||||||
|
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
||||||
|
const pages = schema.ui?.pages || [];
|
||||||
|
const tabsPage = pages.find(
|
||||||
|
(p): p is PluginPageSchema & { type: 'tabs' } =>
|
||||||
|
p.type === 'tabs' && p.label === pageLabel,
|
||||||
|
);
|
||||||
|
if (tabsPage && 'tabs' in tabsPage) {
|
||||||
|
setTabs(tabsPage.tabs);
|
||||||
|
const firstLabel = tabsPage.tabs.find((t) => 'label' in t)?.label || '';
|
||||||
|
setActiveKey(firstLabel);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.warning('Schema 加载失败,部分功能不可用');
|
||||||
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSchema();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, pageLabel]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTabContent = (tab: PluginPageSchema) => {
|
||||||
|
if (tab.type === 'crud') {
|
||||||
|
return (
|
||||||
|
<PluginCRUDPage
|
||||||
|
pluginIdOverride={pluginId}
|
||||||
|
entityOverride={tab.entity}
|
||||||
|
enableViews={tab.enable_views}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tab.type === 'tree') {
|
||||||
|
return (
|
||||||
|
<PluginTreePage
|
||||||
|
pluginIdOverride={pluginId}
|
||||||
|
entityOverride={tab.entity}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tab.type === 'kanban') {
|
||||||
|
return (
|
||||||
|
<PluginKanbanPageFromConfig
|
||||||
|
pluginId={pluginId!}
|
||||||
|
page={tab}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div>不支持的页面类型: {tab.type}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = tabs.map((tab) => ({
|
||||||
|
key: 'label' in tab ? tab.label : '',
|
||||||
|
label: 'label' in tab ? tab.label : '',
|
||||||
|
children: renderTabContent(tab),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Tabs activeKey={activeKey} onChange={setActiveKey} items={items} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
apps/web/src/pages/PluginTreePage.tsx
Normal file
187
apps/web/src/pages/PluginTreePage.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Tree, Descriptions, Card, Empty, Spin, message } from 'antd';
|
||||||
|
import type { TreeProps } from 'antd';
|
||||||
|
import { listPluginData, type PluginDataRecord } from '../api/pluginData';
|
||||||
|
import {
|
||||||
|
getPluginSchema,
|
||||||
|
type PluginFieldSchema,
|
||||||
|
type PluginPageSchema,
|
||||||
|
type PluginSchemaResponse,
|
||||||
|
} from '../api/plugins';
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
children: TreeNode[];
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginTreePageProps {
|
||||||
|
pluginIdOverride?: string;
|
||||||
|
entityOverride?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件树形页面 — 通过路由参数自加载 schema
|
||||||
|
* 路由: /plugins/:pluginId/tree/:entityName
|
||||||
|
* 也支持通过 props 覆盖(用于 tabs 内嵌)
|
||||||
|
*/
|
||||||
|
export function PluginTreePage({ pluginIdOverride, entityOverride }: PluginTreePageProps = {}) {
|
||||||
|
const routeParams = useParams<{ pluginId: string; entityName: string }>();
|
||||||
|
const pluginId = pluginIdOverride || routeParams.pluginId || '';
|
||||||
|
const entityName = entityOverride || routeParams.entityName || '';
|
||||||
|
const [records, setRecords] = useState<PluginDataRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedNode, setSelectedNode] = useState<TreeNode | null>(null);
|
||||||
|
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
|
||||||
|
const [treeConfig, setTreeConfig] = useState<{
|
||||||
|
idField: string;
|
||||||
|
parentField: string;
|
||||||
|
labelField: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// 加载 schema
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId || !entityName) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
async function loadSchema() {
|
||||||
|
try {
|
||||||
|
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
|
||||||
|
const entity = schema.entities?.find((e) => e.name === entityName);
|
||||||
|
if (entity) {
|
||||||
|
setFields(entity.fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = schema.ui?.pages || [];
|
||||||
|
const treePage = pages.find(
|
||||||
|
(p): p is PluginPageSchema & { type: 'tree'; entity: string; id_field: string; parent_field: string; label_field: string } =>
|
||||||
|
p.type === 'tree' && p.entity === entityName,
|
||||||
|
);
|
||||||
|
if (treePage) {
|
||||||
|
setTreeConfig({
|
||||||
|
idField: treePage.id_field,
|
||||||
|
parentField: treePage.parent_field,
|
||||||
|
labelField: treePage.label_field,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.warning('Schema 加载失败,部分功能不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSchema();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, entityName]);
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId || !entityName) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let allRecords: PluginDataRecord[] = [];
|
||||||
|
let page = 1;
|
||||||
|
let hasMore = true;
|
||||||
|
while (hasMore) {
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
const result = await listPluginData(pluginId!, entityName!, page, 100);
|
||||||
|
allRecords = [...allRecords, ...result.data];
|
||||||
|
hasMore = result.data.length === 100 && allRecords.length < result.total;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
setRecords(allRecords);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.warning('数据加载失败');
|
||||||
|
}
|
||||||
|
if (!abortController.signal.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
loadAll();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, entityName]);
|
||||||
|
|
||||||
|
const idField = treeConfig?.idField || 'id';
|
||||||
|
const parentField = treeConfig?.parentField || 'parent_id';
|
||||||
|
const labelField = treeConfig?.labelField || fields[1]?.name || 'name';
|
||||||
|
|
||||||
|
// 构建树结构
|
||||||
|
const treeData = useMemo(() => {
|
||||||
|
const nodeMap = new Map<string, TreeNode>();
|
||||||
|
const rootNodes: TreeNode[] = [];
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const data = record.data;
|
||||||
|
const key = String(data[idField] || record.id);
|
||||||
|
const title = String(data[labelField] || '未命名');
|
||||||
|
nodeMap.set(key, {
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
children: [],
|
||||||
|
raw: { ...data, _id: record.id, _version: record.version },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const data = record.data;
|
||||||
|
const key = String(data[idField] || record.id);
|
||||||
|
const parentKey = data[parentField] ? String(data[parentField]) : null;
|
||||||
|
const node = nodeMap.get(key)!;
|
||||||
|
|
||||||
|
if (parentKey && nodeMap.has(parentKey)) {
|
||||||
|
nodeMap.get(parentKey)!.children.push(node);
|
||||||
|
} else {
|
||||||
|
rootNodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootNodes;
|
||||||
|
}, [records, idField, parentField, labelField]);
|
||||||
|
|
||||||
|
const onSelect: TreeProps['onSelect'] = (selectedKeys, info) => {
|
||||||
|
if (selectedKeys.length > 0) {
|
||||||
|
setSelectedNode(info.node as unknown as TreeNode);
|
||||||
|
} else {
|
||||||
|
setSelectedNode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, display: 'flex', gap: 16 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Card title={(entityName || '') + ' 层级'} size="small">
|
||||||
|
{treeData.length === 0 ? (
|
||||||
|
<Empty description="暂无数据" />
|
||||||
|
) : (
|
||||||
|
<Tree showLine defaultExpandAll treeData={treeData} onSelect={onSelect} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Card title="节点详情" size="small">
|
||||||
|
{selectedNode ? (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Descriptions.Item key={field.name} label={field.display_name || field.name}>
|
||||||
|
{String(selectedNode.raw[field.name] ?? '-')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
))}
|
||||||
|
</Descriptions>
|
||||||
|
) : (
|
||||||
|
<Empty description="点击左侧节点查看详情" />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
298
apps/web/src/pages/dashboard/DashboardWidgets.tsx
Normal file
298
apps/web/src/pages/dashboard/DashboardWidgets.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Col, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography } from 'antd';
|
||||||
|
import {
|
||||||
|
InfoCircleOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Column, Pie, Funnel, Line } from '@ant-design/charts';
|
||||||
|
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes';
|
||||||
|
import { TAG_COLORS, WIDGET_ICON_MAP } from './dashboardConstants';
|
||||||
|
|
||||||
|
// ── 计数动画 Hook ──
|
||||||
|
|
||||||
|
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();
|
||||||
|
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(end * eased));
|
||||||
|
if (progress < 1) requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}, [end, duration]);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 共享工具 ──
|
||||||
|
|
||||||
|
function prepareChartData(data: WidgetData['data'], dimensionOrder?: string[]) {
|
||||||
|
return dimensionOrder
|
||||||
|
? dimensionOrder
|
||||||
|
.map((key) => data.find((d) => d.key === key))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((d) => ({ key: d!.key, count: d!.count }))
|
||||||
|
: data.map((d) => ({ key: d.key, count: d.count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_COLOR_MAP: Record<string, string> = {
|
||||||
|
blue: '#3B82F6', green: '#10B981', orange: '#F59E0B', red: '#EF4444',
|
||||||
|
purple: '#8B5CF6', cyan: '#06B6D4', magenta: '#EC4899', gold: '#EAB308',
|
||||||
|
lime: '#84CC16', geekblue: '#6366F1', volcano: '#F97316',
|
||||||
|
};
|
||||||
|
|
||||||
|
function tagStrokeColor(color: string): string {
|
||||||
|
return TAG_COLOR_MAP[color] || '#3B82F6';
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetCardShell({
|
||||||
|
title,
|
||||||
|
widgetType,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
widgetType: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP[widgetType]} {title}</span>}
|
||||||
|
className="erp-fade-in"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartEmpty() {
|
||||||
|
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 基础子组件 ──
|
||||||
|
|
||||||
|
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 function StatCard({ stat, loading, delay }: { stat: EntityStat; loading: boolean; delay: string }) {
|
||||||
|
return (
|
||||||
|
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)} key={stat.name}>
|
||||||
|
<div
|
||||||
|
className={`erp-stat-card ${delay}`}
|
||||||
|
style={{ '--card-gradient': stat.gradient, '--card-icon-bg': stat.iconBg } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div className="erp-stat-card-bar" />
|
||||||
|
<div className="erp-stat-card-body">
|
||||||
|
<div className="erp-stat-card-info">
|
||||||
|
<div className="erp-stat-card-title">{stat.displayName}</div>
|
||||||
|
<div className="erp-stat-card-value">
|
||||||
|
<StatValue value={stat.count} loading={loading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="erp-stat-card-icon">{stat.icon}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 骨架屏卡片 */
|
||||||
|
export function SkeletonStatCard({ delay }: { delay: string }) {
|
||||||
|
return (
|
||||||
|
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)}>
|
||||||
|
<div className={`erp-stat-card ${delay}`}>
|
||||||
|
<div className="erp-stat-card-bar" style={{ opacity: 0.3 }} />
|
||||||
|
<div className="erp-stat-card-body">
|
||||||
|
<div className="erp-stat-card-info">
|
||||||
|
<div style={{ width: 80, height: 14, marginBottom: 12 }}>
|
||||||
|
<Skeleton.Input active size="small" style={{ width: 80, height: 14 }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 60, height: 32 }}>
|
||||||
|
<Skeleton.Input active style={{ width: 60, height: 32 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 48, height: 48 }}>
|
||||||
|
<Skeleton.Avatar active shape="square" size={48} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字段分布卡片 */
|
||||||
|
export function BreakdownCard({
|
||||||
|
breakdown, totalCount, index,
|
||||||
|
}: { breakdown: FieldBreakdown; totalCount: number; index: number }) {
|
||||||
|
const maxCount = Math.max(...breakdown.items.map((i) => i.count), 1);
|
||||||
|
return (
|
||||||
|
<Col xs={24} sm={12} lg={8} key={breakdown.fieldName}>
|
||||||
|
<div className="erp-content-card erp-fade-in" style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}>
|
||||||
|
<div className="erp-section-header" style={{ marginBottom: 16 }}>
|
||||||
|
<InfoCircleOutlined className="erp-section-icon" style={{ fontSize: 14 }} />
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--erp-text-primary)' }}>
|
||||||
|
{breakdown.displayName}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--erp-text-tertiary)' }}>
|
||||||
|
{breakdown.items.length} 项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{breakdown.items.map((item, idx) => {
|
||||||
|
const percent = totalCount > 0 ? Math.round((item.count / totalCount) * 100) : 0;
|
||||||
|
const barPercent = maxCount > 0 ? Math.round((item.count / maxCount) * 100) : 0;
|
||||||
|
const color = TAG_COLORS[idx % TAG_COLORS.length];
|
||||||
|
return (
|
||||||
|
<div key={`${breakdown.fieldName}-${item.key}-${idx}`}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<Tooltip title={`${item.key}: ${item.count} (${percent}%)`}>
|
||||||
|
<Tag color={color} style={{ margin: 0, maxWidth: '60%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{item.key || '(空)'}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--erp-text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{item.count}
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 400, color: 'var(--erp-text-tertiary)', marginLeft: 4 }}>{percent}%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={barPercent}
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor={tagStrokeColor(color)}
|
||||||
|
trailColor="var(--erp-border-light)"
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{breakdown.items.length === 0 && (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" style={{ padding: '12px 0' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 骨架屏分布卡片 */
|
||||||
|
export function SkeletonBreakdownCard({ index }: { index: number }) {
|
||||||
|
return (
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<div className="erp-content-card erp-fade-in" style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Widget 图表子组件 ──
|
||||||
|
|
||||||
|
/** 统计卡片 widget */
|
||||||
|
function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
||||||
|
const { widget, count } = widgetData;
|
||||||
|
const animatedValue = useCountUp(count ?? 0);
|
||||||
|
const color = widget.color || '#4F46E5';
|
||||||
|
return (
|
||||||
|
<Card size="small" className="erp-fade-in" style={{ height: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 40, height: 40, borderRadius: 10, background: `${color}18`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', color, fontSize: 20,
|
||||||
|
}}>
|
||||||
|
{WIDGET_ICON_MAP[widget.type] || <DashboardOutlined />}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{widget.title}</Typography.Text>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{animatedValue.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 柱状图 widget */
|
||||||
|
function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
||||||
|
const { widget, data } = widgetData;
|
||||||
|
const chartData = prepareChartData(data, widget.dimension_order);
|
||||||
|
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
|
||||||
|
return (
|
||||||
|
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<Column data={chartData} xField="key" yField="count" colorField="key"
|
||||||
|
style={{ maxWidth: 40, maxWidthRatio: 0.6 }}
|
||||||
|
axis={{ x: { label: { style: axisLabelStyle } }, y: { label: { style: axisLabelStyle } } }}
|
||||||
|
/>
|
||||||
|
) : <ChartEmpty />}
|
||||||
|
</WidgetCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 饼图 widget */
|
||||||
|
function PieWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
||||||
|
const { widget, data } = widgetData;
|
||||||
|
const chartData = data.map((d) => ({ key: d.key, count: d.count }));
|
||||||
|
return (
|
||||||
|
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<Pie data={chartData} angleField="count" colorField="key" radius={0.8} innerRadius={0.5}
|
||||||
|
label={{ text: 'key', position: 'outside' as const }}
|
||||||
|
legend={{ color: { position: 'bottom' as const } }}
|
||||||
|
/>
|
||||||
|
) : <ChartEmpty />}
|
||||||
|
</WidgetCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 漏斗图 widget */
|
||||||
|
function FunnelWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
||||||
|
const { widget, data } = widgetData;
|
||||||
|
const chartData = prepareChartData(data, widget.dimension_order);
|
||||||
|
return (
|
||||||
|
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<Funnel data={chartData} xField="key" yField="count" legend={{ position: 'bottom' as const }} />
|
||||||
|
) : <ChartEmpty />}
|
||||||
|
</WidgetCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 折线图 widget */
|
||||||
|
function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
||||||
|
const { widget, data } = widgetData;
|
||||||
|
const chartData = prepareChartData(data, widget.dimension_order);
|
||||||
|
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
|
||||||
|
return (
|
||||||
|
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<Line data={chartData} xField="key" yField="count" smooth
|
||||||
|
axis={{ x: { label: { style: axisLabelStyle } }, y: { label: { style: axisLabelStyle } } }}
|
||||||
|
/>
|
||||||
|
) : <ChartEmpty />}
|
||||||
|
</WidgetCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染单个 widget */
|
||||||
|
export function WidgetRenderer({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
||||||
|
switch (widgetData.widget.type) {
|
||||||
|
case 'stat_card': return <StatWidgetCard widgetData={widgetData} />;
|
||||||
|
case 'bar_chart': return <BarWidgetCard widgetData={widgetData} isDark={isDark} />;
|
||||||
|
case 'pie_chart': return <PieWidgetCard widgetData={widgetData} />;
|
||||||
|
case 'funnel_chart': return <FunnelWidgetCard widgetData={widgetData} />;
|
||||||
|
case 'line_chart': return <LineWidgetCard widgetData={widgetData} isDark={isDark} />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
apps/web/src/pages/dashboard/dashboardConstants.tsx
Normal file
85
apps/web/src/pages/dashboard/dashboardConstants.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
PhoneOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
RiseOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
PieChartOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
|
FunnelPlotOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
// ── 色板配置 ──
|
||||||
|
|
||||||
|
export const ENTITY_PALETTE: Record<string, { gradient: string; iconBg: string; tagColor: string }> = {
|
||||||
|
customer: {
|
||||||
|
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
|
||||||
|
iconBg: 'rgba(79, 70, 229, 0.12)',
|
||||||
|
tagColor: 'purple',
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
gradient: 'linear-gradient(135deg, #059669, #10B981)',
|
||||||
|
iconBg: 'rgba(5, 150, 105, 0.12)',
|
||||||
|
tagColor: 'green',
|
||||||
|
},
|
||||||
|
communication: {
|
||||||
|
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)',
|
||||||
|
iconBg: 'rgba(217, 119, 6, 0.12)',
|
||||||
|
tagColor: 'orange',
|
||||||
|
},
|
||||||
|
customer_tag: {
|
||||||
|
gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)',
|
||||||
|
iconBg: 'rgba(124, 58, 237, 0.12)',
|
||||||
|
tagColor: 'volcano',
|
||||||
|
},
|
||||||
|
customer_relationship: {
|
||||||
|
gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)',
|
||||||
|
iconBg: 'rgba(225, 29, 72, 0.12)',
|
||||||
|
tagColor: 'red',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PALETTE = {
|
||||||
|
gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)',
|
||||||
|
iconBg: 'rgba(37, 99, 235, 0.12)',
|
||||||
|
tagColor: 'blue',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TAG_COLORS = [
|
||||||
|
'blue', 'green', 'orange', 'red', 'purple', 'cyan',
|
||||||
|
'magenta', 'gold', 'lime', 'geekblue', 'volcano',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 图标映射 ──
|
||||||
|
|
||||||
|
export const ENTITY_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
customer: <TeamOutlined />,
|
||||||
|
contact: <TeamOutlined />,
|
||||||
|
communication: <PhoneOutlined />,
|
||||||
|
customer_tag: <TagsOutlined />,
|
||||||
|
customer_relationship: <RiseOutlined />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
|
||||||
|
stat_card: <DashboardOutlined />,
|
||||||
|
bar_chart: <BarChartOutlined />,
|
||||||
|
pie_chart: <PieChartOutlined />,
|
||||||
|
funnel_chart: <FunnelPlotOutlined />,
|
||||||
|
line_chart: <LineChartOutlined />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 延迟类名工具 ──
|
||||||
|
|
||||||
|
const DELAY_CLASSES = [
|
||||||
|
'erp-fade-in erp-fade-in-delay-1',
|
||||||
|
'erp-fade-in erp-fade-in-delay-2',
|
||||||
|
'erp-fade-in erp-fade-in-delay-3',
|
||||||
|
'erp-fade-in erp-fade-in-delay-4',
|
||||||
|
'erp-fade-in erp-fade-in-delay-4',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getDelayClass(index: number): string {
|
||||||
|
return DELAY_CLASSES[index % DELAY_CLASSES.length];
|
||||||
|
}
|
||||||
26
apps/web/src/pages/dashboard/dashboardTypes.ts
Normal file
26
apps/web/src/pages/dashboard/dashboardTypes.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import type { AggregateItem } from '../../api/pluginData';
|
||||||
|
import type { DashboardWidget } from '../../api/plugins';
|
||||||
|
|
||||||
|
// ── 类型定义 ──
|
||||||
|
|
||||||
|
export interface EntityStat {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
count: number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
gradient: string;
|
||||||
|
iconBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldBreakdown {
|
||||||
|
fieldName: string;
|
||||||
|
displayName: string;
|
||||||
|
items: AggregateItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetData {
|
||||||
|
widget: DashboardWidget;
|
||||||
|
data: AggregateItem[];
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
41
apps/web/src/pages/graph/graphLayout.ts
Normal file
41
apps/web/src/pages/graph/graphLayout.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 关系图谱 — 布局算法
|
||||||
|
*
|
||||||
|
* 纯函数模块,不依赖 React。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GraphNode, NodePosition } from './graphTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算环形布局位置。
|
||||||
|
*
|
||||||
|
* 节点均匀分布在以 (centerX, centerY) 为圆心、radius 为半径的圆周上。
|
||||||
|
* 单个节点退化为圆心;两个及以上节点按角度排列,起始角在正上方 (-PI/2)。
|
||||||
|
*/
|
||||||
|
export function computeCircularLayout(
|
||||||
|
nodes: GraphNode[],
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
radius: number,
|
||||||
|
): Map<string, NodePosition> {
|
||||||
|
const positions = new Map<string, NodePosition>();
|
||||||
|
const count = nodes.length;
|
||||||
|
if (count === 0) return positions;
|
||||||
|
|
||||||
|
if (count === 1) {
|
||||||
|
positions.set(nodes[0].id, { x: centerX, y: centerY, vx: 0, vy: 0 });
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.forEach((node, i) => {
|
||||||
|
const angle = (2 * Math.PI * i) / count - Math.PI / 2;
|
||||||
|
positions.set(node.id, {
|
||||||
|
x: centerX + radius * Math.cos(angle),
|
||||||
|
y: centerY + radius * Math.sin(angle),
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
293
apps/web/src/pages/graph/graphRenderer.ts
Normal file
293
apps/web/src/pages/graph/graphRenderer.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* 关系图谱 — Canvas 绘制逻辑
|
||||||
|
*
|
||||||
|
* 纯函数模块,不依赖 React。所有绘制函数接收 CanvasRenderingContext2D,
|
||||||
|
* 不持有状态,可安全在 requestAnimationFrame 循环中调用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GraphEdge } from './graphTypes';
|
||||||
|
|
||||||
|
// ── 常量 ──
|
||||||
|
|
||||||
|
/** 关系类型对应的色板 (base / light / glow) */
|
||||||
|
export const RELATIONSHIP_COLORS: Record<string, { base: string; light: string; glow: string }> = {
|
||||||
|
parent_child: { base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' },
|
||||||
|
sibling: { base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
|
||||||
|
partner: { base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
|
||||||
|
supplier: { base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
|
||||||
|
competitor: { base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 未匹配到已知关系类型时的默认色 */
|
||||||
|
export const DEFAULT_REL_COLOR = { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' };
|
||||||
|
|
||||||
|
/** 关系类型 → 中文标签 */
|
||||||
|
export const REL_LABEL_MAP: Record<string, string> = {
|
||||||
|
parent_child: '母子',
|
||||||
|
sibling: '兄弟',
|
||||||
|
partner: '伙伴',
|
||||||
|
supplier: '供应商',
|
||||||
|
competitor: '竞争',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 普通节点基础半径 */
|
||||||
|
export const NODE_BASE_RADIUS = 18;
|
||||||
|
/** 中心节点半径 */
|
||||||
|
export const NODE_CENTER_RADIUS = 26;
|
||||||
|
/** 悬停放大系数 */
|
||||||
|
export const NODE_HOVER_SCALE = 1.3;
|
||||||
|
/** 节点标签最大字符数 */
|
||||||
|
export const LABEL_MAX_LENGTH = 8;
|
||||||
|
|
||||||
|
// ── Helper ──
|
||||||
|
|
||||||
|
/** 根据 label 获取关系色板,未匹配时返回默认。 */
|
||||||
|
export function getRelColor(label: string) {
|
||||||
|
return RELATIONSHIP_COLORS[label] || DEFAULT_REL_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将关系类型 key 转为中文标签。 */
|
||||||
|
export function getEdgeTypeLabel(label: string): string {
|
||||||
|
return REL_LABEL_MAP[label] || label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 截断过长标签。 */
|
||||||
|
export function truncateLabel(label: string): string {
|
||||||
|
return label.length > LABEL_MAX_LENGTH
|
||||||
|
? label.slice(0, LABEL_MAX_LENGTH) + '...'
|
||||||
|
: label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算节点的度 (degree),即与之相连的边数。 */
|
||||||
|
export function getNodeDegree(nodeId: string, edges: GraphEdge[]): number {
|
||||||
|
return edges.filter((e) => e.source === nodeId || e.target === nodeId).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 degree 映射为节点半径,连接越多节点越大。 */
|
||||||
|
export function degreeToRadius(degree: number, isCenter: boolean): number {
|
||||||
|
const base = isCenter ? NODE_CENTER_RADIUS : NODE_BASE_RADIUS;
|
||||||
|
const bonus = Math.min(degree * 1.2, 10);
|
||||||
|
return base + bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 绘制函数 ──
|
||||||
|
|
||||||
|
/** 绘制二次贝塞尔曲线边并带箭头,返回标签放置坐标。 */
|
||||||
|
export function drawCurvedEdge(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
fromX: number,
|
||||||
|
fromY: number,
|
||||||
|
toX: number,
|
||||||
|
toY: number,
|
||||||
|
color: string,
|
||||||
|
lineWidth: number,
|
||||||
|
highlighted: boolean,
|
||||||
|
alpha: number,
|
||||||
|
): { labelX: number; labelY: number } | undefined {
|
||||||
|
const dx = toX - fromX;
|
||||||
|
const dy = toY - fromY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < 1) return undefined;
|
||||||
|
|
||||||
|
// 控制点:边中点的垂直方向偏移
|
||||||
|
const curvature = Math.min(dist * 0.15, 30);
|
||||||
|
const midX = (fromX + toX) / 2;
|
||||||
|
const midY = (fromY + toY) / 2;
|
||||||
|
const nx = -dy / dist;
|
||||||
|
const ny = dx / dist;
|
||||||
|
const cpX = midX + nx * curvature;
|
||||||
|
const cpY = midY + ny * curvature;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = highlighted ? lineWidth + 1 : lineWidth;
|
||||||
|
|
||||||
|
if (highlighted) {
|
||||||
|
ctx.shadowColor = color;
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(fromX, fromY);
|
||||||
|
ctx.quadraticCurveTo(cpX, cpY, toX, toY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 箭头
|
||||||
|
const arrowT = 0.95;
|
||||||
|
const tangentX = 2 * (1 - arrowT) * (cpX - fromX) + 2 * arrowT * (toX - cpX);
|
||||||
|
const tangentY = 2 * (1 - arrowT) * (cpY - fromY) + 2 * arrowT * (toY - cpY);
|
||||||
|
const tangentLen = Math.sqrt(tangentX * tangentX + tangentY * tangentY);
|
||||||
|
if (tangentLen < 1) {
|
||||||
|
ctx.restore();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrowSize = highlighted ? 10 : 7;
|
||||||
|
const ax = tangentX / tangentLen;
|
||||||
|
const ay = tangentY / tangentLen;
|
||||||
|
|
||||||
|
// 略微提前终点以避免覆盖节点圆
|
||||||
|
const endX = toX - ax * 4;
|
||||||
|
const endY = toY - ay * 4;
|
||||||
|
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(endX, endY);
|
||||||
|
ctx.lineTo(
|
||||||
|
endX - arrowSize * ax + arrowSize * 0.4 * ay,
|
||||||
|
endY - arrowSize * ay - arrowSize * 0.4 * ax,
|
||||||
|
);
|
||||||
|
ctx.lineTo(
|
||||||
|
endX - arrowSize * ax - arrowSize * 0.4 * ay,
|
||||||
|
endY - arrowSize * ay + arrowSize * 0.4 * ax,
|
||||||
|
);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
return { labelX: cpX, labelY: cpY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绘制带渐变填充和阴影的节点圆。 */
|
||||||
|
export function drawNode(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
radius: number,
|
||||||
|
color: string,
|
||||||
|
lightColor: string,
|
||||||
|
glowColor: string,
|
||||||
|
isCenter: boolean,
|
||||||
|
isHovered: boolean,
|
||||||
|
alpha: number,
|
||||||
|
): void {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
|
||||||
|
const r = isHovered ? radius * NODE_HOVER_SCALE : radius;
|
||||||
|
|
||||||
|
// 外发光 / 阴影
|
||||||
|
if (isCenter || isHovered) {
|
||||||
|
ctx.shadowColor = glowColor;
|
||||||
|
ctx.shadowBlur = isCenter ? 20 : 14;
|
||||||
|
} else {
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.12)';
|
||||||
|
ctx.shadowBlur = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渐变填充
|
||||||
|
const gradient = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, 0, x, y, r);
|
||||||
|
if (isCenter) {
|
||||||
|
gradient.addColorStop(0, lightColor);
|
||||||
|
gradient.addColorStop(1, color);
|
||||||
|
} else {
|
||||||
|
gradient.addColorStop(0, 'rgba(255,255,255,0.9)');
|
||||||
|
gradient.addColorStop(1, 'rgba(255,255,255,0.4)');
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 边框
|
||||||
|
ctx.shadowColor = 'transparent';
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.strokeStyle = isCenter ? color : lightColor;
|
||||||
|
ctx.lineWidth = isCenter ? 3 : 1.5;
|
||||||
|
if (isHovered) {
|
||||||
|
ctx.lineWidth += 1;
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 中心节点外圈虚线环
|
||||||
|
if (isCenter) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r + 4, 0, 2 * Math.PI);
|
||||||
|
ctx.strokeStyle = glowColor;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绘制边标签(带背景胶囊)。 */
|
||||||
|
export function drawEdgeLabel(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
label: string,
|
||||||
|
color: string,
|
||||||
|
alpha: number,
|
||||||
|
): void {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha * 0.85;
|
||||||
|
|
||||||
|
const display = getEdgeTypeLabel(label);
|
||||||
|
ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||||
|
const metrics = ctx.measureText(display);
|
||||||
|
const textWidth = metrics.width;
|
||||||
|
const padding = 6;
|
||||||
|
const pillHeight = 18;
|
||||||
|
const pillRadius = 9;
|
||||||
|
|
||||||
|
// 背景胶囊
|
||||||
|
const pillX = x - textWidth / 2 - padding;
|
||||||
|
const pillY = y - pillHeight / 2;
|
||||||
|
|
||||||
|
ctx.fillStyle = color + '18';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(pillX, pillY, textWidth + padding * 2, pillHeight, pillRadius);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = color + '40';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(pillX, pillY, textWidth + padding * 2, pillHeight, pillRadius);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 文本
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(display, x, y);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在节点下方绘制节点标签。 */
|
||||||
|
export function drawNodeLabel(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
radius: number,
|
||||||
|
label: string,
|
||||||
|
textColor: string,
|
||||||
|
isCenter: boolean,
|
||||||
|
isHovered: boolean,
|
||||||
|
): void {
|
||||||
|
ctx.save();
|
||||||
|
const display = truncateLabel(label);
|
||||||
|
const r = isHovered ? radius * NODE_HOVER_SCALE : radius;
|
||||||
|
const fontSize = isCenter ? 13 : isHovered ? 12 : 11;
|
||||||
|
const fontWeight = isCenter ? '600' : isHovered ? '500' : '400';
|
||||||
|
|
||||||
|
ctx.font = `${fontWeight} ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
|
||||||
|
if (isCenter || isHovered) {
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
} else {
|
||||||
|
ctx.globalAlpha = 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillText(display, x, y + r + 8);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
39
apps/web/src/pages/graph/graphTypes.ts
Normal file
39
apps/web/src/pages/graph/graphTypes.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 关系图谱 — 类型定义
|
||||||
|
*
|
||||||
|
* 仅导出接口类型,不含运行时代码。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphConfig {
|
||||||
|
entity: string;
|
||||||
|
relationshipEntity: string;
|
||||||
|
sourceField: string;
|
||||||
|
targetField: string;
|
||||||
|
edgeLabelField: string;
|
||||||
|
nodeLabelField: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodePosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoverState {
|
||||||
|
nodeId: string | null;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
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 {
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
178
apps/web/src/stores/plugin.ts
Normal file
178
apps/web/src/stores/plugin.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { PluginInfo, PluginStatus, PluginPageSchema, PluginSchemaResponse } from '../api/plugins';
|
||||||
|
import { listPlugins, getPluginSchema } from '../api/plugins';
|
||||||
|
|
||||||
|
export interface PluginMenuItem {
|
||||||
|
key: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
pluginId: string;
|
||||||
|
entity?: string;
|
||||||
|
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard' | 'kanban';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginMenuGroup {
|
||||||
|
pluginId: string;
|
||||||
|
pluginName: string;
|
||||||
|
items: PluginMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginStore {
|
||||||
|
plugins: PluginInfo[];
|
||||||
|
loading: boolean;
|
||||||
|
pluginMenuItems: PluginMenuItem[];
|
||||||
|
pluginMenuGroups: PluginMenuGroup[];
|
||||||
|
schemaCache: Record<string, PluginSchemaResponse>;
|
||||||
|
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
|
||||||
|
refreshMenuItems: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||||
|
plugins: [],
|
||||||
|
loading: false,
|
||||||
|
pluginMenuItems: [],
|
||||||
|
pluginMenuGroups: [],
|
||||||
|
schemaCache: {},
|
||||||
|
|
||||||
|
fetchPlugins: async (page = 1, status?: PluginStatus) => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const result = await listPlugins(page, 100, status);
|
||||||
|
set({ plugins: result.data });
|
||||||
|
|
||||||
|
// 先基于 entities 生成回退菜单,确保侧边栏快速渲染
|
||||||
|
get().refreshMenuItems();
|
||||||
|
|
||||||
|
// 并行加载所有运行中插件的 schema,完成后更新菜单
|
||||||
|
const activePlugins = result.data.filter(
|
||||||
|
(p) => p.status === 'running' || p.status === 'enabled'
|
||||||
|
);
|
||||||
|
if (activePlugins.length === 0) return;
|
||||||
|
|
||||||
|
const entries = await Promise.allSettled(
|
||||||
|
activePlugins.map(async (plugin) => {
|
||||||
|
try {
|
||||||
|
const schema = await getPluginSchema(plugin.id) as PluginSchemaResponse;
|
||||||
|
return [plugin.id, schema] as const;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const schemas: Record<string, PluginSchemaResponse> = { ...get().schemaCache };
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.status === 'fulfilled' && entry.value) {
|
||||||
|
schemas[entry.value[0]] = entry.value[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ schemaCache: schemas });
|
||||||
|
get().refreshMenuItems();
|
||||||
|
} finally {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshMenuItems: () => {
|
||||||
|
const { plugins, schemaCache } = get();
|
||||||
|
const items: PluginMenuItem[] = [];
|
||||||
|
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
if (plugin.status !== 'running' && plugin.status !== 'enabled') continue;
|
||||||
|
|
||||||
|
const schema = schemaCache[plugin.id];
|
||||||
|
const pages = (schema as { ui?: { pages: PluginPageSchema[] } })?.ui?.pages;
|
||||||
|
|
||||||
|
if (pages && pages.length > 0) {
|
||||||
|
for (const page of pages) {
|
||||||
|
if (page.type === 'tabs') {
|
||||||
|
items.push({
|
||||||
|
key: `/plugins/${plugin.id}/tabs/${encodeURIComponent(page.label)}`,
|
||||||
|
icon: page.icon || 'AppstoreOutlined',
|
||||||
|
label: page.label,
|
||||||
|
pluginId: plugin.id,
|
||||||
|
pageType: 'tabs' as const,
|
||||||
|
});
|
||||||
|
} else if (page.type === 'tree') {
|
||||||
|
items.push({
|
||||||
|
key: `/plugins/${plugin.id}/tree/${page.entity}`,
|
||||||
|
icon: page.icon || 'ApartmentOutlined',
|
||||||
|
label: page.label,
|
||||||
|
pluginId: plugin.id,
|
||||||
|
entity: page.entity,
|
||||||
|
pageType: 'tree' as const,
|
||||||
|
});
|
||||||
|
} else if (page.type === 'crud') {
|
||||||
|
items.push({
|
||||||
|
key: `/plugins/${plugin.id}/${page.entity}`,
|
||||||
|
icon: page.icon || 'TableOutlined',
|
||||||
|
label: page.label,
|
||||||
|
pluginId: plugin.id,
|
||||||
|
entity: page.entity,
|
||||||
|
pageType: 'crud' as const,
|
||||||
|
});
|
||||||
|
} else if (page.type === 'graph') {
|
||||||
|
items.push({
|
||||||
|
key: `/plugins/${plugin.id}/graph/${page.entity}`,
|
||||||
|
icon: 'ApartmentOutlined',
|
||||||
|
label: page.label,
|
||||||
|
pluginId: plugin.id,
|
||||||
|
entity: page.entity,
|
||||||
|
pageType: 'graph' as const,
|
||||||
|
});
|
||||||
|
} else if (page.type === 'dashboard') {
|
||||||
|
items.push({
|
||||||
|
key: `/plugins/${plugin.id}/dashboard`,
|
||||||
|
icon: 'DashboardOutlined',
|
||||||
|
label: page.label,
|
||||||
|
pluginId: plugin.id,
|
||||||
|
pageType: 'dashboard' as const,
|
||||||
|
});
|
||||||
|
} else if (page.type === 'kanban') {
|
||||||
|
items.push({
|
||||||
|
key: `/plugins/${plugin.id}/kanban/${page.entity}`,
|
||||||
|
icon: 'UnorderedListOutlined',
|
||||||
|
label: page.label,
|
||||||
|
pluginId: plugin.id,
|
||||||
|
entity: page.entity,
|
||||||
|
pageType: 'kanban' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// detail 类型不生成菜单项
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 回退:从 entities 生成菜单
|
||||||
|
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,
|
||||||
|
pageType: 'crud',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ pluginMenuItems: items });
|
||||||
|
|
||||||
|
// 按 pluginId 分组生成三级菜单(复用上方已解构的 plugins)
|
||||||
|
const groupMap = new Map<string, PluginMenuItem[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const list = groupMap.get(item.pluginId) || [];
|
||||||
|
list.push(item);
|
||||||
|
groupMap.set(item.pluginId, list);
|
||||||
|
}
|
||||||
|
const groups: PluginMenuGroup[] = [];
|
||||||
|
for (const [pluginId, groupItems] of groupMap) {
|
||||||
|
const plugin = plugins.find((p) => p.id === pluginId);
|
||||||
|
groups.push({
|
||||||
|
pluginId,
|
||||||
|
pluginName: plugin?.name || pluginId,
|
||||||
|
items: groupItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
set({ pluginMenuGroups: groups });
|
||||||
|
},
|
||||||
|
}));
|
||||||
138
apps/web/src/utils/exprEvaluator.ts
Normal file
138
apps/web/src/utils/exprEvaluator.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* visible_when 表达式解析与求值
|
||||||
|
*
|
||||||
|
* 支持语法:
|
||||||
|
* field == 'value' 等值判断
|
||||||
|
* field != 'value' 不等判断
|
||||||
|
* expr1 AND expr2 逻辑与
|
||||||
|
* expr1 OR expr2 逻辑或
|
||||||
|
* NOT expr 逻辑非
|
||||||
|
* (expr) 括号分组
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ExprNode {
|
||||||
|
type: 'eq' | 'and' | 'or' | 'not';
|
||||||
|
field?: string;
|
||||||
|
value?: string;
|
||||||
|
left?: ExprNode;
|
||||||
|
right?: ExprNode;
|
||||||
|
operand?: ExprNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(input: string): string[] {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < input.length) {
|
||||||
|
if (input[i] === ' ') {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (input[i] === '(' || input[i] === ')') {
|
||||||
|
tokens.push(input[i]);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (input[i] === "'") {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < input.length && input[j] !== "'") j++;
|
||||||
|
tokens.push(input.substring(i, j + 1));
|
||||||
|
i = j + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (input[i] === '=' && input[i + 1] === '=') {
|
||||||
|
tokens.push('==');
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (input[i] === '!' && input[i + 1] === '=') {
|
||||||
|
tokens.push('!=');
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let j = i;
|
||||||
|
while (
|
||||||
|
j < input.length &&
|
||||||
|
!' ()\''.includes(input[j]) &&
|
||||||
|
!(input[j] === '=' && input[j + 1] === '=') &&
|
||||||
|
!(input[j] === '!' && input[j + 1] === '=')
|
||||||
|
) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
tokens.push(input.substring(i, j));
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAtom(tokens: string[]): ExprNode | null {
|
||||||
|
const token = tokens.shift();
|
||||||
|
if (!token) return null;
|
||||||
|
if (token === '(') {
|
||||||
|
const expr = parseOr(tokens);
|
||||||
|
if (tokens[0] === ')') tokens.shift();
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
if (token === 'NOT') {
|
||||||
|
const operand = parseAtom(tokens);
|
||||||
|
return { type: 'not', operand: operand || undefined };
|
||||||
|
}
|
||||||
|
const field = token;
|
||||||
|
const op = tokens.shift();
|
||||||
|
if (op !== '==' && op !== '!=') return null;
|
||||||
|
const rawValue = tokens.shift() || '';
|
||||||
|
const value = rawValue.replace(/^'(.*)'$/, '$1');
|
||||||
|
return { type: 'eq', field, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnd(tokens: string[]): ExprNode | null {
|
||||||
|
let left = parseAtom(tokens);
|
||||||
|
while (tokens[0] === 'AND') {
|
||||||
|
tokens.shift();
|
||||||
|
const right = parseAtom(tokens);
|
||||||
|
if (left && right) {
|
||||||
|
left = { type: 'and', left, right };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOr(tokens: string[]): ExprNode | null {
|
||||||
|
let left = parseAnd(tokens);
|
||||||
|
while (tokens[0] === 'OR') {
|
||||||
|
tokens.shift();
|
||||||
|
const right = parseAnd(tokens);
|
||||||
|
if (left && right) {
|
||||||
|
left = { type: 'or', left, right };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseExpr(input: string): ExprNode | null {
|
||||||
|
const tokens = tokenize(input);
|
||||||
|
return parseOr(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateExpr(node: ExprNode, values: Record<string, unknown>): boolean {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'eq':
|
||||||
|
return String(values[node.field!] ?? '') === node.value;
|
||||||
|
case 'and':
|
||||||
|
return evaluateExpr(node.left!, values) && evaluateExpr(node.right!, values);
|
||||||
|
case 'or':
|
||||||
|
return evaluateExpr(node.left!, values) || evaluateExpr(node.right!, values);
|
||||||
|
case 'not':
|
||||||
|
return !evaluateExpr(node.operand!, values);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateVisibleWhen(
|
||||||
|
expr: string | undefined,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
if (!expr) return true;
|
||||||
|
const ast = parseExpr(expr);
|
||||||
|
return ast ? evaluateExpr(ast, values) : true;
|
||||||
|
}
|
||||||
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
|
sea-orm.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
anyhow.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 {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user