chore: 干净 ERP 基座 — 删除 health/ai/wechat 业务代码
删除内容: - 前端: health/(67文件), ai/(2文件), Copilot, MediaPicker, 相关API/Store/Hook - 后端: wechat_handler, wechat_service, wechat_user entity, analytics handler, ai_workflow_seed - 配置: WechatConfig, AppConfig.wechat, AuthState wechat 字段 - 启动: 微信凭据检查块, ensure_ai_workflows() 调用 - 迁移: 新增 m20260613_000170_drop_wechat_users.rs - 脚本: api_test_health_alert.py, api_test_mp.py, mpsync.sh/ps1 - E2E: health-data page, flows/ 目录 保留: erp-core/auth/workflow/message/config/plugin + 基座前端 + 通用组件
This commit is contained in:
109
.gitignore
vendored
Normal file
109
.gitignore
vendored
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Rust
|
||||||
|
/target/
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Tauri
|
||||||
|
apps/desktop/src-tauri/target/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker data
|
||||||
|
docker/postgres_data/
|
||||||
|
docker/redis_data/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.test_token
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
apps/miniprogram/dist-h5/
|
||||||
|
|
||||||
|
# Runtime uploads
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Temp logs
|
||||||
|
_server_out.txt
|
||||||
|
*.heapsnapshot
|
||||||
|
perf-trace-*.json
|
||||||
|
docs/debug-*.png
|
||||||
|
|
||||||
|
# Development env
|
||||||
|
.env.development
|
||||||
|
docker/docker-compose.override.yml
|
||||||
|
.agents/skills/
|
||||||
|
.claude/skills/
|
||||||
|
.kiro/skills/
|
||||||
|
.trae/skills/
|
||||||
|
.windsurf/skills/
|
||||||
|
skills/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
.logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Playwright reports
|
||||||
|
**/playwright-report/
|
||||||
|
|
||||||
|
# Plans
|
||||||
|
plans/
|
||||||
|
|
||||||
|
# MCP config
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
|
# Superpowers temp
|
||||||
|
.superpowers/brainstorm/
|
||||||
|
|
||||||
|
# Test temp files
|
||||||
|
.test_token*
|
||||||
|
chi_sim.traineddata
|
||||||
|
|
||||||
|
# Local settings
|
||||||
|
.claude/settings.local.json
|
||||||
|
tools/
|
||||||
|
|
||||||
|
# Temp/debug files
|
||||||
|
_temp/
|
||||||
|
tmp/
|
||||||
|
screenshots/
|
||||||
|
server-log.txt
|
||||||
|
snapshot_*.txt
|
||||||
|
_*.txt
|
||||||
|
_server_*.txt
|
||||||
|
tmp_*.txt
|
||||||
|
direct_*.txt
|
||||||
|
server_*.txt
|
||||||
|
server_combined.txt
|
||||||
|
out.txt
|
||||||
|
_wx_login.json
|
||||||
|
.claude/settings.json
|
||||||
|
|
||||||
|
# Trace/debug JSON
|
||||||
|
trace-*.json
|
||||||
|
|
||||||
|
# Graphify knowledge graph (regenerated locally)
|
||||||
|
graphify-out/
|
||||||
|
|
||||||
|
# Native miniprogram (separate project)
|
||||||
|
apps/mp-native/
|
||||||
|
|
||||||
|
# Misc untracked
|
||||||
|
err.txt
|
||||||
|
uploads/g:/hms/.superpowers/
|
||||||
|
.claude/skills/design-handoff/node_modules/
|
||||||
|
.design/config.yml
|
||||||
|
.superpowers/
|
||||||
535
CLAUDE.md
Normal file
535
CLAUDE.md
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
@wiki/index.md
|
||||||
|
整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。
|
||||||
|
|
||||||
|
# HMS 健康管理平台 — 协作与实现规则
|
||||||
|
|
||||||
|
> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉独立,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务。
|
||||||
|
|
||||||
|
> **当前阶段: erp-health 模块开发。** 设计规格已确认,开始实施。
|
||||||
|
|
||||||
|
## 1. 项目定位
|
||||||
|
|
||||||
|
### 1.1 这是什么
|
||||||
|
|
||||||
|
一个 **健康管理 + ERP 基础设施** 架构的医疗 SaaS 平台:
|
||||||
|
|
||||||
|
- **医疗核心** — 患者管理、健康数据、预约排班、随访管理、咨询管理(原生 Rust 模块 erp-health)
|
||||||
|
- **基础底座** — 身份权限、工作流引擎、消息中心、系统配置(继承自 ERP)
|
||||||
|
- **多租户 + 私有化** — 默认 SaaS 共享数据库隔离,支持独立 schema 私有部署
|
||||||
|
- **Web 优先** — 浏览器 SPA 是 PC 管理后台主力,小程序(患者端/医护端)独立开发
|
||||||
|
|
||||||
|
### 1.2 决策原则
|
||||||
|
|
||||||
|
**任何改动都要问:这对健康管理平台的医疗业务和可扩展性有帮助吗?**
|
||||||
|
|
||||||
|
- ✅ 完善模块接口和 trait 定义 → 最高优先
|
||||||
|
- ✅ 确保多租户隔离的正确性 → 最高优先
|
||||||
|
- ✅ 按计划推进 Phase 交付物 → 高优先
|
||||||
|
- ✅ 清晰的模块边界和事件契约 → 高优先
|
||||||
|
- ❌ 跳过 Phase 顺序提前实现远期功能 → 禁止
|
||||||
|
- ❌ 在模块间创建直接耦合 → 永远不做
|
||||||
|
- ❌ 硬编码租户 ID 或绕过多租户中间件 → 永远不做
|
||||||
|
- ❌ 过度设计未来才需要的能力 → 永远不做
|
||||||
|
|
||||||
|
### 1.3 架构铁律
|
||||||
|
|
||||||
|
| 约束 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| 模块间只通过事件总线和 trait 通信 | 保证模块可独立拆分为微服务 |
|
||||||
|
| 所有数据表必须含 `tenant_id` | 多租户是核心能力,不可事后补 |
|
||||||
|
| 使用 UUID v7 作为主键 | 时间排序 + 唯一性,分布式友好 |
|
||||||
|
| 软删除,不硬删除 | ERP 数据不可丢失,审计追溯需要 |
|
||||||
|
| 所有 API 使用 `/api/v1/` 前缀 | 版本化是 SaaS 产品的基本要求 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 工作风格
|
||||||
|
|
||||||
|
### 2.1 按计划推进
|
||||||
|
|
||||||
|
- **严格按 Phase 顺序执行** — Phase 2 依赖 Phase 1 的基础设施
|
||||||
|
- **每个 Task 完成后立即提交** — 不积压,保持可追溯
|
||||||
|
- **先测试后实现** — TDD 流程:写失败测试 → 实现 → 通过 → 提交
|
||||||
|
|
||||||
|
### 2.2 分步编写文档(强制)
|
||||||
|
|
||||||
|
编写计划、设计文档、实施报告等长文档时,**必须分步编写**,禁止一次性输出全文:
|
||||||
|
|
||||||
|
1. **先写大纲** — 确认文档结构和章节划分
|
||||||
|
2. **逐章编写** — 每次只写 1-2 个章节,写完确认后继续下一章
|
||||||
|
3. **最终整合** — 所有章节完成后合并为完整文档
|
||||||
|
|
||||||
|
**原因:** 上下文过长会导致输出截断或卡死。分步编写保证每步都能完整输出,且用户可以中途调整方向。
|
||||||
|
|
||||||
|
**适用范围:** 超过 200 行的文档、实施计划、设计规格、技术报告等。简短的 bugfix 说明、单页 wiki 更新不受此限制。
|
||||||
|
|
||||||
|
### 2.3 讨论记录
|
||||||
|
|
||||||
|
每次发散式讨论(brainstorming、方案探索、需求梳理、技术选型等)**必须建立独立文档**:
|
||||||
|
|
||||||
|
- **存放位置:** `docs/discussions/YYYY-MM-DD-{主题简称}.md`
|
||||||
|
- **文档格式:**
|
||||||
|
```markdown
|
||||||
|
# {讨论主题}
|
||||||
|
> 日期: YYYY-MM-DD | 参与者: ...
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
为什么会有这次讨论
|
||||||
|
|
||||||
|
## 讨论要点
|
||||||
|
- 要点 1
|
||||||
|
- 要点 2
|
||||||
|
|
||||||
|
## 结论 / 待定
|
||||||
|
达成的共识或遗留问题
|
||||||
|
```
|
||||||
|
- **时机:** 讨论结束后立即创建,不要积压。如果讨论横跨多个主题,拆分为多份文档。
|
||||||
|
- **用途:** 作为后续实施的输入和决策追溯的依据,避免"之前讨论过但忘了结论"。
|
||||||
|
|
||||||
|
### 2.4 模块化思维
|
||||||
|
|
||||||
|
开发任何功能时先问:
|
||||||
|
|
||||||
|
1. **它属于哪个模块?** — 不确定就放到 `erp-core` 共享层
|
||||||
|
2. **它的接口是什么?** — 先定义 trait,再实现
|
||||||
|
3. **它需要发什么事件?** — 跨模块通知必须走事件总线
|
||||||
|
4. **其他模块怎么发现它?** — 通过 `ErpModule` trait 注册
|
||||||
|
|
||||||
|
### 2.5 闭环工作法(强制)
|
||||||
|
|
||||||
|
每次改动**必须**按顺序完成以下步骤,不允许跳过:
|
||||||
|
|
||||||
|
0. **阅读 Wiki(强制起点)** — 收到任何任务后,**先读 wiki 再动手**:
|
||||||
|
- 读取 `wiki/index.md` 了解项目全貌和当前进度
|
||||||
|
- 根据任务涉及的范围,读取相关 wiki 页面(`wiki/infrastructure.md`、`wiki/testing.md`、`wiki/wasm-plugin.md` 等)
|
||||||
|
- wiki 中包含实际的环境配置(数据库连接、端口、登录凭据、启动方式),不看 wiki 就无法正确验证
|
||||||
|
- **违反此步骤 = 盲目工作,浪费时间去猜环境配置,产出不可信**
|
||||||
|
1. **现状确认(强制)** — 动手之前,先检查代码里已经有什么:
|
||||||
|
- 用 Grep/Glob/Read 工具搜索相关文件,确认哪些能力已存在
|
||||||
|
- 明确列出"已有"和"缺失",不允许凭印象断言缺失
|
||||||
|
- 如果不确定现有实现状态,停下来问用户,不要编造
|
||||||
|
- 违反此步骤 = 所有后续工作可能脱离实际,白费力气
|
||||||
|
2. **理解需求** — 确认改动的目标模块和影响范围
|
||||||
|
3. **最小实现** — 只改必要的代码,保持模块边界
|
||||||
|
4. **验证通过** — 必须全部通过才可继续:
|
||||||
|
- `cargo check` — 编译无错误
|
||||||
|
- `cargo test --workspace` — 所有测试通过(有相关测试时)
|
||||||
|
- 功能验证 — 启动后端 + 前端服务,在浏览器中实际操作验证改动生效(涉及 API 或 UI 时)
|
||||||
|
- `pnpm build` — 前端生产构建通过(涉及前端时)
|
||||||
|
5. **提交 + 文档 + 推送(三合一,强制)** — 验证通过后按顺序执行:
|
||||||
|
- a. 按 §5 规范提交代码
|
||||||
|
- b. 检查本次变更是否触发 wiki 更新(见下方 wiki 更新触发条件),触发则更新后单独 `docs(wiki)` 提交
|
||||||
|
- c. `git push` 立即推送,不允许"等一下再推"
|
||||||
|
- **禁止连续 5 个非 docs 提交而不更新 wiki 关键数字**
|
||||||
|
|
||||||
|
#### wiki 更新触发条件(步骤 5b 的判定标准)
|
||||||
|
|
||||||
|
以下任一条件满足时,**必须**更新 wiki 后才能继续下一任务:
|
||||||
|
|
||||||
|
- **fix 提交** → `wiki/index.md` 症状导航新增条目或标记"已修复"
|
||||||
|
- **feat 提交(新功能)** → `wiki/index.md` 关键数字更新 + 对应模块 wiki 页更新(实体数/路由数/端点数等)
|
||||||
|
- **数据库迁移变化** → 关键数字中的迁移数/表数更新
|
||||||
|
- **API 路由变化** → 路由数更新
|
||||||
|
- **测试数量变化** → 测试数/断言数更新
|
||||||
|
- **连续 5 个代码提交** → 强制做一次 wiki/index.md 关键数字全文校正(对比代码实际数量)
|
||||||
|
|
||||||
|
**铁律:**
|
||||||
|
- **步骤 0 阅读 Wiki 是绝对起点** — 不读 wiki 就开干 = 连环境配置都不知道,所有验证步骤都是空谈。
|
||||||
|
- **步骤 1 现状确认是强制起点** — 不检查就开干 = 脱离实际,所有产出不可信。
|
||||||
|
- **步骤 4 功能验证必须实际操作** — 只看编译通过不算验证,必须启动服务、在浏览器中确认功能正常。
|
||||||
|
- **步骤 5 三合一是强制流程** — 提交后必须检查 wiki、必须推送,缺一不可。
|
||||||
|
- **每次新会话开始时,先检查是否有未推送的提交并立即推送**。
|
||||||
|
|
||||||
|
### 2.6 Feature DoD — 功能完成定义(强制)
|
||||||
|
|
||||||
|
> 历史数据显示 24% 的提交是 fix,根因是缺少统一的完成标准。
|
||||||
|
> 每个功能标记"完成"前,**必须**逐项检查以下清单,不允许跳过。
|
||||||
|
|
||||||
|
#### 后端
|
||||||
|
|
||||||
|
- [ ] Entity 包含所有标准字段(`id`/`tenant_id`/`created_at`/`updated_at`/`created_by`/`updated_by`/`deleted_at`/`version`)
|
||||||
|
- [ ] Handler 添加 `require_permission` 权限守卫
|
||||||
|
- [ ] 权限码已写入 seed 迁移(每个实体 `.list` + `.manage`,权限码前缀与实体名一致)
|
||||||
|
- [ ] utoipa 注解已添加(`#[derive(utoipa::OpenApi)]` + path/response schema)
|
||||||
|
- [ ] Service 层核心路径有单元/集成测试
|
||||||
|
- [ ] 多租户隔离正确(所有查询含 `tenant_id` 过滤,无手写 SQL 拼接)
|
||||||
|
- [ ] 输入验证完整(必填字段 + 格式校验 + 长度限制)
|
||||||
|
- [ ] 错误处理统一(`AppError`,不 panic,不 unwrap 生产代码)
|
||||||
|
- [ ] 关键操作有 `tracing` 日志(info/warn/error 级别合理)
|
||||||
|
|
||||||
|
#### 前端(Web)
|
||||||
|
|
||||||
|
- [ ] API 路径与后端 OpenAPI spec 一致(不手写路径,从 `api/health/` 模块调用)
|
||||||
|
- [ ] 路由声明权限码(`permissions: [...]`),与后端 handler 一致
|
||||||
|
- [ ] 菜单配置已更新(`parent_id` 正确 + `permission` 字段 + `menu_roles` 关联)
|
||||||
|
- [ ] 错误状态有用户友好提示(不显示原始 error message)
|
||||||
|
- [ ] 不使用 `any` 类型(用 `unknown` + 类型守卫)
|
||||||
|
|
||||||
|
#### 前端(小程序)
|
||||||
|
|
||||||
|
- [ ] Service 层接口契约与后端 DTO 一致(字段名/类型/结构体)
|
||||||
|
- [ ] 登录态处理正确(`useDidShow` 恢复认证、退出清理 Storage)
|
||||||
|
- [ ] 页面间数据通过 API 获取,不用 Storage 传递
|
||||||
|
- [ ] 长者模式适配完成(字号 ≥ 22px)
|
||||||
|
- [ ] 图片使用合法 URL(HTTPS 或相对路径,不用 HTTP)
|
||||||
|
|
||||||
|
#### 安全
|
||||||
|
|
||||||
|
- [ ] 新增端点有权限声明(默认拒绝,不是默认放行)
|
||||||
|
- [ ] 敏感数据有脱敏/加密处理(PII 字段走 AES-256-GCM)
|
||||||
|
- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS、命令注入、路径穿越)
|
||||||
|
- [ ] 无 CORS 通配符、无硬编码密钥、无 fallback 默认密钥
|
||||||
|
- [ ] 日志中无敏感数据输出(密码、token、身份证号、手机号等)
|
||||||
|
- [ ] 文件上传有 MIME 类型验证 + 大小限制 + 路径穿越防护
|
||||||
|
- [ ] API 响应不暴露内部实现细节(数据库错误、堆栈跟踪、文件路径)
|
||||||
|
- [ ] 速率限制已配置(认证端点更严格)
|
||||||
|
- [ ] 密钥通过环境变量注入,`.env.example` 已同步更新
|
||||||
|
|
||||||
|
#### 文档一致性
|
||||||
|
|
||||||
|
- [ ] `wiki/index.md` 关键数字与代码实际状态一致(迁移数、路由数、实体数、测试数等)
|
||||||
|
- [ ] 新增/修复的 bug 已记录在症状导航中(含根因+解决方案)
|
||||||
|
- [ ] 新增功能已记录在对应模块 wiki 页面中(实体、端点、事件等)
|
||||||
|
- [ ] wiki 页面的"最后更新"日期已刷新为当天
|
||||||
|
|
||||||
|
#### 端到端验证
|
||||||
|
|
||||||
|
- [ ] `cargo check` 全 workspace 通过
|
||||||
|
- [ ] `cargo test` 全部通过
|
||||||
|
- [ ] 浏览器中手动验证功能正常(列表/创建/编辑/删除/权限拦截)
|
||||||
|
- [ ] 小程序中验证(涉及小程序页面时)
|
||||||
|
- [ ] 相关路由权限按角色测试通过(至少 admin + 只读角色)
|
||||||
|
- [ ] 本地提交已推送到远程仓库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 实现规则
|
||||||
|
|
||||||
|
### 3.1 错误处理
|
||||||
|
|
||||||
|
- **跨 crate 边界**:使用 `thiserror` 定义类型化错误,转换为 `AppError`
|
||||||
|
- **crate 内部**:可以使用 `anyhow`,但**永远不**跨越 crate 边界
|
||||||
|
- **数据库错误**:通过 `From<sea_orm::DbErr>` 自动转换为 `AppError`
|
||||||
|
- **验证错误**:包含字段级详情,方便 UI 渲染
|
||||||
|
|
||||||
|
### 3.2 数据库操作
|
||||||
|
|
||||||
|
- 所有 SeaORM Entity 必须包含:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||||
|
- 查询时**始终**带 `tenant_id` 过滤(中间件自动注入)
|
||||||
|
- 更新时检查 `version` 字段实现乐观锁
|
||||||
|
- 删除使用软删除(设置 `deleted_at`)
|
||||||
|
|
||||||
|
### 3.3 API 设计
|
||||||
|
|
||||||
|
- 所有端点使用 `/api/v1/` 前缀
|
||||||
|
- 响应统一使用 `ApiResponse<T>` 包装
|
||||||
|
- 分页使用 `Pagination` + `PaginatedResponse<T>`
|
||||||
|
- utoipa 自动生成 OpenAPI 文档
|
||||||
|
- 租户 ID 从 JWT 中间件注入,**不在** API 路径中传递(管理员接口除外)
|
||||||
|
|
||||||
|
#### 新增 API 端点安全检查(强制)
|
||||||
|
|
||||||
|
> 默认拒绝是安全基线 — 绝大多数安全修复源于默认放行模式。
|
||||||
|
> 新增端点时**必须**逐项确认:
|
||||||
|
|
||||||
|
- [ ] 端点已添加 `require_permission` 权限守卫(非公开端点)
|
||||||
|
- [ ] 公开端点已显式标记为 `public`(不继承认证中间件)
|
||||||
|
- [ ] 路由使用 `.nest()` 注册带中间件的子路由(禁止 `.merge()` 防止中间件泄漏)
|
||||||
|
- [ ] 敏感操作有速率限制
|
||||||
|
- [ ] 无 `format!` 拼接 SQL — 所有查询使用 SeaORM 参数化
|
||||||
|
- [ ] FHIR/第三方端点有 `tenant_id` 和 `allowed_patient_ids` 范围过滤
|
||||||
|
- [ ] 无硬编码密钥或 fallback 默认值
|
||||||
|
|
||||||
|
#### 前后端接口同步检查(强制)
|
||||||
|
|
||||||
|
> 前后端接口不一致是高频 bug 来源 — 任何 DTO 变更必须双向同步。
|
||||||
|
> 后端 DTO 变更时**必须**同步检查前端:
|
||||||
|
|
||||||
|
- [ ] DTO 字段名变更 → 前端 TypeScript 接口同步更新
|
||||||
|
- [ ] DTO 新增必填字段 → 前端表单和请求体同步更新
|
||||||
|
- [ ] API 路径变更 → 前端 `api/` 模块路径同步更新
|
||||||
|
- [ ] 返回数据结构变更(数组/对象/嵌套)→ 前端解析逻辑同步更新
|
||||||
|
- [ ] 枚举值变更 → 前端类型定义和 UI 映射同步更新
|
||||||
|
- [ ] 后端新增端点 → 前端 API 模块同步添加调用函数,不允许留空
|
||||||
|
|
||||||
|
#### DTO 输入校验检查(强制)
|
||||||
|
|
||||||
|
> DTO 输入校验是安全防线 — 缺失校验等于暴露攻击面,Update 和 Create 必须对称。
|
||||||
|
> 新增/修改 DTO 时**必须**逐项确认:
|
||||||
|
|
||||||
|
- [ ] 所有请求结构体已 `derive(Validate)`(包括 Update\*Req、查询参数)
|
||||||
|
- [ ] Update\*Req 与 Create\*Req 校验对称(不允许 Update 降级)
|
||||||
|
- [ ] 字符串字段有 `#[validate(length(min, max))]`
|
||||||
|
- [ ] 枚举/类型字段有 `#[validate(custom)]` 限制合法值
|
||||||
|
- [ ] 集合字段有 `#[validate(length(min = 1))]` 非空检查
|
||||||
|
- [ ] 数值范围字段有 `#[validate(range(min, max))]`
|
||||||
|
- [ ] URL 字段有 SSRF 防护(禁止 localhost/内网地址,仅 http/https)
|
||||||
|
- [ ] 密码字段有 `max = 128` 防止 DoS
|
||||||
|
- [ ] handler 层已调用 `req.validate().map_err(|e| AppError::Validation(e.to_string()))?`
|
||||||
|
|
||||||
|
### 3.4 事件总线
|
||||||
|
|
||||||
|
- 模块间通信**只能**通过 `EventBus`
|
||||||
|
- 事件必须持久化到 `domain_events` 表(outbox 模式)
|
||||||
|
- 事件处理失败记录到 dead-letter 存储
|
||||||
|
- 事件类型命名:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed`
|
||||||
|
- **铁律:每个事件必须有至少一个消费者,否则功能不算完成。** 新增事件发布时必须同步实现消费者和对应测试。详见 `docs/discussions/2026-04-28-architecture-retrospective.md` §4。
|
||||||
|
|
||||||
|
### 3.5 Rust 代码规范
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 命名:snake_case (函数/变量), PascalCase (类型/trait), SCREAMING_SNAKE (常量)
|
||||||
|
// 模块公开接口通过 lib.rs 统一导出
|
||||||
|
// 每个 public 函数和 trait 必须有文档注释
|
||||||
|
// 异步函数返回 Result 时使用 AppResult<T> 类型别名
|
||||||
|
// 数据库操作使用 SeaORM 的 Entity + Model + Relation 模式
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 TypeScript / React 代码规范
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 避免 any,优先 unknown + 类型守卫
|
||||||
|
// 函数组件 + hooks
|
||||||
|
// 复杂状态收敛到 Zustand store
|
||||||
|
// API 调用封装到独立的 service 层,不在组件中直接 fetch
|
||||||
|
// 使用 Ant Design 组件,不自行实现已有组件
|
||||||
|
// 国际化文案使用 i18n key,不硬编码中文
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 安全规范
|
||||||
|
|
||||||
|
#### 密钥与凭据管理
|
||||||
|
|
||||||
|
- 所有密钥、token、密码**必须**通过环境变量或密钥管理服务注入,**禁止**硬编码在源码中
|
||||||
|
- 开发环境密钥**必须**与生产环境严格隔离(`cfg(debug_assertions)` 编译期防护)
|
||||||
|
- 生产密钥**禁止**有 fallback 默认值,缺失时启动 panic
|
||||||
|
- 新增密钥时必须同步更新 `.env.example` 和 `wiki/infrastructure.md`
|
||||||
|
|
||||||
|
#### 依赖安全
|
||||||
|
|
||||||
|
- 新增依赖前**必须**检查已知漏洞(`cargo audit` / `npm audit`)
|
||||||
|
- 禁止引入有未修补高危漏洞的依赖版本
|
||||||
|
- 定期更新依赖到最新安全补丁版本
|
||||||
|
|
||||||
|
#### 数据安全
|
||||||
|
|
||||||
|
- PII 数据(姓名、身份证号、手机号、地址等)**必须**加密存储(AES-256-GCM)
|
||||||
|
- 日志中**禁止**输出 PII 数据和认证凭据(密码、token、session key)
|
||||||
|
- 敏感操作(登录、权限变更、数据导出、批量删除)**必须**记录审计日志(操作者、时间、目标、结果)
|
||||||
|
- 文件上传**必须**验证 MIME 类型 + 限制文件大小 + 防止路径穿越(文件名 sanitize)
|
||||||
|
|
||||||
|
#### 传输安全
|
||||||
|
|
||||||
|
- 生产环境**必须**强制 HTTPS,**禁止**降级到 HTTP
|
||||||
|
- HTTP 响应**必须**包含安全头(HSTS、CSP、X-Frame-Options、X-Content-Type-Options、Permissions-Policy)
|
||||||
|
- SSE/WebSocket 长连接认证 token 不通过 URL query 参数传递(使用 header 或 cookie)
|
||||||
|
- API 响应**禁止**暴露内部实现细节(堆栈跟踪、数据库错误、文件路径、SQL 语句)
|
||||||
|
|
||||||
|
#### 认证与授权
|
||||||
|
|
||||||
|
- 密码**必须**使用单向哈希(bcrypt/argon2),**禁止**明文或可逆加密存储
|
||||||
|
- JWT **必须**设置合理过期时间,支持 token 吊销机制
|
||||||
|
- 敏感操作(删除数据、权限变更)需要二次确认
|
||||||
|
- 权限检查在 handler 层执行,**禁止**仅依赖前端隐藏控制访问
|
||||||
|
- `tenant_id` **必须**从 JWT 中间件注入,**禁止**信任客户端传递的值
|
||||||
|
|
||||||
|
#### 速率限制
|
||||||
|
|
||||||
|
- 所有 API 端点**必须**配置速率限制
|
||||||
|
- 认证相关端点(登录、注册、密码重置)限制更严格
|
||||||
|
- 批量操作和数据导出需要独立的速率限制策略
|
||||||
|
- 速率限制超出时返回 429 状态码,响应包含 `Retry-After` header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 测试与验证
|
||||||
|
|
||||||
|
### 4.1 测试要求
|
||||||
|
|
||||||
|
| 测试类型 | 覆盖目标 | 工具 |
|
||||||
|
|----------|---------|------|
|
||||||
|
| 单元测试 | 每个 service 函数 | `#[cfg(test)]` + `tokio::test` |
|
||||||
|
| 集成测试 | API 端点 → 数据库 | Testcontainers + 真实 PostgreSQL |
|
||||||
|
| 多租户测试 | 数据隔离验证 | 独立测试 crate |
|
||||||
|
| E2E 测试 | 前端关键流程 | Playwright |
|
||||||
|
| 插件测试 | 动态表 CRUD + 租户隔离 | Testcontainers |
|
||||||
|
|
||||||
|
### 4.2 验证命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rust 编译检查
|
||||||
|
cargo check
|
||||||
|
|
||||||
|
# Rust 全量测试
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
# 后端服务启动
|
||||||
|
cd crates/erp-server && cargo run
|
||||||
|
|
||||||
|
# Docker 环境
|
||||||
|
cd docker && docker compose up -d
|
||||||
|
|
||||||
|
# 桌面端开发
|
||||||
|
cd apps/desktop && pnpm tauri dev
|
||||||
|
|
||||||
|
# 数据库迁移检查
|
||||||
|
docker exec erp-postgres psql -U erp -c "\dt"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Phase 完成标准
|
||||||
|
|
||||||
|
每个 Phase 完成时必须满足:
|
||||||
|
|
||||||
|
- [ ] `cargo check` 全 workspace 通过
|
||||||
|
- [ ] `cargo test` 全部通过
|
||||||
|
- [ ] PostgreSQL 服务正常运行,迁移自动执行
|
||||||
|
- [ ] 所有迁移可正/反向执行
|
||||||
|
- [ ] API 端点可通过 Swagger UI 测试
|
||||||
|
- [ ] 桌面端可正常启动并展示对应 UI
|
||||||
|
- [ ] 所有代码已提交
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 提交规范
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
```
|
||||||
|
|
||||||
|
**类型:**
|
||||||
|
- `feat` — 新功能
|
||||||
|
- `fix` — 修复问题
|
||||||
|
- `refactor` — 重构
|
||||||
|
- `docs` — 文档更新
|
||||||
|
- `test` — 测试相关
|
||||||
|
- `chore` — 杂项(构建、配置等)
|
||||||
|
- `perf` — 性能优化
|
||||||
|
|
||||||
|
**Scope 对应 crate 或模块名:**
|
||||||
|
|
||||||
|
| scope | 范围 |
|
||||||
|
|-------|------|
|
||||||
|
| `core` | erp-core |
|
||||||
|
| `auth` | erp-auth |
|
||||||
|
| `workflow` | erp-workflow |
|
||||||
|
| `message` | erp-message |
|
||||||
|
| `config` | erp-config |
|
||||||
|
| `server` | erp-server |
|
||||||
|
| `health` | erp-health |
|
||||||
|
| `ai` | erp-ai |
|
||||||
|
| `dialysis` | erp-dialysis |
|
||||||
|
| `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample |
|
||||||
|
| `assessment` | erp-plugin-assessment |
|
||||||
|
| `crm` | erp-plugin-crm |
|
||||||
|
| `inventory` | erp-plugin-inventory |
|
||||||
|
| `web` | Web 前端 |
|
||||||
|
| `ui` | React 组件 |
|
||||||
|
| `db` | 数据库迁移 |
|
||||||
|
| `docker` | Docker 配置 |
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(auth): 添加用户管理 CRUD
|
||||||
|
feat(core): 实现事件总线和模块注册
|
||||||
|
fix(server): 修复数据库连接池配置
|
||||||
|
refactor(auth): 拆分 RBAC 和 ABAC 权限模型
|
||||||
|
chore(docker): 添加 PostgreSQL 健康检查
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 反模式警告
|
||||||
|
|
||||||
|
- ❌ **不要**不看 wiki 就开干 — wiki 包含环境配置、数据库连接、启动方式、已知问题,不看就做等于盲猜,浪费时间且产出不可信
|
||||||
|
- ❌ **不要**在业务 crate 之间创建直接依赖 — 只通过事件和 trait 通信
|
||||||
|
- ❌ **不要**跳过多租户中间件 — 所有数据操作必须带 `tenant_id` 过滤
|
||||||
|
- ❌ **不要**硬编码配置值 — 使用 config.toml + 环境变量
|
||||||
|
- ❌ **不要**跳过迁移直接建表 — 所有 schema 变更通过 SeaORM Migration
|
||||||
|
- ❌ **不要**在前端组件中直接调用 HTTP — 封装到 service 层
|
||||||
|
- ❌ **不要**使用 `anyhow` 跨越 crate 边界 — 内部可用,对外必须转 `AppError`
|
||||||
|
- ❌ **不要**假设只有单租户 — 从第一天就按多租户设计
|
||||||
|
- ❌ **不要**提前实现远期功能 — 严格按 Phase 计划推进
|
||||||
|
- ❌ **不要**忽略 `version` 字段 — 所有更新操作必须检查乐观锁
|
||||||
|
- ❌ **不要**在动态表 SQL 中拼接用户输入 — 使用 `sanitize_identifier` 防注入
|
||||||
|
- ❌ **不要**在插件 crate 中直接依赖 erp-auth — 权限注册用 raw SQL,保持模块边界
|
||||||
|
- ❌ **不要**在 plugin.toml 中使用与实体名不一致的权限码 — `permissions[].code` 前缀必须与 `schema.entities[].name` 完全一致(如实体 `customer_tag` → 权限码 `customer_tag.list`/`customer_tag.manage`,不能写成 `tag.manage`),否则页面 403
|
||||||
|
- ❌ **不要**漏掉实体的 `.list` 权限 — 每个实体必须同时声明 `.list` 和 `.manage`,缺少 `.list` 导致列表页 403
|
||||||
|
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
|
||||||
|
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步。每次新会话开始先检查未推送提交
|
||||||
|
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
|
||||||
|
- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害
|
||||||
|
- ❌ **不要**修复 bug 后跳过症状导航更新 — 每个修复都应该帮助未来遇到同类问题的人快速定位根因
|
||||||
|
- ❌ **不要**新增功能后不更新 wiki 关键数字 — 迁移数/路由数/实体数/测试数必须与代码同步,否则 wiki 指标表就是废数据
|
||||||
|
- ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死
|
||||||
|
- ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘
|
||||||
|
- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交将导致后续反复修复
|
||||||
|
- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限
|
||||||
|
- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口
|
||||||
|
- ❌ **不要**写 Update DTO 时省略校验 — Update*Req 必须与 Create*Req 校验对称(Validate derive / 枚举 custom / Vec min=1 / 密码 max=128)
|
||||||
|
- ❌ **不要**URL 字段不做 SSRF 防护 — 禁止 localhost/127.0.0.1/内网地址,仅允许 http/https 协议
|
||||||
|
- ❌ **不要**handler 层跳过 `.validate()` — 所有 `Json<T>` handler 函数体第一行必须调 `req.validate().map_err(\|e\| AppError::Validation(e.to_string()))?`
|
||||||
|
- ❌ **不要**在日志中输出敏感数据 — 密码、token、身份证号、手机号等 PII 信息禁止写入日志
|
||||||
|
- ❌ **不要**信任客户端传递的 `tenant_id` — 必须从 JWT 中间件注入,客户端可伪造
|
||||||
|
- ❌ **不要**在生产代码中使用 `unwrap()` — 必须处理所有错误,使用 `?` 或 `map_err`
|
||||||
|
- ❌ **不要**将内部错误信息返回给客户端 — 数据库错误、堆栈跟踪、文件路径等必须转换为用户友好的错误消息
|
||||||
|
- ❌ **不要**使用 HTTP 传输敏感数据 — 生产环境必须 HTTPS
|
||||||
|
- ❌ **不要**跳过依赖安全检查 — 新增依赖前运行 `cargo audit` / `npm audit`,禁止引入有高危漏洞的版本
|
||||||
|
- ❌ **不要**文件上传不做安全处理 — 必须验证 MIME 类型 + 限制大小 + sanitize 文件名防路径穿越
|
||||||
|
|
||||||
|
### 场景化指令
|
||||||
|
|
||||||
|
- 当遇到**新增模块** → 实现 `ErpModule` trait,在 `erp-server` 注册
|
||||||
|
- 当遇到**跨模块通信** → 定义事件类型,通过 `EventBus` 发布/订阅
|
||||||
|
- 当遇到**数据查询** → 确保包含 `tenant_id` 过滤,检查软删除条件
|
||||||
|
- 当遇到**新增 API** → 添加 utoipa 注解,确保 OpenAPI 文档同步
|
||||||
|
- 当遇到**新增表** → 创建 SeaORM migration + Entity,包含所有标准字段
|
||||||
|
- 当遇到**新增页面** → 使用 Ant Design 组件,i18n key 引用文案
|
||||||
|
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程和 `.claude/skills/plugin-development/SKILL.md`,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component。**权限码必须与实体名一致(每个实体声明 `.list` + `.manage`)**
|
||||||
|
- 当遇到**新增/修改 DTO** → 参考 `wiki/architecture.md` §4 DTO 输入校验规范:`derive(Validate)` + 字段级校验 + handler 层 `validate()` 调用 + 单元测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 详细参考(wiki)
|
||||||
|
|
||||||
|
以下内容已从本文件迁移到 wiki,需要时查阅:
|
||||||
|
|
||||||
|
| 主题 | wiki 页面 |
|
||||||
|
|------|----------|
|
||||||
|
| 目录结构、crate 依赖、技术栈 | `wiki/architecture.md` §2 |
|
||||||
|
| 模块开发规范、ErpModule trait、迁移规范 | `wiki/architecture.md` §3 |
|
||||||
|
| 安全注意事项(认证/多租户/通用) | `wiki/architecture.md` §4 |
|
||||||
|
| UI 布局规范 | `wiki/frontend.md` §2 |
|
||||||
|
| 常用命令(Rust/前端/数据库/WASM) | `wiki/infrastructure.md` §3 |
|
||||||
|
| 设计文档索引 | `wiki/index.md` |
|
||||||
|
| 开发进度、模块状态 | `wiki/index.md` 关键数字 |
|
||||||
|
| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 |
|
||||||
|
|
||||||
|
## graphify — 代码知识图谱
|
||||||
|
|
||||||
|
> 项目知识图谱位于 `graphify-out/`,当前规模:18,517 节点 / 22,666 边 / 1,841 社区(纯 AST 解析,无 API 成本)。
|
||||||
|
> 工具:`python -m graphify`(已安装 graphifyy 0.8.18)。
|
||||||
|
|
||||||
|
### 开发流程中的使用场景
|
||||||
|
|
||||||
|
| 时机 | 命令 | 目的 |
|
||||||
|
|------|------|------|
|
||||||
|
| **接手新任务,理解代码关系** | `graphify query "概念名"` | 搜索相关节点,比 Grep 更精准(按调用/引用/包含关系) |
|
||||||
|
| **排查 bug,追踪调用链** | `graphify path "A" "B"` | 查找两个模块/函数间的最短路径 |
|
||||||
|
| **理解某个模块的职责** | `graphify explain "模块名"` | 自然语言解释节点及其邻居 |
|
||||||
|
| **代码改动后** | `graphify update .` | 增量更新图谱(AST-only,秒级完成) |
|
||||||
|
| **宏观架构审查** | 读 `graphify-out/GRAPH_REPORT.md` | 全局社区结构、跨文件关系概览 |
|
||||||
|
|
||||||
|
### 使用优先级(融入 §2.5 闭环工作法)
|
||||||
|
|
||||||
|
在 §2.5 步骤 1「现状确认」中,**优先使用 graphify 替代盲目 Grep**:
|
||||||
|
|
||||||
|
1. **先 `graphify query`** — 精确定位相关节点和社区(比 Grep 返回更结构化的结果)
|
||||||
|
2. **再 `graphify path`** — 确认模块间依赖路径(避免遗漏间接依赖)
|
||||||
|
3. **最后 Grep/Glob/Read** — 确认 graphify 发现的具体文件内容
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- `graphify update .` 纯本地 AST 解析,不消耗 LLM token,每次代码改动后都可以运行
|
||||||
|
- 查询结果比 GRAPH_REPORT.md 更精准,优先使用 query/path/explain,仅在需要全局视图时读报告
|
||||||
|
- 首次生成需几分钟(1712 文件),后续增量更新秒级完成
|
||||||
6707
Cargo.lock
generated
Normal file
6707
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
115
Cargo.toml
Normal file
115
Cargo.toml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/erp-core",
|
||||||
|
"crates/erp-server",
|
||||||
|
"crates/erp-auth",
|
||||||
|
"crates/erp-workflow",
|
||||||
|
"crates/erp-message",
|
||||||
|
"crates/erp-config",
|
||||||
|
"crates/erp-server/migration",
|
||||||
|
"crates/erp-plugin",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Async
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# Web
|
||||||
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
|
tower = "0.5"
|
||||||
|
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs", "set-header"] }
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sea-orm = { version = "1.1", features = [
|
||||||
|
"sqlx-postgres", "runtime-tokio-rustls", "macros", "with-uuid", "with-chrono", "with-json"
|
||||||
|
] }
|
||||||
|
sea-orm-migration = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls"] }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# UUID & Time
|
||||||
|
uuid = { version = "1", features = ["v7", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
# Config
|
||||||
|
config = "0.14"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
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 需要下载 GitHub 资源,网络受限时暂不使用
|
||||||
|
# utoipa-swagger-ui = { version = "8", features = ["axum"] }
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
validator = { version = "0.19", features = ["derive"] }
|
||||||
|
|
||||||
|
# Async trait
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||||
|
|
||||||
|
# Crypto
|
||||||
|
aes = "0.8"
|
||||||
|
cbc = "0.1"
|
||||||
|
hex = "0.4"
|
||||||
|
regex-lite = "0.1"
|
||||||
|
|
||||||
|
# CSV and Excel export
|
||||||
|
csv = "1"
|
||||||
|
rust_xlsxwriter = "0.82"
|
||||||
|
|
||||||
|
# Internal crates
|
||||||
|
erp-core = { path = "crates/erp-core" }
|
||||||
|
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" }
|
||||||
|
|
||||||
|
# Async streaming
|
||||||
|
futures = "0.3"
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
async-stream = "0.3"
|
||||||
|
dashmap = "6"
|
||||||
|
|
||||||
|
# Template engine
|
||||||
|
handlebars = "6"
|
||||||
|
|
||||||
|
# HTML sanitization
|
||||||
|
ammonia = "4"
|
||||||
|
|
||||||
|
# Document parsing
|
||||||
|
pdf-extract = "0.7"
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
metrics = "0.24"
|
||||||
|
metrics-exporter-prometheus = "0.16"
|
||||||
113
Dockerfile
Normal file
113
Dockerfile
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# ==============================
|
||||||
|
# Stage 1: Build Rust backend
|
||||||
|
# ==============================
|
||||||
|
FROM rust:1-bookworm AS rust-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 先复制依赖文件以利用 Docker 缓存
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates/erp-core/Cargo.toml crates/erp-core/Cargo.toml
|
||||||
|
COPY crates/erp-auth/Cargo.toml crates/erp-auth/Cargo.toml
|
||||||
|
COPY crates/erp-config/Cargo.toml crates/erp-config/Cargo.toml
|
||||||
|
COPY crates/erp-workflow/Cargo.toml crates/erp-workflow/Cargo.toml
|
||||||
|
COPY crates/erp-message/Cargo.toml crates/erp-message/Cargo.toml
|
||||||
|
COPY crates/erp-plugin/Cargo.toml crates/erp-plugin/Cargo.toml
|
||||||
|
COPY crates/erp-health/Cargo.toml crates/erp-health/Cargo.toml
|
||||||
|
COPY crates/erp-ai/Cargo.toml crates/erp-ai/Cargo.toml
|
||||||
|
COPY crates/erp-dialysis/Cargo.toml crates/erp-dialysis/Cargo.toml
|
||||||
|
COPY crates/erp-server/Cargo.toml crates/erp-server/Cargo.toml
|
||||||
|
COPY crates/erp-server/migration/Cargo.toml crates/erp-server/migration/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-prototype/Cargo.toml crates/erp-plugin-prototype/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-test-sample/Cargo.toml crates/erp-plugin-test-sample/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-assessment/Cargo.toml crates/erp-plugin-assessment/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-crm/Cargo.toml crates/erp-plugin-crm/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-freelance/Cargo.toml crates/erp-plugin-freelance/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-inventory/Cargo.toml crates/erp-plugin-inventory/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-itops/Cargo.toml crates/erp-plugin-itops/Cargo.toml
|
||||||
|
|
||||||
|
# 创建空的 lib.rs/main.rs 占位以缓存依赖
|
||||||
|
RUN mkdir -p crates/erp-core/src && echo "" > crates/erp-core/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-auth/src && echo "" > crates/erp-auth/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-config/src && echo "" > crates/erp-config/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-workflow/src && echo "" > crates/erp-workflow/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-message/src && echo "" > crates/erp-message/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-plugin/src && echo "" > crates/erp-plugin/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-health/src && echo "" > crates/erp-health/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-ai/src && echo "" > crates/erp-ai/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-dialysis/src && echo "" > crates/erp-dialysis/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-server/src && echo "fn main(){}" > crates/erp-server/src/main.rs \
|
||||||
|
&& mkdir -p crates/erp-server/migration/src && echo "" > crates/erp-server/migration/src/lib.rs \
|
||||||
|
&& for crate in erp-plugin-prototype erp-plugin-test-sample erp-plugin-assessment erp-plugin-crm erp-plugin-freelance erp-plugin-inventory erp-plugin-itops; do \
|
||||||
|
mkdir -p crates/$crate/src && echo "" > crates/$crate/src/lib.rs; \
|
||||||
|
done
|
||||||
|
|
||||||
|
# 构建依赖(仅当 Cargo.toml/Cargo.lock 变化时重新编译)
|
||||||
|
RUN cargo build --release -p erp-server 2>/dev/null || true
|
||||||
|
|
||||||
|
# 复制实际源码
|
||||||
|
COPY crates/ crates/
|
||||||
|
|
||||||
|
# 重新构建(增量编译,只编译业务代码)
|
||||||
|
RUN cargo build --release -p erp-server
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Stage 2: Build frontend
|
||||||
|
# ==============================
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
COPY apps/web/package.json apps/web/pnpm-lock.yaml ./apps/web/
|
||||||
|
|
||||||
|
RUN cd apps/web && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY apps/web/ ./apps/web/
|
||||||
|
|
||||||
|
RUN cd apps/web && pnpm build
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Stage 3: Production runtime
|
||||||
|
# ==============================
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制 Rust 二进制
|
||||||
|
COPY --from=rust-builder /app/target/release/erp-server /app/erp-server
|
||||||
|
|
||||||
|
# 复制配置文件
|
||||||
|
COPY config/ /app/config/
|
||||||
|
|
||||||
|
# 复制前端构建产物(可通过 volume 暴露给 OpenResty)
|
||||||
|
COPY --from=frontend-builder /app/apps/web/dist/ /app/static/
|
||||||
|
|
||||||
|
# 创建上传目录
|
||||||
|
RUN mkdir -p /app/uploads
|
||||||
|
|
||||||
|
# 非特权用户运行
|
||||||
|
RUN useradd -r -s /bin/false appuser \
|
||||||
|
&& chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# 环境变量(运行时通过 docker-compose / .env 覆盖)
|
||||||
|
ENV ERP__SERVER__HOST=0.0.0.0
|
||||||
|
ENV ERP__SERVER__PORT=3000
|
||||||
|
ENV ERP__SERVER__METRICS_PORT=9090
|
||||||
|
ENV ERP__STORAGE__UPLOAD_DIR=/app/uploads
|
||||||
|
|
||||||
|
EXPOSE 3000 9090
|
||||||
|
|
||||||
|
VOLUME ["/app/uploads", "/app/static"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3000/api/v1/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/erp-server"]
|
||||||
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 # 启动前端
|
||||||
|
```
|
||||||
43
apps/web/e2e/auth.fixture.ts
Normal file
43
apps/web/e2e/auth.fixture.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { test as base } from '@playwright/test';
|
||||||
|
|
||||||
|
const API_BASE = 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
|
let loginPromise: Promise<{ token: string; user: unknown }> | null = null;
|
||||||
|
|
||||||
|
function login(): Promise<{ token: string; user: unknown }> {
|
||||||
|
if (!loginPromise) {
|
||||||
|
loginPromise = (async () => {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'admin', password: 'Admin@2026' }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success) {
|
||||||
|
return { token: json.data.access_token, user: json.data.user };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Wait before retry on collision
|
||||||
|
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
throw new Error('Login failed after 3 attempts');
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return loginPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend({
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
const { token, user } = await login();
|
||||||
|
await page.addInitScript((args) => {
|
||||||
|
localStorage.setItem('access_token', args.token);
|
||||||
|
localStorage.setItem('refresh_token', args.token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(args.user));
|
||||||
|
}, { token, user });
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
22
apps/web/e2e/check-readiness.ts
Normal file
22
apps/web/e2e/check-readiness.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// apps/web/e2e/check-readiness.ts
|
||||||
|
import type { FullConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
async function check(url: string, label: string): Promise<void> {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) return;
|
||||||
|
} catch { /* retry */ }
|
||||||
|
console.log(`⏳ ${label} 未就绪,等待重试 (${i + 1}/5)...`);
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
}
|
||||||
|
throw new Error(`❌ ${label} 未就绪: ${url}。请确认后端服务已启动 (cd crates/erp-server && cargo run)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function globalSetup(_config: FullConfig) {
|
||||||
|
const apiBase = process.env.E2E_API_URL || 'http://localhost:3000';
|
||||||
|
const webBase = process.env.E2E_BASE_URL || 'http://localhost:5174';
|
||||||
|
await check(`${apiBase}/api/v1/health`, '后端 API');
|
||||||
|
await check(webBase, '前端 SPA');
|
||||||
|
console.log('✅ E2E 环境就绪');
|
||||||
|
}
|
||||||
200
apps/web/e2e/fixtures/api-client.ts
Normal file
200
apps/web/e2e/fixtures/api-client.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
// apps/web/e2e/fixtures/api-client.ts
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PatientData, DoctorData, VitalSignsData, ScheduleData,
|
||||||
|
AppointmentData, FollowUpTemplateData, FollowUpTaskData, AlertRuleData,
|
||||||
|
} from './test-data';
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
|
interface ApiResponse<T> { success: boolean; data: T }
|
||||||
|
interface Versioned { id: string; version: number }
|
||||||
|
type VEntity<T> = T & Versioned;
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
user: { id: string; username: string; display_name: string; roles: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiClient {
|
||||||
|
private token = '';
|
||||||
|
|
||||||
|
async login(username?: string, password?: string): Promise<LoginResponse> {
|
||||||
|
const res = await this.rawPost<{ success: boolean; data: LoginResponse }>(
|
||||||
|
'/auth/login',
|
||||||
|
{
|
||||||
|
username: username || process.env.E2E_ADMIN_USER || 'admin',
|
||||||
|
password: password || process.env.E2E_ADMIN_PASS || 'Admin@2026',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.token = res.data.access_token;
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginAsAdmin(): Promise<LoginResponse> {
|
||||||
|
return this.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(): string { return this.token; }
|
||||||
|
|
||||||
|
async createPatient(overrides?: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.post('/health/patients', overrides ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePatient(id: string, version: number, data: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.put(`/health/patients/${id}`, { ...data, version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePatient(id: string, version: number): Promise<void> {
|
||||||
|
await this.del(`/health/patients/${id}`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDoctor(overrides?: Partial<DoctorData>): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.post('/health/doctors', overrides ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDoctor(id: string, version: number): Promise<void> {
|
||||||
|
await this.del(`/health/doctors/${id}`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createVitalSigns(patientId: string, overrides?: Partial<VitalSignsData>): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.post(`/health/patients/${patientId}/vital-signs`, overrides ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteVitalSigns(patientId: string, id: string, version: number): Promise<void> {
|
||||||
|
await this.del(`/health/patients/${patientId}/vital-signs/${id}`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSchedule(overrides: ScheduleData): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.post('/health/doctor-schedules', overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSchedule(id: string, version: number): Promise<void> {
|
||||||
|
await this.del(`/health/doctor-schedules/${id}`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAppointment(overrides: AppointmentData): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.post('/health/appointments', overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAppointmentStatus(id: string, version: number, status: string): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.put(`/health/appointments/${id}/status`, { status, version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAppointment(id: string, version: number): Promise<void> {
|
||||||
|
await this.del(`/health/appointments/${id}`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.post('/health/follow-up-templates', overrides ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFollowUpTemplate(id: string, version: number): Promise<void> {
|
||||||
|
await this.del(`/health/follow-up-templates/${id}`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFollowUpTask(overrides: FollowUpTaskData): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.post('/health/follow-up-tasks', overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFollowUpTask(id: string, version: number): Promise<void> {
|
||||||
|
await this.del(`/health/follow-up-tasks/${id}`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAlertRule(overrides?: Partial<AlertRuleData>): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.post('/health/alert-rules', overrides ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAlertRule(id: string, version: number): Promise<void> {
|
||||||
|
await this.del(`/health/alert-rules/${id}`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAlerts(): Promise<VEntity<Record<string, unknown>>[]> {
|
||||||
|
const res = await this.get<{ data: VEntity<Record<string, unknown>>[] }>('/health/alerts');
|
||||||
|
return res.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async acknowledgeAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.put(`/health/alerts/${id}/acknowledge`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.put(`/health/alerts/${id}/resolve`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismissAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
|
||||||
|
return this.put(`/health/alerts/${id}/dismiss`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async headers(): Promise<Record<string, string>> {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parseJson<T>(res: Response, method: string, path: string): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`${method} ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.success) throw new Error(`${method} ${path} failed: ${json.error ?? 'unknown'}`);
|
||||||
|
return json.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async get<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { headers: await this.headers() });
|
||||||
|
return this.parseJson<T>(res, 'GET', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await this.headers(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return this.parseJson<T>(res, 'POST', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async put<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: await this.headers(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return this.parseJson<T>(res, 'PUT', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async del(path: string, body?: unknown): Promise<void> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: await this.headers(),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 204) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`DELETE ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.success) throw new Error(`DELETE ${path} failed: ${json.error ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rawPost<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`POST ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.success) throw new Error(`POST ${path} failed: ${json.error ?? 'unknown'}`);
|
||||||
|
return json as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apps/web/e2e/fixtures/auth.fixture.ts
Normal file
73
apps/web/e2e/fixtures/auth.fixture.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// apps/web/e2e/fixtures/auth.fixture.ts
|
||||||
|
import { test as base, type Page } from '@playwright/test';
|
||||||
|
import { ApiClient } from './api-client';
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
|
type E2eFixtures = {
|
||||||
|
api: ApiClient;
|
||||||
|
authenticatedPage: Page;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LoginResult {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
user: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(): Promise<LoginResult> {
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: process.env.E2E_ADMIN_USER || 'admin',
|
||||||
|
password: process.env.E2E_ADMIN_PASS || 'Admin@2026',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`HTTP ${res.status}: ${text.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success) return json.data;
|
||||||
|
throw new Error(`Login unsuccessful: ${json.error ?? 'unknown'}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === 4) throw err;
|
||||||
|
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Login failed after 5 attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend<E2eFixtures>({
|
||||||
|
api: async ({}, use) => {
|
||||||
|
const { access_token } = await login();
|
||||||
|
const client = new ApiClient();
|
||||||
|
client['token'] = access_token;
|
||||||
|
await use(client);
|
||||||
|
},
|
||||||
|
|
||||||
|
authenticatedPage: async ({ page }, use) => {
|
||||||
|
const { access_token, refresh_token, user } = await login();
|
||||||
|
await page.addInitScript((args) => {
|
||||||
|
localStorage.setItem('access_token', args.token);
|
||||||
|
localStorage.setItem('refresh_token', args.refresh);
|
||||||
|
localStorage.setItem('user', JSON.stringify(args.userData));
|
||||||
|
}, { token: access_token, refresh: refresh_token, userData: user });
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
const { access_token, refresh_token, user } = await login();
|
||||||
|
await page.addInitScript((args) => {
|
||||||
|
localStorage.setItem('access_token', args.token);
|
||||||
|
localStorage.setItem('refresh_token', args.refresh);
|
||||||
|
localStorage.setItem('user', JSON.stringify(args.userData));
|
||||||
|
}, { token: access_token, refresh: refresh_token, userData: user });
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
198
apps/web/e2e/fixtures/test-data.ts
Normal file
198
apps/web/e2e/fixtures/test-data.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// apps/web/e2e/fixtures/test-data.ts
|
||||||
|
|
||||||
|
export interface PatientData {
|
||||||
|
name: string;
|
||||||
|
gender?: string;
|
||||||
|
birth_date?: string;
|
||||||
|
blood_type?: string;
|
||||||
|
id_number?: string;
|
||||||
|
allergy_history?: string;
|
||||||
|
medical_history_summary?: string;
|
||||||
|
emergency_contact_name?: string;
|
||||||
|
emergency_contact_phone?: string;
|
||||||
|
source?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoctorData {
|
||||||
|
name: string;
|
||||||
|
department?: string;
|
||||||
|
title?: string;
|
||||||
|
specialty?: string;
|
||||||
|
phone?: string;
|
||||||
|
license_number?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VitalSignsData {
|
||||||
|
record_date: string;
|
||||||
|
systolic_bp_morning?: number;
|
||||||
|
diastolic_bp_morning?: number;
|
||||||
|
heart_rate?: number;
|
||||||
|
body_temperature?: number;
|
||||||
|
spo2?: number;
|
||||||
|
blood_sugar?: number;
|
||||||
|
weight?: number;
|
||||||
|
water_intake_ml?: number;
|
||||||
|
urine_output_ml?: number;
|
||||||
|
notes?: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleData {
|
||||||
|
doctor_id: string;
|
||||||
|
schedule_date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
max_appointments?: number;
|
||||||
|
period_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppointmentData {
|
||||||
|
patient_id: string;
|
||||||
|
doctor_id: string;
|
||||||
|
schedule_id: string;
|
||||||
|
appointment_date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FollowUpTemplateData {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
follow_up_type: string;
|
||||||
|
applicable_scope?: string;
|
||||||
|
fields?: Array<{
|
||||||
|
label: string;
|
||||||
|
field_key: string;
|
||||||
|
field_type: string;
|
||||||
|
required?: boolean;
|
||||||
|
options?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FollowUpTaskData {
|
||||||
|
patient_id: string;
|
||||||
|
follow_up_type: string;
|
||||||
|
planned_date: string;
|
||||||
|
assigned_to?: string;
|
||||||
|
content_template?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertRuleData {
|
||||||
|
name: string;
|
||||||
|
device_type: string;
|
||||||
|
condition_type: string;
|
||||||
|
condition_params: Record<string, unknown>;
|
||||||
|
severity?: string;
|
||||||
|
description?: string;
|
||||||
|
apply_tags?: Record<string, unknown>;
|
||||||
|
notify_roles?: Array<string>;
|
||||||
|
cooldown_minutes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
function uid(): string {
|
||||||
|
counter += 1;
|
||||||
|
return `${Date.now()}_${counter}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makePatient(overrides?: Partial<PatientData>): PatientData {
|
||||||
|
const id = uid();
|
||||||
|
return {
|
||||||
|
name: `E2E患者_${id}`,
|
||||||
|
gender: 'male',
|
||||||
|
birth_date: '1990-01-15',
|
||||||
|
id_number: `110101199001${String(Math.random()).slice(2, 8)}`,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDoctor(overrides?: Partial<DoctorData>): DoctorData {
|
||||||
|
const id = uid();
|
||||||
|
return {
|
||||||
|
name: `E2E医生_${id}`,
|
||||||
|
department: '内科',
|
||||||
|
title: '主治医师',
|
||||||
|
specialty: '全科',
|
||||||
|
license_number: `DOC${id}`,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeVitalSigns(overrides?: Partial<VitalSignsData>): VitalSignsData {
|
||||||
|
return {
|
||||||
|
record_date: new Date().toISOString().slice(0, 10),
|
||||||
|
systolic_bp_morning: 120,
|
||||||
|
diastolic_bp_morning: 80,
|
||||||
|
heart_rate: 72,
|
||||||
|
body_temperature: 36.5,
|
||||||
|
spo2: 98,
|
||||||
|
source: 'web_e2e',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSchedule(doctorId: string, overrides?: Partial<ScheduleData>): ScheduleData {
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const date = tomorrow.toISOString().slice(0, 10);
|
||||||
|
return {
|
||||||
|
doctor_id: doctorId,
|
||||||
|
schedule_date: date,
|
||||||
|
start_time: '09:00',
|
||||||
|
end_time: '12:00',
|
||||||
|
max_appointments: 10,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeAppointment(patientId: string, doctorId: string, scheduleId: string, overrides?: Partial<AppointmentData>): AppointmentData {
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const date = tomorrow.toISOString().slice(0, 10);
|
||||||
|
return {
|
||||||
|
patient_id: patientId,
|
||||||
|
doctor_id: doctorId,
|
||||||
|
schedule_id: scheduleId,
|
||||||
|
appointment_date: date,
|
||||||
|
start_time: '09:00',
|
||||||
|
end_time: '10:00',
|
||||||
|
reason: 'E2E测试预约',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>): FollowUpTemplateData {
|
||||||
|
return {
|
||||||
|
name: `E2E随访模板_${uid()}`,
|
||||||
|
description: 'E2E自动创建的随访模板',
|
||||||
|
follow_up_type: 'phone',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeFollowUpTask(patientId: string, _templateId: string, overrides?: Partial<FollowUpTaskData>): FollowUpTaskData {
|
||||||
|
const plannedDate = new Date();
|
||||||
|
plannedDate.setDate(plannedDate.getDate() + 7);
|
||||||
|
return {
|
||||||
|
patient_id: patientId,
|
||||||
|
follow_up_type: 'phone',
|
||||||
|
planned_date: plannedDate.toISOString().slice(0, 10),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeAlertRule(overrides?: Partial<AlertRuleData>): AlertRuleData {
|
||||||
|
return {
|
||||||
|
name: `E2E告警规则_${uid()}`,
|
||||||
|
device_type: 'heart_rate',
|
||||||
|
condition_type: 'single_threshold',
|
||||||
|
condition_params: { direction: 'above', value: 50 },
|
||||||
|
severity: 'warning',
|
||||||
|
description: 'E2E测试低阈值规则,用于触发告警',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
apps/web/e2e/login.spec.ts
Normal file
15
apps/web/e2e/login.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('登录流程', () => {
|
||||||
|
test('显示登录页面', async ({ page }) => {
|
||||||
|
await page.goto('/#/login');
|
||||||
|
await expect(page.locator('.ant-card, .ant-form')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空表单提交显示验证错误', async ({ page }) => {
|
||||||
|
await page.goto('/#/login');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
// Ant Design 应显示验证错误
|
||||||
|
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2); // 用户名 + 密码
|
||||||
|
});
|
||||||
|
});
|
||||||
56
apps/web/e2e/pages/appointment.page.ts
Normal file
56
apps/web/e2e/pages/appointment.page.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// apps/web/e2e/pages/appointment.page.ts
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export class AppointmentPage {
|
||||||
|
readonly page: Page;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async gotoSchedule() {
|
||||||
|
await this.page.goto('/#/health/schedules');
|
||||||
|
await this.page.waitForSelector('.ant-table, .ant-fullcalendar, [class*="calendar"]', { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async gotoAppointments() {
|
||||||
|
await this.page.goto('/#/health/appointments');
|
||||||
|
await this.page.waitForSelector('.ant-table', { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickCreateSchedule() {
|
||||||
|
await this.page.click('button:has-text("新增排班"), button:has-text("创建")');
|
||||||
|
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillScheduleForm(data: { doctor_id?: string; date: string; start_time: string; end_time: string }) {
|
||||||
|
if (data.doctor_id) {
|
||||||
|
await this.page.click('.ant-select');
|
||||||
|
await this.page.click('.ant-select-item-option');
|
||||||
|
}
|
||||||
|
await this.page.fill('input[placeholder*="日期"]', data.date);
|
||||||
|
await this.page.fill('input[placeholder*="开始"]', data.start_time);
|
||||||
|
await this.page.fill('input[placeholder*="结束"]', data.end_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitScheduleForm() {
|
||||||
|
await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary');
|
||||||
|
await this.page.waitForSelector('.ant-message-success', { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickCreateAppointment() {
|
||||||
|
await this.page.click('button:has-text("新增预约"), button:has-text("创建")');
|
||||||
|
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillAppointmentForm(data: { patient_id: string; doctor_id: string; date: string; reason?: string }) {
|
||||||
|
if (data.reason) {
|
||||||
|
await this.page.fill('textarea, input[placeholder*="原因"]', data.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitAppointmentForm() {
|
||||||
|
await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary');
|
||||||
|
await this.page.waitForSelector('.ant-message-success', { timeout: 10000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/web/e2e/pages/index.ts
Normal file
6
apps/web/e2e/pages/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// apps/web/e2e/pages/index.ts
|
||||||
|
export { LoginPage } from './login.page';
|
||||||
|
export { PatientListPage } from './patient-list.page';
|
||||||
|
export { PatientDetailPage } from './patient-detail.page';
|
||||||
|
export { HealthDataPage } from './health-data.page';
|
||||||
|
export { AppointmentPage } from './appointment.page';
|
||||||
48
apps/web/e2e/pages/login.page.ts
Normal file
48
apps/web/e2e/pages/login.page.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// apps/web/e2e/pages/login.page.ts
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export class LoginPage {
|
||||||
|
readonly page: Page;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto('/#/login');
|
||||||
|
await this.page.waitForSelector('.ant-card, .ant-form');
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillUsername(username: string) {
|
||||||
|
await this.page.fill('input[id="username"], input[placeholder*="用户名"]', username);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillPassword(password: string) {
|
||||||
|
await this.page.fill('input[type="password"]', password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickSubmit() {
|
||||||
|
await this.page.click('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
await this.goto();
|
||||||
|
await this.fillUsername(username);
|
||||||
|
await this.fillPassword(password);
|
||||||
|
await this.clickSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getErrorMessage(): Promise<string> {
|
||||||
|
const el = this.page.locator('.ant-form-item-explain-error, .ant-message-error, .ant-alert-error');
|
||||||
|
return el.first().textContent() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async isLoggedIn(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.page.waitForURL('**/#/', { timeout: 5000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/web/e2e/pages/patient-detail.page.ts
Normal file
44
apps/web/e2e/pages/patient-detail.page.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// apps/web/e2e/pages/patient-detail.page.ts
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export class PatientDetailPage {
|
||||||
|
readonly page: Page;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto(id: string) {
|
||||||
|
await this.page.goto(`/#/health/patients/${id}`);
|
||||||
|
await this.page.waitForSelector('.ant-descriptions, .ant-tabs', { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPatientName(): Promise<string> {
|
||||||
|
const el = this.page.locator('div[style*="font-weight"]').first();
|
||||||
|
return el.textContent() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickTab(tabName: string) {
|
||||||
|
await this.page.click(`.ant-tabs-tab:has-text("${tabName}")`);
|
||||||
|
await this.page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVitalSignsCount(): Promise<number> {
|
||||||
|
return this.page.locator('.ant-table-tbody tr').count();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickAssignDoctor() {
|
||||||
|
await this.page.click('button:has-text("分配医生")');
|
||||||
|
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectDoctor(doctorName: string) {
|
||||||
|
await this.page.click('.ant-select');
|
||||||
|
await this.page.click(`.ant-select-item-option:has-text("${doctorName}")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmAssign() {
|
||||||
|
await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary');
|
||||||
|
await this.page.waitForSelector('.ant-message-success', { timeout: 5000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/web/e2e/pages/patient-list.page.ts
Normal file
66
apps/web/e2e/pages/patient-list.page.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// apps/web/e2e/pages/patient-list.page.ts
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export class PatientListPage {
|
||||||
|
readonly page: Page;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto('/#/health/patients');
|
||||||
|
await this.page.waitForSelector('.ant-table', { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickCreate() {
|
||||||
|
await this.page.click('button:has-text("新增"), button:has-text("新建"), button:has-text("创建")');
|
||||||
|
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillCreateForm(data: { name: string; gender?: string; birth_date?: string }) {
|
||||||
|
const drawer = this.page.locator('.ant-drawer');
|
||||||
|
await drawer.locator('input').first().waitFor({ state: 'visible' });
|
||||||
|
await this.page.locator('.ant-drawer [name="name"] input, .ant-drawer input').first().fill(data.name);
|
||||||
|
if (data.gender) {
|
||||||
|
await drawer.locator('.ant-select').first().click();
|
||||||
|
await this.page.locator(`.ant-select-item-option:has-text("${data.gender === 'male' ? '男' : '女'}")`).first().click();
|
||||||
|
}
|
||||||
|
if (data.birth_date) {
|
||||||
|
await drawer.locator('[name="birth_date"] input, input[placeholder*="出生"]').fill(data.birth_date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitForm() {
|
||||||
|
await this.page.click('.ant-drawer button.ant-btn-primary, button:has-text("保存"), .ant-modal .ant-btn-primary');
|
||||||
|
await this.page.waitForSelector('.ant-message-success', { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchPatient(name: string) {
|
||||||
|
const searchInput = this.page.locator('input[placeholder*="搜索"]').first();
|
||||||
|
await searchInput.fill(name);
|
||||||
|
await searchInput.press('Enter');
|
||||||
|
await this.page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickPatientRow(row: number) {
|
||||||
|
const rows = this.page.locator('.ant-table-tbody tr');
|
||||||
|
await rows.nth(row).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickPatientByName(name: string) {
|
||||||
|
await this.searchPatient(name);
|
||||||
|
const row = this.page.locator(`.ant-table-tbody tr:has-text("${name}")`).first();
|
||||||
|
await row.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTableRowCount(): Promise<number> {
|
||||||
|
return this.page.locator('.ant-table-tbody tr').count();
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasPatientInTable(name: string): Promise<boolean> {
|
||||||
|
await this.searchPatient(name);
|
||||||
|
const count = await this.page.locator(`.ant-table-tbody tr:has-text("${name}")`).count();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/web/e2e/plugins.spec.ts
Normal file
24
apps/web/e2e/plugins.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { test, expect } from './auth.fixture';
|
||||||
|
|
||||||
|
test.describe('插件管理', () => {
|
||||||
|
test('插件管理页面加载', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
// 侧边栏显示"扩展管理插件管理"
|
||||||
|
await page.locator('text=扩展管理').first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
// 页面不崩溃
|
||||||
|
await expect(page.locator('main')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('刷新按钮可点击', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.locator('text=扩展管理').first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const refreshBtn = page.locator('button:has-text("刷新")');
|
||||||
|
if (await refreshBtn.isVisible().catch(() => false)) {
|
||||||
|
await expect(refreshBtn).toBeEnabled();
|
||||||
|
await refreshBtn.click();
|
||||||
|
await expect(page.locator('main')).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/web/e2e/smoke/login.spec.ts
Normal file
15
apps/web/e2e/smoke/login.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('登录流程', () => {
|
||||||
|
test('显示登录页面', async ({ page }) => {
|
||||||
|
await page.goto('/#/login');
|
||||||
|
await expect(page.locator('.ant-card, .ant-form')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空表单提交显示验证错误', async ({ page }) => {
|
||||||
|
await page.goto('/#/login');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
// Ant Design 应显示验证错误
|
||||||
|
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2); // 用户名 + 密码
|
||||||
|
});
|
||||||
|
});
|
||||||
24
apps/web/e2e/smoke/plugins.spec.ts
Normal file
24
apps/web/e2e/smoke/plugins.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { test, expect } from '../fixtures/auth.fixture';
|
||||||
|
|
||||||
|
test.describe('插件管理', () => {
|
||||||
|
test('插件管理页面加载', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
// 侧边栏显示"扩展管理插件管理"
|
||||||
|
await page.locator('text=扩展管理').first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
// 页面不崩溃
|
||||||
|
await expect(page.locator('main')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('刷新按钮可点击', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.locator('text=扩展管理').first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const refreshBtn = page.locator('button:has-text("刷新")');
|
||||||
|
if (await refreshBtn.isVisible().catch(() => false)) {
|
||||||
|
await expect(refreshBtn).toBeEnabled();
|
||||||
|
await refreshBtn.click();
|
||||||
|
await expect(page.locator('main')).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/web/e2e/smoke/tenant-isolation.spec.ts
Normal file
39
apps/web/e2e/smoke/tenant-isolation.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { test, expect } from '../fixtures/auth.fixture';
|
||||||
|
|
||||||
|
test.describe('多租户隔离', () => {
|
||||||
|
test('侧边栏按模块分组显示', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// 验证侧边栏模块分组
|
||||||
|
await expect(page.locator('text=基础模块').first()).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('text=业务模块').first()).toBeVisible();
|
||||||
|
await expect(page.locator('text=系统').first()).toBeVisible();
|
||||||
|
|
||||||
|
// 验证关键菜单项
|
||||||
|
await expect(page.locator('text=工作台').first()).toBeVisible();
|
||||||
|
await expect(page.locator('text=用户管理').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('顶部导航栏显示用户信息', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// 验证顶部导航栏显示管理员信息
|
||||||
|
await expect(page.locator('text=系统管理员').first()).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('页面间导航正常工作', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// 点击侧边栏的用户管理(精确匹配侧边栏区域)
|
||||||
|
const sidebar = page.locator('complementary, [class*=sider], [class*=menu], nav').first();
|
||||||
|
await sidebar.locator('text=用户管理').first().click();
|
||||||
|
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// 点击工作台返回
|
||||||
|
await sidebar.locator('text=工作台').first().click();
|
||||||
|
await expect(page).toHaveURL(/#\/$/, { timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
48
apps/web/e2e/smoke/users.spec.ts
Normal file
48
apps/web/e2e/smoke/users.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { test, expect } from '../fixtures/auth.fixture';
|
||||||
|
|
||||||
|
test.describe('用户管理', () => {
|
||||||
|
test('用户列表页面加载并显示表格', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
// 通过侧边栏导航到用户管理
|
||||||
|
await page.locator('text=用户管理').first().click();
|
||||||
|
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
await expect(page.locator('h4')).toContainText('用户管理');
|
||||||
|
// 新建用户按钮
|
||||||
|
await expect(page.locator('button:has-text("新建用户")')).toBeVisible();
|
||||||
|
// 搜索框
|
||||||
|
await expect(page.locator('input[placeholder*="搜索"]')).toBeVisible();
|
||||||
|
// 表格列头
|
||||||
|
await expect(page.locator('text=用户').first()).toBeVisible();
|
||||||
|
await expect(page.locator('text=状态').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('新建用户弹窗表单验证', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.locator('text=用户管理').first().click();
|
||||||
|
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// 点击新建
|
||||||
|
await page.click('button:has-text("新建用户")');
|
||||||
|
// 弹窗出现
|
||||||
|
await expect(page.locator('.ant-modal')).toBeVisible();
|
||||||
|
// 直接提交应显示验证错误(点击 modal 内最后一个 button 即确认按钮)
|
||||||
|
const modalButtons = page.locator('.ant-modal .ant-modal-footer button');
|
||||||
|
await modalButtons.last().click();
|
||||||
|
// Ant Design 应显示验证错误(用户名 + 密码必填)
|
||||||
|
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2);
|
||||||
|
// 关闭弹窗(点击第一个按钮即取消)
|
||||||
|
await modalButtons.first().click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('搜索框可输入', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.locator('text=用户管理').first().click();
|
||||||
|
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
|
||||||
|
|
||||||
|
const searchInput = page.locator('input[placeholder*="搜索"]');
|
||||||
|
await searchInput.fill('admin');
|
||||||
|
await expect(searchInput).toHaveValue('admin');
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/web/e2e/tenant-isolation.spec.ts
Normal file
39
apps/web/e2e/tenant-isolation.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { test, expect } from './auth.fixture';
|
||||||
|
|
||||||
|
test.describe('多租户隔离', () => {
|
||||||
|
test('侧边栏按模块分组显示', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// 验证侧边栏模块分组
|
||||||
|
await expect(page.locator('text=基础模块').first()).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('text=业务模块').first()).toBeVisible();
|
||||||
|
await expect(page.locator('text=系统').first()).toBeVisible();
|
||||||
|
|
||||||
|
// 验证关键菜单项
|
||||||
|
await expect(page.locator('text=工作台').first()).toBeVisible();
|
||||||
|
await expect(page.locator('text=用户管理').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('顶部导航栏显示用户信息', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// 验证顶部导航栏显示管理员信息
|
||||||
|
await expect(page.locator('text=系统管理员').first()).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('页面间导航正常工作', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// 点击侧边栏的用户管理(精确匹配侧边栏区域)
|
||||||
|
const sidebar = page.locator('complementary, [class*=sider], [class*=menu], nav').first();
|
||||||
|
await sidebar.locator('text=用户管理').first().click();
|
||||||
|
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// 点击工作台返回
|
||||||
|
await sidebar.locator('text=工作台').first().click();
|
||||||
|
await expect(page).toHaveURL(/#\/$/, { timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
48
apps/web/e2e/users.spec.ts
Normal file
48
apps/web/e2e/users.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { test, expect } from './auth.fixture';
|
||||||
|
|
||||||
|
test.describe('用户管理', () => {
|
||||||
|
test('用户列表页面加载并显示表格', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
// 通过侧边栏导航到用户管理
|
||||||
|
await page.locator('text=用户管理').first().click();
|
||||||
|
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
await expect(page.locator('h4')).toContainText('用户管理');
|
||||||
|
// 新建用户按钮
|
||||||
|
await expect(page.locator('button:has-text("新建用户")')).toBeVisible();
|
||||||
|
// 搜索框
|
||||||
|
await expect(page.locator('input[placeholder*="搜索"]')).toBeVisible();
|
||||||
|
// 表格列头
|
||||||
|
await expect(page.locator('text=用户').first()).toBeVisible();
|
||||||
|
await expect(page.locator('text=状态').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('新建用户弹窗表单验证', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.locator('text=用户管理').first().click();
|
||||||
|
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// 点击新建
|
||||||
|
await page.click('button:has-text("新建用户")');
|
||||||
|
// 弹窗出现
|
||||||
|
await expect(page.locator('.ant-modal')).toBeVisible();
|
||||||
|
// 直接提交应显示验证错误(点击 modal 内最后一个 button 即确认按钮)
|
||||||
|
const modalButtons = page.locator('.ant-modal .ant-modal-footer button');
|
||||||
|
await modalButtons.last().click();
|
||||||
|
// Ant Design 应显示验证错误(用户名 + 密码必填)
|
||||||
|
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2);
|
||||||
|
// 关闭弹窗(点击第一个按钮即取消)
|
||||||
|
await modalButtons.first().click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('搜索框可输入', async ({ page }) => {
|
||||||
|
await page.goto('/#/');
|
||||||
|
await page.locator('text=用户管理').first().click();
|
||||||
|
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
|
||||||
|
|
||||||
|
const searchInput = page.locator('input[placeholder*="搜索"]');
|
||||||
|
await searchInput.fill('admin');
|
||||||
|
await expect(searchInput).toHaveValue('admin');
|
||||||
|
});
|
||||||
|
});
|
||||||
5980
apps/web/pnpm-lock.yaml
generated
Normal file
5980
apps/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
apps/web/public/crm.wasm
Normal file
BIN
apps/web/public/crm.wasm
Normal file
Binary file not shown.
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 |
BIN
apps/web/public/inventory.wasm
Normal file
BIN
apps/web/public/inventory.wasm
Normal file
Binary file not shown.
349
apps/web/public/mockServiceWorker.js
Normal file
349
apps/web/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker.
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PACKAGE_VERSION = '2.13.6'
|
||||||
|
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||||
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||||
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
|
addEventListener('install', function () {
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('activate', function (event) {
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('message', async function (event) {
|
||||||
|
const clientId = Reflect.get(event.source || {}, 'id')
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case 'KEEPALIVE_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'KEEPALIVE_RESPONSE',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INTEGRITY_CHECK_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||||
|
payload: {
|
||||||
|
packageVersion: PACKAGE_VERSION,
|
||||||
|
checksum: INTEGRITY_CHECKSUM,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_ACTIVATE': {
|
||||||
|
activeClientIds.add(clientId)
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'MOCKING_ENABLED',
|
||||||
|
payload: {
|
||||||
|
client: {
|
||||||
|
id: client.id,
|
||||||
|
frameType: client.frameType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLIENT_CLOSED': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('fetch', function (event) {
|
||||||
|
const requestInterceptedAt = Date.now()
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (
|
||||||
|
event.request.cache === 'only-if-cached' &&
|
||||||
|
event.request.mode !== 'same-origin'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been terminated (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
*/
|
||||||
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||||
|
const client = await resolveMainClient(event)
|
||||||
|
const requestCloneForEvents = event.request.clone()
|
||||||
|
const response = await getResponse(
|
||||||
|
event,
|
||||||
|
client,
|
||||||
|
requestId,
|
||||||
|
requestInterceptedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||||
|
|
||||||
|
// Clone the response so both the client and the library could consume it.
|
||||||
|
const responseClone = response.clone()
|
||||||
|
|
||||||
|
sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'RESPONSE',
|
||||||
|
payload: {
|
||||||
|
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||||
|
request: {
|
||||||
|
id: requestId,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
type: responseClone.type,
|
||||||
|
status: responseClone.status,
|
||||||
|
statusText: responseClone.statusText,
|
||||||
|
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||||
|
body: responseClone.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the main client for the given event.
|
||||||
|
* Client that issues a request doesn't necessarily equal the client
|
||||||
|
* that registered the worker. It's with the latter the worker should
|
||||||
|
* communicate with during the response resolving phase.
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @returns {Promise<Client | undefined>}
|
||||||
|
*/
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId)
|
||||||
|
|
||||||
|
if (activeClientIds.has(event.clientId)) {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client?.frameType === 'top-level') {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === 'visible'
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {Client | undefined} client
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||||
|
// Clone the request because it might've been already used
|
||||||
|
// (i.e. its body has been read and sent to the client).
|
||||||
|
const requestClone = event.request.clone()
|
||||||
|
|
||||||
|
function passthrough() {
|
||||||
|
// Cast the request headers to a new Headers instance
|
||||||
|
// so the headers can be manipulated with.
|
||||||
|
const headers = new Headers(requestClone.headers)
|
||||||
|
|
||||||
|
// Remove the "accept" header value that marked this request as passthrough.
|
||||||
|
// This prevents request alteration and also keeps it compliant with the
|
||||||
|
// user-defined CORS policies.
|
||||||
|
const acceptHeader = headers.get('accept')
|
||||||
|
if (acceptHeader) {
|
||||||
|
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||||
|
const filteredValues = values.filter(
|
||||||
|
(value) => value !== 'msw/passthrough',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filteredValues.length > 0) {
|
||||||
|
headers.set('accept', filteredValues.join(', '))
|
||||||
|
} else {
|
||||||
|
headers.delete('accept')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(requestClone, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass mocking when the client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that a request has been intercepted.
|
||||||
|
const serializedRequest = await serializeRequest(event.request)
|
||||||
|
const clientMessage = await sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'REQUEST',
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
interceptedAt: requestInterceptedAt,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[serializedRequest.body],
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case 'MOCK_RESPONSE': {
|
||||||
|
return respondWithMock(clientMessage.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PASSTHROUGH': {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Client} client
|
||||||
|
* @param {any} message
|
||||||
|
* @param {Array<Transferable>} transferrables
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
function sendToClient(client, message, transferrables = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.postMessage(message, [
|
||||||
|
channel.port2,
|
||||||
|
...transferrables.filter(Boolean),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Response} response
|
||||||
|
* @returns {Response}
|
||||||
|
*/
|
||||||
|
function respondWithMock(response) {
|
||||||
|
// Setting response status code to 0 is a no-op.
|
||||||
|
// However, when responding with a "Response.error()", the produced Response
|
||||||
|
// instance will have status code set to 0. Since it's not possible to create
|
||||||
|
// a Response instance with status code 0, handle that use-case separately.
|
||||||
|
if (response.status === 0) {
|
||||||
|
return Response.error()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockedResponse = new Response(response.body, response)
|
||||||
|
|
||||||
|
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||||
|
value: true,
|
||||||
|
enumerable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return mockedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
async function serializeRequest(request) {
|
||||||
|
return {
|
||||||
|
url: request.url,
|
||||||
|
mode: request.mode,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
cache: request.cache,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body: await request.arrayBuffer(),
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
}
|
||||||
|
}
|
||||||
2
apps/web/public/robots.txt
Normal file
2
apps/web/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
258
apps/web/src/App.tsx
Normal file
258
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { useEffect, lazy, Suspense, useMemo } from 'react';
|
||||||
|
import { HashRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { ConfigProvider, theme as antdTheme, Spin, Result } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import MainLayout from './layouts/MainLayout';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
import { useAuthStore } from './stores/auth';
|
||||||
|
import { useAppStore } from './stores/app';
|
||||||
|
import type { ThemeName } from './stores/app';
|
||||||
|
import { ROUTE_PERMISSIONS, FROZEN_ROUTES, validateRouteCoverage } from './routeConfig';
|
||||||
|
|
||||||
|
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 PluginMarket = lazy(() => import('./pages/PluginMarket'));
|
||||||
|
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 FrozenRoute() {
|
||||||
|
return <Result status="info" title="功能暂未开放" subTitle="该功能正在优化中,敬请期待" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForbiddenPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
||||||
|
<Result
|
||||||
|
status="403"
|
||||||
|
title="权限不足"
|
||||||
|
subTitle="您没有访问此页面的权限,请联系管理员"
|
||||||
|
extra={<button onClick={() => navigate('/')} style={{ cursor: 'pointer', color: 'var(--ant-color-primary)', background: 'none', border: 'none', fontSize: 14 }}>返回首页</button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const permissions = useAuthStore((s) => s.permissions);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
|
||||||
|
const path = location.pathname;
|
||||||
|
|
||||||
|
// 冻结路由检查
|
||||||
|
if (FROZEN_ROUTES.some((frozen) => path.startsWith(frozen))) {
|
||||||
|
return <FrozenRoute />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首页/工作台始终放行
|
||||||
|
if (path === '/' || path === '') return <>{children}</>;
|
||||||
|
|
||||||
|
const matchedPrefix = Object.keys(ROUTE_PERMISSIONS).find(
|
||||||
|
(prefix) => path === prefix || path.startsWith(prefix + '/'),
|
||||||
|
);
|
||||||
|
if (matchedPrefix) {
|
||||||
|
const required = ROUTE_PERMISSIONS[matchedPrefix];
|
||||||
|
const hasAccess = required.some((r) => permissions.includes(r));
|
||||||
|
if (!hasAccess) return <ForbiddenPage />;
|
||||||
|
} else {
|
||||||
|
return <ForbiddenPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseToken = {
|
||||||
|
borderRadius: 10,
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
borderRadiusSM: 6,
|
||||||
|
fontFamily: "'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif",
|
||||||
|
fontSize: 14,
|
||||||
|
fontSizeHeading4: 20,
|
||||||
|
controlHeight: 40,
|
||||||
|
controlHeightLG: 44,
|
||||||
|
controlHeightSM: 32,
|
||||||
|
boxShadow: 'none',
|
||||||
|
boxShadowSecondary: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseComponents = {
|
||||||
|
Button: { primaryShadow: 'none', fontWeight: 500 },
|
||||||
|
Card: { paddingLG: 20 },
|
||||||
|
Menu: { itemBorderRadius: 10, itemMarginInline: 8, itemHeight: 40 },
|
||||||
|
Modal: { borderRadiusLG: 16 },
|
||||||
|
Tag: { borderRadiusSM: 6 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const themeConfigs: Record<ThemeName, { token: Record<string, unknown>; components: Record<string, Record<string, unknown>> }> = {
|
||||||
|
blue: {
|
||||||
|
token: {
|
||||||
|
...baseToken,
|
||||||
|
colorPrimary: '#2563eb',
|
||||||
|
colorSuccess: '#059669',
|
||||||
|
colorWarning: '#d97706',
|
||||||
|
colorError: '#dc2626',
|
||||||
|
colorInfo: '#0284c7',
|
||||||
|
colorBgLayout: '#f8fafc',
|
||||||
|
colorBgContainer: '#ffffff',
|
||||||
|
colorBgElevated: '#ffffff',
|
||||||
|
colorBorder: '#e2e8f0',
|
||||||
|
colorBorderSecondary: '#f1f5f9',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
...baseComponents,
|
||||||
|
Table: { headerBg: '#f1f5f9', headerColor: '#475569', rowHoverBg: '#f1f5f9', fontSize: 14 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warm: {
|
||||||
|
token: {
|
||||||
|
...baseToken,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderRadiusLG: 14,
|
||||||
|
borderRadiusSM: 8,
|
||||||
|
colorPrimary: '#C4623A',
|
||||||
|
colorSuccess: '#5B7A5E',
|
||||||
|
colorWarning: '#C4873A',
|
||||||
|
colorError: '#B54A4A',
|
||||||
|
colorInfo: '#8B7A5E',
|
||||||
|
colorBgLayout: '#F5F0EB',
|
||||||
|
colorBgContainer: '#ffffff',
|
||||||
|
colorBgElevated: '#ffffff',
|
||||||
|
colorBorder: '#E8E2DC',
|
||||||
|
colorBorderSecondary: '#F0EBE5',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
...baseComponents,
|
||||||
|
Table: { headerBg: '#EDE8E2', headerColor: '#7A756E', rowHoverBg: '#F5F0EB', fontSize: 14 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
token: {
|
||||||
|
...baseToken,
|
||||||
|
colorPrimary: '#60A5FA',
|
||||||
|
colorSuccess: '#34D399',
|
||||||
|
colorWarning: '#FBBF24',
|
||||||
|
colorError: '#F87171',
|
||||||
|
colorInfo: '#38BDF8',
|
||||||
|
colorBgLayout: '#0F172A',
|
||||||
|
colorBgContainer: '#1E293B',
|
||||||
|
colorBgElevated: '#334155',
|
||||||
|
colorBorder: '#334155',
|
||||||
|
colorBorderSecondary: 'rgba(255,255,255,0.06)',
|
||||||
|
boxShadow: 'none',
|
||||||
|
boxShadowSecondary: '0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2)',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
...baseComponents,
|
||||||
|
Table: { headerBg: '#1E293B', headerColor: '#94A3B8', rowHoverBg: '#1E293B', fontSize: 14 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emerald: {
|
||||||
|
token: {
|
||||||
|
...baseToken,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderRadiusLG: 14,
|
||||||
|
borderRadiusSM: 8,
|
||||||
|
colorPrimary: '#5B7A5E',
|
||||||
|
colorSuccess: '#3D7A42',
|
||||||
|
colorWarning: '#B8863A',
|
||||||
|
colorError: '#A54A4A',
|
||||||
|
colorInfo: '#4A7A8B',
|
||||||
|
colorBgLayout: '#F4F7F4',
|
||||||
|
colorBgContainer: '#ffffff',
|
||||||
|
colorBgElevated: '#ffffff',
|
||||||
|
colorBorder: '#D5DED5',
|
||||||
|
colorBorderSecondary: '#E5ECE5',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
...baseComponents,
|
||||||
|
Table: { headerBg: '#EDF2ED', headerColor: '#5A6E5A', rowHoverBg: '#F4F7F4', fontSize: 14 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
|
||||||
|
const themeName = useAppStore((s) => s.theme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFromStorage();
|
||||||
|
}, [loadFromStorage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', themeName);
|
||||||
|
}, [themeName]);
|
||||||
|
|
||||||
|
// DEV mode: validate all routes have permission declarations
|
||||||
|
useEffect(() => {
|
||||||
|
validateRouteCoverage([
|
||||||
|
"/users", "/roles", "/organizations", "/workflow", "/messages", "/settings",
|
||||||
|
"/plugins/admin", "/plugins/market",
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDark = themeName === 'dark';
|
||||||
|
const antTheme = useMemo(() => themeConfigs[themeName] ?? themeConfigs.blue, [themeName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<a href="#root" className="erp-skip-link">跳转到主要内容</a>
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
...antTheme,
|
||||||
|
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HashRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<MainLayout>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<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/market" element={<PluginMarket />} />
|
||||||
|
<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>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</MainLayout>
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</HashRouter>
|
||||||
|
</ConfigProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/web/src/api/auditLogs.test.ts
Normal file
51
apps/web/src/api/auditLogs.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* auditLogs API 契约测试
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as auditLogsApi from './auditLogs'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auditLogs API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listAuditLogs 应调用 GET /audit-logs 并传递查询参数', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await auditLogsApi.listAuditLogs({ resource_type: 'user', user_id: 'u-001', page: 1, page_size: 10 })
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/audit-logs', {
|
||||||
|
params: expect.objectContaining({
|
||||||
|
resource_type: 'user',
|
||||||
|
user_id: 'u-001',
|
||||||
|
page: 1,
|
||||||
|
page_size: 10,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('listAuditLogs 默认应传 page=1 page_size=20', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await auditLogsApi.listAuditLogs()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/audit-logs', {
|
||||||
|
params: expect.objectContaining({ page: 1, page_size: 20 }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
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 './types';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
55
apps/web/src/api/auth.test.ts
Normal file
55
apps/web/src/api/auth.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* auth API 契约测试
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as authApi from './auth'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('login 应调用 POST /auth/login 并传递用户名密码', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
await authApi.login({ username: 'admin', password: '123456' })
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/auth/login', {
|
||||||
|
username: 'admin',
|
||||||
|
password: '123456',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout 应调用 POST /auth/logout', async () => {
|
||||||
|
mockPost.mockResolvedValue(undefined)
|
||||||
|
await authApi.logout()
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/auth/logout')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changePassword 应调用 POST /auth/change-password', async () => {
|
||||||
|
mockPost.mockResolvedValue(undefined)
|
||||||
|
await authApi.changePassword('oldPass', 'newPass')
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/auth/change-password', {
|
||||||
|
current_password: 'oldPass',
|
||||||
|
new_password: 'newPass',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
55
apps/web/src/api/auth.ts
Normal file
55
apps/web/src/api/auth.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
261
apps/web/src/api/client.ts
Normal file
261
apps/web/src/api/client.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { message as antMessage } from "antd";
|
||||||
|
|
||||||
|
// 请求缓存:短时间内相同请求复用结果
|
||||||
|
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 || {})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAdapter = axios.getAdapter(axios.defaults.adapter);
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: "/api/v1",
|
||||||
|
timeout: 10000,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
adapter: (config) => {
|
||||||
|
// GET 请求检查缓存
|
||||||
|
if (config.method === "get" && config.url) {
|
||||||
|
const key = getCacheKey(config);
|
||||||
|
const entry = requestCache.get(key);
|
||||||
|
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: entry.data,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK (cached)",
|
||||||
|
headers: new axios.AxiosHeaders(),
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultAdapter(config);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decode JWT payload without external library
|
||||||
|
function decodeJwtPayload(
|
||||||
|
token: string,
|
||||||
|
): { exp?: number; sub?: string } | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const payload = JSON.parse(
|
||||||
|
atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")),
|
||||||
|
);
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired or about to expire (within 30s buffer)
|
||||||
|
function isTokenExpiringSoon(token: string): boolean {
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
if (!payload?.exp) return true;
|
||||||
|
return Date.now() / 1000 > payload.exp - 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request interceptor: attach access token + proactive refresh
|
||||||
|
client.interceptors.request.use(async (config) => {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
if (token) {
|
||||||
|
// If token is about to expire, proactively refresh before sending the request
|
||||||
|
if (isTokenExpiringSoon(token)) {
|
||||||
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
|
if (refreshToken && !isRefreshing) {
|
||||||
|
isRefreshing = true;
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post("/api/v1/auth/refresh", {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
const newAccess = data.data.access_token;
|
||||||
|
const newRefresh = data.data.refresh_token;
|
||||||
|
|
||||||
|
// 验证新 token 的用户身份一致
|
||||||
|
const currentUserSub = decodeJwtPayload(token)?.sub;
|
||||||
|
const newTokenSub = decodeJwtPayload(newAccess)?.sub;
|
||||||
|
if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
window.location.hash = "/login";
|
||||||
|
return Promise.reject(new Error("身份验证失败,请重新登录"));
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem("access_token", newAccess);
|
||||||
|
localStorage.setItem("refresh_token", newRefresh);
|
||||||
|
processQueue(null, newAccess);
|
||||||
|
config.headers.Authorization = `Bearer ${newAccess}`;
|
||||||
|
return config;
|
||||||
|
} catch {
|
||||||
|
processQueue(new Error("refresh failed"), null);
|
||||||
|
// Continue with old token, let 401 handler deal with it
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
if (
|
||||||
|
error.response?.status === 401 &&
|
||||||
|
!originalRequest._retry &&
|
||||||
|
!originalRequest.url?.includes("/auth/login")
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 验证新 token 的用户身份与当前用户一致,防止并发场景下身份切换
|
||||||
|
const currentToken = localStorage.getItem("access_token");
|
||||||
|
const currentUserSub = currentToken
|
||||||
|
? decodeJwtPayload(currentToken)?.sub
|
||||||
|
: null;
|
||||||
|
const newTokenSub = decodeJwtPayload(newAccessToken)?.sub;
|
||||||
|
if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) {
|
||||||
|
// 身份不一致,强制登出
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
window.location.hash = "/login";
|
||||||
|
return Promise.reject(new Error("身份验证失败,请重新登录"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 globalErrorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
function showGlobalError(msg: string) {
|
||||||
|
// 防止短时间内弹出大量相同提示
|
||||||
|
if (globalErrorTimer) return;
|
||||||
|
antMessage.error(msg, 3);
|
||||||
|
globalErrorTimer = setTimeout(() => {
|
||||||
|
globalErrorTimer = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局错误拦截 — 在响应拦截器之后、组件 catch 之前执行
|
||||||
|
// 组件可通过 axios config 中设置 skipGlobalError: true 来抑制全局提示
|
||||||
|
declare module "axios" {
|
||||||
|
interface AxiosRequestConfig {
|
||||||
|
skipGlobalError?: boolean;
|
||||||
|
}
|
||||||
|
interface InternalAxiosRequestConfig {
|
||||||
|
skipGlobalError?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.config?.skipGlobalError) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
if (!error.response) {
|
||||||
|
showGlobalError("网络连接异常,请检查网络");
|
||||||
|
} else if (error.response.status === 403) {
|
||||||
|
// 403 通常是权限不足,不全局提示 — 组件层通过 AuthButton 已隐藏操作入口
|
||||||
|
} else if (error.response.status === 404) {
|
||||||
|
// 404 通常由组件自行处理(如跳转),不全局提示
|
||||||
|
} else if (error.response.status >= 500) {
|
||||||
|
showGlobalError("服务器异常,请稍后重试");
|
||||||
|
}
|
||||||
|
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 function handleApiError(err: unknown, fallback = "操作失败"): string {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message || fallback;
|
||||||
|
antMessage.error(msg);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default client;
|
||||||
197
apps/web/src/api/config-modules.test.ts
Normal file
197
apps/web/src/api/config-modules.test.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* config-modules API 契约测试(menus + settings + languages + numberingRules + themes)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as menusApi from './menus'
|
||||||
|
import * as settingsApi from './settings'
|
||||||
|
import * as languagesApi from './languages'
|
||||||
|
import * as numberingApi from './numberingRules'
|
||||||
|
import * as themesApi from './themes'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// menus
|
||||||
|
// ============================================================
|
||||||
|
describe('menus API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('getMenus 应调用 GET /config/menus', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await menusApi.getMenus()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/config/menus')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getMenusForUser 应调用 GET /menus/user', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await menusApi.getMenusForUser()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/menus/user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('batchSaveMenus 应调用 PUT /config/menus 并传递 menus 数组', async () => {
|
||||||
|
mockPut.mockResolvedValue(undefined)
|
||||||
|
const menus = [{ title: '仪表盘', path: '/dashboard' }]
|
||||||
|
await menusApi.batchSaveMenus(menus)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/config/menus', { menus })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createMenu 应调用 POST /config/menus', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { title: '新菜单', path: '/new', sort_order: 10 }
|
||||||
|
await menusApi.createMenu(req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/config/menus', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteMenu 应调用 DELETE /config/menus/:id 并在 body 传递 version', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await menusApi.deleteMenu('menu-001', 3)
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/config/menus/menu-001', { data: { version: 3 } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// settings
|
||||||
|
// ============================================================
|
||||||
|
describe('settings API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('getSetting 应调用 GET /config/settings/:key 并传递 scope 参数', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await settingsApi.getSetting('site.name', 'global', 'org-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/config/settings/site.name', {
|
||||||
|
params: { scope: 'global', scope_id: 'org-001' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateSetting 应调用 PUT /config/settings/:key', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
await settingsApi.updateSetting('site.name', '新名称', 1)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/config/settings/site.name', {
|
||||||
|
setting_value: '新名称',
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteSetting 应调用 DELETE /config/settings/:key', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await settingsApi.deleteSetting('site.name', 2)
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/config/settings/site.name', { data: { version: 2 } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// languages
|
||||||
|
// ============================================================
|
||||||
|
describe('languages API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listLanguages 应调用 GET /config/languages', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await languagesApi.listLanguages()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/config/languages')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateLanguage 应调用 PUT /config/languages/:code', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
await languagesApi.updateLanguage('zh-CN', { is_active: true, name: '简体中文' })
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/config/languages/zh-CN', {
|
||||||
|
is_active: true,
|
||||||
|
name: '简体中文',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// numberingRules
|
||||||
|
// ============================================================
|
||||||
|
describe('numberingRules API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listNumberingRules 应调用 GET /config/numbering-rules', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await numberingApi.listNumberingRules(1, 10)
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/config/numbering-rules', {
|
||||||
|
params: { page: 1, page_size: 10 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createNumberingRule 应调用 POST /config/numbering-rules', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '患者编号', code: 'patient', prefix: 'P', seq_length: 6 }
|
||||||
|
await numberingApi.createNumberingRule(req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/config/numbering-rules', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateNumberingRule 应调用 PUT /config/numbering-rules/:id', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
const req = { prefix: 'HMS', version: 1 }
|
||||||
|
await numberingApi.updateNumberingRule('nr-001', req)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/config/numbering-rules/nr-001', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generateNumber 应调用 POST /config/numbering-rules/:id/generate', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
await numberingApi.generateNumber('nr-001')
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/config/numbering-rules/nr-001/generate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteNumberingRule 应调用 DELETE /config/numbering-rules/:id', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await numberingApi.deleteNumberingRule('nr-001', 1)
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/config/numbering-rules/nr-001', { data: { version: 1 } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// themes
|
||||||
|
// ============================================================
|
||||||
|
describe('themes API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('getTheme 应调用 GET /config/themes', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await themesApi.getTheme()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/config/themes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateTheme 应调用 PUT /config/themes', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
const theme = { primary_color: '#1890ff', brand_name: 'HMS' }
|
||||||
|
await themesApi.updateTheme(theme)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/config/themes', theme)
|
||||||
|
})
|
||||||
|
})
|
||||||
96
apps/web/src/api/dictionaries.test.ts
Normal file
96
apps/web/src/api/dictionaries.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* dictionaries API 契约测试
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as dictApi from './dictionaries'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dictionaries API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listDictionaries 应调用 GET /config/dictionaries 并传递分页参数', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await dictApi.listDictionaries(1, 10)
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/config/dictionaries', {
|
||||||
|
params: { page: 1, page_size: 10 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createDictionary 应调用 POST /config/dictionaries', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '性别', code: 'gender', description: '性别字典' }
|
||||||
|
await dictApi.createDictionary(req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/config/dictionaries', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateDictionary 应调用 PUT /config/dictionaries/:id', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '性别(更新)', version: 1 }
|
||||||
|
await dictApi.updateDictionary('dict-001', req)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/config/dictionaries/dict-001', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteDictionary 应调用 DELETE /config/dictionaries/:id 并在 body 传递 version', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await dictApi.deleteDictionary('dict-001', 2)
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/config/dictionaries/dict-001', {
|
||||||
|
data: { version: 2 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('listItemsByCode 应调用 GET /config/dictionaries/items 并传递 code 参数', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await dictApi.listItemsByCode('gender')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/config/dictionaries/items', {
|
||||||
|
params: { code: 'gender' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createDictionaryItem 应调用 POST /config/dictionaries/:id/items', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { label: '男', value: 'male', sort_order: 1 }
|
||||||
|
await dictApi.createDictionaryItem('dict-001', req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/config/dictionaries/dict-001/items', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateDictionaryItem 应调用 PUT /config/dictionaries/:dictId/items/:itemId', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
const req = { label: '女', version: 1 }
|
||||||
|
await dictApi.updateDictionaryItem('dict-001', 'item-001', req)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/config/dictionaries/dict-001/items/item-001', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteDictionaryItem 应调用 DELETE /config/dictionaries/:dictId/items/:itemId', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await dictApi.deleteDictionaryItem('dict-001', 'item-001', 1)
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/config/dictionaries/dict-001/items/item-001', {
|
||||||
|
data: { version: 1 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
111
apps/web/src/api/dictionaries.ts
Normal file
111
apps/web/src/api/dictionaries.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { PaginatedResponse } from './types';
|
||||||
|
|
||||||
|
export interface DictionaryItemInfo {
|
||||||
|
id: string;
|
||||||
|
dictionary_id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sort_order: number;
|
||||||
|
color?: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
items: DictionaryItemInfo[];
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDictionaryRequest {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDictionaryRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, version: number) {
|
||||||
|
await client.delete(`/config/dictionaries/${id}`, { data: { version } });
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, version: number) {
|
||||||
|
await client.delete(`/config/dictionaries/${dictionaryId}/items/${itemId}`, { data: { version } });
|
||||||
|
}
|
||||||
34
apps/web/src/api/languages.ts
Normal file
34
apps/web/src/api/languages.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface LanguageInfo {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLanguageRequest {
|
||||||
|
is_active: boolean;
|
||||||
|
name?: 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;
|
||||||
|
}
|
||||||
63
apps/web/src/api/menus.ts
Normal file
63
apps/web/src/api/menus.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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[];
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMenus() {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: MenuInfo[] }>('/config/menus');
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMenusForUser() {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: MenuInfo[] }>('/menus/user');
|
||||||
|
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, version: number) {
|
||||||
|
await client.delete(`/config/menus/${id}`, { data: { version } });
|
||||||
|
}
|
||||||
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 './types';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
100
apps/web/src/api/messages.test.ts
Normal file
100
apps/web/src/api/messages.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* messages + messageTemplates API 契约测试
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as messagesApi from './messages'
|
||||||
|
import * as templateApi from './messageTemplates'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('messages API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listMessages 应调用 GET /messages 并传递查询参数', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await messagesApi.listMessages({ page: 2, page_size: 10, is_read: false, priority: 'high' })
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/messages', {
|
||||||
|
params: expect.objectContaining({
|
||||||
|
page: 2,
|
||||||
|
page_size: 10,
|
||||||
|
is_read: false,
|
||||||
|
priority: 'high',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getUnreadCount 应调用 GET /messages/unread-count', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await messagesApi.getUnreadCount()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/messages/unread-count')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('markRead 应调用 PUT /messages/:id/read', async () => {
|
||||||
|
mockPut.mockResolvedValue({ data: { success: true } })
|
||||||
|
await messagesApi.markRead('msg-001')
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/messages/msg-001/read')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('markAllRead 应调用 PUT /messages/read-all', async () => {
|
||||||
|
mockPut.mockResolvedValue({ data: { success: true } })
|
||||||
|
await messagesApi.markAllRead()
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/messages/read-all')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteMessage 应调用 DELETE /messages/:id', async () => {
|
||||||
|
mockDelete.mockResolvedValue({ data: { success: true } })
|
||||||
|
await messagesApi.deleteMessage('msg-001')
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/messages/msg-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendMessage 应调用 POST /messages 并传递请求体', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { title: '通知', body: '内容', recipient_id: 'u-001' }
|
||||||
|
await messagesApi.sendMessage(req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/messages', req)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('messageTemplates API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listTemplates 应调用 GET /message-templates 并传递分页参数', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await templateApi.listTemplates(1, 10)
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/message-templates', {
|
||||||
|
params: { page: 1, page_size: 10 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createTemplate 应调用 POST /message-templates', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '预约提醒', code: 'appointment_reminder', title_template: '预约提醒', body_template: '您有预约' }
|
||||||
|
await templateApi.createTemplate(req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/message-templates', req)
|
||||||
|
})
|
||||||
|
})
|
||||||
98
apps/web/src/api/messages.ts
Normal file
98
apps/web/src/api/messages.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { PaginatedResponse } from './types';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionUpdateReq {
|
||||||
|
dnd_enabled: boolean;
|
||||||
|
dnd_start?: string;
|
||||||
|
dnd_end?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSubscription(req: SubscriptionUpdateReq) {
|
||||||
|
await client.put('/message-subscriptions', req);
|
||||||
|
}
|
||||||
73
apps/web/src/api/numberingRules.ts
Normal file
73
apps/web/src/api/numberingRules.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { PaginatedResponse } from './types';
|
||||||
|
|
||||||
|
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;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, version: number) {
|
||||||
|
await client.delete(`/config/numbering-rules/${id}`, { data: { version } });
|
||||||
|
}
|
||||||
126
apps/web/src/api/orgs.test.ts
Normal file
126
apps/web/src/api/orgs.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* orgs API 契约测试(组织/部门/岗位)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as orgsApi from './orgs'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('organizations API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listOrgTree 应调用 GET /organizations', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await orgsApi.listOrgTree()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/organizations')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createOrg 应调用 POST /organizations', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '总公司', code: 'HQ' }
|
||||||
|
await orgsApi.createOrg(req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/organizations', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateOrg 应调用 PUT /organizations/:id', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '改名', version: 1 }
|
||||||
|
await orgsApi.updateOrg('org-001', req)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/organizations/org-001', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteOrg 应调用 DELETE /organizations/:id', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await orgsApi.deleteOrg('org-001')
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/organizations/org-001')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('departments API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listDeptTree 应调用 GET /organizations/:orgId/departments', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await orgsApi.listDeptTree('org-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/organizations/org-001/departments')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createDept 应调用 POST /organizations/:orgId/departments', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '内科', code: 'NK' }
|
||||||
|
await orgsApi.createDept('org-001', req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/organizations/org-001/departments', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateDept 应调用 PUT /departments/:id', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '内科(更新)', version: 1 }
|
||||||
|
await orgsApi.updateDept('dept-001', req)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/departments/dept-001', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteDept 应调用 DELETE /departments/:id', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await orgsApi.deleteDept('dept-001')
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/departments/dept-001')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('positions API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listPositions 应调用 GET /departments/:deptId/positions', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await orgsApi.listPositions('dept-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/departments/dept-001/positions')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createPosition 应调用 POST /departments/:deptId/positions', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '主治医师', code: 'ZYS' }
|
||||||
|
await orgsApi.createPosition('dept-001', req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/departments/dept-001/positions', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updatePosition 应调用 PUT /positions/:id', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '主任医师', version: 1 }
|
||||||
|
await orgsApi.updatePosition('pos-001', req)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/positions/pos-001', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletePosition 应调用 DELETE /positions/:id', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await orgsApi.deletePosition('pos-001')
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/positions/pos-001')
|
||||||
|
})
|
||||||
|
})
|
||||||
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;
|
||||||
|
}
|
||||||
131
apps/web/src/api/pluginData.test.ts
Normal file
131
apps/web/src/api/pluginData.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* pluginData API 契约测试
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
patch: (...args: unknown[]) => mockPatch(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as pluginDataApi from './pluginData'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pluginData CRUD', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listPluginData 应调用 GET /plugins/:pid/:entity 并传递分页和过滤参数', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await pluginDataApi.listPluginData('crm', 'customer', 1, 20, {
|
||||||
|
filter: { status: 'active' },
|
||||||
|
search: '张',
|
||||||
|
sort_by: 'name',
|
||||||
|
sort_order: 'asc',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/plugins/crm/customer', {
|
||||||
|
params: expect.objectContaining({
|
||||||
|
page: '1',
|
||||||
|
page_size: '20',
|
||||||
|
search: '张',
|
||||||
|
sort_by: 'name',
|
||||||
|
sort_order: 'asc',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getPluginData 应调用 GET /plugins/:pid/:entity/:id', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await pluginDataApi.getPluginData('crm', 'customer', 'rec-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/plugins/crm/customer/rec-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createPluginData 应调用 POST /plugins/:pid/:entity 并包裹 data', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const recordData = { name: '客户A', phone: '13800138000' }
|
||||||
|
await pluginDataApi.createPluginData('crm', 'customer', recordData)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/plugins/crm/customer', { data: recordData })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updatePluginData 应调用 PUT /plugins/:pid/:entity/:id', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
const recordData = { name: '客户A(更新)' }
|
||||||
|
await pluginDataApi.updatePluginData('crm', 'customer', 'rec-001', recordData, 2)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/plugins/crm/customer/rec-001', {
|
||||||
|
data: recordData,
|
||||||
|
version: 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletePluginData 应调用 DELETE /plugins/:pid/:entity/:id', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await pluginDataApi.deletePluginData('crm', 'customer', 'rec-001')
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/plugins/crm/customer/rec-001')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pluginData 高级查询', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('countPluginData 应调用 GET /plugins/:pid/:entity/count', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await pluginDataApi.countPluginData('crm', 'customer', { filter: { status: 'active' } })
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/plugins/crm/customer/count', {
|
||||||
|
params: expect.objectContaining({
|
||||||
|
filter: '{"status":"active"}',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aggregatePluginData 应调用 GET /plugins/:pid/:entity/aggregate', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await pluginDataApi.aggregatePluginData('crm', 'customer', 'status')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/plugins/crm/customer/aggregate', {
|
||||||
|
params: { group_by: 'status' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('batchPluginData 应调用 POST /plugins/:pid/:entity/batch', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { action: 'delete', ids: ['rec-1', 'rec-2'] }
|
||||||
|
await pluginDataApi.batchPluginData('crm', 'customer', req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/plugins/crm/customer/batch', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolveRefLabels 应调用 POST /plugins/:pid/:entity/resolve-labels', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const fields = { customer_tag_id: ['tag-1', 'tag-2'] }
|
||||||
|
await pluginDataApi.resolveRefLabels('crm', 'customer', fields)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/plugins/crm/customer/resolve-labels', { fields })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('importPluginData 应调用 POST /plugins/:pid/:entity/import', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const rows = [{ name: '客户A' }, { name: '客户B' }]
|
||||||
|
await pluginDataApi.importPluginData('crm', 'customer', rows)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/plugins/crm/customer/import', { rows })
|
||||||
|
})
|
||||||
|
})
|
||||||
281
apps/web/src/api/pluginData.ts
Normal file
281
apps/web/src/api/pluginData.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 跨插件引用 API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ResolveLabelsResult {
|
||||||
|
labels: Record<string, Record<string, string | null>>;
|
||||||
|
meta: Record<string, {
|
||||||
|
target_plugin: string;
|
||||||
|
target_entity: string;
|
||||||
|
label_field: string;
|
||||||
|
plugin_installed: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRefLabels(
|
||||||
|
pluginId: string,
|
||||||
|
entity: string,
|
||||||
|
fields: Record<string, string[]>,
|
||||||
|
): Promise<ResolveLabelsResult> {
|
||||||
|
const { data } = await client.post<{ success: boolean; data: ResolveLabelsResult }>(
|
||||||
|
`/plugins/${pluginId}/${entity}/resolve-labels`,
|
||||||
|
{ fields },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicEntity {
|
||||||
|
manifest_id: string;
|
||||||
|
plugin_id: string;
|
||||||
|
entity_name: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginEntityRegistry(): Promise<PublicEntity[]> {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: PublicEntity[] }>(
|
||||||
|
'/plugin-registry/entities',
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 数据导入导出 API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ExportOptions {
|
||||||
|
filter?: Record<string, string>;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_order?: 'asc' | 'desc';
|
||||||
|
format?: 'json' | 'csv' | 'xlsx';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportPluginData(
|
||||||
|
pluginId: string,
|
||||||
|
entity: string,
|
||||||
|
options?: ExportOptions,
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
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: Record<string, unknown>[] }>(
|
||||||
|
`/plugins/${pluginId}/${entity}/export`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportPluginDataAsBlob(
|
||||||
|
pluginId: string,
|
||||||
|
entity: string,
|
||||||
|
format: 'csv' | 'xlsx',
|
||||||
|
options?: Omit<ExportOptions, 'format'>,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const params: Record<string, string> = { format };
|
||||||
|
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 response = await client.get(
|
||||||
|
`/plugins/${pluginId}/${entity}/export`,
|
||||||
|
{ params, responseType: 'blob' },
|
||||||
|
);
|
||||||
|
return response.data as Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportRowError {
|
||||||
|
row: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
success_count: number;
|
||||||
|
error_count: number;
|
||||||
|
errors: ImportRowError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importPluginData(
|
||||||
|
pluginId: string,
|
||||||
|
entity: string,
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
): Promise<ImportResult> {
|
||||||
|
const { data } = await client.post<{ success: boolean; data: ImportResult }>(
|
||||||
|
`/plugins/${pluginId}/${entity}/import`,
|
||||||
|
{ rows },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
132
apps/web/src/api/plugins.test.ts
Normal file
132
apps/web/src/api/plugins.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* plugins API 契约测试(插件管理 + 市场)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as pluginsApi from './plugins'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('plugins management API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listPlugins 应调用 GET /admin/plugins 并传递分页和状态', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await pluginsApi.listPlugins(1, 10, 'enabled')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/admin/plugins', {
|
||||||
|
params: { page: 1, page_size: 10, status: 'enabled' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getPlugin 应调用 GET /admin/plugins/:id', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await pluginsApi.getPlugin('plug-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/admin/plugins/plug-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('installPlugin 应调用 POST /admin/plugins/:id/install', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
await pluginsApi.installPlugin('plug-001')
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/admin/plugins/plug-001/install')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enablePlugin 应调用 POST /admin/plugins/:id/enable', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
await pluginsApi.enablePlugin('plug-001')
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/admin/plugins/plug-001/enable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disablePlugin 应调用 POST /admin/plugins/:id/disable', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
await pluginsApi.disablePlugin('plug-001')
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/admin/plugins/plug-001/disable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('purgePlugin 应调用 DELETE /admin/plugins/:id', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await pluginsApi.purgePlugin('plug-001')
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/admin/plugins/plug-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getPluginHealth 应调用 GET /admin/plugins/:id/health', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await pluginsApi.getPluginHealth('plug-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/admin/plugins/plug-001/health')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updatePluginConfig 应调用 PUT /admin/plugins/:id/config', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
const config = { theme: 'dark' }
|
||||||
|
await pluginsApi.updatePluginConfig('plug-001', config, 1)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/admin/plugins/plug-001/config', {
|
||||||
|
config,
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getPluginSchema 应调用 GET /admin/plugins/:id/schema', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await pluginsApi.getPluginSchema('plug-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/admin/plugins/plug-001/schema')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('plugin market API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listMarketEntries 应调用 GET /market/entries', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await pluginsApi.listMarketEntries({ page: 1, page_size: 10, category: 'crm' })
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/market/entries', {
|
||||||
|
params: { page: 1, page_size: 10, category: 'crm' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getMarketEntry 应调用 GET /market/entries/:id', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await pluginsApi.getMarketEntry('mkt-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/market/entries/mkt-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('installFromMarket 应调用 POST /market/entries/:id/install', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
await pluginsApi.installFromMarket('mkt-001')
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/market/entries/mkt-001/install')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submitMarketReview 应调用 POST /market/entries/:id/reviews', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const review = { rating: 5, review_text: '很好用' }
|
||||||
|
await pluginsApi.submitMarketReview('mkt-001', review)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/market/entries/mkt-001/reviews', review)
|
||||||
|
})
|
||||||
|
})
|
||||||
375
apps/web/src/api/plugins.ts
Normal file
375
apps/web/src/api/plugins.ts
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { PaginatedResponse } from './types';
|
||||||
|
|
||||||
|
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 PluginFieldValidation {
|
||||||
|
pattern?: string;
|
||||||
|
message?: string;
|
||||||
|
min_length?: number;
|
||||||
|
max_length?: number;
|
||||||
|
min_value?: number;
|
||||||
|
max_value?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
ref_plugin?: string;
|
||||||
|
ref_fallback_label?: string;
|
||||||
|
cascade_from?: string;
|
||||||
|
cascade_filter?: string;
|
||||||
|
validation?: PluginFieldValidation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginRelationSchema {
|
||||||
|
entity: string;
|
||||||
|
foreign_key: string;
|
||||||
|
on_delete: 'cascade' | 'nullify' | 'restrict';
|
||||||
|
name?: string;
|
||||||
|
type?: 'one_to_many' | 'many_to_one' | 'many_to_many';
|
||||||
|
display_field?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginEntitySchema {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
fields: PluginFieldSchema[];
|
||||||
|
relations?: PluginRelationSchema[];
|
||||||
|
data_scope?: boolean;
|
||||||
|
is_public?: boolean;
|
||||||
|
importable?: boolean;
|
||||||
|
exportable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSchemaResponse {
|
||||||
|
entities: PluginEntitySchema[];
|
||||||
|
ui?: PluginUiSchema;
|
||||||
|
settings?: PluginSettings;
|
||||||
|
numbering?: PluginNumbering[];
|
||||||
|
trigger_events?: PluginTriggerEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
| 'stat_cards' | 'action_list' | 'funnel' | 'card_list';
|
||||||
|
entity: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
dimension_field?: string;
|
||||||
|
dimension_order?: string[];
|
||||||
|
metric?: string;
|
||||||
|
// stat_cards
|
||||||
|
cards?: StatCardDef[];
|
||||||
|
// action_list
|
||||||
|
max_items?: number;
|
||||||
|
queries?: ActionQueryDef[];
|
||||||
|
// funnel
|
||||||
|
lane_field?: string;
|
||||||
|
value_field?: string;
|
||||||
|
lane_order?: string[];
|
||||||
|
// card_list
|
||||||
|
filter?: string;
|
||||||
|
title_field?: string;
|
||||||
|
subtitle_field?: string;
|
||||||
|
tags?: string[];
|
||||||
|
label?: string;
|
||||||
|
label_field?: string;
|
||||||
|
action?: string;
|
||||||
|
sort?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatCardDef {
|
||||||
|
entity: string;
|
||||||
|
aggregate?: string;
|
||||||
|
field?: string;
|
||||||
|
filter?: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionQueryDef {
|
||||||
|
entity: string;
|
||||||
|
filter?: string;
|
||||||
|
sort?: string;
|
||||||
|
label_field: string;
|
||||||
|
subtitle_field?: string;
|
||||||
|
action: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginSectionSchema =
|
||||||
|
| { type: 'fields'; label: string; fields: string[] }
|
||||||
|
| { type: 'crud'; label: string; entity: string; filter_field?: string; enable_views?: string[] };
|
||||||
|
|
||||||
|
// ── P2 平台通用服务 — Settings 类型 ──
|
||||||
|
|
||||||
|
export type PluginSettingType =
|
||||||
|
| 'text' | 'number' | 'boolean' | 'select' | 'multiselect'
|
||||||
|
| 'color' | 'date' | 'datetime' | 'json';
|
||||||
|
|
||||||
|
export interface PluginSettingField {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
field_type: PluginSettingType;
|
||||||
|
default_value?: unknown;
|
||||||
|
required: boolean;
|
||||||
|
description?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
range?: [number, number];
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSettings {
|
||||||
|
fields: PluginSettingField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P2 平台通用服务 — Numbering 类型 ──
|
||||||
|
|
||||||
|
export interface PluginNumbering {
|
||||||
|
entity: string;
|
||||||
|
field: string;
|
||||||
|
prefix: string;
|
||||||
|
format: string;
|
||||||
|
reset_rule: 'never' | 'daily' | 'monthly' | 'yearly';
|
||||||
|
seq_length: number;
|
||||||
|
separator?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P2 平台通用服务 — TriggerEvent 类型 ──
|
||||||
|
|
||||||
|
export interface PluginTriggerEvent {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
entity: string;
|
||||||
|
on: 'create' | 'update' | 'delete' | 'create_or_update';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 插件市场 API ──
|
||||||
|
|
||||||
|
export interface MarketEntry {
|
||||||
|
id: string;
|
||||||
|
plugin_id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
author?: string;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
icon_url?: string;
|
||||||
|
screenshots?: string[];
|
||||||
|
min_platform_version?: string;
|
||||||
|
status: string;
|
||||||
|
download_count: number;
|
||||||
|
rating_avg: number;
|
||||||
|
rating_count: number;
|
||||||
|
changelog?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketEntryDetail extends MarketEntry {
|
||||||
|
dependency_warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketReview {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
market_entry_id: string;
|
||||||
|
rating: number;
|
||||||
|
review_text?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMarketEntries(params?: {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
category?: string;
|
||||||
|
search?: string;
|
||||||
|
}) {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MarketEntry> }>(
|
||||||
|
'/market/entries',
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMarketEntry(id: string) {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: MarketEntryDetail }>(
|
||||||
|
`/market/entries/${id}`,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installFromMarket(id: string) {
|
||||||
|
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||||
|
`/market/entries/${id}/install`,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMarketReviews(id: string) {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: MarketReview[] }>(
|
||||||
|
`/market/entries/${id}/reviews`,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitMarketReview(id: string, review: { rating: number; review_text?: string }) {
|
||||||
|
const { data } = await client.post<{ success: boolean; data: MarketReview }>(
|
||||||
|
`/market/entries/${id}/reviews`,
|
||||||
|
review,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
86
apps/web/src/api/roles.test.ts
Normal file
86
apps/web/src/api/roles.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* roles API 契约测试
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as rolesApi from './roles'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('roles API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listRoles 应调用 GET /roles 并传递分页参数', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await rolesApi.listRoles(1, 10)
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/roles', { params: { page: 1, page_size: 10 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRole 应调用 GET /roles/:id', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await rolesApi.getRole('r-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/roles/r-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createRole 应调用 POST /roles', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '医生', code: 'doctor', description: '医生角色' }
|
||||||
|
await rolesApi.createRole(req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/roles', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateRole 应调用 PUT /roles/:id', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '高级医生', version: 1 }
|
||||||
|
await rolesApi.updateRole('r-001', req)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/roles/r-001', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteRole 应调用 DELETE /roles/:id', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await rolesApi.deleteRole('r-001')
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/roles/r-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('assignPermissions 应调用 POST /roles/:id/permissions', async () => {
|
||||||
|
mockPost.mockResolvedValue(undefined)
|
||||||
|
await rolesApi.assignPermissions('r-001', ['p-1', 'p-2'])
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/roles/r-001/permissions', { permission_ids: ['p-1', 'p-2'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRolePermissions 应调用 GET /roles/:id/permissions', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await rolesApi.getRolePermissions('r-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/roles/r-001/permissions')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('listPermissions 应调用 GET /permissions', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await rolesApi.listPermissions()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/permissions')
|
||||||
|
})
|
||||||
|
})
|
||||||
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 './types';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
30
apps/web/src/api/settings.ts
Normal file
30
apps/web/src/api/settings.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
export interface SettingInfo {
|
||||||
|
id: string;
|
||||||
|
scope: string;
|
||||||
|
scope_id?: string;
|
||||||
|
setting_key: string;
|
||||||
|
setting_value: unknown;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSetting(key: string, scope?: string, scopeId?: string) {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: SettingInfo }>(
|
||||||
|
`/config/settings/${encodeURIComponent(key)}`,
|
||||||
|
{ params: { scope, scope_id: scopeId } },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSetting(key: string, settingValue: unknown, version?: number) {
|
||||||
|
const { data } = await client.put<{ success: boolean; data: SettingInfo }>(
|
||||||
|
`/config/settings/${encodeURIComponent(key)}`,
|
||||||
|
{ setting_value: settingValue, version },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSetting(key: string, version: number) {
|
||||||
|
await client.delete(`/config/settings/${encodeURIComponent(key)}`, { data: { version } });
|
||||||
|
}
|
||||||
49
apps/web/src/api/themes.ts
Normal file
49
apps/web/src/api/themes.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
export interface ThemeConfig {
|
||||||
|
primary_color?: string;
|
||||||
|
logo_url?: string;
|
||||||
|
sidebar_style?: 'light' | 'dark';
|
||||||
|
brand_name?: string;
|
||||||
|
brand_slogan?: string;
|
||||||
|
brand_features?: string;
|
||||||
|
brand_copyright?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandConfig {
|
||||||
|
brand_name: string;
|
||||||
|
brand_slogan: string;
|
||||||
|
brand_features: string;
|
||||||
|
brand_copyright: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRAND_DEFAULTS: BrandConfig = {
|
||||||
|
brand_name: 'HMS 健康管理平台',
|
||||||
|
brand_slogan: '新一代健康管理平台',
|
||||||
|
brand_features: '患者管理 · 健康监测 · 随访管理 · AI 智能分析',
|
||||||
|
brand_copyright: 'HMS 健康管理平台 · ©汕头市智界科技有限公司',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getPublicBrand(): Promise<BrandConfig> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/public/brand');
|
||||||
|
const json = await res.json();
|
||||||
|
if (json?.success && json?.data) return json.data;
|
||||||
|
} catch {}
|
||||||
|
return BRAND_DEFAULTS;
|
||||||
|
}
|
||||||
7
apps/web/src/api/types.ts
Normal file
7
apps/web/src/api/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
16
apps/web/src/api/upload.ts
Normal file
16
apps/web/src/api/upload.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
url: string;
|
||||||
|
filename?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(file: File): Promise<UploadResult> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const { data: result } = await client.post('/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
83
apps/web/src/api/users.test.ts
Normal file
83
apps/web/src/api/users.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* users API 契约测试
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as usersApi from './users'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('users API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listUsers 应调用 GET /users 并传递分页和搜索参数', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await usersApi.listUsers(2, 10, '张')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/users', {
|
||||||
|
params: { page: 2, page_size: 10, search: '张' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('listUsers 空搜索时应传 search 为 undefined', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await usersApi.listUsers(1, 20, '')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/users', {
|
||||||
|
params: { page: 1, page_size: 20, search: undefined },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getUser 应调用 GET /users/:id', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await usersApi.getUser('u-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/users/u-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createUser 应调用 POST /users', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { username: 'newuser', password: 'pass123', display_name: '新用户' }
|
||||||
|
await usersApi.createUser(req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/users', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateUser 应调用 PUT /users/:id', async () => {
|
||||||
|
mockPut.mockResolvedValue(fakeRes)
|
||||||
|
const req = { display_name: '改名', version: 1 }
|
||||||
|
await usersApi.updateUser('u-001', req)
|
||||||
|
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/users/u-001', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteUser 应调用 DELETE /users/:id', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined)
|
||||||
|
await usersApi.deleteUser('u-001')
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/users/u-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('assignRoles 应调用 POST /users/:id/roles', async () => {
|
||||||
|
mockPost.mockResolvedValue(undefined)
|
||||||
|
await usersApi.assignRoles('u-001', ['role-1', 'role-2'])
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/users/u-001/roles', { role_ids: ['role-1', 'role-2'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
54
apps/web/src/api/users.ts
Normal file
54
apps/web/src/api/users.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { UserInfo } from './auth';
|
||||||
|
import type { PaginatedResponse } from './types';
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword(id: string, req: { new_password: string; version: number }) {
|
||||||
|
await client.post(`/users/${id}/reset-password`, req);
|
||||||
|
}
|
||||||
141
apps/web/src/api/workflow.test.ts
Normal file
141
apps/web/src/api/workflow.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* workflow API 契约测试(definitions + instances + tasks)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as defApi from './workflowDefinitions'
|
||||||
|
import * as instApi from './workflowInstances'
|
||||||
|
import * as taskApi from './workflowTasks'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('workflowDefinitions API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listProcessDefinitions 应调用 GET /workflow/definitions', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await defApi.listProcessDefinitions(1, 10)
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/workflow/definitions', {
|
||||||
|
params: { page: 1, page_size: 10 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getProcessDefinition 应调用 GET /workflow/definitions/:id', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await defApi.getProcessDefinition('wf-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/workflow/definitions/wf-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createProcessDefinition 应调用 POST /workflow/definitions', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { name: '审批流程', key: 'approval', nodes: [], edges: [] }
|
||||||
|
await defApi.createProcessDefinition(req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/workflow/definitions', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('publishProcessDefinition 应调用 POST /workflow/definitions/:id/publish', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
await defApi.publishProcessDefinition('wf-001')
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/workflow/definitions/wf-001/publish')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('workflowInstances API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('startInstance 应调用 POST /workflow/instances', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { definition_id: 'wf-001', business_key: 'BIZ-001' }
|
||||||
|
await instApi.startInstance(req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/workflow/instances', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('listInstances 应调用 GET /workflow/instances 并传递分页', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await instApi.listInstances(1, 10)
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/workflow/instances', {
|
||||||
|
params: { page: 1, page_size: 10 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suspendInstance 应调用 POST /workflow/instances/:id/suspend', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
await instApi.suspendInstance('inst-001')
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/workflow/instances/inst-001/suspend')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resumeInstance 应调用 POST /workflow/instances/:id/resume', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
await instApi.resumeInstance('inst-001')
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/workflow/instances/inst-001/resume')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('terminateInstance 应调用 POST /workflow/instances/:id/terminate', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
await instApi.terminateInstance('inst-001')
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/workflow/instances/inst-001/terminate')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('workflowTasks API', () => {
|
||||||
|
const fakeRes = { data: { success: true, data: {} } }
|
||||||
|
|
||||||
|
it('listPendingTasks 应调用 GET /workflow/tasks/pending', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await taskApi.listPendingTasks(1, 10)
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/workflow/tasks/pending', {
|
||||||
|
params: { page: 1, page_size: 10 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('listCompletedTasks 应调用 GET /workflow/tasks/completed', async () => {
|
||||||
|
mockGet.mockResolvedValue(fakeRes)
|
||||||
|
await taskApi.listCompletedTasks(1, 10)
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/workflow/tasks/completed', {
|
||||||
|
params: { page: 1, page_size: 10 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completeTask 应调用 POST /workflow/tasks/:id/complete', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { outcome: 'approved', form_data: { comment: '同意' } }
|
||||||
|
await taskApi.completeTask('task-001', req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/workflow/tasks/task-001/complete', req)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delegateTask 应调用 POST /workflow/tasks/:id/delegate', async () => {
|
||||||
|
mockPost.mockResolvedValue(fakeRes)
|
||||||
|
const req = { delegate_to: 'u-002' }
|
||||||
|
await taskApi.delegateTask('task-001', req)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/workflow/tasks/task-001/delegate', req)
|
||||||
|
})
|
||||||
|
})
|
||||||
90
apps/web/src/api/workflowDefinitions.ts
Normal file
90
apps/web/src/api/workflowDefinitions.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type { PaginatedResponse } from './types';
|
||||||
|
|
||||||
|
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[];
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 './types';
|
||||||
|
|
||||||
|
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 './types';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
13
apps/web/src/components/AuthButton.tsx
Normal file
13
apps/web/src/components/AuthButton.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { usePermission } from '../hooks/usePermission';
|
||||||
|
|
||||||
|
interface AuthButtonProps {
|
||||||
|
code: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthButton({ code, children }: AuthButtonProps) {
|
||||||
|
const { hasPermission } = usePermission(code);
|
||||||
|
if (!hasPermission) return null;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
107
apps/web/src/components/DrawerForm.tsx
Normal file
107
apps/web/src/components/DrawerForm.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Drawer, Form, Typography, Divider, Button, Space } from 'antd';
|
||||||
|
import { useThemeMode } from '../hooks/useThemeMode';
|
||||||
|
|
||||||
|
export interface FormSection {
|
||||||
|
title: string;
|
||||||
|
fields: React.ReactNode;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DrawerFormProps {
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (values: Record<string, unknown>) => Promise<void>;
|
||||||
|
initialValues?: Record<string, unknown>;
|
||||||
|
loading?: boolean;
|
||||||
|
width?: number | string;
|
||||||
|
sections?: FormSection[];
|
||||||
|
children?: React.ReactNode;
|
||||||
|
columns?: 1 | 2;
|
||||||
|
form?: ReturnType<typeof Form.useForm>[0];
|
||||||
|
onValuesChange?: (changedValues: Record<string, unknown>, allValues: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerForm({
|
||||||
|
title,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
initialValues,
|
||||||
|
loading,
|
||||||
|
width = 640,
|
||||||
|
sections,
|
||||||
|
children,
|
||||||
|
columns = 2,
|
||||||
|
form: externalForm,
|
||||||
|
onValuesChange,
|
||||||
|
}: DrawerFormProps) {
|
||||||
|
const [internalForm] = Form.useForm();
|
||||||
|
const form = externalForm ?? internalForm;
|
||||||
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.resetFields();
|
||||||
|
if (initialValues) {
|
||||||
|
form.setFieldsValue(initialValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, initialValues, form]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
await onSubmit(values);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// validateFields 失败时 error 包含 errorFields(预期行为,不记录)
|
||||||
|
// 其他类型的错误才记录
|
||||||
|
if (error && typeof error === 'object' && !('errorFields' in error)) {
|
||||||
|
console.error('[DrawerForm] submit error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridStyle: React.CSSProperties =
|
||||||
|
columns === 2
|
||||||
|
? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={title}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={width}
|
||||||
|
styles={{
|
||||||
|
body: { background: isDark ? '#141414' : undefined },
|
||||||
|
}}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onClose}>取消</Button>
|
||||||
|
<Button type="primary" onClick={handleSubmit} loading={loading}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" initialValues={initialValues} onValuesChange={onValuesChange}>
|
||||||
|
{sections
|
||||||
|
? sections.map((s, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{i > 0 && <Divider style={{ margin: '16px 0' }} />}
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
style={{ fontSize: 14, marginBottom: 12, display: 'block' }}
|
||||||
|
>
|
||||||
|
{s.title}
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={gridStyle}>{s.fields}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: children && <div style={gridStyle}>{children}</div>}
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/web/src/components/EntityName.tsx
Normal file
23
apps/web/src/components/EntityName.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Tooltip, Typography } from 'antd';
|
||||||
|
|
||||||
|
interface EntityNameProps {
|
||||||
|
name?: string | null;
|
||||||
|
id?: string;
|
||||||
|
fallbackLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityName({ name, id, fallbackLabel = '未知' }: EntityNameProps) {
|
||||||
|
if (name !== undefined && name !== null && name !== '') return <span>{name}</span>;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={`ID: ${id.slice(0, 8)}...`}>
|
||||||
|
<Typography.Text type="secondary" style={{ cursor: 'help' }}>
|
||||||
|
{fallbackLabel}
|
||||||
|
</Typography.Text>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Typography.Text type="secondary">{fallbackLabel}</Typography.Text>;
|
||||||
|
}
|
||||||
141
apps/web/src/components/EntitySelect.tsx
Normal file
141
apps/web/src/components/EntitySelect.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { Select, Spin, Input, Tooltip } from 'antd';
|
||||||
|
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { listPluginData, getPluginEntityRegistry } from '../api/pluginData';
|
||||||
|
|
||||||
|
interface EntitySelectProps {
|
||||||
|
pluginId: string;
|
||||||
|
entity: string;
|
||||||
|
labelField: string;
|
||||||
|
searchFields?: string[];
|
||||||
|
/** 跨插件引用的目标插件 manifest ID(如 "erp-crm") */
|
||||||
|
refPlugin?: string;
|
||||||
|
/** 目标插件未安装时的降级显示文本 */
|
||||||
|
fallbackLabel?: 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,
|
||||||
|
refPlugin,
|
||||||
|
fallbackLabel,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
cascadeFrom,
|
||||||
|
cascadeFilter,
|
||||||
|
cascadeValue,
|
||||||
|
placeholder,
|
||||||
|
}: EntitySelectProps) {
|
||||||
|
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [targetUnavailable, setTargetUnavailable] = useState(false);
|
||||||
|
const [resolvedPluginId, setResolvedPluginId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 跨插件时:先解析 manifest_id → plugin UUID
|
||||||
|
useEffect(() => {
|
||||||
|
if (!refPlugin) {
|
||||||
|
setResolvedPluginId(pluginId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const registry = await getPluginEntityRegistry();
|
||||||
|
const match = registry.find((e) => e.manifest_id === refPlugin && e.entity_name === entity);
|
||||||
|
if (!cancelled) {
|
||||||
|
setResolvedPluginId(match ? match.plugin_id : null);
|
||||||
|
if (!match) setTargetUnavailable(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setTargetUnavailable(true);
|
||||||
|
setResolvedPluginId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [refPlugin, pluginId, entity]);
|
||||||
|
|
||||||
|
const effectivePluginId = resolvedPluginId || pluginId;
|
||||||
|
|
||||||
|
const fetchData = useCallback(
|
||||||
|
async (keyword?: string) => {
|
||||||
|
if (!resolvedPluginId && refPlugin) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filter: Record<string, string> | undefined =
|
||||||
|
cascadeFrom && cascadeFilter && cascadeValue
|
||||||
|
? { [cascadeFilter]: cascadeValue }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const result = await listPluginData(effectivePluginId, entity, 1, 20, {
|
||||||
|
search: keyword,
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = (result.data || []).map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: String(item.data?.[labelField] ?? item.id),
|
||||||
|
}));
|
||||||
|
setOptions(items);
|
||||||
|
setTargetUnavailable(false);
|
||||||
|
} catch {
|
||||||
|
if (refPlugin) {
|
||||||
|
setTargetUnavailable(true);
|
||||||
|
setOptions([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[effectivePluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue, refPlugin, resolvedPluginId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolvedPluginId || !refPlugin) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [fetchData, resolvedPluginId, refPlugin]);
|
||||||
|
|
||||||
|
// 目标插件未安装 → 降级显示
|
||||||
|
if (targetUnavailable) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={value || ''}
|
||||||
|
placeholder={fallbackLabel || `外部引用 (${refPlugin})`}
|
||||||
|
disabled
|
||||||
|
suffix={
|
||||||
|
<Tooltip title="目标插件未安装,此字段暂时不可用">
|
||||||
|
<QuestionCircleOutlined style={{ color: '#999' }} />
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
apps/web/src/components/ErrorBoundary.tsx
Normal file
54
apps/web/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Component, type ReactNode } from 'react';
|
||||||
|
import { Button, Result } from 'antd';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
pageLevel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title={this.props.pageLevel ? '页面加载出错' : '出了点问题'}
|
||||||
|
subTitle={this.props.pageLevel
|
||||||
|
? `错误信息:${this.state.error?.message || '未知错误'}`
|
||||||
|
: '请刷新页面重试'}
|
||||||
|
extra={[
|
||||||
|
<Button key="retry" type="primary" onClick={this.handleReset}>
|
||||||
|
重试
|
||||||
|
</Button>,
|
||||||
|
<Button key="home" onClick={() => window.location.hash = '/'}>
|
||||||
|
返回首页
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/web/src/components/FilterBar.tsx
Normal file
38
apps/web/src/components/FilterBar.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Button, Flex, Space } from 'antd';
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import { useThemeMode } from '../hooks/useThemeMode';
|
||||||
|
|
||||||
|
interface FilterBarProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onReset?: () => void;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({ children, onReset, extra }: FilterBarProps) {
|
||||||
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: isDark ? '#1f1f1f' : '#fafafa',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space wrap size="middle">
|
||||||
|
{children}
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
{onReset && (
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={onReset}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{extra}
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
apps/web/src/components/NotificationPanel.tsx
Normal file
187
apps/web/src/components/NotificationPanel.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Badge, List, Popover, Button, Empty, Typography } from 'antd';
|
||||||
|
import { BellOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useMessageStore } from '../stores/message';
|
||||||
|
import { useThemeMode } from '../hooks/useThemeMode';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function NotificationPanel() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const unreadCount = useMessageStore((s) => s.unreadCount);
|
||||||
|
const recentMessages = useMessageStore((s) => s.recentMessages);
|
||||||
|
const markAsRead = useMessageStore((s) => s.markAsRead);
|
||||||
|
const isDark = useThemeMode();
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initializedRef.current) return;
|
||||||
|
initializedRef.current = true;
|
||||||
|
|
||||||
|
const { fetchUnreadCount, fetchRecentMessages, connectSSE } = useMessageStore.getState();
|
||||||
|
fetchUnreadCount();
|
||||||
|
fetchRecentMessages();
|
||||||
|
|
||||||
|
const disconnectSSE = connectSSE();
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchUnreadCount();
|
||||||
|
fetchRecentMessages();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
disconnectSSE();
|
||||||
|
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: '#2563eb' }}
|
||||||
|
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 ? '#0f172a' : '#eff6ff') : 'transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!item.is_read) {
|
||||||
|
markAsRead(item.id);
|
||||||
|
}
|
||||||
|
navigate('/messages');
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (item.is_read) {
|
||||||
|
e.currentTarget.style.background = isDark ? '#0f172a' : '#f1f5f9';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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: '#2563eb',
|
||||||
|
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 ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
}}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={() => navigate('/messages')}
|
||||||
|
style={{ fontSize: 13, color: '#2563eb', 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 ? '#0f172a' : '#f8fafc';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge count={unreadCount} size="small" offset={[4, -4]}>
|
||||||
|
<BellOutlined style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: isDark ? '#94a3b8' : '#475569',
|
||||||
|
}} />
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/web/src/components/PageContainer.tsx
Normal file
79
apps/web/src/components/PageContainer.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Card, Flex, Space, Typography, Button } from 'antd';
|
||||||
|
import { useThemeMode } from '../hooks/useThemeMode';
|
||||||
|
import { FilterBar } from './FilterBar';
|
||||||
|
|
||||||
|
interface PageContainerProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
filters?: React.ReactNode;
|
||||||
|
onResetFilters?: () => void;
|
||||||
|
filterExtra?: React.ReactNode;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
batchActions?: React.ReactNode;
|
||||||
|
selectedCount?: number;
|
||||||
|
onClearSelection?: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageContainer({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
filters,
|
||||||
|
onResetFilters,
|
||||||
|
filterExtra,
|
||||||
|
actions,
|
||||||
|
batchActions,
|
||||||
|
selectedCount,
|
||||||
|
onClearSelection,
|
||||||
|
onBack,
|
||||||
|
children,
|
||||||
|
loading,
|
||||||
|
}: PageContainerProps) {
|
||||||
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
{onBack && (
|
||||||
|
<Button type="text" size="small" onClick={onBack} style={{ marginRight: 8 }}>
|
||||||
|
←
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
</Typography.Title>
|
||||||
|
{subtitle && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
{selectedCount ? batchActions : actions}
|
||||||
|
{selectedCount ? (
|
||||||
|
<Button size="small" onClick={onClearSelection}>
|
||||||
|
取消选择 ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{filters && (
|
||||||
|
<FilterBar onReset={onResetFilters} extra={filterExtra}>
|
||||||
|
{filters}
|
||||||
|
</FilterBar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
style={{ background: isDark ? '#141414' : '#fff' }}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
apps/web/src/components/PluginSettingsForm.tsx
Normal file
232
apps/web/src/components/PluginSettingsForm.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Switch,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
|
Button,
|
||||||
|
message,
|
||||||
|
Divider,
|
||||||
|
Typography,
|
||||||
|
Tooltip,
|
||||||
|
} from 'antd';
|
||||||
|
import { QuestionCircleOutlined, SaveOutlined } from '@ant-design/icons';
|
||||||
|
import type {
|
||||||
|
PluginSettingField,
|
||||||
|
PluginSettingType,
|
||||||
|
} from '../api/plugins';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface PluginSettingsFormProps {
|
||||||
|
/** manifest 中声明的 settings 字段 */
|
||||||
|
fields: PluginSettingField[];
|
||||||
|
/** 当前存储的配置值 */
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
/** 插件版本(乐观锁) */
|
||||||
|
recordVersion: number;
|
||||||
|
/** 保存回调 */
|
||||||
|
onSave: (config: Record<string, unknown>, version: number) => Promise<unknown>;
|
||||||
|
/** 是否只读 */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 manifest settings 声明自动渲染配置表单 */
|
||||||
|
const PluginSettingsForm: React.FC<PluginSettingsFormProps> = ({
|
||||||
|
fields,
|
||||||
|
values,
|
||||||
|
recordVersion,
|
||||||
|
onSave,
|
||||||
|
readOnly = false,
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
|
const initialValues = useMemo(() => {
|
||||||
|
const merged: Record<string, unknown> = {};
|
||||||
|
for (const f of fields) {
|
||||||
|
merged[f.name] = values[f.name] ?? f.default_value ?? getDefaultForType(f.field_type);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}, [fields, values]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const formValues = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
await onSave(formValues, recordVersion);
|
||||||
|
message.success('配置已保存');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err && typeof err === 'object' && 'errorFields' in err) {
|
||||||
|
// antd 表单校验错误,无需额外提示
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.error(err instanceof Error ? err.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [form, onSave, recordVersion]);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const groups = new Map<string, PluginSettingField[]>();
|
||||||
|
for (const f of fields) {
|
||||||
|
const group = f.group ?? '';
|
||||||
|
const list = groups.get(group) ?? [];
|
||||||
|
list.push(f);
|
||||||
|
groups.set(group, list);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
const renderField = (field: PluginSettingField) => {
|
||||||
|
const label = (
|
||||||
|
<span>
|
||||||
|
{field.display_name}
|
||||||
|
{field.description && (
|
||||||
|
<Tooltip title={field.description}>
|
||||||
|
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rules: Array<{ required: boolean; message?: string; type?: 'string' | 'number' | 'boolean' | 'url' | 'email' }> = [];
|
||||||
|
if (field.required) {
|
||||||
|
rules.push({ required: true, message: `请输入${field.display_name}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = renderWidget(field, readOnly);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={field.name}
|
||||||
|
name={field.name}
|
||||||
|
label={label}
|
||||||
|
rules={rules}
|
||||||
|
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
|
||||||
|
>
|
||||||
|
{widget}
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupEntries = Array.from(grouped.entries());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={initialValues}
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
{groupEntries.map(([group, groupFields], gi) => (
|
||||||
|
<React.Fragment key={group || `__default_${gi}`}>
|
||||||
|
{group ? (
|
||||||
|
<Divider type="horizontal" orientationMargin={0} plain>
|
||||||
|
<Text strong>{group}</Text>
|
||||||
|
</Divider>
|
||||||
|
) : null}
|
||||||
|
{groupFields.map(renderField)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
loading={saving}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderWidget(field: PluginSettingField, readOnly: boolean): React.ReactNode {
|
||||||
|
switch (field.field_type) {
|
||||||
|
case 'text':
|
||||||
|
return <Input disabled={readOnly} placeholder={`请输入${field.display_name}`} />;
|
||||||
|
case 'number': {
|
||||||
|
const props: Record<string, unknown> = {
|
||||||
|
disabled: readOnly,
|
||||||
|
placeholder: `请输入${field.display_name}`,
|
||||||
|
style: { width: '100%' },
|
||||||
|
};
|
||||||
|
if (field.range) {
|
||||||
|
props.min = field.range[0];
|
||||||
|
props.max = field.range[1];
|
||||||
|
}
|
||||||
|
return <InputNumber {...props} />;
|
||||||
|
}
|
||||||
|
case 'boolean':
|
||||||
|
return <Switch disabled={readOnly} />;
|
||||||
|
case 'select':
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
disabled={readOnly}
|
||||||
|
placeholder={`请选择${field.display_name}`}
|
||||||
|
options={(field.options ?? []).map((o) => {
|
||||||
|
if (typeof o === 'object' && o !== null && 'label' in o && 'value' in o) {
|
||||||
|
return o as { label: string; value: string };
|
||||||
|
}
|
||||||
|
return { label: String(o), value: String(o) };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'multiselect':
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
disabled={readOnly}
|
||||||
|
placeholder={`请选择${field.display_name}`}
|
||||||
|
options={(field.options ?? []).map((o) => {
|
||||||
|
if (typeof o === 'object' && o !== null && 'label' in o && 'value' in o) {
|
||||||
|
return o as { label: string; value: string };
|
||||||
|
}
|
||||||
|
return { label: String(o), value: String(o) };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'color':
|
||||||
|
return <Input type="color" disabled={readOnly} style={{ width: 80 }} />;
|
||||||
|
case 'date':
|
||||||
|
return <DatePicker disabled={readOnly} style={{ width: '100%' }} />;
|
||||||
|
case 'datetime':
|
||||||
|
return <DatePicker showTime disabled={readOnly} style={{ width: '100%' }} />;
|
||||||
|
case 'json':
|
||||||
|
return <Input.TextArea disabled={readOnly} rows={4} placeholder="JSON 格式" />;
|
||||||
|
default:
|
||||||
|
return <Input disabled={readOnly} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultForType(type: PluginSettingType): unknown {
|
||||||
|
switch (type) {
|
||||||
|
case 'text':
|
||||||
|
case 'color':
|
||||||
|
return '';
|
||||||
|
case 'number':
|
||||||
|
return 0;
|
||||||
|
case 'boolean':
|
||||||
|
return false;
|
||||||
|
case 'select':
|
||||||
|
return undefined;
|
||||||
|
case 'multiselect':
|
||||||
|
return [];
|
||||||
|
case 'date':
|
||||||
|
case 'datetime':
|
||||||
|
return undefined;
|
||||||
|
case 'json':
|
||||||
|
return '';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PluginSettingsForm;
|
||||||
64
apps/web/src/components/ThemeSwitcher.tsx
Normal file
64
apps/web/src/components/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Dropdown } from 'antd';
|
||||||
|
import { BgColorsOutlined } from '@ant-design/icons';
|
||||||
|
import { useAppStore, THEME_OPTIONS } from '../stores/app';
|
||||||
|
|
||||||
|
export default function ThemeSwitcher() {
|
||||||
|
const theme = useAppStore((s) => s.theme);
|
||||||
|
const setTheme = useAppStore((s) => s.setTheme);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div style={{
|
||||||
|
padding: 8,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
minWidth: 220,
|
||||||
|
background: 'var(--erp-bg-container)',
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: 'var(--erp-shadow-lg)',
|
||||||
|
}}>
|
||||||
|
{THEME_OPTIONS.map((opt) => {
|
||||||
|
const active = theme === opt.key;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={opt.key}
|
||||||
|
onClick={() => setTheme(opt.key)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: `2px solid ${active ? opt.preview.primary : 'transparent'}`,
|
||||||
|
background: active ? `${opt.preview.primary}08` : 'transparent',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 色块预览 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
||||||
|
<div style={{ width: 20, height: 20, borderRadius: 4, background: opt.preview.primary }} />
|
||||||
|
<div style={{ width: 20, height: 20, borderRadius: 4, background: opt.preview.bg, border: '1px solid #e0e0e0' }} />
|
||||||
|
<div style={{ width: 20, height: 20, borderRadius: 4, background: opt.preview.surface, border: '1px solid #e0e0e0' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: active ? opt.preview.primary : '#333' }}>{opt.label}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#999', marginTop: 1 }}>{opt.desc}</div>
|
||||||
|
</div>
|
||||||
|
{active && (
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: 4, background: opt.preview.primary, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown popupRender={() => content} trigger={['click']} placement="bottomRight">
|
||||||
|
<div className="erp-header-btn" title="切换主题">
|
||||||
|
<BgColorsOutlined style={{ fontSize: 16 }} />
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/web/src/hooks/useApiRequest.ts
Normal file
41
apps/web/src/hooks/useApiRequest.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
|
||||||
|
function extractErrorMessage(err: unknown): string {
|
||||||
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
|
const resp = (err as { response?: { data?: { message?: string } } }).response;
|
||||||
|
return resp?.data?.message || '';
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseApiRequestReturn {
|
||||||
|
execute: <T>(fn: () => Promise<T>, successMsg?: string, errorMsg?: string) => Promise<T | null>;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApiRequest(): UseApiRequestReturn {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const execute = useCallback(async <T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
successMsg?: string,
|
||||||
|
errorMsg = '操作失败',
|
||||||
|
): Promise<T | null> => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
if (successMsg) message.success(successMsg);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = extractErrorMessage(err);
|
||||||
|
message.error(msg || errorMsg);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { execute, loading };
|
||||||
|
}
|
||||||
24
apps/web/src/hooks/useCountUp.ts
Normal file
24
apps/web/src/hooks/useCountUp.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
74
apps/web/src/hooks/useCrudDrawer.ts
Normal file
74
apps/web/src/hooks/useCrudDrawer.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApiRequest';
|
||||||
|
|
||||||
|
export interface UseCrudDrawerOptions<T> {
|
||||||
|
getId: (record: T) => string;
|
||||||
|
onCreate: (values: Record<string, unknown>) => Promise<void>;
|
||||||
|
onUpdate: (id: string, values: Record<string, unknown> & { version: number }) => Promise<void>;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCrudDrawerReturn<T> {
|
||||||
|
open: boolean;
|
||||||
|
editingRecord: T | null;
|
||||||
|
initialValues: Record<string, unknown> | undefined;
|
||||||
|
openCreate: (defaults?: Record<string, unknown>) => void;
|
||||||
|
openEdit: (record: T, fieldMap?: (record: T) => Record<string, unknown>) => void;
|
||||||
|
close: () => void;
|
||||||
|
handleSubmit: (values: Record<string, unknown>) => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCrudDrawer<T extends { version: number }>(
|
||||||
|
options: UseCrudDrawerOptions<T>,
|
||||||
|
): UseCrudDrawerReturn<T> {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editingRecord, setEditingRecord] = useState<T | null>(null);
|
||||||
|
const [initialValues, setInitialValues] = useState<Record<string, unknown> | undefined>(undefined);
|
||||||
|
const { execute, loading } = useApiRequest();
|
||||||
|
|
||||||
|
const openCreate = useCallback((defaults?: Record<string, unknown>) => {
|
||||||
|
setEditingRecord(null);
|
||||||
|
setInitialValues(defaults);
|
||||||
|
setOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEdit = useCallback((record: T, fieldMap?: (record: T) => Record<string, unknown>) => {
|
||||||
|
setEditingRecord(record);
|
||||||
|
setInitialValues(fieldMap ? fieldMap(record) : (record as unknown as Record<string, unknown>));
|
||||||
|
setOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setEditingRecord(null);
|
||||||
|
setInitialValues(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (values: Record<string, unknown>) => {
|
||||||
|
if (editingRecord) {
|
||||||
|
await execute(
|
||||||
|
() => options.onUpdate(options.getId(editingRecord), { ...values, version: (editingRecord as unknown as { version: number }).version }),
|
||||||
|
'更新成功',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await execute(() => options.onCreate(values), '创建成功');
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
options.onSuccess?.();
|
||||||
|
},
|
||||||
|
[editingRecord, options, close, execute],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
editingRecord,
|
||||||
|
initialValues,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
close,
|
||||||
|
handleSubmit,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
}
|
||||||
6
apps/web/src/hooks/useDarkMode.ts
Normal file
6
apps/web/src/hooks/useDarkMode.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { theme } from 'antd';
|
||||||
|
|
||||||
|
export function useDarkMode(): boolean {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
return token.colorBgBase !== '#ffffff' && token.colorBgBase !== '#fff';
|
||||||
|
}
|
||||||
78
apps/web/src/hooks/useDebouncedValue.test.ts
Normal file
78
apps/web/src/hooks/useDebouncedValue.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { useDebouncedValue } from './useDebouncedValue'
|
||||||
|
|
||||||
|
describe('useDebouncedValue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns initial value immediately', () => {
|
||||||
|
const { result } = renderHook(() => useDebouncedValue('hello'))
|
||||||
|
expect(result.current).toBe('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('debounces value updates', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value }) => useDebouncedValue(value, 300),
|
||||||
|
{ initialProps: { value: 'a' } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.current).toBe('a')
|
||||||
|
|
||||||
|
rerender({ value: 'b' })
|
||||||
|
expect(result.current).toBe('a')
|
||||||
|
|
||||||
|
act(() => { vi.advanceTimersByTime(299) })
|
||||||
|
expect(result.current).toBe('a')
|
||||||
|
|
||||||
|
act(() => { vi.advanceTimersByTime(1) })
|
||||||
|
expect(result.current).toBe('b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets timer on rapid updates', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value }) => useDebouncedValue(value, 200),
|
||||||
|
{ initialProps: { value: 'a' } },
|
||||||
|
)
|
||||||
|
|
||||||
|
rerender({ value: 'b' })
|
||||||
|
act(() => { vi.advanceTimersByTime(100) })
|
||||||
|
|
||||||
|
rerender({ value: 'c' })
|
||||||
|
act(() => { vi.advanceTimersByTime(100) })
|
||||||
|
expect(result.current).toBe('a')
|
||||||
|
|
||||||
|
act(() => { vi.advanceTimersByTime(100) })
|
||||||
|
expect(result.current).toBe('c')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses custom delay', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value }) => useDebouncedValue(value, 500),
|
||||||
|
{ initialProps: { value: 'x' } },
|
||||||
|
)
|
||||||
|
|
||||||
|
rerender({ value: 'y' })
|
||||||
|
act(() => { vi.advanceTimersByTime(499) })
|
||||||
|
expect(result.current).toBe('x')
|
||||||
|
|
||||||
|
act(() => { vi.advanceTimersByTime(1) })
|
||||||
|
expect(result.current).toBe('y')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works with numeric values', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value }) => useDebouncedValue(value, 100),
|
||||||
|
{ initialProps: { value: 0 } },
|
||||||
|
)
|
||||||
|
|
||||||
|
rerender({ value: 42 })
|
||||||
|
act(() => { vi.advanceTimersByTime(100) })
|
||||||
|
expect(result.current).toBe(42)
|
||||||
|
})
|
||||||
|
})
|
||||||
12
apps/web/src/hooks/useDebouncedValue.ts
Normal file
12
apps/web/src/hooks/useDebouncedValue.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
31
apps/web/src/hooks/useDictionary.ts
Normal file
31
apps/web/src/hooks/useDictionary.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { listItemsByCode, type DictionaryItemInfo } from '../api/dictionaries';
|
||||||
|
|
||||||
|
export interface DictOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDictionary(code: string, fallback?: DictOption[]) {
|
||||||
|
const [items, setItems] = useState<DictionaryItemInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
listItemsByCode(code)
|
||||||
|
.then((data) => setItems(data))
|
||||||
|
.catch(() => setItems([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const options = useMemo<DictOption[]>(() => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
return items
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
.map((item) => ({ value: item.value, label: item.label }));
|
||||||
|
}
|
||||||
|
return fallback ?? [];
|
||||||
|
}, [items, fallback]);
|
||||||
|
|
||||||
|
return { items, options, loading };
|
||||||
|
}
|
||||||
33
apps/web/src/hooks/useListData.ts
Normal file
33
apps/web/src/hooks/useListData.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface UseListDataReturn<T> {
|
||||||
|
data: T[];
|
||||||
|
loading: boolean;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useListData<T>(fetchFn: () => Promise<T[]>, autoFetch = true): UseListDataReturn<T> {
|
||||||
|
const [data, setData] = useState<T[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const fetchFnRef = useRef(fetchFn);
|
||||||
|
fetchFnRef.current = fetchFn;
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchFnRef.current();
|
||||||
|
setData(result);
|
||||||
|
} catch {
|
||||||
|
setData([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}, [refresh, autoFetch]);
|
||||||
|
|
||||||
|
return { data, loading, refresh };
|
||||||
|
}
|
||||||
125
apps/web/src/hooks/usePaginatedData.ts
Normal file
125
apps/web/src/hooks/usePaginatedData.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
|
||||||
|
interface PaginatedState<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchResult<T> = { data: T[]; total: number };
|
||||||
|
type OptionsConfig<F> = { pageSize?: number; defaultFilters: F; autoFetch?: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用分页数据 Hook,封装 data / total / page / loading / fetch 逻辑。
|
||||||
|
*
|
||||||
|
* 支持三种签名:
|
||||||
|
* 1. 泛型筛选 (page, pageSize, filters: F) — 带结构化筛选的列表页
|
||||||
|
* 2. 三参数 (page, pageSize, search: string) — 带搜索的列表页
|
||||||
|
* 3. 两参数 (page, pageSize) — 纯分页,不含搜索
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 重载签名
|
||||||
|
export function usePaginatedData<T, F>(
|
||||||
|
fetchFn: (page: number, pageSize: number, filters: F) => Promise<FetchResult<T>>,
|
||||||
|
options: OptionsConfig<F>,
|
||||||
|
): PaginatedResult<T, F>;
|
||||||
|
|
||||||
|
export function usePaginatedData<T>(
|
||||||
|
fetchFn:
|
||||||
|
| ((page: number, pageSize: number, search: string) => Promise<FetchResult<T>>)
|
||||||
|
| ((page: number, pageSize: number) => Promise<FetchResult<T>>),
|
||||||
|
pageSize?: number,
|
||||||
|
autoFetch?: boolean,
|
||||||
|
): PaginatedResult<T, string>;
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any -- 实现签名必须兼容所有重载 */
|
||||||
|
export function usePaginatedData<T, F = string>(
|
||||||
|
fetchFn: (...args: any[]) => Promise<FetchResult<T>>,
|
||||||
|
pageSizeOrOptions?: number | OptionsConfig<F>,
|
||||||
|
autoFetch = true,
|
||||||
|
): PaginatedResult<T, F> {
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
const isOptions = typeof pageSizeOrOptions === 'object' && pageSizeOrOptions !== null;
|
||||||
|
const options = pageSizeOrOptions as OptionsConfig<F>;
|
||||||
|
const pageSize = isOptions ? options.pageSize ?? 20 : (pageSizeOrOptions as number) ?? 20;
|
||||||
|
const shouldAutoFetch = isOptions ? options.autoFetch ?? true : autoFetch;
|
||||||
|
const defaultFilters = isOptions ? options.defaultFilters : ('' as unknown as F);
|
||||||
|
|
||||||
|
const [state, setState] = useState<PaginatedState<T>>({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [filters, setFilters] = useState<F>(defaultFilters);
|
||||||
|
|
||||||
|
const fetchFnRef = useRef(fetchFn);
|
||||||
|
|
||||||
|
const searchTextRef = useRef(searchText);
|
||||||
|
|
||||||
|
const filtersRef = useRef(filters);
|
||||||
|
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFnRef.current = fetchFn;
|
||||||
|
searchTextRef.current = searchText;
|
||||||
|
filtersRef.current = filters;
|
||||||
|
stateRef.current = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 所有 fetch 统一走 useEffect,通过 fetchTrigger 触发
|
||||||
|
const [fetchTrigger, setFetchTrigger] = useState(0);
|
||||||
|
const pendingPageRef = useRef<number | undefined>(undefined);
|
||||||
|
const isFirstRender = useRef(true);
|
||||||
|
|
||||||
|
// refresh 只负责设置目标页并递增 trigger,实际 fetch 在 useEffect 中执行
|
||||||
|
const refresh = useCallback((p?: number) => {
|
||||||
|
pendingPageRef.current = p;
|
||||||
|
setFetchTrigger((t) => t + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const targetPage = pendingPageRef.current ?? stateRef.current.page;
|
||||||
|
pendingPageRef.current = undefined;
|
||||||
|
|
||||||
|
if (isFirstRender.current) {
|
||||||
|
isFirstRender.current = false;
|
||||||
|
if (!shouldAutoFetch) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- 数据获取 hook:loading → fetch → setState 是标准模式
|
||||||
|
setState((s) => ({ ...s, loading: true }));
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
fetchFnRef.current(targetPage, pageSize, filtersRef.current ?? searchTextRef.current)
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setState({ data: result.data, total: result.total, page: targetPage, loading: false });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.warn('[usePaginatedData] 加载数据失败:', err);
|
||||||
|
message.error('加载数据失败');
|
||||||
|
setState((s) => ({ ...s, loading: false }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// fetchTrigger 变化 = 手动 refresh;filters 变化 = 筛选刷新
|
||||||
|
}, [shouldAutoFetch, filters, fetchTrigger, pageSize]);
|
||||||
|
|
||||||
|
return { ...state, searchText, setSearchText, filters, setFilters, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedResult<T, F> extends PaginatedState<T> {
|
||||||
|
searchText: string;
|
||||||
|
setSearchText: (text: string) => void;
|
||||||
|
filters: F;
|
||||||
|
setFilters: (filters: F | ((prev: F) => F)) => void;
|
||||||
|
refresh: (page?: number) => void;
|
||||||
|
}
|
||||||
38
apps/web/src/hooks/usePermFilteredTabs.ts
Normal file
38
apps/web/src/hooks/usePermFilteredTabs.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { TAB_PERMISSIONS } from '../routeConfig';
|
||||||
|
|
||||||
|
export interface TabItem {
|
||||||
|
key: string;
|
||||||
|
[prop: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据权限过滤详情页 Tab 列表。
|
||||||
|
*
|
||||||
|
* @param prefix - Tab 权限映射前缀(如 "patient"),对应 routeConfig.ts 中 "patient#tabKey"
|
||||||
|
* @param tabs - 完整 Tab 列表
|
||||||
|
* @returns 过滤后有权限可见的 Tab 列表
|
||||||
|
*/
|
||||||
|
export function usePermFilteredTabs<T extends TabItem>(prefix: string, tabs: T[]): T[] {
|
||||||
|
const permissions = useAuthStore((s) => s.permissions);
|
||||||
|
|
||||||
|
return tabs.filter((tab) => {
|
||||||
|
const lookupKey = `${prefix}#${tab.key}`;
|
||||||
|
const requiredPerm = TAB_PERMISSIONS[lookupKey];
|
||||||
|
|
||||||
|
// 未在 TAB_PERMISSIONS 中声明的 Tab,安全默认:不显示
|
||||||
|
if (requiredPerm === undefined && !(lookupKey in TAB_PERMISSIONS)) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn(
|
||||||
|
`[usePermFilteredTabs] Tab "${lookupKey}" 未在 routeConfig.ts TAB_PERMISSIONS 中声明,已隐藏。请添加声明。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显式声明为 undefined(无需权限)→ 始终可见
|
||||||
|
if (requiredPerm === undefined) return true;
|
||||||
|
|
||||||
|
return permissions.includes(requiredPerm);
|
||||||
|
});
|
||||||
|
}
|
||||||
6
apps/web/src/hooks/usePermission.ts
Normal file
6
apps/web/src/hooks/usePermission.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
|
||||||
|
export function usePermission(code: string): { hasPermission: boolean } {
|
||||||
|
const permissions = useAuthStore((s) => s.permissions);
|
||||||
|
return { hasPermission: permissions.includes(code) };
|
||||||
|
}
|
||||||
15
apps/web/src/hooks/useThemeMode.test.ts
Normal file
15
apps/web/src/hooks/useThemeMode.test.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { useThemeMode } from './useThemeMode'
|
||||||
|
|
||||||
|
describe('useThemeMode', () => {
|
||||||
|
it('should return false when no ConfigProvider is present (light default)', () => {
|
||||||
|
const { result } = renderHook(() => useThemeMode())
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return a boolean value', () => {
|
||||||
|
const { result } = renderHook(() => useThemeMode())
|
||||||
|
expect(typeof result.current).toBe('boolean')
|
||||||
|
})
|
||||||
|
})
|
||||||
11
apps/web/src/hooks/useThemeMode.ts
Normal file
11
apps/web/src/hooks/useThemeMode.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useAppStore } from '../stores/app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前是否处于暗色主题模式。
|
||||||
|
*
|
||||||
|
* 通过 store 的主题名称判断,替代旧的 token 色值检测,
|
||||||
|
* 支持多主题系统(blue / warm / dark / emerald)。
|
||||||
|
*/
|
||||||
|
export function useThemeMode(): boolean {
|
||||||
|
return useAppStore((s) => s.theme) === 'dark';
|
||||||
|
}
|
||||||
1368
apps/web/src/index.css
Normal file
1368
apps/web/src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
375
apps/web/src/layouts/MainLayout.tsx
Normal file
375
apps/web/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import { useCallback, useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme, Menu } from 'antd';
|
||||||
|
import type { MenuItemType, SubMenuType } from 'antd/es/menu/hooks/useItems';
|
||||||
|
import {
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
} 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 { getMenusForUser, type MenuInfo } from '../api/menus';
|
||||||
|
import { getIcon } from '../utils/iconRegistry';
|
||||||
|
import NotificationPanel from '../components/NotificationPanel';
|
||||||
|
import ThemeSwitcher from '../components/ThemeSwitcher';
|
||||||
|
import AiSidebar from '../components/ai/AiSidebar';
|
||||||
|
|
||||||
|
const { Header, Sider, Content, Footer } = Layout;
|
||||||
|
|
||||||
|
// 路由标题 fallback — 仅保留后端菜单无法覆盖的路由
|
||||||
|
// 1. 动态参数路由(:id/:id/edit)— 菜单表不会存储这些路径
|
||||||
|
// 2. 无后端菜单记录的静态页面路由
|
||||||
|
const routeTitleFallback: Record<string, string> = {
|
||||||
|
// 动态参数路由
|
||||||
|
'/health/patients/:id': '患者详情',
|
||||||
|
'/health/consultations/:id': '咨询详情',
|
||||||
|
'/health/articles/new': '新建文章',
|
||||||
|
'/health/articles/:id/edit': '编辑文章',
|
||||||
|
'/health/care-plans/:id': '护理计划详情',
|
||||||
|
'/health/shifts/:id': '班次详情',
|
||||||
|
'/health/ble-gateways/:id': '网关详情',
|
||||||
|
// 无后端菜单的静态路由
|
||||||
|
'/health/follow-up-records': '随访记录',
|
||||||
|
'/health/article-categories': '分类管理',
|
||||||
|
'/health/article-tags': '标签管理',
|
||||||
|
'/health/schedules': '排班管理',
|
||||||
|
'/health/appointments': '预约管理',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
||||||
|
for (const m of menus) {
|
||||||
|
if (m.path === path) return m.title;
|
||||||
|
if (m.children) {
|
||||||
|
const found = getTitleFromMenus(path, m.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将后端 MenuInfo 树转为 Ant Design Menu 的 items 格式
|
||||||
|
type AntMenuItem = MenuItemType | SubMenuType;
|
||||||
|
|
||||||
|
function buildMenuItems(menus: MenuInfo[]): AntMenuItem[] {
|
||||||
|
return menus
|
||||||
|
.filter((m) => m.visible !== false && m.menu_type !== 'button')
|
||||||
|
.map((m) => {
|
||||||
|
const visibleChildren = m.children?.filter((c) => c.visible !== false && c.menu_type !== 'button') || [];
|
||||||
|
if ((m.menu_type === 'directory') && visibleChildren.length > 0) {
|
||||||
|
return {
|
||||||
|
key: m.id,
|
||||||
|
icon: getIcon(m.icon),
|
||||||
|
label: m.title,
|
||||||
|
children: buildMenuItems(visibleChildren),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: m.path || m.id,
|
||||||
|
icon: getIcon(m.icon),
|
||||||
|
label: m.title,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找包含指定 path 的所有父级 key(用于自动展开 openKeys)
|
||||||
|
function findParentKeys(menus: MenuInfo[], targetPath: string): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
function walk(items: MenuInfo[], parents: string[]): boolean {
|
||||||
|
for (const m of items) {
|
||||||
|
if (m.path === targetPath) {
|
||||||
|
keys.push(...parents);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (m.children) {
|
||||||
|
if (walk(m.children, [...parents, m.id])) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
walk(menus, []);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插件菜单也纳入 Menu items
|
||||||
|
function buildPluginItems(groups: PluginMenuGroup[]): AntMenuItem[] {
|
||||||
|
return groups.map((g) => ({
|
||||||
|
key: `plugin-${g.pluginId}`,
|
||||||
|
icon: <AppstoreOutlined />,
|
||||||
|
label: g.pluginName,
|
||||||
|
children: g.items.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
icon: getIcon(item.icon),
|
||||||
|
label: item.label,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { sidebarCollapsed, toggleSidebar } = useAppStore();
|
||||||
|
const themeConfig = useAppStore((s) => s.themeConfig);
|
||||||
|
const loadThemeConfig = useAppStore((s) => s.loadThemeConfig);
|
||||||
|
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 || '/';
|
||||||
|
|
||||||
|
// 动态菜单状态
|
||||||
|
const [dynamicMenus, setDynamicMenus] = useState<MenuInfo[]>([]);
|
||||||
|
const [menuLoading, setMenuLoading] = useState(true);
|
||||||
|
const [aiSidebarOpen, setAiSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const menus = await getMenusForUser();
|
||||||
|
if (!cancelled) {
|
||||||
|
// 根据用户权限过滤菜单:菜单项声明 permission 时,用户必须有对应权限
|
||||||
|
const perms = useAuthStore.getState().permissions;
|
||||||
|
const isAdmin = useAuthStore.getState().user?.roles?.some((r) => typeof r === 'object' && r.code === 'admin') ?? false;
|
||||||
|
if (isAdmin) {
|
||||||
|
setDynamicMenus(menus);
|
||||||
|
} else {
|
||||||
|
const filterByPerm = (items: MenuInfo[]): MenuInfo[] =>
|
||||||
|
items
|
||||||
|
.map((m) => ({
|
||||||
|
...m,
|
||||||
|
children: m.children ? filterByPerm(m.children) : undefined,
|
||||||
|
}))
|
||||||
|
.filter((m) => {
|
||||||
|
if (m.menu_type === 'directory') return true;
|
||||||
|
if (!m.permission) return false;
|
||||||
|
return perms.includes(m.permission);
|
||||||
|
})
|
||||||
|
.filter((m) => m.menu_type === 'directory' || (m.children && m.children.length > 0) || (m.permission && perms.includes(m.permission)));
|
||||||
|
setDynamicMenus(filterByPerm(menus));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback: 使用空数组,保留插件菜单
|
||||||
|
}
|
||||||
|
if (!cancelled) setMenuLoading(false);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 合并动态菜单 + 插件菜单为 Ant Design Menu items
|
||||||
|
const allMenuItems = useMemo(() => {
|
||||||
|
const items = buildMenuItems(dynamicMenus);
|
||||||
|
if (pluginMenuGroups.length > 0) {
|
||||||
|
items.push(...buildPluginItems(pluginMenuGroups));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [dynamicMenus, pluginMenuGroups]);
|
||||||
|
|
||||||
|
// openKeys: 自动展开包含当前路由的父级
|
||||||
|
const autoExpandKeys = useMemo(() => {
|
||||||
|
const keys = findParentKeys(dynamicMenus, currentPath);
|
||||||
|
for (const g of pluginMenuGroups) {
|
||||||
|
if (g.items.some((it) => it.key === currentPath)) {
|
||||||
|
keys.push(`plugin-${g.pluginId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}, [currentPath, dynamicMenus, pluginMenuGroups]);
|
||||||
|
|
||||||
|
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
||||||
|
const [lastExpandedPath, setLastExpandedPath] = useState(currentPath);
|
||||||
|
if (currentPath !== lastExpandedPath) {
|
||||||
|
setLastExpandedPath(currentPath);
|
||||||
|
if (autoExpandKeys.length > 0) {
|
||||||
|
setOpenKeys((prev) => [...new Set([...prev, ...autoExpandKeys])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载插件菜单
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlugins(1, 'running');
|
||||||
|
}, [fetchPlugins]);
|
||||||
|
|
||||||
|
// 加载主题配置
|
||||||
|
useEffect(() => {
|
||||||
|
loadThemeConfig();
|
||||||
|
}, [loadThemeConfig]);
|
||||||
|
|
||||||
|
const handleLogout = useCallback(async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login');
|
||||||
|
}, [logout, navigate]);
|
||||||
|
|
||||||
|
// 标题查找:先从动态菜单查找,再 fallback(支持动态路径参数匹配)
|
||||||
|
const headerTitle = useMemo(() => {
|
||||||
|
const fromMenus = getTitleFromMenus(currentPath, dynamicMenus);
|
||||||
|
if (fromMenus) return fromMenus;
|
||||||
|
// 尝试模式匹配 routeTitleFallback 的 key(如 /health/patients/:id)
|
||||||
|
for (const [pattern, title] of Object.entries(routeTitleFallback)) {
|
||||||
|
const regex = new RegExp('^' + pattern.replace(/:[^/]+/g, '[^/]+') + '$');
|
||||||
|
if (regex.test(currentPath)) return title;
|
||||||
|
}
|
||||||
|
return pluginMenuItems.find((p) => p.key === currentPath)?.label || '页面';
|
||||||
|
}, [currentPath, dynamicMenus, pluginMenuItems]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
<Sider
|
||||||
|
trigger={null}
|
||||||
|
collapsible
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
width={240}
|
||||||
|
collapsedWidth={72}
|
||||||
|
className="erp-sider-dark"
|
||||||
|
>
|
||||||
|
{/* Logo 区域 */}
|
||||||
|
<div className="erp-sidebar-logo" onClick={() => navigate('/')}>
|
||||||
|
<div className="erp-sidebar-logo-icon">H</div>
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<span className="erp-sidebar-logo-text">{themeConfig?.brand_name || 'HMS 健康'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 菜单 */}
|
||||||
|
{menuLoading ? (
|
||||||
|
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||||
|
<Spin size="small" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
inlineCollapsed={sidebarCollapsed}
|
||||||
|
items={allMenuItems}
|
||||||
|
selectedKeys={[currentPath]}
|
||||||
|
openKeys={openKeys}
|
||||||
|
onOpenChange={setOpenKeys}
|
||||||
|
onClick={({ key }) => navigate(key)}
|
||||||
|
className="erp-sidebar-menu"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Sider>
|
||||||
|
|
||||||
|
{/* 右侧主区域 */}
|
||||||
|
<Layout
|
||||||
|
className="erp-main-layout"
|
||||||
|
style={{ marginLeft: sidebarWidth }}
|
||||||
|
>
|
||||||
|
{/* 顶部导航栏 */}
|
||||||
|
<Header className="erp-header">
|
||||||
|
{/* 左侧:折叠按钮 + 标题 */}
|
||||||
|
<Space size="middle" style={{ alignItems: 'center' }}>
|
||||||
|
<div className="erp-header-btn" onClick={toggleSidebar}>
|
||||||
|
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
</div>
|
||||||
|
<span className="erp-header-title">
|
||||||
|
{headerTitle}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 右侧:搜索 + 主题切换 + 通知 + 用户 */}
|
||||||
|
<Space size={4} style={{ alignItems: 'center' }}>
|
||||||
|
<Tooltip title="搜索">
|
||||||
|
<div className="erp-header-btn">
|
||||||
|
<SearchOutlined style={{ fontSize: 16 }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<ThemeSwitcher />
|
||||||
|
|
||||||
|
<NotificationPanel />
|
||||||
|
|
||||||
|
<div className="erp-header-divider" />
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{user?.display_name || user?.username || 'User'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</Space>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<Content style={{ padding: 20, minHeight: 'calc(100vh - 56px - 48px)' }}>
|
||||||
|
<div key={currentPath}>{children}</div>
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
{/* 底部 */}
|
||||||
|
<Footer className="erp-footer">
|
||||||
|
{themeConfig?.brand_copyright || 'HMS 健康管理平台'}
|
||||||
|
</Footer>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
{/* AI 助手浮动按钮 + 侧边栏 */}
|
||||||
|
<Tooltip title="AI 健康助手" placement="left">
|
||||||
|
<div
|
||||||
|
onClick={() => setAiSidebarOpen(true)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
right: 24,
|
||||||
|
bottom: 32,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #1677ff 0%, #722ed1 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 4px 12px rgba(22, 119, 255, 0.4)',
|
||||||
|
zIndex: 1000,
|
||||||
|
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1.1)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 16px rgba(22, 119, 255, 0.6)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(22, 119, 255, 0.4)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RobotOutlined style={{ color: '#fff', fontSize: 22 }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<AiSidebar open={aiSidebarOpen} onClose={() => setAiSidebarOpen(false)} />
|
||||||
|
</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>,
|
||||||
|
)
|
||||||
179
apps/web/src/pages/Home.tsx
Normal file
179
apps/web/src/pages/Home.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Row, Col, Spin, Empty } from 'antd';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
PartitionOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
BellOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
ApartmentOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useThemeMode } from '../hooks/useThemeMode';
|
||||||
|
import { useMessageStore } from '../stores/message';
|
||||||
|
import { listAuditLogs, type AuditLogItem } from '../api/auditLogs';
|
||||||
|
import { listPendingTasks, type TaskInfo } from '../api/workflowTasks';
|
||||||
|
|
||||||
|
// --- Shared utilities ---
|
||||||
|
|
||||||
|
function formatTimeAgo(dateStr: string): string {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 1) return '刚刚';
|
||||||
|
if (minutes < 60) return `${minutes} 分钟前`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} 小时前`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days} 天前`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
create: '创建', created: '创建', update: '更新', updated: '更新', delete: '删除', deleted: '删除',
|
||||||
|
login: '登录', 'user.create': '创建', 'user.update': '更新', 'user.delete': '删除',
|
||||||
|
};
|
||||||
|
const RESOURCE_LABELS: Record<string, string> = {
|
||||||
|
user: '用户', role: '角色', process_instance: '流程实例', organization: '组织',
|
||||||
|
message: '消息', plugin: '插件',
|
||||||
|
};
|
||||||
|
const RESOURCE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
user: <UserOutlined />, role: <SafetyCertificateOutlined />,
|
||||||
|
organization: <ApartmentOutlined />,
|
||||||
|
process_instance: <FileTextOutlined />, message: <BellOutlined />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatActionLabel(action: string): string {
|
||||||
|
return ACTION_LABELS[action] || ACTION_LABELS[action.split('.').pop() || ''] || action;
|
||||||
|
}
|
||||||
|
function formatResourceLabel(resource: string): string {
|
||||||
|
return RESOURCE_LABELS[resource] || RESOURCE_LABELS[resource.split('.').pop() || ''] || resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Component ---
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isDark = useThemeMode();
|
||||||
|
const fetchUnreadCount = useMessageStore((s) => s.fetchUnreadCount);
|
||||||
|
|
||||||
|
const [pendingTasks, setPendingTasks] = useState<TaskInfo[]>([]);
|
||||||
|
const [recentActivities, setRecentActivities] = useState<AuditLogItem[]>([]);
|
||||||
|
const [activitiesLoading, setActivitiesLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchUnreadCount();
|
||||||
|
|
||||||
|
listPendingTasks(1, 5)
|
||||||
|
.then((result) => { if (!cancelled) setPendingTasks(result.data); })
|
||||||
|
.catch((err) => console.warn('[Home] 获取待办任务失败:', err));
|
||||||
|
|
||||||
|
listAuditLogs({ page: 1, page_size: 5 })
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) setRecentActivities(result.data.filter((a) => a.action !== 'login_failed'));
|
||||||
|
})
|
||||||
|
.catch((err) => console.warn('[Home] 获取审计日志失败:', err))
|
||||||
|
.finally(() => { if (!cancelled) setActivitiesLoading(false); });
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNavigate = useCallback((path: string) => {
|
||||||
|
navigate(path);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 欢迎语 */}
|
||||||
|
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
|
||||||
|
<h2 style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: isDark ? '#f8fafc' : 'var(--erp-text-primary)',
|
||||||
|
margin: '0 0 4px',
|
||||||
|
letterSpacing: '-0.5px',
|
||||||
|
}}>
|
||||||
|
工作台
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--erp-text-secondary)', margin: 0 }}>
|
||||||
|
待办任务与最近动态
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 待办任务 + 最近动态 */}
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<div className="erp-content-card erp-fade-in erp-fade-in-delay-1">
|
||||||
|
<div className="erp-section-header">
|
||||||
|
<CheckCircleOutlined className="erp-section-icon" />
|
||||||
|
<span className="erp-section-title">待办任务</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--erp-text-secondary)' }}>
|
||||||
|
{pendingTasks.length} 项待处理
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="erp-task-list">
|
||||||
|
{pendingTasks.length === 0 ? (
|
||||||
|
<Empty description="暂无待办任务" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
) : (
|
||||||
|
pendingTasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="erp-task-item"
|
||||||
|
style={{ '--task-color': 'var(--erp-primary)' } as React.CSSProperties}
|
||||||
|
onClick={() => handleNavigate('/workflow')}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate('/workflow'); }}
|
||||||
|
>
|
||||||
|
<div className="erp-task-item-icon"><PartitionOutlined /></div>
|
||||||
|
<div className="erp-task-item-content">
|
||||||
|
<div className="erp-task-item-title">{task.node_name || task.definition_name || '流程任务'}</div>
|
||||||
|
<div className="erp-task-item-meta">
|
||||||
|
<span>{task.definition_name || '工作流'}</span>
|
||||||
|
<span>{task.status === 'pending' ? '待处理' : task.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="erp-task-priority erp-task-priority-medium">一般</span>
|
||||||
|
<RightOutlined style={{ color: 'var(--erp-text-tertiary)', fontSize: 12 }} />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<div className="erp-content-card erp-fade-in erp-fade-in-delay-2" style={{ height: '100%' }}>
|
||||||
|
<div className="erp-section-header">
|
||||||
|
<ClockCircleOutlined className="erp-section-icon" />
|
||||||
|
<span className="erp-section-title">最近动态</span>
|
||||||
|
</div>
|
||||||
|
<div className="erp-activity-list">
|
||||||
|
{activitiesLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
|
||||||
|
) : recentActivities.length === 0 ? (
|
||||||
|
<Empty description="暂无动态" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
) : (
|
||||||
|
recentActivities.map((log) => (
|
||||||
|
<div key={log.id} className="erp-activity-item">
|
||||||
|
<div className="erp-activity-dot">
|
||||||
|
{RESOURCE_ICONS[log.resource_type] || <FileTextOutlined />}
|
||||||
|
</div>
|
||||||
|
<div className="erp-activity-content">
|
||||||
|
<div className="erp-activity-text">
|
||||||
|
{formatActionLabel(log.action)}了{formatResourceLabel(log.resource_type)}
|
||||||
|
</div>
|
||||||
|
<div className="erp-activity-time">{formatTimeAgo(log.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/web/src/pages/Login.tsx
Normal file
117
apps/web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
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';
|
||||||
|
import { handleApiError } from '../api/client';
|
||||||
|
import ThemeSwitcher from '../components/ThemeSwitcher';
|
||||||
|
import { getPublicBrand, type BrandConfig } from '../api/themes';
|
||||||
|
|
||||||
|
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 [brand, setBrand] = useState<BrandConfig | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPublicBrand().then(setBrand);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onFinish = async (values: { username: string; password: string }) => {
|
||||||
|
try {
|
||||||
|
await login(values.username, values.password);
|
||||||
|
messageApi.success('登录成功');
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
handleApiError(err, '登录失败,请检查用户名和密码');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-root">
|
||||||
|
{contextHolder}
|
||||||
|
|
||||||
|
{/* 左侧品牌展示区 */}
|
||||||
|
<div className="login-brand-panel">
|
||||||
|
<div className="deco-circle" style={{ top: '-20%', right: '-10%', width: 500, height: 500 }} />
|
||||||
|
<div className="deco-circle" style={{ bottom: '-15%', left: '-8%', width: 400, height: 400, background: 'rgba(255, 255, 255, 0.03)' }} />
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', zIndex: 1, textAlign: 'center', maxWidth: 480 }}>
|
||||||
|
<div className="brand-icon">
|
||||||
|
<SafetyCertificateOutlined />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="brand-title">{brand?.brand_name || 'HMS 健康管理平台'}</h1>
|
||||||
|
<p className="brand-desc">{brand?.brand_slogan || '新一代健康管理平台'}</p>
|
||||||
|
<p className="brand-sub-desc">{brand?.brand_features || '患者管理 · 健康监测 · 随访管理 · AI 智能分析'}</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 className="feature-item-value">{item.value}</div>
|
||||||
|
<div className="feature-item-label">{item.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧登录表单区 */}
|
||||||
|
<main className="login-form-panel">
|
||||||
|
<div className="login-theme-switcher">
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: 360, width: '100%', margin: '0 auto' }}>
|
||||||
|
<h2 className="form-title">欢迎回来</h2>
|
||||||
|
<p className="form-subtitle">请登录您的账户以继续</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: 'var(--login-input-icon-color)' }} />}
|
||||||
|
placeholder="用户名"
|
||||||
|
style={{ height: 44, borderRadius: 'var(--erp-radius-md)' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined style={{ color: 'var(--login-input-icon-color)' }} />}
|
||||||
|
placeholder="密码"
|
||||||
|
style={{ height: 44, borderRadius: 'var(--erp-radius-md)' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
block
|
||||||
|
style={{ height: 44, borderRadius: 'var(--erp-radius-md)', fontSize: 15, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className="form-footer">
|
||||||
|
{brand?.brand_copyright || 'HMS 健康管理平台 · ©汕头市智界科技有限公司'}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
371
apps/web/src/pages/Organizations.tsx
Normal file
371
apps/web/src/pages/Organizations.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Tree,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Table,
|
||||||
|
Popconfirm,
|
||||||
|
Empty,
|
||||||
|
Tag,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
ApartmentOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { DataNode } from 'antd/es/tree';
|
||||||
|
import { useThemeMode } from '../hooks/useThemeMode';
|
||||||
|
import { DrawerForm } from '../components/DrawerForm';
|
||||||
|
import { useCrudDrawer } from '../hooks/useCrudDrawer';
|
||||||
|
import { useApiRequest } from '../hooks/useApiRequest';
|
||||||
|
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 isDark = useThemeMode();
|
||||||
|
const { execute } = useApiRequest();
|
||||||
|
|
||||||
|
const cardStyle = {
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Org tree state ---
|
||||||
|
const [orgTree, setOrgTree] = useState<OrganizationInfo[]>([]);
|
||||||
|
const [selectedOrg, setSelectedOrg] = useState<OrganizationInfo | null>(null);
|
||||||
|
|
||||||
|
// --- Department tree state ---
|
||||||
|
const [deptTree, setDeptTree] = useState<DepartmentInfo[]>([]);
|
||||||
|
const [selectedDept, setSelectedDept] = useState<DepartmentInfo | null>(null);
|
||||||
|
|
||||||
|
// --- Position list state ---
|
||||||
|
const [positions, setPositions] = useState<PositionInfo[]>([]);
|
||||||
|
|
||||||
|
// --- Ref for drawer onSuccess callback (avoids before-declaration issue) ---
|
||||||
|
const refreshOrgTreeRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
// --- Fetch org tree ---
|
||||||
|
const fetchOrgTree = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const tree = await listOrgTree();
|
||||||
|
setOrgTree(tree);
|
||||||
|
if (selectedOrg) {
|
||||||
|
const stillExists = findOrgInTree(tree, selectedOrg.id);
|
||||||
|
if (!stillExists) { setSelectedOrg(null); setDeptTree([]); setPositions([]); }
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}, [selectedOrg]);
|
||||||
|
|
||||||
|
refreshOrgTreeRef.current = () => { fetchOrgTree(); };
|
||||||
|
|
||||||
|
useEffect(() => { fetchOrgTree(); }, [fetchOrgTree]);
|
||||||
|
|
||||||
|
// --- Dept Drawer ---
|
||||||
|
const [deptDrawerOpen, setDeptDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
// --- Position Drawer ---
|
||||||
|
const [positionDrawerOpen, setPositionDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
// --- Org Drawer (uses ref to avoid before-declaration) ---
|
||||||
|
const orgDrawer = useCrudDrawer<OrganizationInfo>({
|
||||||
|
getId: (r) => r.id,
|
||||||
|
onCreate: async (values) => {
|
||||||
|
await createOrg({ ...(values as { name: string; code?: string; sort_order?: number }), parent_id: selectedOrg?.id });
|
||||||
|
},
|
||||||
|
onUpdate: async (id, values) => {
|
||||||
|
await updateOrg(id, values as { name: string; code?: string; sort_order?: number; version: number });
|
||||||
|
},
|
||||||
|
onSuccess: () => { refreshOrgTreeRef.current(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Fetch dept tree ---
|
||||||
|
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 { /* silent */ }
|
||||||
|
}, [selectedOrg, selectedDept]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchDeptTree(); }, [fetchDeptTree]);
|
||||||
|
|
||||||
|
// --- Fetch positions ---
|
||||||
|
const fetchPositions = useCallback(async () => {
|
||||||
|
if (!selectedDept) return;
|
||||||
|
try {
|
||||||
|
setPositions(await listPositions(selectedDept.id));
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}, [selectedDept]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPositions(); }, [fetchPositions]);
|
||||||
|
|
||||||
|
// --- Org handlers ---
|
||||||
|
const handleDeleteOrg = async (id: string) => {
|
||||||
|
await execute(() => deleteOrg(id), '组织已删除');
|
||||||
|
setSelectedOrg(null); setDeptTree([]); setPositions([]);
|
||||||
|
fetchOrgTree();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Dept handlers ---
|
||||||
|
const handleCreateDept = async (values: Record<string, unknown>) => {
|
||||||
|
if (!selectedOrg) return;
|
||||||
|
await execute(() => createDept(selectedOrg.id, {
|
||||||
|
name: values.name as string, code: values.code as string | undefined,
|
||||||
|
parent_id: selectedDept?.id, sort_order: values.sort_order as number | undefined,
|
||||||
|
}), '部门创建成功');
|
||||||
|
setDeptDrawerOpen(false);
|
||||||
|
fetchDeptTree();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDept = async (id: string) => {
|
||||||
|
await execute(() => deleteDept(id), '部门已删除');
|
||||||
|
setSelectedDept(null); setPositions([]);
|
||||||
|
fetchDeptTree();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Position handlers ---
|
||||||
|
const handleCreatePosition = async (values: Record<string, unknown>) => {
|
||||||
|
if (!selectedDept) return;
|
||||||
|
await execute(() => createPosition(selectedDept.id, {
|
||||||
|
name: values.name as string, code: values.code as string | undefined,
|
||||||
|
level: values.level as number | undefined, sort_order: values.sort_order as number | undefined,
|
||||||
|
}), '岗位创建成功');
|
||||||
|
setPositionDrawerOpen(false);
|
||||||
|
fetchPositions();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePosition = async (id: string) => {
|
||||||
|
await execute(() => deletePosition(id), '岗位已删除');
|
||||||
|
fetchPositions();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 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 ? '#0f172a' : '#eff6ff', border: 'none', color: '#2563eb', 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 ? '#0f172a' : '#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; }
|
||||||
|
setSelectedOrg(findOrgInTree(orgTree, selectedKeys[0] as string));
|
||||||
|
setSelectedDept(null); setPositions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectDept = (selectedKeys: React.Key[]) => {
|
||||||
|
if (selectedKeys.length === 0) { setSelectedDept(null); setPositions([]); return; }
|
||||||
|
setSelectedDept(findDeptInTree(deptTree, selectedKeys[0] as string));
|
||||||
|
};
|
||||||
|
|
||||||
|
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: '#2563eb' }} />组织架构管理</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 ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
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={() => orgDrawer.openCreate()} />
|
||||||
|
{selectedOrg && (
|
||||||
|
<>
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => orgDrawer.openEdit(selectedOrg, (r) => ({
|
||||||
|
name: r.name, code: r.code, sort_order: r.sort_order,
|
||||||
|
}))} />
|
||||||
|
<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 ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
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={() => setDeptDrawerOpen(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 ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
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={() => setPositionDrawerOpen(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 Drawer */}
|
||||||
|
<DrawerForm
|
||||||
|
title={orgDrawer.editingRecord ? '编辑组织' : selectedOrg ? `在 ${selectedOrg.name} 下新建子组织` : '新建根组织'}
|
||||||
|
open={orgDrawer.open}
|
||||||
|
onClose={orgDrawer.close}
|
||||||
|
onSubmit={orgDrawer.handleSubmit}
|
||||||
|
initialValues={orgDrawer.initialValues}
|
||||||
|
loading={orgDrawer.loading}
|
||||||
|
width={480}
|
||||||
|
columns={1}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</DrawerForm>
|
||||||
|
|
||||||
|
{/* Dept Drawer */}
|
||||||
|
<DrawerForm
|
||||||
|
title={selectedDept ? `在 ${selectedDept.name} 下新建子部门` : `在 ${selectedOrg?.name} 下新建部门`}
|
||||||
|
open={deptDrawerOpen}
|
||||||
|
onClose={() => setDeptDrawerOpen(false)}
|
||||||
|
onSubmit={handleCreateDept}
|
||||||
|
initialValues={{ sort_order: 0 }}
|
||||||
|
loading={false}
|
||||||
|
width={480}
|
||||||
|
columns={1}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</DrawerForm>
|
||||||
|
|
||||||
|
{/* Position Drawer */}
|
||||||
|
<DrawerForm
|
||||||
|
title={`在 ${selectedDept?.name} 下新建岗位`}
|
||||||
|
open={positionDrawerOpen}
|
||||||
|
onClose={() => setPositionDrawerOpen(false)}
|
||||||
|
onSubmit={handleCreatePosition}
|
||||||
|
initialValues={{ level: 1, sort_order: 0 }}
|
||||||
|
loading={false}
|
||||||
|
width={480}
|
||||||
|
columns={1}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</DrawerForm>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
409
apps/web/src/pages/PluginAdmin.tsx
Normal file
409
apps/web/src/pages/PluginAdmin.tsx
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
Upload,
|
||||||
|
Modal,
|
||||||
|
Input,
|
||||||
|
Drawer,
|
||||||
|
Descriptions,
|
||||||
|
Popconfirm,
|
||||||
|
Form,
|
||||||
|
Tabs,
|
||||||
|
theme,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
UploadOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
CloudDownloadOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
HeartOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { PluginInfo, PluginStatus, PluginSchemaResponse } from '../api/plugins';
|
||||||
|
import {
|
||||||
|
listPlugins,
|
||||||
|
uploadPlugin,
|
||||||
|
installPlugin,
|
||||||
|
enablePlugin,
|
||||||
|
disablePlugin,
|
||||||
|
uninstallPlugin,
|
||||||
|
purgePlugin,
|
||||||
|
getPluginHealth,
|
||||||
|
getPluginSchema,
|
||||||
|
updatePluginConfig,
|
||||||
|
} from '../api/plugins';
|
||||||
|
import PluginSettingsForm from '../components/PluginSettingsForm';
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
|
||||||
|
uploaded: { color: '#475569', 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 [schemaData, setSchemaData] = useState<PluginSchemaResponse | 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]);
|
||||||
|
|
||||||
|
// 打开详情时加载 schema(含 settings)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailPlugin) {
|
||||||
|
setSchemaData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getPluginSchema(detailPlugin.id)
|
||||||
|
.then(setSchemaData)
|
||||||
|
.catch(() => setSchemaData(null));
|
||||||
|
}, [detailPlugin]);
|
||||||
|
|
||||||
|
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="enable"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
loading={actionLoading === id}
|
||||||
|
onClick={() => handleAction(id, () => enablePlugin(id), '启用')}
|
||||||
|
>
|
||||||
|
启用
|
||||||
|
</Button>,
|
||||||
|
<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: '#475569', 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={!['uninstalled', 'disabled', 'uploaded', 'installed'].includes(record.status)}>
|
||||||
|
清除
|
||||||
|
</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);
|
||||||
|
setSchemaData(null);
|
||||||
|
}}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
{detailPlugin && (
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="info"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'info',
|
||||||
|
label: '基本信息',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<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={() => 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>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(schemaData?.settings
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'settings',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<SettingOutlined /> 配置
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<PluginSettingsForm
|
||||||
|
fields={schemaData.settings.fields}
|
||||||
|
values={detailPlugin.config as Record<string, unknown>}
|
||||||
|
recordVersion={detailPlugin.record_version}
|
||||||
|
onSave={async (config, version) => {
|
||||||
|
const updated = await updatePluginConfig(
|
||||||
|
detailPlugin.id,
|
||||||
|
config,
|
||||||
|
version,
|
||||||
|
);
|
||||||
|
setDetailPlugin({ ...detailPlugin, ...updated });
|
||||||
|
}}
|
||||||
|
readOnly={detailPlugin.status !== 'enabled' && detailPlugin.status !== 'running'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/web/src/pages/PluginCRUDPage.tsx
Normal file
1
apps/web/src/pages/PluginCRUDPage.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './PluginCRUDPage/PluginCRUDPageInner';
|
||||||
102
apps/web/src/pages/PluginCRUDPage/DetailDrawer.tsx
Normal file
102
apps/web/src/pages/PluginCRUDPage/DetailDrawer.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Drawer, Descriptions, Tag } from 'antd';
|
||||||
|
import type { PluginFieldSchema, PluginEntitySchema, PluginSectionSchema } from '../../api/plugins';
|
||||||
|
import PluginCRUDPageInner from './PluginCRUDPageInner';
|
||||||
|
|
||||||
|
interface DetailDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
record: Record<string, unknown> | null;
|
||||||
|
displayName: string;
|
||||||
|
fields: PluginFieldSchema[];
|
||||||
|
sections: PluginSectionSchema[];
|
||||||
|
allEntities: PluginEntitySchema[];
|
||||||
|
pluginId: string;
|
||||||
|
entityName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailDrawer({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
displayName,
|
||||||
|
fields,
|
||||||
|
sections,
|
||||||
|
allEntities,
|
||||||
|
pluginId,
|
||||||
|
entityName: _entityName,
|
||||||
|
onClose,
|
||||||
|
}: DetailDrawerProps) {
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={displayName + ' 详情'}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
{sections.length > 0 ? (
|
||||||
|
sections.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 = record[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 && (
|
||||||
|
<PluginCRUDPageInner
|
||||||
|
pluginIdOverride={pluginId}
|
||||||
|
entityOverride={section.entity}
|
||||||
|
filterField={section.filter_field}
|
||||||
|
filterValue={String(record._id ?? '')}
|
||||||
|
enableViews={section.enable_views}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
{fields.map((field) => {
|
||||||
|
const val = record[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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/web/src/pages/PluginCRUDPage/ImportModal.tsx
Normal file
91
apps/web/src/pages/PluginCRUDPage/ImportModal.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, Upload, Alert, Button, message } from 'antd';
|
||||||
|
import { importPluginData, type ImportResult } from '../../api/pluginData';
|
||||||
|
|
||||||
|
interface ImportModalProps {
|
||||||
|
open: boolean;
|
||||||
|
pluginId: string;
|
||||||
|
entityName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportModal({ open, pluginId, entityName, onClose, onSuccess }: ImportModalProps) {
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
setImportResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="导入数据"
|
||||||
|
open={open}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={importResult ? (
|
||||||
|
<Button onClick={handleClose}>关闭</Button>
|
||||||
|
) : null}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{importResult ? (
|
||||||
|
<div>
|
||||||
|
<Alert
|
||||||
|
type={importResult.error_count > 0 ? 'warning' : 'success'}
|
||||||
|
message={`导入完成:成功 ${importResult.success_count} 条,失败 ${importResult.error_count} 条`}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
{importResult.errors.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4>错误详情</h4>
|
||||||
|
{importResult.errors.map((err, i) => (
|
||||||
|
<Alert
|
||||||
|
key={i}
|
||||||
|
type="error"
|
||||||
|
message={`第 ${err.row + 1} 行`}
|
||||||
|
description={err.errors.join('; ')}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Upload.Dragger
|
||||||
|
accept=".json"
|
||||||
|
maxCount={1}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
const rows = JSON.parse(text);
|
||||||
|
if (!Array.isArray(rows)) {
|
||||||
|
message.error('文件格式错误:需要 JSON 数组');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImporting(true);
|
||||||
|
const result = await importPluginData(pluginId, entityName, rows);
|
||||||
|
setImportResult(result);
|
||||||
|
if (result.success_count > 0) onSuccess();
|
||||||
|
} catch {
|
||||||
|
message.error('文件解析失败,请确认格式为 JSON 数组');
|
||||||
|
}
|
||||||
|
setImporting(false);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
showUploadList={false}
|
||||||
|
disabled={importing}
|
||||||
|
>
|
||||||
|
<p style={{ fontSize: 16, padding: '24px 0' }}>
|
||||||
|
{importing ? '导入中...' : '点击或拖拽 JSON 文件到此处'}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#999' }}>支持 JSON 数组格式,单次上限 1000 行</p>
|
||||||
|
</Upload.Dragger>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
488
apps/web/src/pages/PluginCRUDPage/PluginCRUDPageInner.tsx
Normal file
488
apps/web/src/pages/PluginCRUDPage/PluginCRUDPageInner.tsx
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
DatePicker,
|
||||||
|
Switch,
|
||||||
|
Select,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
Popconfirm,
|
||||||
|
Segmented,
|
||||||
|
Timeline,
|
||||||
|
Dropdown,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
createPluginData,
|
||||||
|
updatePluginData,
|
||||||
|
deletePluginData,
|
||||||
|
batchPluginData,
|
||||||
|
exportPluginData,
|
||||||
|
exportPluginDataAsBlob,
|
||||||
|
} from '../../api/pluginData';
|
||||||
|
import EntitySelect from '../../components/EntitySelect';
|
||||||
|
import type { PluginFieldSchema } from '../../api/plugins';
|
||||||
|
import { evaluateVisibleWhen } from '../../utils/exprEvaluator';
|
||||||
|
import { usePluginData } from './usePluginData';
|
||||||
|
import DetailDrawer from './DetailDrawer';
|
||||||
|
import ImportModal from './ImportModal';
|
||||||
|
|
||||||
|
const { Search } = Input;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface PluginCRUDPageProps {
|
||||||
|
pluginIdOverride?: string;
|
||||||
|
entityOverride?: string;
|
||||||
|
filterField?: string;
|
||||||
|
filterValue?: string;
|
||||||
|
enableViews?: string[];
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginCRUDPageInner({
|
||||||
|
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, total, page, loading, fields, displayName,
|
||||||
|
sortBy, sortOrder,
|
||||||
|
resolvedLabels, labelMeta,
|
||||||
|
entityDef, allEntities, detailSections,
|
||||||
|
hasDetailPage, filterableFields,
|
||||||
|
setPage, setSortBy, setSortOrder,
|
||||||
|
fetchData,
|
||||||
|
} = usePluginData(pluginId, entityName, filterField, filterValue);
|
||||||
|
|
||||||
|
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>>({});
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<string>('table');
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const enableViews = enableViewsProp ||
|
||||||
|
(() => {
|
||||||
|
return ['table'];
|
||||||
|
})();
|
||||||
|
|
||||||
|
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 = useMemo(() => [
|
||||||
|
...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>;
|
||||||
|
if (f.ref_entity) {
|
||||||
|
const uuid = String(val ?? '');
|
||||||
|
if (!uuid || uuid === '-') return '-';
|
||||||
|
const label = resolvedLabels[f.name]?.[uuid];
|
||||||
|
const installed = labelMeta[f.name]?.plugin_installed !== false;
|
||||||
|
if (!installed) return <Tag color="default">{f.ref_fallback_label || '外部引用'}</Tag>;
|
||||||
|
if (label === null) return <Tag color="warning">无效引用</Tag>;
|
||||||
|
if (label) return <Tag color="blue">{label}</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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [fields, resolvedLabels, labelMeta, hasDetailPage, handleDelete]);
|
||||||
|
|
||||||
|
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}
|
||||||
|
refPlugin={field.ref_plugin}
|
||||||
|
fallbackLabel={field.ref_fallback_label}
|
||||||
|
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 />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
{entityDef?.exportable && (
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'json', label: 'JSON' },
|
||||||
|
{ key: 'csv', label: 'CSV' },
|
||||||
|
{ key: 'xlsx', label: 'Excel (.xlsx)' },
|
||||||
|
],
|
||||||
|
onClick: async ({ key }) => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const ts = Date.now();
|
||||||
|
if (key === 'json') {
|
||||||
|
const rows = await exportPluginData(pluginId, entityName, {
|
||||||
|
sort_by: sortBy, sort_order: sortOrder,
|
||||||
|
});
|
||||||
|
const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${entityName}_export_${ts}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
message.success(`导出 ${rows.length} 条记录`);
|
||||||
|
} else {
|
||||||
|
const blob = await exportPluginDataAsBlob(
|
||||||
|
pluginId, entityName, key as 'csv' | 'xlsx',
|
||||||
|
{ sort_by: sortBy, sort_order: sortOrder },
|
||||||
|
);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${entityName}_export_${ts}.${key === 'csv' ? 'csv' : 'xlsx'}`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
message.success('导出成功');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('导出失败');
|
||||||
|
}
|
||||||
|
setExporting(false);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button icon={<DownloadOutlined />} loading={exporting}>导出</Button>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
{entityDef?.importable && (
|
||||||
|
<Button icon={<UploadOutlined />} onClick={() => setImportModalOpen(true)}>
|
||||||
|
导入
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!compact && (
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
{fields.some((f) => f.searchable) && (
|
||||||
|
<Search
|
||||||
|
placeholder="搜索..."
|
||||||
|
allowClear
|
||||||
|
style={{ width: 240 }}
|
||||||
|
onSearch={(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) => {
|
||||||
|
const newFilters: Record<string, string> = {};
|
||||||
|
if (value) newFilters[field.name] = value;
|
||||||
|
setPage(1);
|
||||||
|
fetchData(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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' ? (
|
||||||
|
<Timeline
|
||||||
|
items={records.map((record) => {
|
||||||
|
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 {
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editRecord ? '编辑' : '新增'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditRecord(null); setFormValues({}); }}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={(_, allValues) => setFormValues(allValues)}>
|
||||||
|
{fields.map((field) => {
|
||||||
|
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}` }] : []),
|
||||||
|
...(field.validation?.pattern ? [{ pattern: new RegExp(field.validation.pattern), message: field.validation.message || '格式不正确' }] : []),
|
||||||
|
]}
|
||||||
|
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
|
||||||
|
>
|
||||||
|
{renderFormField(field)}
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<DetailDrawer
|
||||||
|
open={detailOpen}
|
||||||
|
record={detailRecord}
|
||||||
|
displayName={displayName}
|
||||||
|
fields={fields}
|
||||||
|
sections={detailSections}
|
||||||
|
allEntities={allEntities}
|
||||||
|
pluginId={pluginId}
|
||||||
|
entityName={entityName}
|
||||||
|
onClose={() => { setDetailOpen(false); setDetailRecord(null); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImportModal
|
||||||
|
open={importModalOpen}
|
||||||
|
pluginId={pluginId}
|
||||||
|
entityName={entityName}
|
||||||
|
onClose={() => setImportModalOpen(false)}
|
||||||
|
onSuccess={() => fetchData()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
apps/web/src/pages/PluginCRUDPage/usePluginData.ts
Normal file
207
apps/web/src/pages/PluginCRUDPage/usePluginData.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
import {
|
||||||
|
listPluginData,
|
||||||
|
resolveRefLabels,
|
||||||
|
type PluginDataListOptions,
|
||||||
|
} from '../../api/pluginData';
|
||||||
|
import {
|
||||||
|
getPluginSchema,
|
||||||
|
type PluginFieldSchema,
|
||||||
|
type PluginEntitySchema,
|
||||||
|
type PluginPageSchema,
|
||||||
|
type PluginSectionSchema,
|
||||||
|
} from '../../api/plugins';
|
||||||
|
|
||||||
|
export interface PluginDataState {
|
||||||
|
records: Record<string, unknown>[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
loading: boolean;
|
||||||
|
fields: PluginFieldSchema[];
|
||||||
|
displayName: string;
|
||||||
|
filters: Record<string, string>;
|
||||||
|
searchText: string;
|
||||||
|
sortBy: string | undefined;
|
||||||
|
sortOrder: 'asc' | 'desc';
|
||||||
|
resolvedLabels: Record<string, Record<string, string | null>>;
|
||||||
|
labelMeta: Record<string, { plugin_installed: boolean }>;
|
||||||
|
entityDef: PluginEntitySchema | null;
|
||||||
|
allEntities: PluginEntitySchema[];
|
||||||
|
allPages: PluginPageSchema[];
|
||||||
|
detailSections: PluginSectionSchema[];
|
||||||
|
hasDetailPage: boolean;
|
||||||
|
filterableFields: PluginFieldSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginDataActions {
|
||||||
|
setRecords: React.Dispatch<React.SetStateAction<Record<string, unknown>[]>>;
|
||||||
|
setPage: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
setFilters: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||||
|
setSearchText: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
setSortBy: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||||
|
setSortOrder: React.Dispatch<React.SetStateAction<'asc' | 'desc'>>;
|
||||||
|
fetchData: (p?: number, overrides?: {
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_order?: 'asc' | 'desc';
|
||||||
|
}) => Promise<void>;
|
||||||
|
handleFilterChange: (fieldName: string, value: string | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginDataHook = PluginDataState & PluginDataActions;
|
||||||
|
|
||||||
|
export function usePluginData(
|
||||||
|
pluginId: string,
|
||||||
|
entityName: string,
|
||||||
|
filterField?: string,
|
||||||
|
filterValue?: string,
|
||||||
|
): PluginDataHook {
|
||||||
|
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 [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [sortBy, setSortBy] = useState<string | undefined>();
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
|
const [resolvedLabels, setResolvedLabels] = useState<Record<string, Record<string, string | null>>>({});
|
||||||
|
const [labelMeta, setLabelMeta] = useState<Record<string, { plugin_installed: boolean }>>({});
|
||||||
|
|
||||||
|
const [entityDef, setEntityDef] = useState<PluginEntitySchema | null>(null);
|
||||||
|
const [allEntities, setAllEntities] = useState<PluginEntitySchema[]>([]);
|
||||||
|
const [allPages, setAllPages] = useState<PluginPageSchema[]>([]);
|
||||||
|
const [detailSections, setDetailSections] = useState<PluginSectionSchema[]>([]);
|
||||||
|
|
||||||
|
const filterableFields = fields.filter((f) => f.filterable);
|
||||||
|
const hasDetailPage = allPages.some(
|
||||||
|
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 加载 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 || '');
|
||||||
|
setEntityDef(entity);
|
||||||
|
}
|
||||||
|
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(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// 数据加载后解析跨插件引用标签
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId || !entityName || !records.length || !fields.length) return;
|
||||||
|
const refFields = fields.filter((f) => f.ref_entity);
|
||||||
|
if (!refFields.length) return;
|
||||||
|
|
||||||
|
const fieldUuids: Record<string, string[]> = {};
|
||||||
|
for (const f of refFields) {
|
||||||
|
const uuids = [...new Set(
|
||||||
|
records.map((r) => r[f.name]).filter(Boolean).map(String),
|
||||||
|
)];
|
||||||
|
if (uuids.length) fieldUuids[f.name] = uuids;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.keys(fieldUuids).length) return;
|
||||||
|
|
||||||
|
resolveRefLabels(pluginId, entityName, fieldUuids)
|
||||||
|
.then((result) => {
|
||||||
|
setResolvedLabels(result.labels);
|
||||||
|
setLabelMeta(result.meta as Record<string, { plugin_installed: boolean }>);
|
||||||
|
})
|
||||||
|
.catch((err) => console.warn('[usePluginData] 获取标签元数据失败:', err));
|
||||||
|
}, [records, fields, pluginId, entityName]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
records, total, page, loading, fields, displayName,
|
||||||
|
filters, searchText, sortBy, sortOrder,
|
||||||
|
resolvedLabels, labelMeta,
|
||||||
|
entityDef, allEntities, allPages, detailSections,
|
||||||
|
hasDetailPage, filterableFields,
|
||||||
|
setRecords, setPage, setFilters, setSearchText, setSortBy, setSortOrder,
|
||||||
|
fetchData, handleFilterChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user