Compare commits
107 Commits
eb856b1d73
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
080d2cb3d6 | ||
|
|
39a12500e3 | ||
|
|
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 前缀
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -25,3 +25,13 @@ Thumbs.db
|
||||
# Docker data
|
||||
docker/postgres_data/
|
||||
docker/redis_data/
|
||||
|
||||
# Test artifacts
|
||||
.test_token
|
||||
*.heapsnapshot
|
||||
perf-trace-*.json
|
||||
docs/debug-*.png
|
||||
|
||||
# Development env
|
||||
.env.development
|
||||
docker/docker-compose.override.yml
|
||||
|
||||
74
CLAUDE.md
74
CLAUDE.md
@@ -1,3 +1,6 @@
|
||||
@wiki/index.md
|
||||
整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。
|
||||
|
||||
# ERP 平台底座 — 协作与实现规则
|
||||
|
||||
> **ERP Platform Base** 是一个模块化的商业 SaaS ERP 底座,目标是提供核心基础设施(身份权限、工作流、消息、配置),使行业业务模块(进销存、生产、财务等)可以快速插接。
|
||||
@@ -126,11 +129,18 @@ erp-server (→ 所有 crate,组装入口)
|
||||
|
||||
1. **理解需求** — 确认改动的目标模块和影响范围
|
||||
2. **最小实现** — 只改必要的代码,保持模块边界
|
||||
3. **自动验证** — `cargo check` / `cargo test` / `pnpm dev` 必须通过
|
||||
4. **提交** — 按 §10 规范提交
|
||||
5. **文档同步** — 更新相关文档(如果涉及架构变化)
|
||||
3. **验证通过** — 必须全部通过才可继续:
|
||||
- `cargo check` — 编译无错误
|
||||
- `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 # 连接数据库
|
||||
|
||||
# === 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 |
|
||||
| `config` | erp-config |
|
||||
| `server` | erp-server |
|
||||
| `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample |
|
||||
| `crm` | erp-plugin-crm |
|
||||
| `web` | Web 前端 |
|
||||
| `ui` | React 组件 |
|
||||
| `db` | 数据库迁移 |
|
||||
@@ -422,6 +445,10 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
|------|------|
|
||||
| `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` | 平台底座设计规格 |
|
||||
| `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` | 平台底座实施计划 |
|
||||
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | WASM 插件系统设计规格 |
|
||||
| `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` | WASM 插件原型验证计划 |
|
||||
| `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 1 | 基础设施 (workspace + core + Docker + 桌面端) | 🚧 进行中 |
|
||||
| Phase 2 | 身份与权限 (Auth) | ⏳ 待开始 |
|
||||
| Phase 3 | 系统配置 (Config) | ⏳ 待开始 |
|
||||
| Phase 4 | 工作流引擎 (Workflow) | ⏳ 待开始 |
|
||||
| Phase 5 | 消息中心 (Message) | ⏳ 待开始 |
|
||||
| Phase 6 | 整合与打磨 | ⏳ 待开始 |
|
||||
| Phase 1 | 基础设施 (workspace + core + Docker + 桌面端) | ✅ 完成 |
|
||||
| Phase 2 | 身份与权限 (Auth) | ✅ 完成 |
|
||||
| Phase 3 | 系统配置 (Config) | ✅ 完成 |
|
||||
| Phase 4 | 工作流引擎 (Workflow) | ✅ 完成 |
|
||||
| Phase 5 | 消息中心 (Message) | ✅ 完成 |
|
||||
| Phase 6 | 整合与打磨 | ✅ 完成 |
|
||||
| - | WASM 插件原型 (V1-V6) | ✅ 验证通过 |
|
||||
| - | 插件系统集成到主服务 | ✅ 已集成 |
|
||||
| - | CRM 插件 (Phase 1-3) | ✅ 完成 |
|
||||
|
||||
### 已实现模块
|
||||
|
||||
| Crate | 功能 | 状态 |
|
||||
|-------|------|------|
|
||||
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait | 🚧 进行中 |
|
||||
| erp-common | 共享工具 | 🚧 进行中 |
|
||||
| erp-server | Axum 服务入口、配置、数据库连接 | 🚧 进行中 |
|
||||
| erp-auth | 身份与权限 | ⏳ 待开始 |
|
||||
| erp-workflow | 工作流引擎 | ⏳ 待开始 |
|
||||
| erp-message | 消息中心 | ⏳ 待开始 |
|
||||
| erp-config | 系统配置 | ⏳ 待开始 |
|
||||
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait、审计日志 | ✅ 完成 |
|
||||
| erp-common | 共享工具 | ✅ 完成 |
|
||||
| erp-server | Axum 服务入口、配置、数据库连接、CORS | ✅ 完成 |
|
||||
| erp-auth | 身份与权限 (用户/角色/权限/组织/部门/岗位) | ✅ 完成 |
|
||||
| erp-workflow | 工作流引擎 (BPMN 解析/Token 驱动/任务分配) | ✅ 完成 |
|
||||
| erp-message | 消息中心 (CRUD/模板/订阅/通知面板) | ✅ 完成 |
|
||||
| erp-config | 系统配置 (字典/菜单/设置/编号规则/主题) | ✅ 完成 |
|
||||
| erp-plugin | 插件管理 (WASM 运行时/生命周期/动态表/数据CRUD) | ✅ 已集成 |
|
||||
| erp-plugin-prototype | WASM 插件 Host 运行时 (Wasmtime + bindgen + Host API) | ✅ 原型验证 |
|
||||
| erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 |
|
||||
| erp-plugin-crm | CRM 客户管理插件 (5 实体/9 权限/6 页面) | ✅ 完成 |
|
||||
|
||||
<!-- ARCH-SNAPSHOT-END -->
|
||||
|
||||
@@ -472,6 +506,11 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- ❌ **不要**假设只有单租户 — 从第一天就按多租户设计
|
||||
- ❌ **不要**提前实现远期功能 — 严格按 Phase 计划推进
|
||||
- ❌ **不要**忽略 `version` 字段 — 所有更新操作必须检查乐观锁
|
||||
- ❌ **不要**在动态表 SQL 中拼接用户输入 — 使用 `sanitize_identifier` 防注入
|
||||
- ❌ **不要**在插件 crate 中直接依赖 erp-auth — 权限注册用 raw SQL,保持模块边界
|
||||
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
|
||||
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步
|
||||
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
|
||||
|
||||
### 场景化指令
|
||||
|
||||
@@ -481,5 +520,6 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- 当遇到**新增 API** → 添加 utoipa 注解,确保 OpenAPI 文档同步
|
||||
- 当遇到**新增表** → 创建 SeaORM migration + Entity,包含所有标准字段
|
||||
- 当遇到**新增页面** → 使用 Ant Design 组件,i18n key 引用文案
|
||||
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component
|
||||
|
||||
<!-- ANTI-PATTERN-END -->
|
||||
|
||||
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"
|
||||
members = [
|
||||
"crates/erp-core",
|
||||
"crates/erp-common",
|
||||
"crates/erp-server",
|
||||
"crates/erp-auth",
|
||||
"crates/erp-workflow",
|
||||
"crates/erp-message",
|
||||
"crates/erp-config",
|
||||
"crates/erp-server/migration",
|
||||
"crates/erp-plugin-prototype",
|
||||
"crates/erp-plugin-test-sample",
|
||||
"crates/erp-plugin",
|
||||
"crates/erp-plugin-crm",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -20,7 +24,7 @@ license = "MIT"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Web
|
||||
axum = "0.8"
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
|
||||
|
||||
@@ -58,17 +62,24 @@ jsonwebtoken = "9"
|
||||
# Password hashing
|
||||
argon2 = "0.5"
|
||||
|
||||
# Cryptographic hashing (token storage)
|
||||
sha2 = "0.10"
|
||||
|
||||
# API docs
|
||||
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-swagger-ui = { version = "8", features = ["axum"] }
|
||||
# utoipa-swagger-ui 需要下载 GitHub 资源,网络受限时暂不使用
|
||||
# utoipa-swagger-ui = { version = "8", features = ["axum"] }
|
||||
|
||||
# Validation
|
||||
validator = { version = "0.19", features = ["derive"] }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
|
||||
# Internal crates
|
||||
erp-core = { path = "crates/erp-core" }
|
||||
erp-common = { path = "crates/erp-common" }
|
||||
erp-auth = { path = "crates/erp-auth" }
|
||||
erp-workflow = { path = "crates/erp-workflow" }
|
||||
erp-message = { path = "crates/erp-message" }
|
||||
erp-config = { path = "crates/erp-config" }
|
||||
erp-plugin = { path = "crates/erp-plugin" }
|
||||
|
||||
64
README.md
Normal file
64
README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# ERP Platform Base
|
||||
|
||||
模块化商业 SaaS ERP 平台底座。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | Rust (Axum 0.8 + SeaORM + Tokio) |
|
||||
| 数据库 | PostgreSQL 16+ |
|
||||
| 缓存 | Redis 7+ |
|
||||
| 前端 | Vite + React 18 + TypeScript + Ant Design 5 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
erp/
|
||||
├── crates/
|
||||
│ ├── erp-core/ # 基础类型、错误、事件总线、模块 trait
|
||||
│ ├── erp-common/ # 共享工具
|
||||
│ ├── erp-auth/ # 身份与权限 (Phase 2)
|
||||
│ ├── erp-workflow/ # 工作流引擎 (Phase 4)
|
||||
│ ├── erp-message/ # 消息中心 (Phase 5)
|
||||
│ ├── erp-config/ # 系统配置 (Phase 3)
|
||||
│ └── erp-server/ # Axum 服务入口
|
||||
│ └── migration/ # SeaORM 数据库迁移
|
||||
├── apps/web/ # React SPA 前端
|
||||
├── docker/ # Docker 开发环境
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启动基础设施
|
||||
|
||||
```bash
|
||||
cd docker && docker compose up -d
|
||||
```
|
||||
|
||||
### 2. 启动后端
|
||||
|
||||
```bash
|
||||
cargo run -p erp-server
|
||||
```
|
||||
|
||||
### 3. 启动前端
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm install && pnpm dev
|
||||
```
|
||||
|
||||
### 4. 访问
|
||||
|
||||
- 前端: http://localhost:5173
|
||||
- 后端 API: http://localhost:3000
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
cargo check # 编译检查
|
||||
cargo test --workspace # 运行测试
|
||||
cargo run -p erp-server # 启动后端
|
||||
cd apps/web && pnpm dev # 启动前端
|
||||
```
|
||||
24
apps/web/.gitignore
vendored
Normal file
24
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
apps/web/README.md
Normal file
73
apps/web/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
apps/web/eslint.config.js
Normal file
23
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
16
apps/web/index.html
Normal file
16
apps/web/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="ERP 平台底座 — 模块化 SaaS 企业资源管理系统,提供身份权限、工作流引擎、消息中心、系统配置等核心基础设施" />
|
||||
<meta name="theme-color" content="#4F46E5" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>ERP Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
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
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
argon2.workspace = true
|
||||
sha2.workspace = true
|
||||
validator.workspace = true
|
||||
utoipa.workspace = true
|
||||
async-trait.workspace = true
|
||||
|
||||
77
crates/erp-auth/src/auth_state.rs
Normal file
77
crates/erp-auth/src/auth_state.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use erp_core::events::EventBus;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Auth-specific state extracted from the server's AppState via `FromRef`.
|
||||
///
|
||||
/// This avoids a circular dependency between erp-auth and erp-server.
|
||||
/// The server crate implements `FromRef<AppState> for AuthState` so that
|
||||
/// Axum handlers in erp-auth can extract `State<AuthState>` directly.
|
||||
///
|
||||
/// Contains everything the auth handlers need:
|
||||
/// - Database connection for user/credential lookups
|
||||
/// - EventBus for publishing domain events
|
||||
/// - JWT configuration for token signing and validation
|
||||
/// - Default tenant ID for the bootstrap phase
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub jwt_secret: String,
|
||||
pub access_ttl_secs: i64,
|
||||
pub refresh_ttl_secs: i64,
|
||||
pub default_tenant_id: Uuid,
|
||||
}
|
||||
|
||||
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.
|
||||
///
|
||||
/// Falls back to parsing the raw string as seconds if no unit suffix is recognized.
|
||||
pub fn parse_ttl(ttl: &str) -> i64 {
|
||||
let ttl = ttl.trim();
|
||||
if let Some(num) = ttl.strip_suffix('s') {
|
||||
num.parse::<i64>().unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('m') {
|
||||
num.parse::<i64>().map(|n| n * 60).unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('h') {
|
||||
num.parse::<i64>().map(|n| n * 3600).unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('d') {
|
||||
num.parse::<i64>().map(|n| n * 86400).unwrap_or(900)
|
||||
} else {
|
||||
ttl.parse::<i64>().unwrap_or(900)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_seconds() {
|
||||
assert_eq!(parse_ttl("900s"), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_minutes() {
|
||||
assert_eq!(parse_ttl("15m"), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_hours() {
|
||||
assert_eq!(parse_ttl("1h"), 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_days() {
|
||||
assert_eq!(parse_ttl("7d"), 604800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_raw_number() {
|
||||
assert_eq!(parse_ttl("300"), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_fallback_on_invalid() {
|
||||
assert_eq!(parse_ttl("invalid"), 900);
|
||||
}
|
||||
}
|
||||
436
crates/erp-auth/src/dto.rs
Normal file
436
crates/erp-auth/src/dto.rs
Normal file
@@ -0,0 +1,436 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
// --- Auth DTOs ---
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct LoginReq {
|
||||
#[validate(length(min = 1, message = "用户名不能为空"))]
|
||||
pub username: String,
|
||||
#[validate(length(min = 1, message = "密码不能为空"))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct LoginResp {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires_in: u64,
|
||||
pub user: UserResp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct RefreshReq {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// 修改密码请求
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct ChangePasswordReq {
|
||||
#[validate(length(min = 1, message = "当前密码不能为空"))]
|
||||
pub current_password: String,
|
||||
#[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))]
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
// --- User DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UserResp {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub status: String,
|
||||
pub roles: Vec<RoleResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateUserReq {
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub username: String,
|
||||
#[validate(length(min = 6, max = 128))]
|
||||
pub password: String,
|
||||
#[validate(email)]
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateUserReq {
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// --- Role DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct RoleResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
pub description: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateRoleReq {
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub name: String,
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub code: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateRoleReq {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct AssignRolesReq {
|
||||
pub role_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
// --- Permission DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PermissionResp {
|
||||
pub id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub resource: String,
|
||||
pub action: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct AssignPermissionsReq {
|
||||
pub permission_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
// --- Organization DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct OrganizationResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub path: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub children: Vec<OrganizationResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateOrganizationReq {
|
||||
#[validate(length(min = 1))]
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateOrganizationReq {
|
||||
pub name: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// --- Department DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DepartmentResp {
|
||||
pub id: Uuid,
|
||||
pub org_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub manager_id: Option<Uuid>,
|
||||
pub path: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub children: Vec<DepartmentResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateDepartmentReq {
|
||||
#[validate(length(min = 1))]
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub manager_id: Option<Uuid>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateDepartmentReq {
|
||||
pub name: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub manager_id: Option<Uuid>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// --- Position DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PositionResp {
|
||||
pub id: Uuid,
|
||||
pub dept_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreatePositionReq {
|
||||
#[validate(length(min = 1))]
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub level: Option<i32>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdatePositionReq {
|
||||
pub name: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub level: Option<i32>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use validator::Validate;
|
||||
|
||||
#[test]
|
||||
fn login_req_valid() {
|
||||
let req = LoginReq {
|
||||
username: "admin".to_string(),
|
||||
password: "password123".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_req_empty_username_fails() {
|
||||
let req = LoginReq {
|
||||
username: "".to_string(),
|
||||
password: "password123".to_string(),
|
||||
};
|
||||
let result = req.validate();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_valid() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "oldPassword123".to_string(),
|
||||
new_password: "newPassword456".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_empty_current_fails() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "".to_string(),
|
||||
new_password: "newPassword456".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_short_new_fails() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "oldPassword123".to_string(),
|
||||
new_password: "12345".to_string(), // min 6
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_long_new_fails() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "oldPassword123".to_string(),
|
||||
new_password: "a".repeat(129), // max 128
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_req_empty_password_fails() {
|
||||
let req = LoginReq {
|
||||
username: "admin".to_string(),
|
||||
password: "".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_valid() {
|
||||
let req = CreateUserReq {
|
||||
username: "alice".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
email: Some("alice@example.com".to_string()),
|
||||
phone: None,
|
||||
display_name: Some("Alice".to_string()),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_short_password_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "bob".to_string(),
|
||||
password: "12345".to_string(), // min 6
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_empty_username_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_invalid_email_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "charlie".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
email: Some("not-an-email".to_string()),
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_long_username_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "a".repeat(51), // max 50
|
||||
password: "secret123".to_string(),
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_role_req_valid() {
|
||||
let req = CreateRoleReq {
|
||||
name: "管理员".to_string(),
|
||||
code: "admin".to_string(),
|
||||
description: Some("系统管理员".to_string()),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_role_req_empty_name_fails() {
|
||||
let req = CreateRoleReq {
|
||||
name: "".to_string(),
|
||||
code: "admin".to_string(),
|
||||
description: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_role_req_empty_code_fails() {
|
||||
let req = CreateRoleReq {
|
||||
name: "管理员".to_string(),
|
||||
code: "".to_string(),
|
||||
description: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_org_req_valid() {
|
||||
let req = CreateOrganizationReq {
|
||||
name: "总部".to_string(),
|
||||
code: Some("HQ".to_string()),
|
||||
parent_id: None,
|
||||
sort_order: Some(0),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_org_req_empty_name_fails() {
|
||||
let req = CreateOrganizationReq {
|
||||
name: "".to_string(),
|
||||
code: None,
|
||||
parent_id: None,
|
||||
sort_order: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dept_req_valid() {
|
||||
let req = CreateDepartmentReq {
|
||||
name: "技术部".to_string(),
|
||||
code: Some("TECH".to_string()),
|
||||
parent_id: None,
|
||||
manager_id: None,
|
||||
sort_order: Some(1),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_position_req_valid() {
|
||||
let req = CreatePositionReq {
|
||||
name: "高级工程师".to_string(),
|
||||
code: Some("SENIOR".to_string()),
|
||||
level: Some(3),
|
||||
sort_order: None,
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_position_req_empty_name_fails() {
|
||||
let req = CreatePositionReq {
|
||||
name: "".to_string(),
|
||||
code: None,
|
||||
level: None,
|
||||
sort_order: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
}
|
||||
68
crates/erp-auth/src/entity/department.rs
Normal file
68
crates/erp-auth/src/entity/department.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "departments")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub org_id: Uuid,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manager_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::organization::Entity",
|
||||
from = "Column::OrgId",
|
||||
to = "super::organization::Column::Id",
|
||||
on_delete = "Restrict"
|
||||
)]
|
||||
Organization,
|
||||
#[sea_orm(has_many = "super::position::Entity")]
|
||||
Position,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::ManagerId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "SetNull"
|
||||
)]
|
||||
Manager,
|
||||
}
|
||||
|
||||
impl Related<super::organization::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Organization.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::position::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Position.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Manager.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
10
crates/erp-auth/src/entity/mod.rs
Normal file
10
crates/erp-auth/src/entity/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod department;
|
||||
pub mod organization;
|
||||
pub mod permission;
|
||||
pub mod position;
|
||||
pub mod role;
|
||||
pub mod role_permission;
|
||||
pub mod user;
|
||||
pub mod user_credential;
|
||||
pub mod user_role;
|
||||
pub mod user_token;
|
||||
40
crates/erp-auth/src/entity/organization.rs
Normal file
40
crates/erp-auth/src/entity/organization.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "organizations")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::department::Entity")]
|
||||
Department,
|
||||
}
|
||||
|
||||
impl Related<super::department::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Department.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
37
crates/erp-auth/src/entity/permission.rs
Normal file
37
crates/erp-auth/src/entity/permission.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "permissions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub resource: String,
|
||||
pub action: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::role_permission::Entity")]
|
||||
RolePermission,
|
||||
}
|
||||
|
||||
impl Related<super::role_permission::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::RolePermission.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
42
crates/erp-auth/src/entity/position.rs
Normal file
42
crates/erp-auth/src/entity/position.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "positions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub dept_id: Uuid,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::department::Entity",
|
||||
from = "Column::DeptId",
|
||||
to = "super::department::Column::Id",
|
||||
on_delete = "Restrict"
|
||||
)]
|
||||
Department,
|
||||
}
|
||||
|
||||
impl Related<super::department::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Department.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
44
crates/erp-auth/src/entity/role.rs
Normal file
44
crates/erp-auth/src/entity/role.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "roles")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::role_permission::Entity")]
|
||||
RolePermission,
|
||||
#[sea_orm(has_many = "super::user_role::Entity")]
|
||||
UserRole,
|
||||
}
|
||||
|
||||
impl Related<super::role_permission::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::RolePermission.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user_role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserRole.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
51
crates/erp-auth/src/entity/role_permission.rs
Normal file
51
crates/erp-auth/src/entity/role_permission.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "role_permissions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub role_id: Uuid,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub permission_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::role::Entity",
|
||||
from = "Column::RoleId",
|
||||
to = "super::role::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Role,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::permission::Entity",
|
||||
from = "Column::PermissionId",
|
||||
to = "super::permission::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Permission,
|
||||
}
|
||||
|
||||
impl Related<super::role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Role.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::permission::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Permission.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
59
crates/erp-auth/src/entity/user.rs
Normal file
59
crates/erp-auth/src/entity/user.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub username: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_login_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::user_credential::Entity")]
|
||||
UserCredential,
|
||||
#[sea_orm(has_many = "super::user_token::Entity")]
|
||||
UserToken,
|
||||
#[sea_orm(has_many = "super::user_role::Entity")]
|
||||
UserRole,
|
||||
}
|
||||
|
||||
impl Related<super::user_credential::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserCredential.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user_token::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserToken.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user_role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserRole.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
41
crates/erp-auth/src/entity/user_credential.rs
Normal file
41
crates/erp-auth/src/entity/user_credential.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_credentials")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub credential_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_data: Option<serde_json::Value>,
|
||||
pub verified: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
51
crates/erp-auth/src/entity/user_role.rs
Normal file
51
crates/erp-auth/src/entity/user_role.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_roles")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: Uuid,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub role_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::role::Entity",
|
||||
from = "Column::RoleId",
|
||||
to = "super::role::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Role,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Role.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user