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:
iven
2026-06-13 00:32:50 +08:00
commit 3772afd987
438 changed files with 86511 additions and 0 deletions

109
.gitignore vendored Normal file
View 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
View 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
- [ ] 图片使用合法 URLHTTPS 或相对路径,不用 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

File diff suppressed because it is too large Load Diff

115
Cargo.toml Normal file
View 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
View 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
View 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 # 启动前端
```

View 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';

View 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 环境就绪');
}

View 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;
}
}

View 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';

View 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,
};
}

View 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); // 用户名 + 密码
});
});

View 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 });
}
}

View 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';

View 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;
}
}
}

View 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 });
}
}

View 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;
}
}

View 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();
}
});
});

View 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); // 用户名 + 密码
});
});

View 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();
}
});
});

View 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 });
});
});

View 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');
});
});

View 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 });
});
});

View 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

File diff suppressed because it is too large Load Diff

BIN
apps/web/public/crm.wasm Normal file

Binary file not shown.

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
View 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

Binary file not shown.

View 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,
}
}

View File

@@ -0,0 +1,2 @@
User-agent: *
Allow: /

258
apps/web/src/App.tsx Normal file
View 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>
</>
);
}

View 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 }),
})
})
})

View 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;
}

View 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
View 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
View 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;

View 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)
})
})

View 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 },
})
})
})

View 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 } });
}

View 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
View 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 } });
}

View 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;
}

View 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)
})
})

View 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);
}

View 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 } });
}

View 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
View 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;
}

View 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 })
})
})

View 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;
}

View 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
View 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;
}

View 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
View 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;
}

View 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 } });
}

View 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;
}

View File

@@ -0,0 +1,7 @@
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}

View 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;
}

View 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
View 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);
}

View 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)
})
})

View 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;
}

View 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;
}

View 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;
}

View 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}</>;
}

View 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>
);
}

View 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>;
}

View 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
/>
);
}

View 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;
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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 };
}

View 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;
}

View 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,
};
}

View File

@@ -0,0 +1,6 @@
import { theme } from 'antd';
export function useDarkMode(): boolean {
const { token } = theme.useToken();
return token.colorBgBase !== '#ffffff' && token.colorBgBase !== '#fff';
}

View 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)
})
})

View 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;
}

View 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 };
}

View 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 };
}

View 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 -- 数据获取 hookloading → 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 变化 = 手动 refreshfilters 变化 = 筛选刷新
}, [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;
}

View 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);
});
}

View 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) };
}

View 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')
})
})

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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 = &quot;my-plugin&quot;
name = &quot;我的插件&quot;
version = &quot;0.1.0&quot;"
/>
</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>
);
}

View File

@@ -0,0 +1 @@
export { default } from './PluginCRUDPage/PluginCRUDPageInner';

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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