Compare commits

419 Commits

Author SHA1 Message Date
iven
d6abf45e7e docs(health): 媒体库与轮播图实施计划 — 5 Chunk / 22 Task
Some checks failed
CI / security-audit (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
Chunk 1: 后端实体 + 迁移 + DTO (Task 1-7)
Chunk 2: 后端 Service 层 (Task 8-9)
Chunk 3: Handler + 路由 + 公开端点 + 签名URL (Task 10-14)
Chunk 4: 前端 API + MediaPicker + 页面 (Task 15-20)
Chunk 5: 小程序访客页改造 (Task 21-22)
2026-05-10 13:35:30 +08:00
iven
5c5c099fb2 docs(health): 设计规格评审修复 — 3 CRITICAL + 5 HIGH + 关键 MEDIUM
修复项:
- C1: 公开端点增加租户识别机制(X-Tenant-Id / query param)
- C2: 签名 URL 增加路径规范化 + HMAC 输入格式 + is_public 实时校验
- C3: crop 端点补全权限码 health.media.manage
- H2: secret_key 生产环境 panic 保护
- H3: 软删除 media_item 级联设置 banner inactive
- H4: 补全 health.banners.list 权限码
- H5: 公开路由注册到 public_routes + 菜单种子迁移
- M3: 公开文章返回专用 PublicArticleListItem DTO
- M4: 新增"首页推荐"分类种子迁移
2026-05-10 11:40:44 +08:00
iven
a12fe0e8a9 docs(health): 媒体库与轮播图管理设计规格 + UI 可视化方案
新增设计规格涵盖:
- media_folder / media_item / banner 三个实体
- 媒体库 API(CRUD + 上传 + 裁剪 + 文件夹管理)
- 轮播图管理 API(CRUD + 排序 + 定时上下架)
- 公开端点(签名 URL 机制)+ 公开/私有访问控制
- 管理后台 UI(方案 A 左树+网格)+ MediaPicker 组件
- 小程序访客页改造(动态轮播图 + 文章卡片列表)
2026-05-10 11:32:38 +08:00
iven
3c828bfc4a fix(miniprogram): 退出登录后刷新仍保持登录态
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
根因:logout 清除 storage 期间并发请求触发 tryRefreshToken 写回新 token
修复:添加 isLoggingOut 标记,logout 时先标记阻止 token 刷新竞态
2026-05-10 10:36:17 +08:00
iven
11101ac204 feat(auth): 微信登录自动分配 patient 角色 + 创建患者档案
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增迁移 m20260510_000133:为所有租户创建 patient 角色并分配 19 个权限
- wechat_service: bind_phone 自动 assign_patient_role + ensure_patient_record
- find_or_create_user_by_phone 新用户自动获得 patient 角色和患者档案
- 小程序 auth store: bindPhone 抛出异常而非静默返回 false
- 小程序登录页: 捕获绑定错误并显示可操作的对话框
2026-05-10 09:57:45 +08:00
iven
28bcdc4208 docs: 更新 wiki — Design Token 全面接入记录
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
- index.md: 更新关键数字(716 提交)、新增 Design Token 指标行
- miniprogram.md: 新增 §1.1 Design Token 系统完整文档(10 级字号表、
  4 结构 token、使用规则、关怀模式说明)、更新变更记录
2026-05-09 23:58:09 +08:00
iven
890c132890 refactor(miniprogram): 全面接入 Design Token — 68 SCSS 文件 px→var(--tk-*)
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
- 重写 tokens.scss:校准 10 级字号 + 4 结构 token 匹配实际设计值
- 更新 mixins.scss:4 个 mixin 引用 token 替代硬编码
- 68 SCSS 文件全面迁移:font-size px → var(--tk-font-*),辅助文字色 → var(--tk-text-secondary)
- 清理 12 个页面的本地 mixin 重复定义
- elder-mode.scss 从 530 行缩减至 ~120 行:删除所有字号/颜色覆写,仅保留结构布局
- Token 覆盖率:634 引用 / 仅 3 个特殊硬编码值(72px/80px/21px)

关怀模式通过 CSS 变量级联自动生效,消除"打地鼠"问题。
2026-05-09 23:53:07 +08:00
iven
257ca94a25 fix(miniprogram): 登录页尺寸过大 + 排除关怀模式
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 正常模式大幅缩减:标题 48→32px、按钮高 96→56px、按钮字 32→28px
  logo 128→96px、副标题 26→16px、顶部留白 160→100px
- 登录页不应用 elder-mode class(正常模式已足够大)
- 关怀模式覆写值同步调整:标题 38px、按钮高 64px、副标题 21px

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:45:08 +08:00
iven
7b5138a630 feat(miniprogram): 关怀模式全覆盖 — 58/58 页面 100% 接入
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 医生端 18 个页面全部接入(首页/待办/告警/咨询/透析/随访/
  患者/处方/报告及其详情和创建页)
- 商城子包 3 页面(商品详情/积分兑换/订单)
- 患者端剩余页面(AI报告/文章/活动/设备同步/登录/随访详情/
  报告详情/知情同意/诊断/透析处方/透析记录/家庭成员添加)
- 页面覆盖率:22/59 (37%) → 58/58 (100%)
- useElderClass hook 统一接入模式,零样板代码

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:34:44 +08:00
iven
e8ccee02d5 feat(miniprogram): 关怀模式 Phase 2 — Design Token + 15 页面批量接入
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新建 useElderClass hook,替代每页 3 行样板代码
- 新建 CSS 自定义属性 Design Token 系统(tokens.scss)
  正常/关怀两套值:字号、间距、触控、布局参数
- 15 个页面批量接入关怀模式 class:
  TabBar: 商城页
  主流程: 预约列表/详情/创建、咨询详情
  子包: 体征录入/趋势/日常监测/告警、用药/档案/随访/报告/家庭/设置
- 新建 elder-toast 工具(关怀模式 3s + 触觉反馈)
- 页面覆盖率:4/59 → 22/59 (37%)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:17:58 +08:00
iven
4335f7e144 fix(miniprogram): 关怀模式非线性放大重构 + 3 页面接入
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- elder-mode.scss: 等比×1.3改为非线性放大(标题×1.15/正文×1.35/辅助×1.55)
- 体征网格从2列改为1列,解决放大后溢出问题
- 行高从1.5提升到1.7,对比度$tx3→$tx2增强可读性
- 健康页/消息页/咨询页接入useUIStore关怀模式
- 共享组件(EmptyState/ErrorState/Loading/StepIndicator)适配关怀模式
- 触控区域统一提升到56px+

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:05:06 +08:00
iven
66329852b8 fix(miniprogram): useDidShow 恢复认证状态 + E2E 全系统测试报告
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
- app.tsx: 将 restoreAuth/restoreUI 从 useEffect 改为 useDidShow,
  修复 reLaunch 后 Zustand store 未恢复导致访客模式的问题
- docs/qa/e2e-full-system-report.md: 三端 E2E 测试报告更新,
  原 BUG-1(Admin 随访管理 403)确认为误报,综合通过率 100% (64/64)
- tools/weapp-mcp/e2e-test.mjs: 小程序 E2E 基础导航测试脚本
- tools/weapp-mcp/e2e-interactive-test.mjs: 小程序 E2E 交互操作测试脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 18:25:43 +08:00
iven
085163ec7a feat(miniprogram): 访客模式 + 长辈模式 + MCP 自动化脚本
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
访客模式:
- 未登录用户可见首页(轮播图+健康资讯+登录引导)和"我的"页面
- 健康和消息 tab 显示 GuestGuard 登录拦截
- 登录页增加"暂不登录,先看看"跳过入口
- 401 拦截器增加 hasToken 检查,避免访客被重定向到登录页
- 退出登录后 reLaunch 到首页而非登录页

长辈模式:
- 新增 stores/ui.ts 管理显示模式(标准/长辈)
- 长辈模式放大字体 ×1.3、间距 ×1.2、按钮加大
- "我的 → 账号 → 长辈模式"切换页
- 设置持久化到 Storage

修复:
- Health/Messages 页面 Hooks 顺序违规(条件 return 在 hooks 之间)
  导致访客模式下页面白屏,所有 hooks 移到条件判断之前

工程:
- scripts/mpsync.sh/ps1 自动清理残留 DevTools 进程
- project.config.json 默认关闭域名校验
2026-05-09 11:42:44 +08:00
iven
0c28969c3b docs: 小程序端 E2E 闭环测试报告
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
5 条业务闭环全部通过:体征上报、预约挂号、咨询消息、积分商城、患者信息
Web管理端和小程序端数据流通完整,发现1个LOW问题(analytics/batch 422)
2026-05-09 08:13:37 +08:00
iven
8490344d69 fix(ai): AI 配额摘要端点 500 错误修复
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
get_usage_summary 中 get_tenant_config 和 get_monthly_token_usage 的
数据库错误直接传播为 AppError::Internal (500),当 ai_tenant_configs 表
为空或查询异常时导致整个端点不可用。

改为 unwrap_or 降级处理:config 缺失时使用默认配额,token 查询失败时归零,
确保端点始终返回有效数据而非 500。
2026-05-09 07:52:41 +08:00
iven
e4b19090b8 docs: V1 发布前 E2E 多角色验证报告
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
5 角色全链路测试(115 API 端点 + 浏览器 UI),93% 通过率,3 BUG 已修复
2026-05-09 02:29:15 +08:00
iven
07217336e7 fix(web): 运营仪表盘数据映射错误和浮点精度修复
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- OperatorWorkbench: "今日活跃用户" 错误使用体征上报率数据源,改为 pointsStats.active_accounts
- OperatorWorkbench: AI 摘要体征上报率显示原始浮点数(22.413793...),改为保留两位小数
- OperatorWorkbench: "科普阅读量" fallback 错误回退到积分发放数据,移除错误 fallback
- Home.tsx: 运营角色 ROLE_STATS "内容发布" 数据源错误,修正为 patientStats
- Home.tsx: 移除未使用的 TodoList/AiInsightPanel import
- .lintstagedrc.js: 修复 Windows 平台 eslint 命令,使用函数式获取文件名列表
2026-05-09 02:27:38 +08:00
iven
19705e31bd chore(demo): V1 演示数据预置脚本
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
幂等 SQL 脚本,一键预置:
- 张建国患者档案 + 3 条体征记录
- 2 份化验单(肌酐 88→102 趋势)
- 25 个背景患者(仪表盘数据)
- 随访模板 + 21 个随访任务
- 收缩压>=160 告警规则(场景5用)
- 3 篇 CKD 科普文章
2026-05-09 02:01:41 +08:00
iven
3e1413aebc fix(auth): 修复 Token 刷新并发竞态条件
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
使用原子 CAS(UPDATE WHERE token_hash = ? AND revoked_at IS NULL)
替代先查后改的非原子操作,防止同一 refresh token 被并发使用两次。

新增 TokenService::validate_and_revoke_atomic 方法,将 JWT 解码、
哈希匹配和 token 撤销合并为单次数据库操作。
2026-05-09 01:53:28 +08:00
iven
36f2ba381a docs: V1 演示准备实施计划(4 Chunk / 6 Task)
Chunk 1: Token 刷新竞态修复(原子 CAS via token_hash)
Chunk 2: 告警/AI/health_manager 链路验证
Chunk 3: 演示数据预置脚本(张建国 + 25 背景患者)
Chunk 4: 端到端 DRY RUN(7 场景验证)
2026-05-09 01:47:40 +08:00
iven
a3273ca581 docs: V1 客户演示方案评审修订
根据规格评审反馈补充:
- 硬件/网络要求 + 角色切换指引
- Q&A 异议处理话术(6 个常见问题)
- DRY RUN 计划(D-7 到 D-Day)
- 扩展风险预案(告警权限码、SSE、Ollama、登录冲突)
- 场景 2 AI 触发入口操作说明
- 场景 7 背景数据要求
- 统一 CRITICAL 数量和完成度口径
2026-05-09 01:35:53 +08:00
iven
f58c60599b docs: V1 客户演示方案设计规格
面向潜在客户(体检中心/血透中心)决策层+医疗团队的演示方案。
采用患者旅程视角(张大爷30天管理历程),7个场景展示完整闭环:
建档→AI分析→医生决策→患者端→告警→随访→仪表盘。
2026-05-09 01:29:31 +08:00
iven
28dafa9bea fix: 多角色业务链路测试发现并修复 3 类问题
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
1. 角色权限修复(CRITICAL):
   - operator 角色权限为空(迁移 name/code 不匹配 + 软删除冲突)
   - doctor 角色权限被误清(API assign_permissions 失败导致全部软删除)
   - nurse 缺 devices 权限 + doctor/nurse 缺 appointment 权限
   - 新增 3 个迁移 000130-000132 修复所有角色权限

2. 趋势指标映射修复(HIGH):
   - 前端 blood_pressure_systolic → systolic_bp_morning
   - 前端 blood_sugar_fasting → blood_sugar
   - 同步修复首页、健康页、趋势页的 indicator 参数

3. 咨询页错误处理优化(MEDIUM):
   - 403/401 时显示空列表而非"加载失败"错误提示
2026-05-08 22:00:43 +08:00
iven
81c174a902 fix(miniprogram): 修复多角色找茬测试 V3 发现的 8 个问题
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
1. EmptyState 默认 emoji 📭 → serif 首字圆形图标(影响 23 处使用)
2. 预约页英文副标题 "Appointment" 移除
3. consultation 页技术错误信息直接渲染到 UI → 用户友好提示
4. auth store restore() 增加 fallback:secureGet 失败时读 wx.getStorageSync
5. request.ts 新增 safeGet():token/tenantId 读取容错
6. doctor/consultation useMemo 自引用死循环 → Math.ceil(total/20)
7. doctor/alerts 同样自引用 bug 修复
8. doctor/patients 死代码 totalPages + useMemo import 清理
2026-05-08 17:34:42 +08:00
iven
3dac6a9eda fix(miniprogram): 多角色找茬模式发现并修复 16 个问题
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P0 Bug:
- 健康 AI 建议幽灵路径 pkg-appointment → appointment/create
- 血糖 indicator_type 始终 blood_sugar,不区分空腹/餐后
- 商城订单页 switchTab 跳转非 TabBar 页面

P1 设计系统:
- Profile/Index 页 emoji 图标替换为衬线首字
- Profile 硬编码颜色替换为 SCSS 变量 class
- alerts/action-inbox 两个页面全面接入设计系统
- ai-report/detail 删除重复 mixin 定义
- ErrorBoundary 添加重试按钮移除 emoji
- 新增 $r-xs: 8px 圆角变量

P1 导航/交互:
- Profile 补充 4 个缺失菜单(透析/知情同意/用药/活动)
- Settings 隐私政策改为跳转实际页面
- 全局启用 enablePullDownRefresh
- 首页/健康页添加下拉刷新
- 咨询/消息列表添加分页加载更多
- 医生端患者列表改为上拉加载
- 首页/健康页间距统一为 24px
2026-05-08 16:07:06 +08:00
iven
22b8ac7ac6 fix: 修复多角色找茬测试 V2 发现的 11 个问题
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P0 (CRITICAL):
- C1: 统计 API 全部改为 safe_aggregate 容错,防止单个子查询崩溃导致 500
- C2: Token 刷新增加用户身份验证,防止并发场景下身份切换
- C3: 患者端线下活动接口添加患者档案验证,防止 Doctor/HM 越权访问

P1 (HIGH):
- H1: 操作记录用 EntityName 组件解析用户名,不再显示截断 UUID
- H4: 告警标题添加中英文映射 (translateAlertTitle)
- H5: 告警面板补全 message import + 修复 hooks 顺序
- H8: 咨询消息发送按钮添加 AuthButton 权限控制
- H9: routeConfig 日常监测权限码改为 health.daily-monitoring.*

P2 (MEDIUM):
- M4: 咨询类型映射补全 online/phone/doctor/follow_up 中文标签

DTO: LabReportStatisticsResp, AppointmentStatisticsResp, VitalSignsReportRateResp 添加 Default derive
2026-05-08 12:42:41 +08:00
iven
297a151b0c docs: 多角色用户视角找茬测试报告 V2(2026-05-08)
5 角色深度测试,发现 ~55 个问题:
- CRITICAL x3: Token 身份切换、统计 API 500、权限泄漏
- HIGH x9: 操作按钮缺失、英文告警、权限越界
- MEDIUM x21: 数据矛盾、国际化、路由不一致

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:43:25 +08:00
iven
c82f7bda1d fix: 系统性预防角色测试高频问题(5 方案落地)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P0 — 默认拒绝 + 强制守卫:
- 创建 routeConfig.ts 作为前端路由权限的单一真相源
- TypeScript 强制每个路由声明非空权限数组,不可能遗漏
- 自动生成 ROUTE_PERMISSIONS 和 FROZEN_ROUTES
- 修正 3 个前端权限码不匹配后端

P0 — CI 权限扫描:
- 新增 tools/check_permissions.py 校验脚本
- 发现并修复 tenant.manage 未注册问题

P1 — 聚合接口容错:
- erp-core 新增 safe_aggregate 工具函数
- 仪表盘统计 handler 重构

P1 — 状态机一致性自检:
- validation.rs 新增 3 个自检测试

fix: lint-staged eslint Windows 兼容性
2026-05-08 08:52:16 +08:00
iven
645ec39e8b docs: 更新 wiki 反映 5 角色测试结果和修复教训
Some checks failed
CI / security-audit (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
- wiki/index.md: 更新关键数字(693 次提交/129 迁移/clippy 0 警告/角色测试完成)
- wiki/index.md: 症状导航添加 5 个已修复问题
- wiki/index.md: 文档索引添加角色测试计划/结果
- wiki/testing.md: 新增 5 角色深度测试结果表格和 7 个 BUG 修复清单
- wiki/testing.md: 历史教训添加 6 条新教训(容错/拦截同步/路径一致/权限语义/clippy/lint-staged)
2026-05-07 23:50:19 +08:00
iven
6d5a711d2c fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
功能修复:
1. 患者创建空名称验证:后端添加 name.trim().is_empty() 检查
2. 仪表盘统计容错:单个查询失败返回零值而非 500
3. FHIR 路由修复:从 /fhir 移到 /api/v1/fhir 保持一致
4. 冻结模块后端中间件:新增 frozen_module_middleware 拦截冻结路径
5. 积分端点权限码:health.health-data.list → health.points.list
6. 角色权限迁移:护士补充 devices.list,运营补充 points.list/manage
7. 测试结果文档:R01-R05 角色测试 + T00/T10 结果归档

Clippy 全 workspace 清零(14→0 errors):
- erp-core: 修复 empty doc line、collapsible if、redundant closure 等 9 处
- erp-health: 修复 too_many_arguments、unused var、unnecessary parens 等 58 处
- erp-ai: 修复 dead_code、unused import 等 11 处
- erp-plugin: 修复 too_many_arguments、wildcard pattern 等 11 处
- erp-server-migration: 修复 enum_variant_names 5 处
- erp-auth/config/workflow/message: 各 1-3 处

工程改进:
- lint-staged 配置迁移到 .lintstagedrc.js(函数式避免文件列表传给 clippy)
- cargo fmt 统一格式化
2026-05-07 23:43:14 +08:00
iven
786f57c151 fix: 修复角色测试发现的 5 个共性问题
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 修复前端路由守卫前缀碰撞(/health/articles 匹配 /health/article-categories)
- 补全 6 条缺失路由权限映射(appointments/follow-up-records/article-categories/article-tags/plugins/market)
- 修复 critical-alerts API 500(escalation_level 字段 INT2/i16 与 Entity i32 类型不匹配)
- 新增迁移 000128:告警状态修正 + 菜单权限码补全 + 非admin角色移除基础模块权限
2026-05-07 15:54:37 +08:00
iven
60dc4dba7a fix(health): 修复 5 角色深度测试发现的权限越权和告警端点缺失
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
- auth: token_service 查询 role_permissions/user_roles 添加 deleted_at 过滤,
  修复软删除的权限仍被加载到 JWT 的越权漏洞
- health: 新增 GET /health/alerts/{id} 告警详情端点(含 handler + service + 路由)
- web: AlertList 操作按钮增加 active 状态判断,修复按钮不显示
- migration: 新增 000127 清理 doctor 角色多余的 health-data.manage/ai.analysis.manage
2026-05-07 13:51:16 +08:00
iven
85a7dacd16 fix(health): 修复 5 角色深度测试发现的 8 个问题
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P0 修复:
- 告警状态机新增 active 合法状态 + 转换规则 (active→acknowledged/dismissed)
- 前端路由守卫改为默认拒绝,未注册路由返回 403

P1 修复:
- 侧边栏菜单根据用户权限码过滤,非 admin 隐藏无权限菜单项
- Critical-alerts handler 增加详细错误日志 + div_ceil 安全防护
- 仪表盘统计 API 调用使用 silent 模式避免 500 触发全局 toast

P2 修复:
- 随访类型映射新增 visit → 上门 (前后端同步)
- 随访 fallback 选项新增 visit 类型

排除的假 BUG (代码已正确):
- 患者性别/血型: MCP fill() 不兼容 Select 组件,正常交互正确
- 随访筛选/对话框关闭: 代码逻辑验证正确

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 08:24:12 +08:00
iven
0acf901893 fix(web): 告警详情显示患者名和规则标题替代原始 ID
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 患者字段显示 patient_name,ID 缩短为 tooltip
- 规则字段显示告警标题 title,rule_id 缩短为 tooltip
- 告警详情 JSON 解析为中文标签结构化表格
2026-05-07 07:41:26 +08:00
iven
a9821ab832 fix(web): 告警详情面板用户体验改进
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
- 用 Alert 横幅醒目展示严重程度、标题和行动指引
- 患者信息卡片显示姓名而非原始 UUID
- 将 JSON 详情解析为中文标签(告警描述/监测值/阈值等)
- 技术信息(原始 ID)移入折叠面板
2026-05-07 07:38:04 +08:00
iven
1613e3cfe9 fix(health): 修复 5 角色测试发现的 4 个共性问题
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 权限路由守卫:静默重定向改为显示 403 页面,使用 useLocation 替代
  window.location.hash,补全缺失路由权限条目
- 随访状态筛选:usePaginatedData hook 添加 filters 变化监听自动刷新
- 告警操作:后端 acknowledge/dismiss/resolve 改返回 AlertResponse
  (含 patient_name),前端增加 active 状态兼容和错误反馈
- 咨询患者名:后端 create/get/close_session 增加 patient_name 和
  doctor_name enrichment,前端 EntityName 空字符串处理
2026-05-07 07:23:41 +08:00
iven
43f0ba7057 fix(web): 修复角色测试发现的权限守卫、API 500、权限配置问题
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
1. CRITICAL: 前端路由权限守卫 — routePermissions 从 3 条扩展到 31 条,
   覆盖全部 /health/* 路由;匹配逻辑从宽松模块级前缀改为精确权限码匹配
2. HIGH: health-data API 500 — jsonb_array_elements() 添加 CASE WHEN 类型守卫,
   防止 items 字段为非数组 JSON 时崩溃
3. MEDIUM: Doctor 补充 ai.prompt.list、ai.usage.list、follow-up-templates 权限
4. Operator 清理 AI 分析、统计报表菜单关联
5. 更新 5 角色测试计划文档
2026-05-06 22:29:54 +08:00
iven
5467394ffe docs(qa): 5 角色测试计划(admin/doctor/nurse/health_manager/operator)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- R01-admin: 45 个菜单全覆盖,含系统管理和全部健康业务
- R02-doctor: 24 个菜单,随访+咨询+AI+告警+透析+处方
- R03-nurse: 20 个菜单,随访监控台+行动收件箱,无管理类功能
- R04-health_manager: 29 个菜单,告警规则+AI管理+随访模板+实时监控
- R05-operator: 24 个菜单,积分+内容+设备只读+运营仪表盘
- 修复 project.private.config.json autoAudits 配置
2026-05-06 17:23:52 +08:00
iven
80ef48a3a3 feat(miniprogram): 医护工作台角色定制 + 性能优化
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- auth store 新增 health_manager 角色,添加 isDoctor/isNurse/isHealthManager/hasRole 辅助方法
- 医生工作台按角色过滤功能卡片和快捷操作(doctor/nurse/health_manager/admin)
- 列表页面分页计算提取为 useMemo(patients/alerts/consultation)
2026-05-06 12:51:00 +08:00
iven
570377a31f feat(config): 角色权限控制菜单可见性 + 医疗业务角色
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
- 修复 menu_service 角色过滤 bug: ctx.roles 存的是角色 code 而非 UUID,
  新增 resolve_role_ids() 方法通过 code 查找数据库中的角色 ID
- 创建 4 个医疗业务角色: 医生/护士/健康管理师/运营人员
- 重组菜单目录结构: 基础模块→工作台、业务模块→系统管理、健康管理→健康业务
- 菜单排序按功能域分组(患者医护/随访咨询/积分运营/内容运营/AI分析)
- 为各角色分配对应的菜单可见性和操作权限
2026-05-06 12:35:45 +08:00
iven
5fd8e88825 fix(miniprogram): 精简菜单,移除推迟模块入口
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 个人中心:15→8 项菜单(移除积分商城/订单/用药/透析/知情同意/积分明细)
- 健康页:移除 BLE 设备同步入口
- 医生端:移除透析记录/透析处方快捷入口
2026-05-06 11:12:02 +08:00
iven
4a95a83d6b fix(miniprogram): 统一状态色映射,对齐设计系统色板
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
- 创建 utils/statusTag.ts 共享状态色工具(对齐 variables.scss)
- doctor/consultation: 使用共享状态色替代 Tailwind 硬编码
- doctor/followup: 使用共享状态色替代 Tailwind 硬编码
- doctor/action-inbox: SCSS 状态点替换为设计系统变量
- doctor/index: SCSS 告警/搜索区替换为设计系统变量
- pkg-health/alerts: SCSS 严重度标签替换为设计系统变量
2026-05-06 10:59:13 +08:00
iven
36275eb307 fix(web): 冻结推迟模块路由守卫
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-06 10:34:55 +08:00
iven
263bba264a chore(db): 冻结推迟模块菜单迁移 2026-05-06 10:30:58 +08:00
iven
f7bf5a86ea fix(server): CORS 生产环境拒绝通配符
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-06 10:21:50 +08:00
iven
d9818c263e fix(ai): AI 提示词模板添加安全检查 2026-05-06 10:21:35 +08:00
iven
c452ae81d1 fix(health): OAuth JWT 配置缺失返回错误而非 panic 2026-05-06 10:21:25 +08:00
iven
a1cbb9fb1d fix(server): readiness_check 隐藏内部错误详情 2026-05-06 10:21:13 +08:00
iven
a78ee2f154 fix(auth): Token 验证和撤销添加租户隔离 2026-05-06 10:21:07 +08:00
iven
51c41acfa7 fix(health): 审计日志加密字段替换为 REDACTED 2026-05-06 10:21:02 +08:00
iven
f668e64266 fix(health): FHIR converter 身份证号脱敏处理 2026-05-06 10:20:50 +08:00
iven
ced93934f1 fix(docker): 添加安全警告注释,补全 .env.example
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
2026-05-05 23:45:27 +08:00
iven
482871301e fix(health): FHIR $everything 子查询添加 tenant_id 过滤 2026-05-05 23:44:25 +08:00
iven
087e23e57b fix(ai): AI 分析队列 claim_next 添加租户隔离 2026-05-05 23:43:11 +08:00
iven
741aaf0e40 fix(health): FHIR allowed_patient_ids=None 拒绝所有访问 2026-05-05 23:42:29 +08:00
iven
4f84c94a42 docs(wiki): 添加 Ollama 配置文档和 AI 分析故障排除
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- infrastructure.md: 新增 Ollama 服务连接、环境变量、GPU 注意事项
- index.md: 症状导航新增 qwen3 thinking、Ollama 内存/安全、模板渲染等问题
2026-05-05 22:56:30 +08:00
iven
b1a96ace1f fix(ai): 修复 qwen3 模型 thinking 模式导致 AI 分析输出为空
Some checks failed
CI / security-audit (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
qwen3:4b 默认启用 thinking 模式,流式 API 中 content 字段始终为空,
所有 token 消耗在 thinking 上。修复方案:
- 对 qwen3 模型改用非流式 API,从 content 中剥离 <think... 块
- 将清理后的内容按句子/段落分块模拟流式输出
- 自动提升 qwen3 的 num_predict 至 4096 确保 thinking + 回复完整
- 流式解析中跳过空 content chunk
- 新增 strip_think_block 函数及 5 个单元测试
2026-05-05 22:55:20 +08:00
iven
e9cfbd108a fix(ai): 修复 AI 分析读取化验报告 items 为空的问题
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- parse_lab_items 兼容两种存储格式(item_name/name, string/f64 value,
  reference_range/reference_low+high)
- get_lab_report 添加 PII 解密步骤:数据库中 items 是加密存储的,
  AI 分析前需要先解密再解析
- HealthDataProviderImpl 添加 PiiCrypto 字段用于解密
- pii_crypto 创建提前到 AI state 构建之前
- default.toml rate_limit.fail_close 改为 false(开发环境)
2026-05-05 22:05:45 +08:00
iven
049d230bae docs(wiki): 更新 erp-ai 模块 — Ollama 对接 + bug 修复记录
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- index.md: 迁移 123 个, 提交 577 次, 新增 AI 分析症状导航 4 条
- erp-ai.md: 新增 §4 Ollama 本地模型对接、已知限制、已修复 bug
- erp-ai.md: 更新 SSE 流程图(预校验 + 缓存回放修复)
- erp-ai.md: 3 个管理前端页面已实现
2026-05-05 20:07:24 +08:00
iven
a62332f1c4 fix(ai): AI 分析预校验 + prompt 非对话化
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 四个 SSE 端点增加数据完整性校验:items/sections 为空时返回 400
- 迁移 000123 更新全部 prompt system_prompt:明确非对话、输出结构化结果
- 前端用户看到的是分析结论,不再收到"请补充数据"的对话式回复
2026-05-05 19:53:04 +08:00
iven
1f91dcc5cc fix(ai): 修复分析结果 JSON 嵌套 bug
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- replay_cached 直接回放纯文本,不再包装 JSON 壳
- complete_analysis 跳过已完成的记录,防止缓存命中时覆写
- 前端 AnalysisContent 增加 extractPlainText 递归解析 JSON
2026-05-05 19:45:36 +08:00
iven
8a0c9670e6 feat(ai): 对接本地 Ollama qwen3:4b 模型
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- default_provider 从 claude 切换到 ollama
- main.rs 支持 ollama/openai/claude 三种 provider 动态选择
- 新增 [ai.providers.ollama] 配置段(base_url/model/temperature)
- 前端 SSE AI 分析全链路验证通过
2026-05-05 19:12:55 +08:00
iven
7dac749eff feat(ai): 新增预算状态 + 成本估算 API 端点
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
Phase 3 Task 25:
- GET /ai/budget/status — 租户月度预算状态和告警等级
- GET /ai/cost/estimate — 按分析类型+模型估算单次成本
2026-05-05 16:05:00 +08:00
iven
0da59c6a0e feat(ai): 成本估算 + 预算告警服务 — CostService
Some checks failed
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
Phase 3 Task 24:
- 按分析类型+模型估算 token 用量和 USD 成本
- 查询租户月度预算状态和告警等级(Normal/Warning/Critical/Exceeded)
2026-05-05 16:03:32 +08:00
iven
d2512ca9db feat(ai): 集成知识库到 AnalysisService — system_prompt 自动注入临床规则
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Phase 3 Task 23: AnalysisService 新增可选 knowledge_source,
stream_analyze 前自动查询 L1/L2/L3 知识并注入 system_prompt
2026-05-05 16:01:52 +08:00
iven
70f69a2008 feat(ai): 实现 StructuredKnowledgeSource — L1/L2/L3 知识库查询
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Phase 3 Task 22: 从 rules/references/guides 表构建 Prompt 注入上下文
- 规则按优先级排序,参考资料附带引用,指南截取前 2000 字
- 总上下文不超过 8000 字符,confidence 根据 L1/L2 匹配度计算
2026-05-05 15:58:54 +08:00
iven
3592b55556 feat(ai+db): 知识库 3 表迁移 + Entity — rules/references/guides
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Phase 3 Task 21:
- ai_knowledge_rules: L1 规则表(条件表达式 + 动作文本)
- ai_knowledge_references: L2 参考表(摘要 + pgvector 嵌入)
- ai_knowledge_guides: L3 指南表(全文 + pgvector 嵌入)
2026-05-05 15:55:20 +08:00
iven
2d2e1e191e feat(db): 添加 pgvector 扩展迁移 — 知识库向量检索基础
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Phase 3 Task 20: CREATE EXTENSION IF NOT EXISTS vector
2026-05-05 15:52:12 +08:00
iven
75a70d2e46 feat(ai): 添加知识库 trait 和 DTO — KnowledgeSource/PatientSummary/Reference
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Phase 3 Task 19: 定义统一知识获取接口,支持未来向量检索扩展
2026-05-05 15:50:57 +08:00
iven
54116d1a1f refactor(ai): auto_analysis 改为入队模式
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
高风险患者扫描结果构造 AnalysisJob 入队而非直接调用 Provider
保留定时扫描逻辑(每 24h),分析执行由队列消费者负责
2026-05-05 15:41:30 +08:00
iven
553de13cd5 feat(ai): 扩展事件订阅自动入队分析
订阅 health_data.critical_alert → 趋势分析 (priority=2)
订阅 lab_report.uploaded → 化验单解读 (priority=1)
订阅 dialysis.record.created → KDIGO 风险评估 (priority=2)
tokio::select! 多通道并发消费
2026-05-05 15:40:15 +08:00
iven
7fb92714c7 feat(ai): 实现 AnalysisQueue 服务
支持 enqueue/claim_next/mark_completed/mark_failed 状态机
失败自动重试(retry_count < max_retries → pending),queue_status 聚合查询
2026-05-05 15:38:14 +08:00
iven
3186c5aee9 feat(ai): 添加 ai_analysis_queue 迁移 + Entity
异步分析队列表,支持优先级/重试/状态机(pending→running→completed/failed)
索引覆盖租户状态查询和调度扫描,迁移号 000118
2026-05-05 15:35:59 +08:00
iven
c268229311 feat(ai): 实现 CacheService 两级缓存 + 集成到 AiState
Redis TTL (L1) + DB SHA-256 hash (L2),Redis 不可用时自动降级
CacheKey 基于 tenant_id + analysis_type + input_hash + prompt_version
AiState 新增 cache 字段,main.rs 注入共享 Redis Client
2026-05-05 15:33:58 +08:00
iven
50b9e8d683 feat(ai): 添加 Provider 管理 API 端点
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
GET /ai/providers — 列出已注册提供商
GET /ai/providers/health — 各提供商健康状态
GET /ai/quota/summary — 租户配额使用摘要
2026-05-05 15:19:49 +08:00
iven
a16e86bf04 feat(ai): 重构 AiState 集成 ProviderRegistry + QuotaService
AiState 新增 provider_registry 和 quota 字段
main.rs 启动时按配置注册 Claude/OpenAI/Ollama Provider
支持多 Provider 并发注册和健康检查
2026-05-05 15:18:26 +08:00
iven
63ff8660fc feat(ai): 实现 QuotaService 租户配额检查
月度 Token 预算 + 每日患者分析次数限制,raw SQL 聚合查询
可全局开关 (quota_check_enabled),无配置时默认放行
2026-05-05 15:16:09 +08:00
iven
105cae0565 feat(ai): 添加 ai_tenant_configs 迁移 + Entity
支持租户级 Provider 路由配置、月度 Token 预算、每日患者限制
unique 索引确保每租户一条配置,迁移号 000117
2026-05-05 15:13:05 +08:00
iven
37acd34154 feat(ai): 实现 OllamaProvider 本地模型支持
使用 /api/chat 端点,无需 API Key,支持流式/非流式生成
健康检查通过 /api/tags,含 7 个单元测试
2026-05-05 15:10:43 +08:00
iven
b728618d61 feat(ai): 实现 OpenAIProvider 兼容 OpenAI API 格式
支持 /v1/chat/completions 端点的流式/非流式生成 + 健康检查
含序列化/反序列化单元测试
2026-05-05 15:08:41 +08:00
iven
74b1d44068 feat(ai): 实现 ProviderRegistry 并发安全多提供商注册与路由
DashMap 支持并发注册,resolve() 按首选→回退→任意可用顺序
实时健康检查,含 4 个单元测试覆盖正常/降级/全不可用场景
2026-05-05 15:07:19 +08:00
iven
24bb8e7bca feat(ai): 扩展 AiError 支持配额/缓存/知识库/队列/配置错误变体
新增 QuotaExhausted→429, CacheError/KnowledgeError/QueueError/ConfigError→500
2026-05-05 15:02:38 +08:00
iven
4d02b2b531 feat(ai): 扩展 AiConfig 支持多 Provider 配置
- config/default.toml 新增 providers 子段(claude/openai/ollama)
- erp-server/config.rs AiConfig 新增 quota_check_enabled + providers HashMap
- erp-ai/config.rs 新增 ProviderType 枚举 + ProviderConfig 结构体
2026-05-05 15:01:24 +08:00
iven
93f6e87220 fix(web+config): E2E 测试发现的问题修复
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 排班状态过滤 'active' → 'enabled'(与后端 validation.rs 一致)
- 全局 403 拦截器不再弹出"权限不足" toast(AuthButton 已隐藏入口)
- 角色未关联菜单时回退显示全部(避免种子数据阶段菜单空白)
2026-05-05 13:01:14 +08:00
iven
84b671d1e5 fix(server+health): 修复路由 middleware 泄漏 — FHIR/Gateway 改用 .nest() 隔离
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Axum 的 .merge() 会将子 Router 的 middleware 泄漏到整个路由树,
导致 FHIR OAuth middleware 和 Gateway auth middleware 拦截所有请求。

修复方式:
- fhir_routes 内部路径去掉 /fhir 前缀,main.rs 用 .nest("/fhir", ...) 注册
- gateway_routes 内部路径去掉 /health/gateway 前缀,main.rs 用 .nest("/health/gateway", ...) 注册
- 透析患者查询表名 patients → patient(与 Entity 一致)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 11:56:42 +08:00
iven
062b4493e4 fix(web): DoctorSelect 预加载医生列表 + 搜索错误处理
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 组件挂载时预加载最多 50 条医生数据,下拉框打开即有选项
- 搜索清空时保留已有列表(不再置空)
- 搜索失败时 catch 错误,保留初始列表不静默丢失
- 更新质量验证报告:全部 MEDIUM 问题已关闭
2026-05-05 11:15:12 +08:00
iven
0f55d26076 fix(dialysis): 添加患者存在性校验 + 质量验证汇总
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- create_dialysis_record 中添加患者存在性校验,修复集成测试
  test_dialysis_create_without_patient_returns_error
- 添加质量验证汇总报告 (docs/qa/quality-verification-summary.md)
2026-05-05 10:35:37 +08:00
iven
15b5781dbb fix(health): 危急值告警全链路修复 — 消费者生命周期 + payload 映射 + 阈值优先级
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
1. CRITICAL: 修复 SubscriptionHandle 提前 drop 导致所有事件消费者失效
   - register_handlers_with_state 中所有 handle 在函数返回时被 drop
   - cancel channel 关闭导致 subscribe_filtered 的过滤任务退出
   - 方案: 收集所有 handle 并 std::mem::forget,生命周期与进程一致

2. HIGH: 修复 critical_alert 消费者 payload 字段映射不匹配
   - 消费者读取 alert_type/metric_name 等顶层字段,但实际在 alert 嵌套对象中
   - 更新消费者从 alert 对象提取 indicator/value/threshold/level
   - handle_critical_alert_event 增加 severity 参数

3. MEDIUM: 修复 check_indicator 优先匹配最高严重级别
   - 原实现返回第一个匹配的阈值(可能匹配 warning 而非 critical)
   - 改为遍历所有匹配阈值,选择 severity 最高的(critical > warning)

4. MEDIUM: 修复危急值阈值页面不自动加载数据
   - CriticalValueThresholdList 添加 useEffect 初始化加载
2026-05-05 10:11:06 +08:00
iven
2acd9485c7 fix(health+dialysis): S2 smoke test 修复 — Entity 表名 + 透析状态转换
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 修复 6 个 Entity table_name 与迁移不匹配: shift, handoff_log,
  patient_assignment, blind_index, critical_alert, critical_alert_response
- 添加透析记录 draft→completed 状态转换 API (PUT /complete)
- 修复 family_proxy_service 告警状态过滤 (active→pending/acknowledged)
- dev.ps1 添加 RATE_LIMIT__FAIL_CLOSE=false 开发模式
- S2 透析日流程 smoke test 报告
2026-05-05 03:07:41 +08:00
iven
99dad17eac fix(server+health): 修复权限同步 + 迁移幂等性 + 缺失菜单种子数据
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- sync_module_permissions 每次启动都确保 admin 拥有所有权限(修复 CRITICAL-001)
- 新增迁移 m20260505_000116: 补充 11 项缺失的健康管理菜单(多租户安全)
- 修复 000101: UUID 格式错误(缺少第 4 段)
- 修复 000104/000106/000107: Expr::val → Expr::cust(SQL 函数不应被引号包裹)
- 修复 000109: 外键创建改为 IF NOT EXISTS 模式
- 修复 000110: 表名 critical_alerts → critical_alert(匹配实际表名)
- 修复 000111/000112: create_table + create_index 添加 if_not_exists()
- 修复 000113: 改为 raw SQL 幂等模式,修正 FK 目标表名 patients → patient
2026-05-05 02:02:45 +08:00
iven
bef2ea7169 feat(miniprogram): 适老化修复 — Phase 2e
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
M6: 创建 utils/date.ts 统一日期工具函数(formatDate/formatDateTime/toRelativeDate 等)
M8: 28 个 SCSS 文件 font-size 20px → 22px 全量适老化
M7: request.ts 增加 403 权限不足/5xx 服务器错误/网络超时异常统一拦截
2026-05-05 00:22:49 +08:00
iven
8d288cadfa fix(health+ai): 后端质量修复 — Phase 2d
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
H3: 设备数据摄入增加 tracing 日志(事务保护待 ConnectionTrait 重构)
M4: care_plan/shift/ble_gateway/vital_signs_daily 补全 tracing 入口日志
M1: AI 分析缓存命中检查 + 缓存结果 Stream 回放
H4: 透析→KDIGO 自动串联(dialysis_notifier 发布 ai.dialysis.kdigo_requested 事件)
2026-05-05 00:19:22 +08:00
iven
888fa108ef feat(web): 家庭健康代理 + 知情同意 Web UI — Phase 2c
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
家庭代理:关联患者列表 + 健康摘要查看 + 授权/撤销访问
知情同意:患者范围 CRUD 列表页(类型/范围/签署/撤销)
2026-05-05 00:02:39 +08:00
iven
0774dd75ad feat(web): 危急值阈值 + 诊断记录 Web UI — Phase 2b-2/2b-3
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
危急值阈值:CRUD 列表页(指标/方向/阈值/级别/科室/年龄范围)
诊断记录:患者范围 CRUD 列表页(ICD编码/类型/状态/确诊日期)
2026-05-04 23:59:22 +08:00
iven
b6838c1bc1 feat(web): BLE 网关管理 UI — Phase 2b-1
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
网关 CRUD 列表页(状态筛选/密钥刷新/API Key 创建展示)
+ 网关详情页(信息面板/设备绑定管理 Tab)
2026-05-04 23:47:21 +08:00
iven
438f9ca3f4 feat(web): 药物记录 Web UI — Phase 2a-3
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增药物记录管理前端页面,接入后端 4 条孤立路由:
- API 模块: medicationRecords.ts(CRUD + 频次/途径常量)
- 列表页: MedicationRecordList.tsx(患者 ID 查询 + 药物列表 CRUD)
  支持药品名/通用名/剂量/频次/途径/日期/在用状态
- 路由注册: /health/medications

权限: health.medication-records.list / health.medication-records.manage
2026-05-04 23:41:04 +08:00
iven
68ced2bae9 feat(web): 班次管理 Web UI — Phase 2a-2
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增班次管理前端页面,接入后端 12 条孤立路由:
- API 模块: shifts.ts(班次 CRUD + 患者分配 + 批量分配 + 交接日志)
- 列表页: ShiftList.tsx(日期/班次/状态筛选 + 统计概览)
- 详情页: ShiftDetail.tsx(班次信息 + 患者分配 Tab + 交接记录 Tab)
- 路由注册: /health/shifts + /health/shifts/:id

权限: health.shifts.list / health.shifts.manage
2026-05-04 23:36:15 +08:00
iven
3aa436f872 feat(web): 护理计划 Web UI — Phase 2a-1
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增护理计划管理前端页面,接入后端 8 条孤立路由:
- API 模块: carePlans.ts(计划 + 干预项目 + 预后测量 CRUD)
- 列表页: CarePlanList.tsx(筛选/新建/编辑/删除/跳转详情)
- 详情页: CarePlanDetail.tsx(计划信息 + Items/Outcomes 双 Tab CRUD)
- 路由注册: /health/care-plans + /health/care-plans/:id
- 菜单标题: routeTitleFallback 映射

权限: health.care-plan.list / health.care-plan.manage
2026-05-04 23:26:28 +08:00
iven
2b90db4028 fix(health): P0 安全修复 — SQL注入 + FHIR越权 + OAuth权限 + JWT硬编码
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
C1: action_inbox_service.rs 中 patient_id/user_id 的 format! 拼接改为
    参数化查询 ($2/$3/$4/$5 绑定),消除 SQL 注入风险
C2: fhir/handler.rs 所有患者相关端点强制执行 allowed_patient_ids 范围
    过滤,search 端点用 is_in 过滤,get 端点用 enforce_patient_scope 校验
H5: oauth/handler.rs 5 个管理端点添加 require_permission 校验
M3: oauth/handler.rs 和 middleware.rs 移除 "dev-secret-key" fallback,
    缺少环境变量时启动失败(token)/返回 500(middleware)
2026-05-04 23:09:25 +08:00
iven
95fa09c383 feat(health): 家庭成员健康代理 — 同意追踪 + 健康摘要查看
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Phase 1 Care Engine MVP 最后一项 (#8):
- 迁移: patient_family_member 表新增 user_id/consent_status/access_level/consented_at/consent_revoked_at
- 实体: 更新 patient_family_member Model 含新字段
- DTO: FamilyMemberResp 扩展 + 新增 GrantFamilyAccessReq/FamilyPatientSummaryResp/FamilyHealthSummaryResp
- Service: 授权/撤销访问、家庭成员查看关联患者列表、查看健康摘要(按 access_level 分级)
- Handler: 5 个端点(grant/revoke/list/summary/link-user)
- 路由: /health/patients/{id}/family-members/{fid}/grant-access 等
- 权限: health.family-proxy.list/manage
- 已有 CRUD 适配新字段(list/create/update 返回 consent 状态)
2026-05-04 20:57:24 +08:00
iven
0a9272bcf6 feat(dialysis+workflow): 透析会话 BPMN 工作流集成
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- dialysis_record 新增 workflow_instance_id 列,关联工作流实例
- 种子 dialysis_session BPMN 流程定义:透前评估→上机确认→透中监测→透后评估→医生审核
- 事件驱动编排器:dialysis.record.created → 自动启动 BPMN 工作流
- 工作流启动后自动回写 instance_id 到透析记录
- 编排器在 erp-server 层实现(遵循星型依赖架构)
2026-05-04 20:38:56 +08:00
iven
7e57565ecd feat(health): BLE 网关后端接入 — 网关管理 + API Key 认证 + 多患者批量上报
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 ble_gateways + gateway_patient_bindings 表迁移 (000113)
- 网关 CRUD:注册/编辑/删除/重生成 API Key,含患者绑定管理
- API Key 认证中间件(SHA-256 hash + prefix 快速查找)
- 网关数据上报端点:多患者批量读数,复用 device_reading_service 管道
- 网关心跳端点:固件版本/IP 更新 + last_heartbeat_at
- 10 个管理端路由(JWT)+ 2 个网关端路由(API Key)
- health.ble-gateways.list/manage 权限声明
- 修复 000112 迁移 ForeignKey 借用错误
2026-05-04 20:28:26 +08:00
iven
7b17f94bc0 feat(health): 班次管理与护士分配 — Shift/PatientAssignment/HandoffLog CRUD
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 3 张数据表迁移 (shifts, patient_assignments, shift_handoff_log)
- 3 个 SeaORM Entity (shift, patient_assignment, handoff_log)
- 完整 CRUD 服务层:班次管理、患者分配(含批量分配)、交接记录
- 12 个 API 端点 + health.shifts.list/manage 权限
- 班次列表含患者分配摘要 (patient_count/critical_count/attention_count)
- 乐观锁、软删除、审计日志、事件发布
- 输入验证:period/shift_status/care_level 白名单
2026-05-04 20:11:07 +08:00
iven
3ff17382ff feat(health+message): 关怀已送达通知管道 — care.action.performed 事件 + 温暖消息推送
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 CARE_ACTION_PERFORMED 事件常量(care.action.performed)
- care_plan_service 在护理项完成、测量数据更新、干预项创建时发布关怀行动事件
- erp-message 新增 care_plan.activated/completed + care.action.performed 消息处理
- 温暖消息文案:护理计划启动/完成通知、关怀已送达、健康数据已更新
- 事件测试覆盖新常量、payload 契约、通知分支逻辑
2026-05-04 18:56:52 +08:00
iven
0a5290aee4 feat(ai): KDIGO 透析专用风险评分器 — Phase 1 关怀引擎 MVP 第二步
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增 DialysisRiskScorer:12 条 KDIGO 规则覆盖 Kt/V、血磷、血钾、血红蛋白、
体重增长、eGFR、白蛋白,含 KDIGO CKD G1-G5 分期。同步暴露
POST /ai/dialysis/risk-assessment 端点。76 个测试全部通过。
2026-05-04 18:44:22 +08:00
iven
ef422f354d feat(health): 护理计划实体与服务 — Phase 1 关怀引擎 MVP 第一步
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增护理计划(Care Plan)完整 CRUD:3 张表(care_plans / care_plan_items /
care_plan_outcomes)、3 个 SeaORM Entity、15 个 API 端点、4 个事件常量、
2 个权限码。支持透析/慢性/预防/康复计划类型,条目分干预/监测/目标/教育四类,
预后测量含基线/目标/当前值追踪。
2026-05-04 18:40:22 +08:00
iven
c35ea83799 test(web): 核心健康管理页面测试 — 12 个页面 51 个测试用例
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增测试覆盖:
- PatientDetail: 5 测试(渲染/标签页/数据展示)
- AlertDashboard: 5 测试(渲染/统计卡片/告警列表)
- AlertRuleList: 5 测试(渲染/规则表格/创建按钮)
- DeviceManage: 5 测试(渲染/设备列表/筛选)
- AiAnalysisList: 6 测试(渲染/分析记录/分页)
- AiUsageDashboard: 4 测试(渲染/统计/类型分布)
- ArticleManageList: 5 测试(渲染/文章表格/分类筛选)
- PointsProductList: 5 测试(渲染/商品表格/上下架)
- PointsRuleList: 4 测试(渲染/规则表格)
- PointsOrderList: 5 测试(渲染/订单表格/状态筛选)
- StatisticsDashboard: 2 测试(渲染/权限守卫)
- DoctorSchedule: 3 测试(渲染/排班日历/科室筛选)

测试基础设施:
- 8 个新 fixture 工厂(device/analysis/points/article/alert/schedule)
- 10 组新 MSW handlers
- 5 个新权限码(devices/dashboard/oauth/ai.usage)

前端测试:527/530 通过(3 个预存失败未受影响)
2026-05-04 18:02:55 +08:00
iven
f54fb336dc feat(web): 护士工作台 Phase 1 前端 — NurseWorkbench 组件
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 NurseWorkbench 组件:问候行 + 统计卡片 + 班次患者 + 待办 + 右面板
- actionInbox API 客户端:新增 assigned_to_me/patient_id 参数 + myPatients 端点
- Home.tsx 护士角色路由到 NurseWorkbench(其他角色不受影响)
- 班次患者列表:显示今日分配给护士的患者 + 风险优先级色点
- 快捷操作面板:随访/体征/AI分析/咨询入口
- 今日进度条:完成百分比可视化
2026-05-04 17:48:50 +08:00
iven
a5b3396adc feat(health): 护士工作台 Phase 1 后端 — 用户范围过滤 + 班次患者端点
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- ActionInboxQuery 新增 assigned_to_me 和 patient_id 过滤参数
- list_action_items 支持按 user_id 过滤随访任务段
- get_workbench_stats 支持用户范围随访统计
- 新增 get_nurse_patients: 今日分配给护士的患者列表
- 新增 GET /health/action-inbox/my-patients 端点
- handler 从 TenantContext 提取 user_id 实现无感过滤
2026-05-04 17:45:23 +08:00
iven
69c3de15f5 Merge branch 'worktree-agent-ae2e5c31258292fcf'
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-04 14:09:06 +08:00
iven
b235f67c31 refactor(health): 拆分 4 个千行 service 文件为子模块
points_service.rs (1863行) → points_service/ (mod + account + checkin + product + event)
patient_service.rs (1118行) → patient_service/ (mod + helper + crud + relation + tag)
health_data_service.rs (1056行) → health_data_service/ (mod + vital_signs + lab_report + health_record + alert)
stats_service.rs (1117行) → stats_service/ (mod + operations + health + personal + dashboard)

所有公开 API 通过 pub use 保持不变,handler 层无需修改。
cargo check: 0 error, 0 warning
cargo test: 232 passed, 0 failed
2026-05-04 14:09:02 +08:00
iven
4be26592f4 test(health): 补全事件消费者测试 — 17 个消费者逻辑测试
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
为 erp-health/event.rs 中每个消费者添加正向和异常测试:
- 告警通知:severity 分支决定 template_key
- 告警聚合:suppressed=true 时触发聚合事件
- AI 分析完成:缺少 doctor_id/patient_id 时安全跳过
- AI 行动分发:suggestion_count=0 时跳过分发
- 预约创建:缺少 ID 时安全跳过
- 随访逾期升级:缺少 task_id/assigned_to 时安全跳过
- 危急值告警:完整字段提取 + 缺失 patient_id 安全跳过
- 咨询消息方向:sender_role 决定通知方向
- 知情同意:granted/revoked 不同 template
- 积分通知:缺失 amount 时安全跳过
- 设备读数:类型列表完整性
- workflow.task:UUID 解析 + 无效 UUID 安全处理
- 消费者总数验证

测试从 35 增加到 66(+31)
2026-05-04 13:58:49 +08:00
iven
d68c7be098 feat(ai): 建议状态生命周期 — 转换验证 + 执行端点 + 事件发布
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
建议(ai_suggestion)原有状态枚举完整但缺乏生命周期管理:
- 无转换验证(可从 Rejected 跳到 Approved)
- 无执行端点(护士无法标记"已执行")
- 无状态变更事件

变更:
1. SuggestionStatus.can_transition_to() — 仅允许合法单向转换
   Pending → Approved/Rejected/Expired → Approved → Executed/Rejected/Expired
2. SuggestionService.execute_suggestion() — 记录执行结果
3. SuggestionService.expire_stale_suggestions() — 批量过期超时建议
4. POST /ai/suggestions/{id}/execute — 新执行端点
5. publish_status_event() — 状态变更时发布 ai.suggestion.status_changed 事件
6. 9 个新单元测试覆盖所有转换规则
2026-05-04 13:39:48 +08:00
iven
e78eb1af07 fix(ai): 连接 ai.analysis.requested 事件消费者
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
erp-health 在化验单上传时发布 ai.analysis.requested 事件,
但 erp-ai 的 on_startup 仅订阅 ai.reanalysis.* 前缀。

将订阅前缀从 "ai.reanalysis." 扩大为 "ai.",
新增 ai.analysis.requested 事件的接收和日志记录。
完整自动分析实现依赖 Prompt 模板就绪后补充。
2026-05-04 13:12:47 +08:00
iven
77cf866adf fix(ai): 修复自动分析管道 — 补全建议生成 + 事件发布
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
自动分析批处理(auto_analysis.rs)在完成流式分析后仅保存结果,
缺少三个关键步骤导致关怀引擎无法启动:
1. 不解析双通道输出(StructuredOutput)→ 无结构化建议
2. 不调用 SuggestionService.create_suggestions() → 无建议记录
3. 不发布 ai.analysis.completed 事件 → 下游消费者无感知

修复方案:提取 post_process_analysis() 共享函数,统一处理
解析→创建建议→发布事件的后处理逻辑,SSE handler 和自动分析共用。
2026-05-04 13:10:55 +08:00
iven
1b52787b26 docs(health): 多专家组头脑风暴 — 系统演进方案(4阶段路线图)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
5 个专家组(产品策略师/AI架构师/UX设计师/医疗业务专家/技术负责人)
对代码库深度分析后制定从"综合平台"到"AI主动关怀引擎"的演进路线:
- Phase 0 基础加固(4周):修复AI管道断裂 + 关怀工作台Phase1
- Phase 1 关怀引擎MVP(8周):护理计划/KDIGO评分/班次管理/关怀通知/BLE网关
- Phase 2 患者体验(8周):老年适配UI/家庭代理/结果测量
- Phase 3 平台规模化(10周):HIS-LIS集成/多机构/商业飞轮
2026-05-04 13:03:38 +08:00
iven
1135439403 fix(health): 审计问题修复 — 权限守卫 + OAuth中间件 + FHIR声明 + SSE聚合
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- OAuthClientList/RealtimeMonitor/OfflineEventList/StatisticsDashboard 补权限守卫
- OAuth 中间件注入 TenantContext + FHIR scope→permission 映射
- FHIR CapabilityStatement 移除未实现的 $lastn 操作
- useVitalSSE 修复批量同步事件数据聚合逻辑
2026-05-04 12:02:50 +08:00
iven
d436888ca5 refactor(web): 系统设置模块页面表单一致性重构
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 useCrudDrawer hook 封装 CRUD Drawer 通用模式(状态管理/提交/错误处理)
- 新增 useListData hook 封装非分页列表数据获取
- 11 个页面统一迁移到 DrawerForm + 共享 hooks,消除重复代码
- 错误处理统一使用 useApiRequest.execute(),移除内联 try-catch
- Modal 全部替换为 DrawerForm,保持 UI 一致性
- 净减少 ~1300 行代码(858 增 / 2136 删)
2026-05-04 11:57:38 +08:00
iven
444dc7dd8d fix(health): 数据完整性 + 代码规范修复 — FK约束/版本类型统一/软删除过滤
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
数据完整性:
- 新增 8 个 FK 约束 (follow_up_task→appointment, points_transaction→account/rule/order,
  points_order→product/patient, offline_event_registration→event/patient)
- critical_alert/critical_alert_response version 字段 i64→i32 统一
- vital_signs_daily_service 聚合查询添加 DeletedAt.is_null() 过滤

代码规范:
- 新增 api/upload.ts 封装文件上传,ArticleEditor 改用 service 层
- 新增 messages.updateSubscription,NotificationPreferences 改用 service 层
- 修复 erp-message SSE 测试编译错误 (移除 serde_urlencoded 依赖)
2026-05-04 11:22:54 +08:00
iven
30a578ee00 fix(health): 客户试用前全局审计修复 — P0 权限旁路 + API 路径 + 事件注册
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P0 阻塞修复:
- 修复 PrivateRoute 权限旁路: p.startsWith('auth.') 匹配不到任何权限码,
  改为基于实际权限码的路由级检查 (user.manage/role.manage/organization.manage)
- 修复 deviceReadings API 路径: /patients/{id}/device-readings/daily 改为
  /vital-signs/daily?patient_id=, 消除 404

P1 重要修复:
- 补全事件注册表: 新增 auth(11) + config(8) + workflow(4) + plugin(2) = 25 条
- article_article_tag 联表新增 tenant_id + deleted_at + 审计列 (迁移 107)
- vital_signs_hourly 新增 deleted_at 支持软删除过滤 (迁移 108)
- 6 个页面添加权限守卫 (AlertDashboard/AlertRuleList/DeviceManage/
  AiAnalysisList/AiUsageDashboard)
- DialysisModule 声明 auth 依赖
2026-05-04 11:02:25 +08:00
iven
cde3a863a2 feat(health): FHIR 模块类型定义 + converter 依赖
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-04 02:56:56 +08:00
iven
8cfc5709dc docs: 事件注册表更新 — 告警降噪 + alert.aggregated 事件 2026-05-04 02:56:40 +08:00
iven
29b47ae4e4 fix(health): OAuth 模块编译修复
- 修复 RngCore import:使用 rand_core::RngCore 替代 argon2 password_hash 重导出
- 修复 ActiveModel version/id move 问题:先读取再 unwrap
- 添加 rand_core 依赖
2026-05-04 02:54:20 +08:00
iven
2e9f6621a3 test(health): 告警降噪集成测试骨架
4 个 Testcontainers 测试用例(忽略状态)覆盖:
患者级升级阈值 + 系统级聚合窗口 + critical 不聚合 + 完整流程
2026-05-04 02:54:17 +08:00
iven
3a14b7efe3 feat(health): 日聚合查询 API — GET /health/vital-signs/daily
- 新增 DailyAggQuery DTO(patient_id/device_type/start_date/end_date)
- 新增 get_daily_aggregations handler(需 health.device-readings.list 权限)
- 路由注册到 protected_routes
2026-05-04 02:54:13 +08:00
iven
4c1d98116a feat(health): 告警聚合事件消费者 — alert.aggregated
- 新增 ALERT_AGGREGATED 常量
- alert_notifier 消费者中处理 suppressed=true 告警并发布聚合事件
- 更新事件常量测试和 consumer_id 唯一性测试
2026-05-04 02:51:13 +08:00
iven
bb5298ee0f feat(message): SSE 增强 — Event ID + 心跳保活 + Last-Event-ID + 患者订阅
- 每个 SSE 事件附加 id 字段(UUID v7)用于断点续传
- 30s timeout 心跳保活防止连接断开
- Last-Event-ID header 恢复:重连跳过已发送事件
- ?patient_ids=id1,id2 查询参数选择性订阅患者
2026-05-04 02:49:23 +08:00
iven
975d699e42 feat(health): 告警降噪集成 alert_engine + OAuth service 编译修复
- alert_engine: create_alert_and_notify 调用 noise_reducer,升级严重度+suppressed标记
- oauth/service: 修复 OsRng import + ActiveModel move 问题
- fhir/handler: linter 补全完整实现
2026-05-04 02:43:32 +08:00
iven
62c02e0f15 feat(miniprogram): BLE 增强层 — DataBuffer + GenericBleAdapter + DataSyncScheduler
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- DataBuffer: 离线持久化缓冲(分桶存储 + 去重 + 容量管理)
- GenericBleAdapter: 基于 Bluetooth SIG 标准 Health Profile 的通用适配器
  (Heart Rate 0x180D / Health Thermometer 0x1809 / Blood Pressure 0x1810)
- DataSyncScheduler: 定时自动同步调度(基于时间间隔判断是否需要同步)
- BLEManager: 集成 DataBuffer 替换简单 Storage 缓存
- device-sync 页面: 注册 CustomBandAdapter + 自动同步 + 状态显示
- 新增 vitest 单元测试配置,30 个测试全部通过
2026-05-04 02:42:58 +08:00
iven
70aacf47a0 feat(web): IoT + FHIR V1 Plan 5 — Web 前端实施
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- API 层: deviceReadings 日聚合查询 + OAuth 合作方 CRUD 接口
- 常量: 设备连接状态/连接类型/实时监控指标常量
- Hook: useVitalSSE — 复用全局 SSE 连接的 vital_update 事件
- 页面: RealtimeMonitor 实时体征监控台 (SSE + 告警排序)
- 页面: OAuthClientList FHIR 合作方管理 (CRUD + Secret 重置)
- 增强: DeviceManage 设备状态/固件/连接类型列 + 状态筛选
- 路由: 新增 3 个懒加载路由
- 测试: RealtimeMonitor + OAuthClientList 单元测试
2026-05-04 02:40:57 +08:00
iven
24562dd54b feat(health): 告警降噪服务 + FHIR handler stubs
- 新增 alert_noise_reducer:患者级升级(30min/3次阈值) + 系统级聚合(5min窗口)
- 补全 FHIR R4 handler stubs(Plan 2 路由注册但 handler 缺失导致编译失败)
2026-05-04 02:36:37 +08:00
iven
c5b686499c feat(health): 日聚合 background task — 每天自动从 hourly 聚合到 daily
- 新增 start_daily_aggregation 定时任务(每 24h 执行)
- on_startup 启动时立即执行一次昨日聚合
- 聚合逻辑调用 vital_signs_daily_service::aggregate_daily_for_all_tenants
2026-05-04 02:35:30 +08:00
iven
8656896847 feat(health): patient_devices 增强 — status/firmware/manufacturer/connection_type/metadata
- 新增迁移:添加 status/firmware_version/manufacturer/connection_type/metadata 列
- 更新 Entity:新增对应字段(含默认值)
- 修复 device_reading_service 自动绑定设备时填充新字段
2026-05-04 02:32:19 +08:00
iven
43894446d9 feat(health): vital_signs_daily 日聚合表 + Entity + service
- 新增 vital_signs_daily 表迁移(带唯一索引 tenant+patient+device_type+date)
- 新增 SeaORM Entity(含 percentile_95 统计字段)
- 实现日聚合 service:从 hourly 聚合到 daily(支持 upsert)
- 实现 aggregate_daily_for_all_tenants 多租户遍历聚合
- 实现 query_daily 范围查询
- 单元测试:percentile 计算验证
2026-05-04 02:30:03 +08:00
iven
fa0a788cf9 docs(plan): IoT + FHIR V1 Plan 2 — FHIR API 层实施计划
4 Chunk 9 Task:FHIR 基础类型 + CapabilityStatement +
Patient/Observation 转换 + 6 资源端点 + $everything 操作。
分步 TDD 流程,每步有具体代码和验证命令。
2026-05-04 01:27:18 +08:00
iven
feab61b132 docs(plan): IoT + FHIR V1 Plan 1 — 数据层增强实施计划
6 个 Task:vital_signs_daily 表迁移 + Entity + Service +
patient_devices 增强 + 日聚合 background task + 查询 API。
TDD 流程,每步有具体代码和验证命令。
2026-05-04 01:14:15 +08:00
iven
2afe3a8848 docs: IoT 设备采集 + FHIR 开放平台生态设计规格
发散式探讨产出:BLE 适配器 + 设备网关混合架构,HL7 FHIR R4 输出,
OAuth2 合作伙伴认证,渐进演进 V1-V3 路线图。

Spec review 发现大量已有基础设施(device_readings/alert_engine/SSE/BLE),
设计已据此修正为"增强现有 + 新增 FHIR 层"策略。
2026-05-04 01:08:01 +08:00
iven
5140552ff6 fix(health): 走查止血 — 患者名显示修复 + 枚举补全 + 医护统计 + 设备选择器
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
后端:
- alert_service: list_alerts 批量查询 patient_name 填充 AlertResponse
- consultation_service: list_sessions 批量查询 patient_name/doctor_name
- erp-ai handler: list_analysis 通过 raw SQL 查询 patient_name

前端:
- AlertList/AlertDashboard: 使用后端返回的 patient_name 替代 ID 截断
- ConsultationDetail: 使用 patient_name/doctor_name 替代 ID 截断
- AiAnalysisList: 使用 patient_name 替代 ID 截断
- constants/health: SEVERITY 补 high/medium, STATUS 补 active
- AdminDashboard: 医护人数改为 API 查询(useStatsData 新增 doctorCount)
- DeviceManage: 患者 ID 输入改为 PatientSelect 搜索选择器
2026-05-04 00:03:40 +08:00
iven
20bd9e8cb4 docs: 全系统前端走查报告 + 多专家组头脑风暴
35+ 页面逐页走查,发现 P0 问题 4 项、P1 问题 6 项、P2 建议 4 项。
三专家组分析:架构组定位 EntityName 根因,测试组发现枚举缺失,
产品组制定 3 阶段修复路径(止血 → 补短板 → 治本)。
2026-05-04 00:03:22 +08:00
iven
f4b5d55f24 fix(test): 增加页面测试超时至 15s — 覆盖率模式下避免 timeout
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-03 23:21:58 +08:00
iven
6709df62ed test(web): 第一批列表页测试 — 7 个页面 + 修复导入路径
- AppointmentList / FollowUpTaskList / FollowUpRecordList / ConsultationList
- FollowUpTemplateList / DialysisManageList / OfflineEventList
- 修复 FollowUpTemplateList 导入路径 bug (../../../ → ../../)
2026-05-03 23:19:55 +08:00
iven
c0e0e2a6c3 test(web): PatientList/AlertList/DoctorList 页面测试 — 验证工厂模式
- 添加 matchMedia + ResizeObserver mock (Ant Design 依赖)
- renderWithProviders 注入 auth state + localStorage token
- 修复 fixture 批量生成自动分配唯一 id
- PatientList 5 测试 / AlertList 3 测试 / DoctorList 4 测试
2026-05-03 23:12:34 +08:00
iven
37cdeebb95 test(web): 添加 createListPageTests 工厂 — 6 类标准测试用例自动生成 2026-05-03 23:05:46 +08:00
iven
c93ae0bc66 test(web): 添加 renderWithProviders — MemoryRouter + AntD ConfigProvider 包裹器 2026-05-03 23:04:19 +08:00
iven
0e789b530a test(web): 添加测试数据工厂 — healthFixtures + 批量生成 + 分页包装 2026-05-03 23:03:04 +08:00
iven
120df86e58 test(web): 添加健康模块 msw handlers — 患者告警预约医生 4 组 mock API 2026-05-03 23:01:57 +08:00
iven
8f7f75ac25 docs(plan): 页面/组件测试第一批实施计划 — 3 Chunk 13 Task 1155 行
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- Chunk 1: 测试基础设施(msw health handlers + fixtures + renderWithProviders)
- Chunk 2: ListPage 测试工厂(createListPageTests + 3 页面验证)
- Chunk 3: 第一批 7 个列表页测试(预约/随访/咨询/透析/活动)
2026-05-03 22:58:51 +08:00
iven
1602b7bbad docs(wiki): Wiki 全面刷新 + Q2 路线图 + 测试补强设计规格
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- Wiki 7 文件关键数字刷新:迁移 96→103、实体 45→46、前端 163→225、测试 5→36
- 修复 architecture.md PostgreSQL 版本不一致(18→16)
- 修复 erp-ai.md 实体数 3→6、erp-health.md 实体数 45→46
- 更新 index.md 文档索引:specs 41、plans 38、discussions 18
- 新增事件注册表/方法论/分析报告引用
- 新增页面/组件测试设计规格(模式化工厂方案)
- 新增 Q2 路线图规格(技术债 + 新功能并行 8 周)
2026-05-03 22:33:08 +08:00
iven
6d1a7fba98 test(web): API 契约测试 — 25 个模块 244 个测试全覆盖
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
验证每个 API 模块的 URL/HTTP Method/参数序列化:
- health(14): patients/appointments/alerts/articles/consultations/
  dashboard/deviceReadings/doctors/followUp/healthData/points/
  followUpTemplates/api
- 基础模块(11): auth/users/roles/orgs/dictionaries/messages/
  plugins/pluginData/config-modules/workflow/auditLogs

前端测试总数: 140(store) + 244(api) = 384
2026-05-03 20:09:49 +08:00
iven
bc6206c0df chore: 编译器警告清理 — 22 条全部消除,workspace 零警告
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
erp-ai(8): 移除未用 import + serde 结构体 #[allow(dead_code)]
erp-plugin(5): 移除未用 import + FromQueryResult 结构体允许
erp-health(8): 移除未用 import/变量 + FromQueryResult 字段允许
erp-server(1): AnalyticsEvent.timestamp 允许(未来分析集成)
2026-05-03 20:09:26 +08:00
iven
e9451875a8 docs(wiki): Docker 配置对齐 — PostgreSQL 版本校正 + fail-close 标记
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- PostgreSQL 18→16(与 docker-compose.yml 实际版本对齐)
- 限流 fail-open 标记为已修复(默认 fail-close)
2026-05-03 19:59:20 +08:00
iven
0d3e45300f refactor(web): 前端错误处理统一化 — 9 个文件 13 处替换 handleApiError
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
统一使用 api/client.ts 的 handleApiError() 替代内联错误提取:
- Login/Users/Roles/Organizations/Settings 操作失败提示
- ArticleEditor/ArticleTagManage/ArticleCategoryManage 表单错误
- FamilyMembersTab 家庭成员操作

零 response?.data?.message 内联模式残留
2026-05-03 19:59:12 +08:00
iven
443bfbae61 docs(wiki): 数据一致性刷新 — 7 处校正
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- architecture.md: 删除重复插件行、44→45 实体、22→39 权限
- testing.md: 迁移计数 50→104
- database.md: 补充 m000092-000104 迁移覆盖
- infrastructure.md: 更新日期 2026-04-23→2026-05-03、PostgreSQL 18→16
- erp-health.md: 44→45 实体、22→39 权限、消费者数校正
2026-05-03 19:58:19 +08:00
iven
7a016e4ed5 test(health): 事件系统单元测试 — EventBus + 消费者过滤 + payload 验证
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
event.rs 新增测试模块:
- EventBus subscribe_filtered 过滤非匹配事件
- 消费者幂等性验证(is_event_processed)
- DomainEvent payload 构造
- 事件常量一致性校验

erp-health lib 测试总数: 212 → 213
2026-05-03 19:49:21 +08:00
iven
7a73a90238 test(web): Store 单元测试 — plugin(25) + workbench(27) = 52 新测试
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
plugin.test.ts: fetchPlugins/refreshMenuItems/pluginMenuGroups 全覆盖
workbenchStore.test.ts: selectTask/setTab/refreshTasks/refreshStats/completeTask 全覆盖

前端 Store 测试总数: 22 → 140 (6 个文件)
2026-05-03 19:49:08 +08:00
iven
8a53948934 feat(health): 深度 tracing 补全 — health_data 45 处 + action_inbox 8 处
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
health_data_service: 每个公开函数覆盖 entry/success/error 三层
- 13 个函数全覆盖(vital_signs/lab_report/health_record)
- 16 info + 13 info success + 3 debug + 13 error = 45 处

action_inbox_service: 追加 debug 级别中间结果日志
2026-05-03 19:44:49 +08:00
iven
3ddd04b422 feat(health): 孤立事件清理 — 新增 3 个消费者,孤立率 36% → 0%
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增消费者:
- lab_report.uploaded → 触发 AI 自动分析请求
- lab_report.reviewed → 通知患者审核结果
- patient.updated → 审计日志记录

保留为纯通知的事件(无需消费者):
- article.published/rejected, daily_monitoring.created,
  doctor.online_status_changed

保留 TODO 标记(业务流程未实现):
- patient.deceased/verified
2026-05-03 19:42:41 +08:00
iven
80bc60f5e4 feat(health): action_inbox + health_data_service tracing 补全
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
action_inbox_service: 从 0 → 8 处 tracing(4 个公开函数全覆盖)
health_data_service: 从 3 → 12 处 tracing(13 个公开函数全覆盖)
2026-05-03 19:41:04 +08:00
iven
34504d4179 fix(server): 限流 fail-close 默认开启 + 配置测试
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
生产安全:Redis 不可达时默认拒绝请求(503)而非放行。
- config/default.toml: fail_close 默认值 false → true
- config.rs: Default + serde default 均改为 true
- 新增 2 个单元测试验证默认值和 serde 行为
2026-05-03 19:37:58 +08:00
iven
c6c94ebb84 docs: HMS 功能思维导图 + 系统设计文档 + 演进路线图
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
患者端/医护端/管理后台/平台技术能力思维导图 +
系统功能文档 + 演进路线图 + 设计思路
2026-05-03 19:32:39 +08:00
iven
ec87ae85cf docs(wiki): 全量 wiki 更新 — C1 晚间血压已修复标记 + 数据校正
- miniprogram.md: 晚间血压 CRITICAL 标记为已修复
- index.md: 症状导航表更新
- architecture/database/erp-core/erp-health/frontend/testing: 同步更新
2026-05-03 19:32:30 +08:00
iven
c208dcc6f5 docs(specs): 7 份设计规格 — 工作台/适老化/硬编码清理/项目分析
新增: 适老化小程序/Action Inbox/统一工作台/医生操作台/
硬编码清理/健康管理台/全项目深度分析报告
2026-05-03 19:32:25 +08:00
iven
d712ad78c3 docs: 审计报告(8 份) + 讨论记录(4 份)
审计报告: 基线快照/功能清单/后端完整性/事件系统/参数配置/
差距模式/错误处理/测试覆盖/审计总结报告
讨论记录: 设备管线/端到端测试/三端审计/工作台重构
2026-05-03 19:32:15 +08:00
iven
78c783d332 feat(miniprogram): 配置更新 + 家庭成员/设置页面优化
- dev.ts: 开发环境配置调整
- project.config.json: 自动化审计配置
- family-add: 添加家庭成员页面优化
- settings: 设置页面优化
- config/: 新增项目配置文件
2026-05-03 19:32:09 +08:00
iven
3e4baa38a6 feat(web): 透析 API + 积分账户组件 + 工作台 store + 统计页修复
- dialysis.ts: 新增透析管理 API 模块
- PointsAccountTab.tsx: 积分账户标签页组件
- workbenchStore.ts: 工作台状态管理
- StatisticsDashboard.tsx: 统计页空列表修复
- auth.test.ts: 修复权限码拼写 health.alert → health.alerts
- api.test.ts: API 契约测试
2026-05-03 19:32:00 +08:00
iven
70322e4132 feat(miniprogram): 医生端 API 服务层 — 7 个模块
新增医生端完整 API 调用层:alerts / appointment / consultation /
dashboard / followup / labReport / patient
2026-05-03 19:31:51 +08:00
iven
3412d807e3 fix(core): 跨 crate 小修复 — dto 合并、tracing 补全、死代码清理
- erp-ai: 删除孤立 dto.rs(已合并到子模块)
- erp-core: audit_service tracing 优化
- erp-health: points_handler 补充返回值、alert_engine 修正日志级别
- erp-plugin: host/data_handler/market_handler tracing 统一
- erp-dialysis/event: 移除无用 import
- erp-workflow/executor: tracing 格式统一
2026-05-03 19:31:46 +08:00
iven
d378e154c4 docs: 全项目深度分析与多专家组头脑风暴报告
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
覆盖 5 个专家视角(架构/安全/前端/质量/管理),
数据经实际代码库校正,产出 22 项优先级行动矩阵。
2026-05-03 19:01:27 +08:00
iven
bba47b7b1c test(web): health store 单元测试 — 名称缓存/批量解析/去重
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
17 个测试覆盖:
- getPatientName/getDoctorName 缓存命中/miss
- resolvePatientName/resolveDoctorName API 调用+缓存+降级
- 并发去重(同一 id 只触发一次 API 调用)
- batchResolve 批量解析 + 部分失败降级 + 输入去重
2026-05-03 10:05:27 +08:00
iven
9d07ea0be0 test(web): 前端 Store 单元测试 + patient_service tracing 补全
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Store 测试 (71 个):
- auth.test.ts: 22 tests — 登录/登出/权限/JWT解析/localStorage持久化
- app.test.ts: 24 tests — 主题切换/侧边栏/配置加载/状态隔离
- message.test.ts: 25 tests — 未读计数/消息列表/SSE连接/标记已读

Tracing 补全:
- create_patient: 身份证号重复时 warn 日志
- update_patient/delete_patient: 版本冲突时 warn 日志含 expected/actual
2026-05-03 09:58:13 +08:00
iven
84afeaf9f2 feat(health): 事件消费者补全 + 无效消费者清理
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增消费者:
- appointment.created → 患者预约创建通知
- consultation.opened/closed/new_message → 咨询全流程通知
- follow_up.created → 随访任务分配通知
- points.earned/exchanged/expired → 积分变动通知

清理:
- 删除 message.sent no-op 消费者(仅打日志无实际作用)
- 为 workflow.task.completed 消费者补充幂等检查
- 孤立事件率从 57% 降至 ~20%(剩余为 TODO 预留项)
2026-05-03 09:51:26 +08:00
iven
209acaa15d feat(server): 限流 fail-close 统一配置
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 RateLimitConfig 结构体,支持 config.toml + 环境变量
- apply_rate_limit 统一读取 fail_close 配置,生产环境可设为拒绝请求
- account_lockout_middleware 改为从 AppState.config 读取,不再直接读环境变量
- default.toml 添加 [rate_limit] 配置节
2026-05-03 09:46:02 +08:00
iven
1a6409eb30 feat(miniprogram): 用药提醒从 localStorage 迁移到服务端 API
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 medication-reminder.ts service(list/create/update/delete)
- 重写 medication/index.tsx 页面,通过后端 API 持久化数据
- 支持乐观锁(version)、患者 ID 关联、提醒时间数组
- 移除旧的 localStorage 读写逻辑
2026-05-03 09:38:24 +08:00
iven
32df9c0655 feat(web): 随访模板管理页面 — CRUD + 路由 + 菜单迁移
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 FollowUpTemplateList.tsx 页面(列表/新建/编辑/详情弹窗)
- 新增 followUpTemplates.ts API 客户端(list/get/create/update/delete)
- 注册路由 /health/follow-up-templates + 菜单标题 fallback
- 新增迁移 seed_follow_up_template_menu 注册菜单和权限
2026-05-03 09:31:43 +08:00
iven
2e4d98c479 fix(web): 统计页空列表接入真实 API + 运营待办去硬编码
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- DoctorDashboard: 咨询消息接入 consultationApi.listSessions
- NurseDashboard: 随访队列接入 followUpApi.listTasks
- OperatorDashboard: 热门文章接入 articleApi.list
- OperatorWorkbench: 5 条硬编码待办替换为 actionInboxApi 真实数据
2026-05-03 00:02:58 +08:00
iven
603af83aa9 fix: P0 止血 — 消除崩溃风险 + 伪CAS修复 + 硬编码清除 + 晚间血压
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 sea_orm_ext 模块: safe_version() / bump_version() 替代 14 处 unwrap()
- 修复 points_service 伪 CAS 逻辑 bug: 在 Set() 前提取原始版本并重新验证
- AdminDashboard: API 失败时显示 unknown 状态而非虚假绿色 healthy
- AdminDashboard: 今日操作改用真实数据,移除 "0 错误" 硬编码
- OperatorWorkbench: 移除硬编码 "美玲",改用真实用户名
- Home.tsx: operator "内容发布" 从硬编码 0 改为真实积分统计
- 小程序体征录入: 新增晚间血压 indicator_type,映射到 evening 字段
2026-05-02 23:42:01 +08:00
iven
dd44c1526f feat(web): 工作台页面改造 — 管理员/运营数据改用真实 API
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- AdminDashboard 移除硬编码模块列表,改用 system-health/user-activity/modules API
- OperatorWorkbench 移除硬编码积分动态和文章统计,改用 points-recent-activity/article-stats API
- 新增 dashboard.ts API 客户端,AxiosResponse 解包到 data.data
- Home.tsx 集成 4 个角色工作台组件路由
- useDashboardRole 支持 health_manager 角色
2026-05-02 11:56:26 +08:00
iven
0006e427e2 feat(health): 5 个工作台管理统计 API — 系统健康/用户活跃/模块状态/积分动态/文章统计
- DTO: SystemHealthResp, UserActivityResp, ModuleStatusResp, PointsActivityItem, ArticleStatsResp
- Service: get_article_stats, get_points_recent_activity, get_module_status, get_user_activity, get_system_health
- Handler: 5 个新端点 + 权限码 health.dashboard.manage
- 路由: /health/admin/system-health, user-activity, modules, points/recent-activity, articles/stats
2026-05-02 11:49:34 +08:00
iven
2cc0f5af25 refactor(miniprogram): 体征阈值改用动态 API — 替代硬编码参考范围
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- health.ts 新增 getHealthThresholds/findThreshold/DEFAULT_THRESHOLDS
- 24h storage 缓存 + 降级到内置默认值
- health/index.tsx: REF_RANGES → buildRefRange(thresholds)
- pkg-health/input: WARN_THRESHOLDS → getWarnForIndicator(thresholds)
2026-05-02 11:40:54 +08:00
iven
e8ee441ae1 feat(health): Track 3 医疗阈值 — warning 种子 + 患者端只读 API
- 新增 6 条 warning 级别阈值种子数据(血压/心率/血糖参考范围)
- 新增 GET /health/critical-value-thresholds/public 患者端只读接口
- 扩展 indicator 验证支持 blood_sugar_fasting/postprandial 等新指标
2026-05-02 11:37:21 +08:00
iven
23cd62a70f feat(db): 健康模块字典种子数据 — 6 个字典 + 43 个条目
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- health_department (11 科室)
- health_title (9 职称)
- health_device_type (8 设备类型)
- health_follow_up_type (5 随访类型)
- health_consultation_type (3 咨询类型)
- health_relationship (5 关系类型)
2026-05-02 11:34:35 +08:00
iven
63ead0c442 refactor(web): 新增 useDictionary hook + 4 个页面下拉选项改用字典 API
- 新增 useDictionary hook 支持字典 API 获取 + fallback 降级
- DoctorList 科室/职称改用 useDictionary (health_department/health_title)
- FollowUpTaskList 随访类型改用 useDictionary (health_follow_up_type)
- ConsultationList 咨询类型改用 useDictionary (health_consultation_type)
- FamilyMembersTab 家庭关系改用 useDictionary (health_relationship)
2026-05-02 11:27:11 +08:00
iven
b6e780e649 refactor(web): 统一健康模块静态映射常量到 constants/health.ts
- 收敛 SEVERITY_COLOR/LABEL (5处→1处)
- 收敛 ALERT_STATUS_COLOR/LABEL (3处→1处)
- 收敛 DEVICE_TYPE_OPTIONS/COLOR (3处→1处)
- 收敛 GENDER_LABEL (4处→1处)
- StatusTag 组件改引用 STATUS_TAG_CONFIG
- DoctorDashboard 严重度映射改引用常量
2026-05-02 11:24:34 +08:00
iven
3bc4597041 fix(health): 工作台 UNION ALL 排序 + 团队概览 display_name NULL 处理
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- UNION ALL 查询包装子查询解决 PostgreSQL ORDER BY 限制
- get_team_overview 的 display_name 改为 Option<String> 防止 NULL 解码失败
2026-05-02 00:21:27 +08:00
iven
5e52b0a34c feat(health): 工作台遗留项修复 — UNION ALL 聚合 + 团队概览 + 较昨日对比
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
1. 待办列表 UNION ALL 聚合:list_action_items 现从 ai_suggestion + alerts + follow_up_task 三表查询,
   ActionType 扩展为 AiSuggestion/Alert/Followup/DataAnomaly 四种类型,
   get_action_thread 按类型构建不同线程时间线(AI 建议/告警/随访)
2. 真实团队概览:get_team_overview 从 doctor_profile + follow_up_task + alerts 聚合成员统计和风险分布
3. 统计卡片较昨日描述:PersonalStatsResp 新增 6 个 yesterday_* 字段,
   Home.tsx 统计卡片底部渲染"较昨日+N"绿色/红色描述
4. 前端 ActionDetailDrawer 改用 item.id(action_type:uuid 格式)调用线程 API
2026-05-01 23:25:38 +08:00
iven
310a3cec90 refactor(web): 重写工作台 UI 匹配原型设计
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Home.tsx 改用 CSS Grid 布局替代 antd Row/Col,统计卡片添加
顶部渐变色条。TodoList/AiInsightPanel/TeamOverviewPanel 全部
重写为自定义 inline style,复刻原型中的紧急度圆点、类型标签、
AI 渐变图标、成员进度条、风险分布色块等视觉元素。
2026-05-01 22:17:19 +08:00
iven
963556c079 fix(health): 修复工作台统计 SQL 表名 — alerts/follow_up_task
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- alert → alerts(实际表名复数)
- follow_up_plan → follow_up_task(表不存在,改用 pending 状态的随访任务)
2026-05-01 21:42:53 +08:00
iven
4aa014de0d feat(web): Home.tsx 集成统一工作台 — 医生行动收件箱 + 主任团队概览
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 医生/护士角色:待办任务行替换为行动收件箱(TodoList) + AI 概览面板
- 主任角色:在最近动态下方新增 TeamOverviewPanel 团队概览
- 所有角色:点击待办项可打开 ActionDetailDrawer 查看详情和操作
- admin/operator 角色保持原有待办任务+最近动态布局
2026-05-01 21:22:28 +08:00
iven
ab2c9bbc43 feat(web): 工作台面板组件 — AiInsightPanel / TeamOverviewPanel / ActionDetailDrawer
- AiInsightPanel: 工作台统计概览(待处理/AI建议/紧急告警/到期随访+完成率)
- TeamOverviewPanel: 主任团队概览(成员列表+风险分布+完成率进度条)
- ActionDetailDrawer: 待办详情抽屉(患者信息+操作时间线+快捷操作按钮)
2026-05-01 21:19:46 +08:00
iven
620af8988b feat(web): 工作台前端 API 客户端 + TodoList 组件
- actionInbox.ts 新增 WorkbenchStats/TeamOverview 类型和 stats()/team() API
- 新建 workbench/TodoList.tsx 待办列表组件(分页 + 类型/优先级标签)
2026-05-01 21:17:39 +08:00
iven
61397186e7 feat(health): 添加工作台统计和团队概览 API
- ActionInboxService 新增 get_workbench_stats 和 get_team_overview
- Handler 新增 /health/action-inbox/stats 和 /team 端点
- 注册 health.action-inbox.team 权限码
2026-05-01 21:14:23 +08:00
iven
f13a240000 fix(migration): 修复权限关联 — 使用 permission_id 外键关联
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 18:38:03 +08:00
iven
a174f88b6f fix(migration): 修复表名 tenants → tenant(单数)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 18:36:26 +08:00
iven
5261468953 fix(migration): 修复行动收件箱菜单迁移 — 使用正确的 menus 表字段
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 18:34:22 +08:00
iven
8e177ca705 feat(web): 家属管理 Tab — 列表+添加/编辑/删除家属
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 18:24:57 +08:00
iven
7764f7f8a6 feat(web): 患者详情 AI 标签页添加趋势分析+体检方案触发按钮
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 18:23:10 +08:00
iven
8a972f8f4d feat(web): SSE 分析 API 封装 + 化验报告页 AI 解读按钮
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 analysisSse.ts SSE 流式分析 API 封装(ReadableStream 解析)
- 化验报告页操作列添加 AI 解读按钮(SSE 实时流式输出)
- 分析结果展示在 Table 下方的 Card 中
2026-05-01 18:21:40 +08:00
iven
a1fa51206f feat(miniprogram): 个人中心添加我的预约+在线咨询入口
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 18:19:23 +08:00
iven
0fb8b98c72 feat(miniprogram): 通知 Tab 对接 erp-message 消息 API — 替换空壳 2026-05-01 18:18:51 +08:00
iven
f4b536accb fix(miniprogram): AI 建议卡片跳转修复 — 按建议类型跳转对应页面
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 18:17:46 +08:00
iven
8dd269d150 feat(web): 患者快捷导航 + 列表页 URL patient_id 筛选 + AI 列表患者 Link
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 患者详情页增加快捷导航卡片(预约/咨询/透析/随访/AI)
- 5 个列表页支持 URL ?patient_id=xxx 自动筛选
- AI 分析列表患者 ID 改为可点击 Link 跳转详情
2026-05-01 18:17:07 +08:00
iven
0f32d28ddb feat(web): 患者详情页增加快捷导航卡片 — 预约/咨询/透析/随访/AI 2026-05-01 18:13:02 +08:00
iven
ebae393e90 chore(server): domain_events 清理周期从 90 天缩短为 7 天
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 17:44:00 +08:00
iven
797c4e9e20 fix(health): 危急值告警查询添加 tracing 错误日志
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 17:43:05 +08:00
iven
4cde4acddc feat(migration): 行动收件箱菜单种子数据 + 权限关联
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 17:41:41 +08:00
iven
e1ebae4ed0 fix(web): 补充告警/收件箱/设备等菜单图标映射
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 AlertOutlined/BellOutlined/ControlOutlined/InboxOutlined/ApiOutlined/ReadOutlined/ExperimentOutlined 图标导入和映射
2026-05-01 17:40:17 +08:00
iven
ae1c9ccc77 feat(web): Login/MainLayout 从主题配置读取品牌信息
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- Login.tsx 从 /api/v1/public/brand 读取品牌名称/标语/特性/版权
- MainLayout 侧边栏 Logo 和 Footer 从 themeConfig 读取
- 启动时调用 loadThemeConfig 缓存主题配置
- 移除所有硬编码品牌文字(HMR Platform → 动态读取)
2026-05-01 17:39:21 +08:00
iven
669ca44360 feat(web): 主题设置联动 — 扩展 ThemeConfig 品牌字段 + 设置页面表单
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- ThemeConfig 接口增加 brand_name/brand_slogan/brand_features/brand_copyright
- 新增 BrandConfig 接口和 getPublicBrand 公开品牌信息获取
- app store 增加 themeConfig 缓存和 loadThemeConfig 方法
- ThemeSettings 页面增加品牌设置表单(品牌名称/标语/特性/版权)
2026-05-01 17:37:10 +08:00
iven
6eb2bf9c80 feat(config): ThemeResp 增加品牌字段 + 公开品牌信息端点
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- ThemeResp 新增 brand_name/brand_slogan/brand_features/brand_copyright 字段
- default_theme 提供品牌默认值
- 新增 PublicBrandResp 和 GET /api/v1/public/brand 公开端点(无需认证)
- ConfigModule 增加 public_routes 方法
- 更新测试覆盖品牌字段
2026-05-01 17:34:43 +08:00
iven
a95e3d8645 fix(plugin): 修复测试编译失败 — 补充 parse_manifest 导入
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 17:28:31 +08:00
iven
95d7989a9f docs: 三端审计修复实施计划 Phase 3 — 6 个 Task (#12-#15)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
SSE 分析 API 包装器、AI 触发按钮、家庭成员 Tab、E2E 清理夹具、统计验证
2026-05-01 17:25:29 +08:00
iven
73119fe026 docs: 三端审计修复实施计划 Phase 2 — 6 个 Task (#7-#11)
Chunk 2: 体验补全阶段
- Task 9: 患者详情快捷导航卡片
- Task 10: 5 个列表页支持 URL patient_id 过滤
- Task 11: AI 分析列表患者 Link
- Task 12: 小程序 AI 建议跳转修复
- Task 13: 小程序通知 Tab 对接 erp-message API
- Task 14: 小程序咨询功能入口
2026-05-01 17:20:45 +08:00
iven
ac2797e1b7 docs: 修正 #10 通知端点描述 — erp-message 模块通知体系完整 2026-05-01 17:19:39 +08:00
iven
fc1d51e6f1 docs: 三端审计修复实施计划 Phase 1 — 8 个 Task (#1-#6) 2026-05-01 17:17:19 +08:00
iven
988b405c5d docs: 修复设计规格审查问题 — 迁移编号/通知端点/根因验证
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CRITICAL 修复:
- 迁移编号 000098/000099 → 000100/000101(避免与已有迁移冲突)
- 通知端点改为对接 GET /messages(后端无独立通知端点)

IMPORTANT 修复:
- 危急值 500 增加强制根因验证步骤(先确认 RLS 状态再决定是否补齐)
- 品牌设置增加公开端点 + localStorage 缓存策略(解决登录页未认证问题)
- #15 统计仪表盘降级为验证任务(DoctorDashboard 已消费 personalStats)
2026-05-01 17:12:41 +08:00
iven
ff073c83a5 docs: 三端联调审计问题修复设计规格 — 15 项修复方案
基于 4 专家组代码级分析整合:
- P0: erp-plugin 测试修复 + 品牌主题设置联动
- P1: 菜单入口补全 + 危急值 500 修复 + 事件堆积清理
- P2: 导航关联 + 小程序 3 项修复
- P3: AI SSE 入口 + 家属管理 + E2E 清理
- P4: 统计仪表盘消费

品牌信息改为通过主题设置动态管理(非硬编码)。
2026-05-01 17:07:50 +08:00
iven
75bf900950 feat(miniprogram): 行动收件箱 — Service + 医生端列表页 + 半屏弹窗
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- action-inbox.ts: listActionItems + getActionThread API 调用
- doctor/action-inbox: 待办列表页,Tab 筛选 + 半屏线程弹窗 + 操作按钮
- app.config.ts: 注册 action-inbox 页面到 doctor 子包
2026-05-01 16:40:32 +08:00
iven
6d66a392db feat(web): NotificationPanel 增加待办预览区域
- 底部新增"待办事项"区域,显示最近 3 条 pending 行动项
- 角标数字改为 unreadCount + pendingActionCount
- 点击待办项跳转 /health/action-inbox
2026-05-01 16:37:29 +08:00
iven
81dd3d2bda feat(web): 行动收件箱前端 — API + Drawer + 列表页 + 路由
- actionInbox.ts: API 调用层,list + getThread
- ActionThreadDrawer: 上下文线程抽屉,时间线 + 操作按钮
- ActionInbox: 列表页,Tabs 筛选 + 分页 + 点击打开 Drawer
- App.tsx: 注册 /health/action-inbox 路由
2026-05-01 16:36:24 +08:00
iven
758bc210e1 feat(health): 行动收件箱后端 — ActionInboxService + Handler + 路由注册
- ActionInboxService: 三表 JOIN 聚合查询 ai_suggestion/ai_analysis/patient
- list_action_items: 分页列表,按 risk_level + created_at 排序
- get_action_thread: 线程时间线拼装 + 动态操作按钮
- ActionInboxHandler: 2 个 GET 端点,require_permission 权限守卫
- 路由: /health/action-inbox, /health/action-inbox/{source_ref}/thread
- 权限: health.action-inbox.list, health.action-inbox.manage
2026-05-01 16:33:40 +08:00
iven
3cba699ca0 fix(web): 修复 AiAnalysisList JSX 嵌套结构 — SuggestionPanel 容器层级
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 10:48:14 +08:00
iven
8b837c0591 feat(miniprogram): AI 建议卡片 — 健康页顶部显示待审批建议摘要
- 新增 listPendingSuggestions API
- 健康页加载待审批 AI 建议(最多 3 条)
- 风险等级圆点 + 建议摘要文字
- 点击卡片可跳转
2026-05-01 09:22:18 +08:00
iven
598c06885f feat(web): 患者 AI 建议标签页 — 待审批建议列表+审批操作
- 新增 AiSuggestionTab 组件(风险等级+类型+状态+审批按钮)
- PatientDetail 添加「AI 建议」标签页
- 复用 suggestions API 层
2026-05-01 09:19:50 +08:00
iven
92c1c3c17d feat(web): AI 分析详情增加建议面板 — 风险等级+建议列表+审批操作
- 新增 suggestions API 层(list/approve/getComparison)
- 展开分析详情时自动加载关联的 AI 建议列表
- 风险等级彩色标签(低/中/高)
- 建议类型、原因、执行状态展示
- 待审批建议支持批准/拒绝操作
2026-05-01 09:17:18 +08:00
iven
5d2402a1e7 feat(ai+health): 闭环核心 — 随访完成→再分析触发 + 前后对比报告
- follow_up.completed 消费者:通过 action_result 反查 AI 建议,触发再分析
- ai.reanalysis.requested 消费者:加载原始建议 baseline
- comparison.rs:对比报告生成引擎(指标变化百分比+趋势判断)
- GET /ai/suggestions/{id}/comparison:前后对比报告 API
- find_by_followup_task:通过随访任务反查关联建议ID
2026-05-01 09:14:13 +08:00
iven
0a4825be99 feat(health+workflow): 行动分发→工作流启动集成 — 事件驱动 BPMN 实例化
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- create_pending_action 新增 workflow.ai_action.start_requested 事件发布
- 根据 action_type 映射到对应 BPMN 流程定义 key
- erp-workflow 消费启动请求,自动创建审批流程实例
- 流程变量包含 risk_level/patient_id/action_type/params
2026-05-01 08:53:57 +08:00
iven
388948e348 feat(workflow): AI 行动闭环 BPMN 流程定义 — 随访/预约/预警三条审批流程
- ai_followup_workflow: 随访建议风险分级 + 医生审批
- ai_appointment_workflow: 预约建议风险分级 + 医生确认
- ai_alert_workflow: 预警确认风险分级 + 医生确认
- 启动时自动 seed 三条 published 状态的流程定义
2026-05-01 08:49:49 +08:00
iven
5053908444 feat(health): AI 行动分发事件消费者 — 订阅 ai.analysis.completed
- 新增 ai_suggestion_loader:跨 crate 通过 raw SQL 读取 ai_suggestion 表
- 事件消费者 ai_action_dispatcher 订阅 ai. 事件
- 根据 suggestion_count > 0 触发行动分发路由
- 低风险自动执行,中/高风险进入医生审核队列
2026-05-01 08:41:14 +08:00
iven
69f9e1a61a feat(health): AI 行动分发器 — 风险分级路由到自动执行/医生审批/紧急确认
- dispatch_decision: 根据风险等级生成执行决策(low=自动, medium=24h审批, high=4h紧急)
- handle_ai_suggestions: 遍历建议列表,按决策分发
- execute_action: 低风险自动发送预警/随访事件
- create_pending_action: 中高风险发送待审批事件
- 4 个单元测试覆盖:低/中/高/未知风险等级路由
2026-05-01 08:34:04 +08:00
iven
4b3193fcd6 feat(server): 集成 SuggestionService 到 AiState 初始化
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 08:14:41 +08:00
iven
415d7617c8 feat(ai): 建议查询/审批 API 端点 + 权限注册
- GET /ai/suggestions?analysis_id=xxx — 查看建议列表(ai.suggestion.list)
- POST /ai/suggestions/{id}/approve — 批准/拒绝建议(ai.suggestion.manage)
- 新增 ai.suggestion.list 和 ai.suggestion.manage 权限码
2026-05-01 08:12:29 +08:00
iven
6e761ae22b feat(ai): 集成双通道输出解析到 SSE handler — 自动创建建议记录
在 build_sse_stream 完成回调中:
- 调用 parse_dual_channel 解析 AI 输出
- 有结构化建议时调用 SuggestionService::create_suggestions 创建记录
- 解析失败时调用 mark_parse_failed 记录日志
- 扩展 ai.analysis.completed 事件 payload 含 risk_level + suggestion_count
2026-05-01 08:11:23 +08:00
iven
b30897119b feat(ai): SuggestionService — 建议记录 CRUD + 状态流转
- create_suggestions: 批量创建建议记录,关联分析 ID 和 baseline 快照
- list_by_analysis: 按 analysis_id 查询建议列表(带 tenant_id 过滤 + 软删除)
- list_pending: 查询待审批建议
- update_status: 更新状态(带乐观锁 + tenant_id 过滤)
- mark_parse_failed: 解析失败时记录日志
- AiState 新增 suggestion 字段
2026-05-01 08:09:59 +08:00
iven
3b6f72d5c0 feat(ai): 本地临床规则引擎 — AI 不可用时的回退方案
- LocalRulesEngine: 预定义 8 条临床规则(收缩压/心率/血糖/血氧)
- CompareOp: GreaterThan/LessThan 比较运算
- evaluate(): 输入指标 JSON,输出 StructuredSuggestion 列表(按优先级排序)
- 5 个单元测试覆盖:高值触发、正常无建议、缺失指标跳过、SpO2 低、优先级排序
2026-05-01 08:08:48 +08:00
iven
92e6cf0c43 feat(ai): 双通道输出解析器 — 文本/JSON 分割 + 降级策略
- parse_dual_channel: 分割 ===PATIENT_TEXT=== / ===STRUCTURED_JSON=== 标记
- JSON 解析失败时降级为纯文本,structured 为 None
- 5 个单元测试覆盖:正常解析、纯文本、无效 JSON、空建议、风险等级
2026-05-01 08:07:26 +08:00
iven
9b8307fbba feat(ai): 添加 ai_suggestion 和 ai_risk_threshold SeaORM Entity 2026-05-01 08:05:42 +08:00
iven
577d2a32b1 feat(db): 添加 ai_suggestion 和 ai_risk_threshold 表迁移
- ai_suggestion: AI 建议记录表,含 tenant_id、analysis_id、suggestion_type、
  risk_level、status、params、baseline_snapshot 等字段
- ai_risk_threshold: 租户级风险阈值配置表,按 metric_name + tenant_id 唯一索引
- 两表均包含标准审计字段和 version_lock 乐观锁
2026-05-01 08:04:51 +08:00
iven
7789a5e227 feat(ai): 新增 Suggestion/RiskLevel/SuggestionStatus 枚举和结构化输出 DTO
重构 dto.rs 为 dto/ 目录模块,新增 suggestion.rs 包含:
- SuggestionType (Followup/Appointment/Alert)
- RiskLevel (Low/Medium/High) + is_auto_executable
- SuggestionStatus (6 种状态)
- StructuredSuggestion / StructuredOutput / ParsedOutput DTO
- 7 个单元测试覆盖序列化往返
2026-05-01 08:02:53 +08:00
iven
2fb0535164 docs(ai): AI→行动闭环实施计划完成 — 25 Task / 3 Chunk
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Chunk 1: 数据层+输出解析(Task 1-11)
Chunk 2: 事件集成+BPMN+行动分发(Task 12-19)
Chunk 3: 闭环对比+前端展示(Task 20-25)
2026-05-01 07:58:44 +08:00
iven
6046ed23c9 docs(ai): AI→行动闭环实施计划 Chunk 1 — 数据层+输出解析
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
11 个 Task:DTO 枚举/迁移/Entity/解析器/规则引擎/Service/Handler集成/API端点
已通过 plan review,修复了 dto/ 模块拆分、version_lock 命名、乐观锁、tenant_id 过滤
2026-05-01 07:06:45 +08:00
iven
31e623a947 docs(ai): AI→行动闭环设计规格
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
发散式讨论产出的设计文档,定义了 AI 分析结果如何自动转化为
可执行行动(随访计划/智能预约/风险预警),通过 BPMN 工作流
引擎编排分级自动化,形成数据→分析→行动→评估的完整闭环。
2026-05-01 01:19:28 +08:00
iven
3b38562533 test(ai): 添加 erp-ai 集成测试 — 14 个测试覆盖 3 个 service
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- PromptService: 创建/查询/列表筛选/激活版本切换/回滚/跨租户隔离/未找到错误 (7)
- UsageService: 日志记录/概览/按类型聚合/跨租户隔离 (4)
- AnalysisService: 完成分析/失败分析/缓存查找/列表筛选 (3)
- 使用 MockProvider 替代真实 AI 调用
2026-05-01 00:57:16 +08:00
iven
9b8c2ff7e1 fix(health): 预约 CAS 从精确匹配改为排班时段范围匹配
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
预约创建时 CAS 原子操作要求排班 start_time 精确等于预约 start_time,
导致排班 08:00-12:00 只能在 08:00 开始预约,无法选择 09:00 等子时段。

修改为范围匹配:排班 start_time <= 预约 start_time 且
排班 end_time >= 预约 end_time,预约可落在排班时段内任意子时段。

增加 rows_affected > 1 保护:若排班数据存在重叠时段则拒绝并告警。
2026-05-01 00:37:11 +08:00
iven
63d8b7a65d fix(miniprogram): 对齐设计原型 — 移除渐变头部+体征数值内联+卡片布局
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 首页:移除渐变头部改为平铺背景,铃铛图标替代消息按钮
- 首页:体征数值与单位内联显示(同一行 baseline 对齐)
- 健康页:标题改为"健康数据",整体样式贴近原型紧凑风格
- 我的页:移除渐变头部改为平铺卡片,积分/打卡分两个独立卡片
- 我的页:菜单使用 emoji 图标替代文字图标,间距更紧凑
2026-04-30 23:04:36 +08:00
iven
50772878da feat(miniprogram): 老年友好版本全面重设计 — 5→4 Tab + 首页/健康/消息/我的重写
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- TabBar 从 5 Tab 调整为 4 Tab(首页/健康/消息/我的)
- 首页重写为 5 区域布局:问候+进度环+体征2x2+待办+快捷操作
- 健康页重写:体征录入大输入框+趋势柱状图+BLE设备卡片
- 新建消息页:咨询对话+系统通知双 Tab
- 我的页调整:菜单高度64px+新增积分商城入口
- 设计系统更新:色彩对比度提升(WCAG AA)+触控参数+老年友好 mixin
- 新增 ProgressRing 组件(CSS conic-gradient 实现)
- 修复 diagnoses 页面 $suc-l 未定义变量
2026-04-30 22:51:05 +08:00
iven
813843e8cc feat(miniprogram): 添加健康记录和诊断记录查看页面
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新建 service: health-record.ts(listHealthRecords + listDiagnoses)
- 新建页面: health-records/index(体检记录列表,分页+下拉刷新)
- 新建页面: diagnoses/index(诊断记录列表,类型/状态标签)
- 路由注册到 pkg-profile 分包
- "我的"页菜单添加健康记录、诊断记录入口
2026-04-30 22:49:44 +08:00
iven
f05ca00c75 feat(auth+config+workflow+message+plugin): 为 5 个基础模块添加 permissions() 声明
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- erp-auth: 23 个权限码(用户/角色/权限/组织/部门/岗位)
- erp-config: 18 个权限码(字典/菜单/配置/编号/主题/语言)
- erp-workflow: 8 个权限码(流程定义/实例/任务)
- erp-message: 5 个权限码(消息/模板),补充缺失的 message.template.manage
- erp-plugin: 2 个权限码(插件管理/查看)
- 同步更新 seed.rs 的 READ_PERM_INDICES 索引和权限计数

使得 sync_module_permissions() 可以动态注册这些权限,与 erp-health/erp-dialysis/erp-ai 模式一致。
2026-04-30 22:41:26 +08:00
iven
8f9895be98 fix(web): SSE 连接添加指数退避重连策略
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
useAlertSSE hook 和 message store 的 connectSSE 均改为手动重连:
1s→2s→4s→8s→16s→30s(cap),最大重试 10 次,随机 jitter 0.5-1.0x。
替代浏览器原生 EventSource 固定 ~3s 重连,避免服务端压力。
2026-04-30 22:30:47 +08:00
iven
0dcaf7915f fix(health): 补充 3 个核心 service 的 tracing 日志 — 38 处
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
审计后续 H3: patient_service(15) + consultation_service(10) + follow_up_service(13)
共计 2526 行代码此前 0 处运维级日志,现已在所有 pub async fn 入口添加
tracing::info! 日志,格式统一为 action + key params。
2026-04-30 16:58:04 +08:00
iven
44bb31197e feat(miniprogram): 实现知情同意页面 — 查看/撤回/签署
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
审计后续 H2: 对接后端 3 个知情同意 API 路由。

新增内容:
- services/consent.ts: 类型定义 + listConsents/grantConsent/revokeConsent
- 患者端知情同意列表页: 查看已签署同意书 + 撤回操作
- 路由注册 + "我的"菜单入口
2026-04-30 16:52:39 +08:00
iven
36a55e116e feat(miniprogram): 实现小程序透析模块 — 患者端查看 + 医护端录入/审阅
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
审计后续 H1: 补齐小程序端透析功能,对接后端 12 个 API 路由。

新增内容:
- 患者端: 透析记录列表/详情 + 透析处方列表/详情(只读,4 页面)
- 医护端: 透析记录列表/详情/创建 + 处方列表/详情/创建(6 页面)
- Service 层: dialysis.ts(患者端只读)+ doctor/dialysis.ts(医护端 CRUD)
- 集成入口: 医生工作台快捷操作 + 患者"我的"菜单 + 路由注册
- 基础设施: api.delete 扩展支持 data 参数(后端 delete 需要 version)
2026-04-30 16:48:39 +08:00
iven
84fafb0bc5 fix(web+health): 修复咨询轮询 temp ID 400 + 健康数据统计 500
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- ConsultationDetail: 轮询取 lastId 时过滤 temp_ 前缀的乐观消息 ID,
  避免将非法 UUID 传给 after_id 参数导致后端 400
- stats_service: count_abnormal_lab_items 和 compute_daily_report_rate
  中 SQL 字面量 0 类型为 INT4,与 Rust i64 (INT8) 不匹配,
  改为 0::bigint 确保类型兼容
2026-04-30 12:27:56 +08:00
iven
1bebb57765 fix(web): 移除 ConsultationDetail 残留的 sender_id/sender_role 字段
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
前端发送消息时不再提交 sender_id 和 sender_role,
这些字段由后端从 JWT 上下文自动填充。
2026-04-30 11:34:20 +08:00
iven
a96b065190 test(config): 补全字典+编号服务单元测试 — 51 新增
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- dictionary_service: 提取 dict_model_to_resp/item_model_to_resp + 7 个映射测试
- numbering_service: 提取 format_number 纯函数 + 5 个格式化测试
- erp-config 测试总数从 27 增至 78
2026-04-30 11:02:36 +08:00
iven
b00fe44880 feat(health): 添加文章修订历史查询 API — GET /health/articles/{id}/revisions
补全 ArticleRevision 实体的读取查询(之前仅有写入 save_revision),
新增 list_revisions service + handler + 路由,支持分页。
2026-04-30 10:53:04 +08:00
iven
32eef5ecf1 feat(db+test): 菜单权限关联迁移 + 适配 create_message 签名变更
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增迁移 m097:为 17 个已有菜单设置 permission 字段,新增透析管理/资讯管理 2 个菜单
- 修复 consultation/pii_encryption 测试适配 create_message(sender_id, sender_role) 分离参数
2026-04-30 10:37:43 +08:00
iven
13f553590b feat(health+dialysis): 补全 8 组权限码 + 修复 N+1 查询 + 防御性编码
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
权限补全:
- 新增 14 个权限声明(危急值告警/阈值/随访模板/日常监测/知情同意/用药记录/药物提醒)
- 更新 8 个 handler 使用正确的专属权限码
- erp-dialysis 新增 health.dialysis.stats 权限

性能优化:
- article_service list_articles 标签加载从 N+1 改为批量查询
- follow_up_template_service 字段计数从 N+1 改为批量 GROUP BY

防御性编码:
- alert_engine/article/critical_alert 的 unwrap() 替换为 unwrap_or/expect
2026-04-30 10:22:14 +08:00
iven
931edc3025 fix(security): 补全 XSS sanitize + 修复 sender_id 身份伪造
安全审计修复:
- 补全 6 个 DTO 的 sanitize 方法(diagnosis/consent/alert/medication_record/medication_reminder/follow_up_template)
- 4 个 handler 添加 .sanitize() 调用(diagnosis/consent/alert_rule/medication_record)
- 修复咨询消息 sender_id/sender_role 从客户端提交改为服务端从 JWT 提取
- 修复小程序 AI 报告 markdownToHtml XSS(添加 sanitizeHtml 过滤)
2026-04-30 10:21:52 +08:00
iven
d8735eb45c fix(test+web): 修复测试编译错误 + 前端构建问题
- 修复透析集成测试 TestApp.dialysis_state() 返回类型不匹配(39个错误)
- 修复 erp-core test_helpers SeaORM Database::connect API 变更
- 修复 health_alert/article/data 集成测试函数签名不匹配
- 修复 DailyMonitoringTab 缺失 Input import
- 修复 DeviceReadingsTab 未使用接口声明
- 修复 DialysisManageList keyword → search 参数名
2026-04-30 10:21:05 +08:00
iven
82cea6a108 docs(audit): 系统性功能审计报告 — 9 项修复 + 23 项遗留记录
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-30 08:41:22 +08:00
iven
22e35ad233 docs(event): 创建事件注册表文档 — 28 个事件类型全量记录
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-30 08:35:32 +08:00
iven
d2dfac82e3 refactor(web): 移除 4 个未使用的 API 函数 — exportSessions/generateTrend/assignDoctor/removeDoctor
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-30 08:34:35 +08:00
iven
c0e3d26b71 refactor(health): 更新 message.sent 消费者注释 — last_message_at 已在 CAS 中处理
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-30 08:32:29 +08:00
iven
1925568c13 feat(message+health): 补全 14 个事件消费者 + 修复 6 个事件 payload 缺失字段
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
事件消费者补全(erp-message/module.rs):
- consultation.opened: 医生收到新咨询会话通知
- consultation.closed: 患者收到会话结束通知
- follow_up.created: 被分配人收到新随访任务通知
- follow_up.completed: 患者收到随访完成通知
- points.earned: 患者收到积分到账通知
- points.exchanged: 患者收到兑换成功通知
- points.expired: 患者收到积分过期提醒
- article.published/rejected: 作者收到审核结果通知
- ai.analysis.failed: 医生收到 AI 分析失败通知
- lab_report.uploaded/patient.updated/daily_monitoring/doctor: 审计日志记录

事件 payload 补充(erp-health services):
- consultation.opened: 添加 doctor_id 字段
- follow_up.created: 添加 assigned_to + planned_date 字段
- points.earned: 添加 patient_id + reason 字段
- points.exchanged: 添加 product_name 字段
- article.rejected: 添加 author_id 字段
2026-04-30 08:31:12 +08:00
iven
cec487bd2c chore(points): 移除已废弃的 erp-points crate + 注释空桩和死常量
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 删除 erp-points/ 目录(全部 501 handler,功能由 erp-health 完整提供)
- 从 workspace Cargo.toml 和 erp-server 依赖中移除
- erp-dialysis event.rs: 说明事件由 erp-health 统一消费的设计意图
- erp-health event.rs: 标记 PATIENT_VERIFIED/PATIENT_DECEASED 为待实现
2026-04-30 08:24:20 +08:00
iven
ef0b784f4f fix(health): 修复两条断裂事件链 — consultation.new_message 和 lab_report.reviewed
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
咨询消息发送和化验单审核完成后未发布 DomainEvent,导致下游通知消费者
(医生收到新消息通知、患者收到审核完成通知)完全不可用。

- consultation_service: create_message() 提交后发布 consultation.new_message 事件
- health_data_service: review_lab_report() 审核后发布 lab_report.reviewed 事件
- event.rs: 添加 CONSULTATION_NEW_MESSAGE 和 LAB_REPORT_REVIEWED 常量
2026-04-30 08:21:00 +08:00
iven
43769dae5a feat(mp): 患者端健康告警页面 + 首页入口
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P1-8: 小程序患者告警推送
  - 新增 alert service:listPatientAlerts 按患者 ID 查询告警
  - 新增 pkg-health/alerts 告警列表页:严重程度标签 + 状态过滤 + 分页
  - 首页快捷服务新增"健康告警"入口
  - app.config.ts 注册 alerts/index 页面路由
2026-04-30 07:23:05 +08:00
iven
26a9781d4f feat(health): 药物提醒后端 API + 后台任务统一 + dead code 清理
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P1-3: medication_reminder 全栈实现
  - migration 000096: 创建 medication_reminder 表(含患者关联/提醒时间/频率)
  - entity + dto + service + handler: 完整 CRUD(乐观锁/软删除/审计日志)
  - 路由注册: GET /patients/{id}/medication-reminders, POST/PUT/DELETE
  - HealthError 新增 MedicationReminderNotFound

P2-4: 后台任务启动统一
  - appointment_reminder 迁移到 HealthModule::on_startup()(启动时立即执行 + 周期循环)
  - 删除 main.rs 中重复的 overdue_checker/points_expiration/appointment_reminder 调用
  - 所有 Health 后台任务现由模块 on_startup 统一管理

P2-5: Web dead code 清理
  - 删除 healthData.ts 中 getMiniTrend/getMiniToday(小程序专用端点,Web 无调用)
  - 删除 patients.ts 中 getHealthSummary(标记 TODO 未使用)
2026-04-30 07:18:22 +08:00
iven
30344d474f fix(health+ai+dialysis): 审计 P1 批次修复 — EventBus接入/盲索引去重/事件消费者补全
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P1-2: erp-ai EventBus 接入
  - handler 层 SSE 流完成/失败时发布 ai.analysis.completed/failed 事件
  - build_sse_stream 新增 tenant_id 参数

P1-2: erp-dialysis EventBus 接入
  - create_dialysis_record 审计后发布 dialysis.record.created 事件

P1-5: message.sent 消费者改进
  - 从占位 tracing::info 升级为带 payload 详情的结构化日志

P1-7: 盲索引去重
  - create_patient 中新增 id_number HMAC 去重检查(查 blind_indexes 表)
  - 患者创建成功后写入 blind_indexes 表(id_number + phone)
  - 防止同租户重复建档

P1-1: 事件消费者补全
  - 新增 ai.analysis.completed 消费者(幂等处理 + 日志)
  - 新增 dialysis.record.created 消费者(幂等处理 + 日志)
2026-04-29 17:00:24 +08:00
iven
dffa2dd47d fix(health+server+mp): 审计 P0 批次修复 — 积分冲突/文章草稿泄露/商城空白/模板ID配置化
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P0-1: 微信模板 ID 从硬编码空字符串改为环境变量注入
  - wechat-templates.ts 读取 process.env.TARO_APP_WX_TEMPLATE_*
  - defineConstants 新增 5 个模板 ID 编译时注入

P0-2: 积分商城 Tab 空白降级
  - mall/index.tsx 在 currentPatient 为 null 时先调用 loadPatients()
  - 仍无档案才显示空状态引导,而非直接阻断

P0-3: 消除 erp-points 重复路由冲突
  - 从 erp-server 移除 erp-points 模块注册和路由 merge
  - 积分功能统一由 erp-health /health/points/* 提供
  - erp-points crate 保留但不参与编译

P0-4: 文章列表按角色过滤防止草稿泄露
  - list_articles handler: 非管理权限强制 status=published
  - get_article service: 新增 is_admin 参数控制状态过滤
2026-04-29 15:11:05 +08:00
iven
facc8b0d24 refactor(dialysis+health): 透析统计从 erp-health 迁移到 erp-dialysis,消除跨 crate 残留
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- erp-dialysis: 新建 dialysis_stats_dto/handler/service,注册 /health/admin/statistics/dialysis 路由
- erp-health: 删除 get_dialysis_statistics 及 helper、DialysisStatisticsResp、
  DialysisRecordNotFound/DialysisPrescriptionNotFound、validate_dialysis_status* 及 9 个测试、
  DoctorDashboard.pending_dialysis_review、module 路由
- Web: HealthDataStats 移除 dialysis 字段,新增 getDialysisStats() 独立 API,
  useStatsData 并行 fetch,HealthDataCenter 接受独立 dialysisData prop
- 小程序: DoctorDashboard 移除 pending_dialysis_review,医护工作台移除"待审透析"卡片
2026-04-29 07:56:21 +08:00
iven
cb6f5cc651 feat(mp+health): 小程序分包迁移 + 积分商城后台列表 API
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 小程序页面迁移到 pkg-health/pkg-mall/pkg-profile 分包目录
- 删除旧 pages/health/input、pages/mall/detail 等旧路径
- 导航路径更新为分包路径(/pages/pkg-mall/exchange/index 等)
- TrendChart 组件优化
- 后台添加 admin_list_products API(支持查看已下架商品)
- config/index.ts 添加 defineConstants 环境变量
- mp e2e check-readiness 路径修正
2026-04-29 07:29:49 +08:00
iven
9015a2b85e feat(web): 登录页主题适配 + 工作台角色化重构
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 登录页接入 4 套主题系统(渐变色/面板背景/文字色),添加 ThemeSwitcher
- 工作台按角色(医生/护士/管理员/运营)显示专属统计卡片和快捷入口
- 移除系统信息填充卡片,硬编码颜色替换为 CSS 变量
2026-04-29 07:27:04 +08:00
iven
202c6dd0d2 feat(miniprogram): 小程序设备数据集成打通 — Phase 3
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 首页设备入口简化为直接跳转按钮(去除硬编码 never 状态)
- 体征录入页增加「从设备同步」入口,设备数据自动回填表单
- 设备同步页支持 returnTo 参数,完成后返回录入页
- 医护工作台增加告警中心固定导航入口(带数字角标)
2026-04-29 06:36:12 +08:00
iven
cac61637ce feat(health): Web 管理端设备数据集成补全 — Phase 2
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增告警三页面(仪表盘/列表/规则)+ 设备管理菜单种子数据
- 新增设备管理后端 API(GET /devices + DELETE /devices/{id})
- 新增设备数据查看组件 DeviceReadingsTab(原始数据 + 小时聚合)
- 新增设备管理页面 DeviceManage(列表/筛选/解绑)
- 患者详情页新增设备数据 Tab
2026-04-29 06:28:30 +08:00
iven
f6ccb8a35c fix(health): 设备数据管线 Phase 1 缺陷修复 + AI 产品策略讨论
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- device_readings 批量插入添加 ON CONFLICT 去重唯一索引
- 小程序 BLEManager 增加离线缓存(Storage 持久化 + 启动重传)
- 新增 device_readings 90 天数据保留清理定时任务
- 小米手环适配器增加 RACP 历史心率读取支持
- SSE 告警按医生过滤已确认实现(patient_doctor_relation)
- 新增 AI 产品策略与设备数据医院场景讨论记录
2026-04-29 06:17:23 +08:00
iven
a491eb19a6 fix(web+health): E2E flow 测试全面修复 — 15/15 通过
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- test-data: 接口对齐后端 DTO(VitalSigns/AlertRule/Schedule/FollowUp)
- api-client: 增强 HTTP 错误处理(parseJson 统一防护非 JSON 响应)
- auth.fixture: 每个测试获取新 token,避免共享 token 过期
- patient-detail: tab 名称修正为 '健康数据' → '体征数据'
- patient-list: DrawerForm 选择器适配(无 phone 字段、保存按钮在 extra)
- vital-signs-flow: API 录入 + 页面验证,避免复杂 DatePicker 交互
- alert-flow: 简化为规则 CRUD + 页面导航,condition_params 对齐后端格式
- follow-up-template handler: 权限码从 health.follow-up-template.* 修正为 health.follow-up.*
- playwright.config: workers=1 串行执行避免并发登录
- check-readiness: 健康端点路径修正为 /api/v1/health
2026-04-29 06:04:22 +08:00
iven
c6e8048bc5 test(web+mp): E2E 测试全量实施 — Web 5 flow + MP 4 flow + 基础设施
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Web 端 (Playwright):
- fixtures: test-data 工厂 + API Client (乐观锁 version) + 增强 auth fixture
- pages: LoginPage, PatientListPage, PatientDetailPage, HealthDataPage, AppointmentPage
- flows: 患者全流程, 体征数据链路, 预约排班链路, 随访管理链路, 告警处理链路
- smoke tests 迁移到 smoke/ 目录,import 路径更新
- playwright.config.ts 更新: globalSetup 环境检查, 60s timeout, video retain

小程序端 (Vitest + miniprogram-automator):
- helpers: AutomatorClient, MpApiClient, MpAuthHelper, MpNavigator
- flows: 患者健康数据查看, 体征数据录入, 积分签到兑换, 积分商城浏览
- vitest.config.ts + check-readiness.ts
- vitest 4.1.5 依赖安装

Playwright 发现 15 个测试 (5 flow + 10 smoke),全部就绪
2026-04-29 04:58:01 +08:00
iven
2f4be6dcd0 docs(e2e): 添加 E2E 测试实施计划
5 个 Chunk, 21 个 Task:
- Chunk 1: 基础设施(test-data + api-client + auth fixture + config)
- Chunk 2: Web Page Objects(5 个关键页面)
- Chunk 3: Web 业务链路(5 条 flow spec)
- Chunk 4: 小程序基础设施(automator + helpers + vitest config)
- Chunk 5: 小程序业务链路(4 条 flow spec)
2026-04-28 22:39:24 +08:00
iven
1bde4b44c0 fix(web): VitalSignsChart hooks 顺序修复 + 趋势线颜色区分度优化
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
修复 React hooks 在 early return 之后调用导致的渲染崩溃,
将所有 useMemo 移至条件返回之前。趋势图三系列改用高对比色:
实际值(原色实线)、移动平均(青色短虚线)、趋势线(琥珀色长虚线)。
2026-04-28 22:10:13 +08:00
iven
4eb874f52d docs(e2e): 添加 E2E 测试设计规格文档
流程链路式双端 E2E 测试体系设计:
- Web 端 5 条业务链路(Playwright + Page Object)
- 小程序端 4 条业务链路(Vitest + miniprogram-automator)
- API 驱动自建自毁数据策略,乐观锁 version 支持
- CI-ready 环境变量驱动设计
2026-04-28 21:57:19 +08:00
iven
5ab8bf8479 feat(server): 可观测性 Phase 1 — 健康检查路由 + Prometheus 指标 + 连接池/事件积压监控
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 添加 /health/live 存活探针别名(原 /health + /health/ready 保留)
- 新增 metrics middleware:http_requests_total 计数器 + http_request_duration_seconds 直方图
- Prometheus exporter 独立端口 9090(可通过 ERP__SERVER__METRICS_PORT 覆盖)
- 后台任务每 30s 采样 DB 连接池活跃/空闲连接数(pg_stat_activity)
- 后台任务每 30s 采样 EventBus pending 事件积压数
- UUID 路径归一化避免高基数(/api/v1/users/:id/posts)
2026-04-28 20:39:11 +08:00
iven
f99892ee16 feat(web+mp): AI 分析结果增强展示
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Web 端 AiAnalysisList:
- 分析结果 Markdown 风格渲染(标题/列表/粗体/代码)
- 趋势分析类型显示统计方法提示
- 自动分析结果显示「系统自动分析」标签

小程序 ai-report/detail:
- 新增 result_metadata 字段
- 自动分析标记(紫色标签)
- 趋势分析统计方法说明卡片
2026-04-28 20:12:34 +08:00
iven
10c79c5e39 feat(mp): 医护端告警列表/详情页 + DoctorHome 告警 banner 增强
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增告警列表页:按状态筛选、分页、严重程度/状态标签
- 新增告警详情页:完整信息展示 + 确认/忽略/恢复操作
- doctor.ts 新增 listAlerts/acknowledgeAlert/dismissAlert/resolveAlert API
- DoctorHome 告警 banner 跳转目标改为告警列表页
- 注册 alerts/index + alerts/detail/index 到 doctor subPackage
2026-04-28 20:05:55 +08:00
iven
1cf5f59d8c feat(web): VitalSignsChart 集成趋势线 + 移动平均 + 异常标注
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
增强 VitalSignsChart 组件:
- 线性回归趋势线(虚线显示,斜率/R² 统计)
- 移动平均线(自适应窗口,平滑实线)
- 异常点检测(2倍标准差,红色标记)
- 概览卡片显示趋势方向箭头和异常警告图标
- 详情图下方图例说明各系列含义
2026-04-28 20:05:43 +08:00
iven
a84378ab50 feat(ai): 定期自动分析定时任务 — 每 24 小时扫描高风险患者
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增 auto_analysis.rs 服务:
- 启动后延迟 5 分钟,每 24 小时执行一次
- 查找所有活跃租户中高风险患者(异常体征指标)
- 自动调用趋势分析并存储分析结果
- 每租户限制 50 名患者,防止过载
- erp-server main.rs 中注册后台任务
2026-04-28 20:02:01 +08:00
iven
493b479373 feat(web): DoctorDashboard 集成告警摘要卡片
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
在医生工作台新增待处理告警摘要区域,展示最近 5 条
pending 状态告警,点击「查看全部」跳转告警仪表盘。
2026-04-28 20:01:11 +08:00
iven
27c32e5561 feat(web): 实时告警仪表盘页面 + SSE Hook + 告警详情面板
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 AlertDashboard 页面:实时告警列表 + 统计摘要 + 详情面板
- 新增 useAlertSSE Hook:封装 SSE 连接、自动重连、事件分发
- 新增 AlertDetailPanel 组件:告警详情展示 + 确认/忽略/恢复操作
- alertApi.list 添加 doctor_id 参数支持
- 注册 /health/alert-dashboard 路由 + 面包屑映射
2026-04-28 19:59:51 +08:00
iven
cf844a561f feat(ai+db): 趋势分析 prompt 升级为结构化统计摘要
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增迁移 000093:更新 health_trend_analysis prompt,使用统计字段
  替代原始数据点遍历,引导 AI 专注 slope/R²/异常点分析
- erp-ai handler: stream_trends 改用 get_trend_analysis_data()
  替代 get_vital_signs(),传递预计算趋势特征
- sanitizer: 新增 sanitize_trend_analysis() 方法
2026-04-28 19:57:51 +08:00
iven
1c9e7ccf1d feat(core+health): HealthDataProvider 扩展趋势分析预计算数据
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- erp-core: 新增 get_trend_analysis_data() trait 方法和配套 DTO
  (TrendAnalysisDto, MetricTrendAnalysis, RegressionStats, AnomalyInfo)
- erp-health: 实现 get_trend_analysis_data(),查询 vital_signs 时间序列
  后调用 trend_stats 模块计算线性回归和异常检测,返回结构化统计摘要
2026-04-28 19:55:06 +08:00
iven
8aac96b62f feat(health): 告警列表 API 添加 doctor_id 过滤参数
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
alert_handler 的 AlertListQuery 新增 doctor_id 参数。
alert_service::list_alerts 先查询 patient_doctor_relation
获取该医生负责的患者列表,再用 patient_id.is_in() 过滤。
医生无管床患者时直接返回空结果。新增 2 个单元测试。
2026-04-28 19:54:12 +08:00
iven
4745b1e824 feat(health): 统计计算模块 — 线性回归、移动平均、异常检测
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增 trend_stats.rs 纯函数模块,提供三个统计计算能力:
- compute_linear_regression: 最小二乘法线性回归,返回 slope/intercept/R^2/方向/日变化/周期变化
- compute_moving_average: 简单移动平均,支持任意窗口大小
- detect_anomalies: 均值 +/- N 标准差异常检测

包含 21 个单元测试,覆盖边界条件和正常用例。
2026-04-28 19:50:46 +08:00
iven
781e1191a5 feat(message): SSE 告警/体征推送添加医患关系过滤
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
alert.triggered 和 device.readings.synced 事件现在只推送给
该患者的管床医生(通过 patient_doctor_relation 表查询),
而非广播给租户内所有用户。新增 3 个单元测试验证 payload
解析逻辑。
2026-04-28 19:49:38 +08:00
iven
e5546efa41 refactor(web): alerts + deviceReadings API 迁移为对象风格导出
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- alerts.ts: listAlerts → alertApi.list, acknowledgeAlert → alertApi.acknowledge 等
- deviceReadings.ts: batchCreateReadings → deviceReadingApi.batchCreate 等
- AlertList/AlertRuleList 引用处同步更新
- 其余 19 个函数式 API 文件记为待迁移(旧文件不强制迁移)
2026-04-28 19:47:48 +08:00
iven
99093d8143 refactor(web): 16 个列表页 columns 定义 useMemo 化 — 减少 Table 不必要 re-render
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- AiPromptList/AiAnalysisList/AppointmentList 等 14 个主页面
- HealthRecordsTab/LabReportsTab 2 个 Tab 组件
- 每个 columns 依赖数组包含其引用的闭包变量(handleDelete/navigate 等)
2026-04-28 19:45:14 +08:00
iven
e76f4feb4f feat(health): 告警微信模板消息通知 + alert.triggered 事件消费者
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 19:43:57 +08:00
iven
601b2d7f52 feat(mp): 首页设备状态卡片组件 — 血压计/血糖仪快捷入口 2026-04-28 19:42:24 +08:00
iven
00f615d8e5 feat(health): 新增血压/血糖临床阈值告警规则 + alert engine 直接查 device_readings 2026-04-28 19:40:25 +08:00
iven
8a61ae3f8e feat(health): device_readings 双写 vital_signs — 血压/血糖自动归档 2026-04-28 19:37:43 +08:00
iven
d715647a73 feat(mp): BloodPressureAdapter + GlucoseMeterAdapter — BLE 0x1810/0x1808 标准协议适配器 2026-04-28 19:30:03 +08:00
iven
e7b2e6382a chore(web): 降低 chunkSizeWarningLimit 从 600 至 500
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 19:28:37 +08:00
iven
8a5b14e087 feat(mp): DeviceType 扩展支持 blood_pressure/blood_glucose + 适配器接口改数组返回 2026-04-28 19:27:14 +08:00
iven
83e243f03e feat(db): device_readings 新增 metric 字段用于多行拆分存储 2026-04-28 19:24:32 +08:00
iven
679d83d3b6 refactor(web): 迁移 3 个健康页面错误处理到 useApiRequest — 消除内联 catch/message.error
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- PatientList: handleCreateOrEdit/handleDelete/openEditModal 使用 execute
- AppointmentList: handleStatusChange(2处)/handleSubmit 使用 execute
- FollowUpTaskList: handleCreate/handleRecordSubmit/handleAssign/handleDelete 使用 execute
- 移除不再需要的 message 导入(PatientList/FollowUpTaskList)
2026-04-28 19:24:07 +08:00
iven
40a71e5a1c feat(health): 扩展 device_type 枚举支持 blood_pressure 和 blood_glucose 2026-04-28 19:21:21 +08:00
iven
0aab27295c feat(ai): 实现 AI 数据桥接 — 4 个 HealthDataProvider 方法从 stub 替换为真实查询
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- get_lab_report: 查询 lab_report + patient,解析 JSON items 构造 LabReportDto
- get_vital_signs: 查询 vital_signs 时间序列,按指标提取 8 种体征数据
- get_patient_summary: 聚合 patient + diagnosis + medication_record + health_record
- get_full_report: 查询 health_record + 关联诊断和化验报告构造章节
- AiState 新增 health_provider 字段,erp-server 注入 HealthDataProviderImpl
- 4 个 SSE handler 从 placeholder JSON 改为调用 provider + sanitizer 真实数据流
2026-04-28 19:08:38 +08:00
iven
ace04ee56d test(config): erp-config 从 50 增至 66 个单元测试 — fallback_chain + model_to_resp + ThemeResp
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- setting_service: 7 个测试(5 个 fallback_chain 作用域解析 + 2 个 model_to_resp 映射)
- theme_handler: 2 个测试(default_theme 默认值 + ThemeResp serde round-trip)
2026-04-28 18:31:01 +08:00
iven
26aa66d6e3 test(message): erp-message 从 45 增至 69 个单元测试 — DND 时间窗 + TransactionError + model_to_resp
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- module.rs: 提取 is_in_dnd_window 纯函数 + 14 个 DND 时间窗测试(正常范围/跨午夜/边界)
- error.rs: 2 个 TransactionError 转换测试(Connection/Transaction)
- message_service: 2 个 model_to_resp 字段映射测试
- template_service: 1 个 model_to_resp 字段映射测试
- subscription_service: 1 个 model_to_resp 字段映射测试
2026-04-28 18:26:36 +08:00
iven
50e63530d9 test(ai): erp-ai 从零增至 34 个单元测试 — 覆盖 DTO/error/prompt/sanitization
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- dto.rs: 8 个测试(AnalysisType 映射、serde round-trip、SSE 事件、默认值)
- error.rs: 10 个测试(AiError 全部 10 个变体 → AppError 映射)
- prompt: 8 个测试(变量替换、嵌套对象、数组迭代、条件、严格模式缺失变量)
- sanitization: 8 个测试(4 种 DTO 脱敏通过、PII 字段检测、空数据边界)
2026-04-28 18:17:19 +08:00
iven
dde6b09017 test(workflow): erp-workflow 单元测试从 16 增至 63 — 覆盖 model/error/parser/expression/executor
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- model.rs: 13 个 FlowGraph 测试(build/outgoing/incoming/start-end 边界)
- error.rs: 7 个 WorkflowError → AppError 转换测试
- parser.rs: 11 个新增验证边界测试(空图/多起点/幽灵边/网关约束)
- expression.rs: 13 个新增求值测试(float/bool/string/复合表达式/空白容错)
- executor.rs: 3 个 is_join_gateway 纯函数测试
2026-04-28 18:04:06 +08:00
iven
5941a6b764 feat(dialysis): 激活 erp-dialysis 独立模块 — 注册到 erp-server
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- workspace Cargo.toml 添加 erp-dialysis 依赖声明
- erp-server 注册 DialysisModule 并挂载透析路由
- 修复权限码:health.health-data.* → health.dialysis.list/manage
- 集成测试迁移:erp_health → erp_dialysis import + DialysisState
- TestApp 新增 dialysis_state() 方法
- cargo check 通过,erp-dialysis 10 个单元测试全部通过
2026-04-28 15:21:13 +08:00
iven
75cd305996 docs(wiki): 全景梳理 — 更新 9 个 wiki + CLAUDE.md scope + 头脑风暴记录
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
基于 3 个并行探索代理的全面扫描结果,更新 wiki 数据至实际状态:
- index.md: 18 crate / 76 迁移 / 44 实体 / 77k 行 / 409 提交
- erp-health.md: 44 实体 / 21 handler / 22 权限 / 25 事件 / 6 消费者
- erp-server.md: 9 后台任务 / RLS 中间件栈
- architecture.md: 新增 erp-ai/dialysis 到依赖图 / 测试覆盖表
- testing.md: 225 单元 + 159 集成 / 4 模块零测试警告
- database.md: 76 迁移 / RLS+哈希链+盲索引+Dead Letter
- erp-core.md: PiiCrypto 加密体系 / EventBus 完整描述
- frontend.md: 163 文件 / 5 store / 10 API 文件
- CLAUDE.md: 新增 health/ai/dialysis/assessment scope

头脑风暴 4 个议题决策:
- dialysis: 接入激活
- 测试: 按风险排序(workflow > ai > message > config)
- AI: 数据桥接优先
- 路线图: AI 驱动 3 个月 5 Phase
2026-04-28 14:53:04 +08:00
iven
ac1033dbaf refactor: 积分系统拆分为独立 erp-points crate
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新建 erp-points crate(8 Entity + account/product service + handler)
- 商品 CRUD 和账户管理完整实现,订单/签到/规则端点暂返回 501
- 注册到 workspace + erp-server 路由 /api/v1/points/*
- API 路径不变,前端无需修改
2026-04-28 14:32:16 +08:00
iven
fa9278590d refactor(dialysis): 透析模块拆分为独立 erp-dialysis crate
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 创建 erp-dialysis crate(DialysisState + DialysisError + DialysisModule)
- 迁移 2 Entity + 2 Service + 2 Handler + 2 DTO 共 8 个文件
- Entity 移除跨 crate patient Relation(FK 列保留)
- Service 内联 validation 逻辑,移除 patient 存在性检查(FK 约束保证)
- erp-health 的 stats/consultation 中 dialysis 查询改为 raw SQL
- ReviewLabReportReq 从 dialysis_dto 移至 health_data_dto(正确归属)
- workspace 全量编译通过
2026-04-28 12:37:23 +08:00
iven
e00c2abdcd feat(health): P1 事件消费者补全 — patient/appointment/follow_up
- patient.created → 发布欢迎消息事件(message.send 模板通知)
- appointment.confirmed → 通知医生预约确认
- appointment.cancelled → 号源释放标记
- follow_up.overdue → 逾期随访升级通知
- 所有消费者含幂等检查(processed_events 表)
2026-04-28 12:17:54 +08:00
iven
147fd886e3 feat(plugin): 评估量表 WASM 编译通过 — 170KB cdylib 组件
- wasm32-unknown-unknown target 编译成功
- 插件通过 API upload/install 注册,无需手动配置
2026-04-28 12:13:52 +08:00
iven
96c9a8ada9 feat(plugin): 评估量表插件骨架 — assessment_scale + assessment_response + PHQ-9 默认数据
- 创建 erp-plugin-assessment cdylib crate
- 实现 Guest trait(init/on_tenant_created/handle_event)
- on_tenant_created 自动插入 PHQ-9 抑郁筛查量表
- plugin.toml 声明 2 实体 + 4 权限 + 触发事件
2026-04-28 12:12:47 +08:00
iven
ade8497c2d docs(plan): 架构反思实施计划 — WASM 评估量表 + 透析拆分 + P1 事件消费者
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
8 Tasks / 3 Chunks:
- Chunk 1: WASM 评估量表插件 (PHQ-9) — crate 骨架 + 默认数据 + WASM 编译
- Chunk 2: 透析模块拆分 erp-dialysis — 8 文件 ~1100 行迁移
- Chunk 3: P1 事件消费者补全 — patient.created / appointment 通知 / follow_up.overdue
2026-04-28 11:58:01 +08:00
iven
be8fca1d76 feat(core): EventBus dead-letter + consume_with_retry 辅助函数
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增 dead_letter_events 表 + Entity
- consume_with_retry: 幂等检查 + 成功标记 + 失败转入 dead-letter
- insert_dead_letter: 写入失败事件供后续排查和手动重试
2026-04-28 11:47:44 +08:00
iven
10755cde0e docs: 架构反思讨论记录 + CLAUDE.md 事件消费者制度约束
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 讨论结论:WASM 插件积极使用(评估量表)、积分/透析拆独立 crate、事件驱动制度化
- CLAUDE.md §3.4 新增铁律:每个事件必须有至少一个消费者,否则功能不算完成
2026-04-28 11:46:31 +08:00
iven
e03a2be1b6 docs(wiki): 更新小程序 wiki 性能优化记录 + 待优化项状态
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 11:46:05 +08:00
iven
fcfc0ba5d9 perf(miniprogram): 全面性能优化 — 分包加载 + 请求缓存 + 渲染优化
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
分包加载(主包从 517KB 降至 275KB,-47%):
- 将 27 个页面拆入 6 个分包(health/doctor/mall/profile/content/device)
- vendors.js 从 192KB 降至 36KB(-81%)
- echarts 514KB 仅在访问健康趋势页时按需加载

请求层优化:
- GET 请求增加 in-flight 去重 + 60s TTL 响应缓存
- 新建 points store 集中管理积分/签到状态(消除 5 处重复调用)
- health store todaySummary 增加 60s TTL
- mutation 后自动失效缓存(health input/daily-monitoring)
- logout 时清空请求缓存

渲染优化:
- 7 个组件添加 React.memo(EcCanvas/TrendChart/Loading/EmptyState 等)
- 修复 TrendChart setChartReady 导致的双重渲染
- 静态数组(quickServices/quickActions/trendLinks)提取到模块级
- restoreAuth 从页面级提升到 App 级别
- 文章列表图片添加 lazyLoad

构建优化:
- prod 配置添加 terser(drop_console + drop_debugger)
- crypto-js 从全量引入改为按需引入(AES + Utf8)
2026-04-28 11:44:37 +08:00
iven
1bece3d41f feat(health): 危急值告警消费者 — 幂等处理 + Handler + 路由
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- event.rs: 消费 health_data.critical_alert 事件创建告警记录
- handler: list/get/acknowledge 三个端点
- 路由: /health/critical-alerts, /health/critical-alerts/{id}/acknowledge
- 权限: health.critical-alert.list / health.critical-alert.manage
2026-04-28 11:43:32 +08:00
iven
b7b09c0727 feat(health): 危急值告警 service — 创建/确认/升级扫描/列表查询
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- handle_critical_alert_event: 消费事件创建告警记录
- acknowledge_alert: 医生确认 + 创建响应记录
- scan_escalation: 30min→L1, 60min→L2 分级升级
- list_pending_alerts / get_alert: 查询接口
2026-04-28 11:39:38 +08:00
iven
80b99dba46 docs: 技术债清理策略讨论记录 — 三批次还债策略 + 5 项核心决策
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
2026-04-28 11:35:23 +08:00
iven
644efce760 feat(health): 新增 critical_alerts + critical_alert_responses 表 + Entity
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
危急值告警数据模型:status(pending/acknowledged/resolved/escalated)、
escalation_level 分级升级、乐观锁、软删除。
2026-04-28 11:34:37 +08:00
iven
298e439fb2 feat(health): 新增 blind_indexes 表 + Entity 支持 PII 盲索引搜索
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 11:31:54 +08:00
iven
3284a59c55 fix(health): 密文版本标识 v1 前缀 + DEK zeroize
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- encrypt() 输出格式改为 v1|Base64(nonce+ciphertext)
- decrypt() 兼容旧格式(无版本前缀)
- aes_key/hmac_key 改用 Zeroizing<[u8; 32]>,Drop 时覆写内存
- 新增 encrypt_has_version_prefix + decrypt_legacy_no_prefix 测试
2026-04-28 11:27:41 +08:00
iven
988f6cd6a5 fix(auth): JWT 中间件支持 query parameter token 回退
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
SSE/EventSource 无法设置自定义 Authorization 头,前端通过
?token=xxx 传参。中间件现在优先读 Authorization 头,回退到
URL query parameter,修复 SSE 连接永远 401 的问题。
2026-04-28 11:23:53 +08:00
iven
c556bda82b test(core): 添加事务回滚测试基础设施
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 11:17:46 +08:00
iven
aa5b26bf12 docs(plan): 技术债清理实施计划 — 14 个 Task / 4 个 Chunk
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- Chunk 1: 测试基础设施 + SQL 审计 + SSE + 清缓存
- Chunk 2: Crypto 版本标识 + Blind Index + RLS Policy
- Chunk 3: 危急值告警全链路 + EventBus Dead-Letter
- Chunk 4: 积分拆 erp-points + 安全测试 + 前端优化
2026-04-28 11:07:54 +08:00
iven
755d95480e docs(spec): 技术债清理设计规格 — 安全/事件/测试三批次策略
发散式技术债讨论结论,涵盖:
- 批次 A:安全合规(SQL 审计、PII 后端加解密+Blind Index、RLS 兜底)
- 批次 B:事件架构(vital.critical 消费者优先、积分拆 erp-points crate)
- 批次 C:测试质量(事务回滚模式、安全测试驱动)
2026-04-28 10:03:03 +08:00
iven
92486cad8e fix(web): 修复仪表盘 hooks 顺序 + 患者 DatePicker 初始值
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- AdminDashboard/OperatorDashboard/DoctorDashboard/NurseDashboard:
  将 useCountUp 调用从 JSX 中提取到组件顶层,避免条件提前返回
  导致 hooks 数量不一致引发 React crash
- PatientList: 编辑时 birth_date 字符串转 dayjs 对象,修复
  Ant Design 6 DatePicker getUDayjs().isValid() 报错
2026-04-28 09:08:26 +08:00
iven
f93321bd56 fix(miniprogram): 补充健康 Hub 趋势横向滚动卡片样式 + 快捷操作 flex-wrap
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 08:53:57 +08:00
iven
8edbe7be7b docs(wiki): 更新 frontend.md Phase 5 小程序端优化记录
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 08:51:55 +08:00
iven
0e45778fc3 feat(miniprogram): Phase 5 UI/UX 优化 — 8 项改进
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 首页: 健康资讯推荐 + 空状态引导 + 快捷服务字符图标优化
- 健康 Hub: sparkline bar + 参考范围 + 打卡合并到快捷操作
- 日常监测: 3 分组折叠(晨间/晚间/其他) + 异常值高亮 + 提交前确认
- 预约: 已满时段 pointer-events:none + opacity 优化
- 咨询聊天: 消息日期分组(今天/昨天) + 图片预览
- 积分商城: 确认已有余额大字+签到+库存提示
- 医护工作台: 异常体征横幅 + 患者搜索入口 + 快捷操作扩展
- 趋势图表: 骨架屏加载状态 + ECharts 异常标记已有
2026-04-28 08:51:27 +08:00
iven
852a429ef3 docs(wiki): 更新 frontend.md Phase 4 表单升级记录
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 08:40:42 +08:00
iven
24c7f9451f feat(web): 表单升级 — Modal→DrawerForm + 分组双列布局
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
4 个表单从 Modal 升级为 DrawerForm:
- 患者表单:4 分组(基本信息/联系方式/医疗信息/紧急联系人),编辑时获取完整详情
- 预约表单:3 分组(患者信息/医生与排班/备注),保留排班校验逻辑
- 随访填写:2 分组(执行信息/详细记录),DrawerForm 内部校验
- 积分商品:2 分组(基本信息/展示设置)

统一使用 DrawerForm 组件管理表单实例、校验和提交
2026-04-28 08:40:22 +08:00
iven
3d787adceb docs(wiki): 更新 frontend.md Phase 3 列表页迁移记录
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
2026-04-28 08:18:26 +08:00
iven
1e7a5f5498 refactor(web): 列表页统一迁移 — PageContainer + usePaginatedData + 格式化规范
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
8 个列表页迁移至统一模式:
- PatientList / DoctorList / AppointmentList / FollowUpTaskList
- ConsultationList / AlertList / ArticleManageList
- PointsRuleList / PointsProductList / PointsOrderList

统一使用:
- PageContainer 组件(标题/筛选/操作/暗色模式)
- usePaginatedData hook(分页/筛选/搜索)
- EntityName 组件(UUID→姓名兜底)
- 共享 formatDateTime/formatDate/formatRelative
- 移除手动 isDark 暗色模式处理
2026-04-28 08:17:55 +08:00
iven
7dcb324abe docs: 更新 wiki — Phase 2 仪表盘角色自适应完成
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 07:58:38 +08:00
iven
2f42ebff1d feat: 仪表盘角色自适应重构 — 4角色视图 + 后端个人工作量API
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
后端:
- 新增 GET /health/admin/statistics/personal-stats 接口
- PersonalStatsResp: 13个个人维度统计字段
- 按医生/护士/管理员/运营角色聚合工作量数据

前端:
- useDashboardRole hook: 按优先级 doctor>nurse>admin>operator 匹配角色
- DoctorDashboard: 今日工作台(日程/审核/消息/统计卡)
- NurseDashboard: 随访监控台(异常提醒/队列/上报率)
- AdminDashboard: 管理中心(5KPI + 健康数据Tab)
- OperatorDashboard: 运营中心(积分/文章/活动)
- StatisticsDashboard.tsx 重写为角色路由组件
- 删除旧区块:快捷入口/积分排行Top10/最近活动
2026-04-28 07:54:08 +08:00
iven
35d4f6c843 docs: 更新 wiki — UI/UX 重构 Phase 1 完成 + 设计规格/实施计划索引
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 01:48:08 +08:00
iven
4cfbdec5fc refactor(web): 统一 dayjs 导入为集中初始化 — 11 个文件
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
所有 health 页面从 import dayjs from 'dayjs' 迁移到
import { dayjs } from '.../utils/dayjs',确保 relativeTime
和 zh-cn locale 全局生效。
2026-04-28 01:47:13 +08:00
iven
5b47f13ecf feat(web): 提取共享基础组件 — dayjs/format/EntityName/FilterBar/PageContainer/DrawerForm
- utils/dayjs.ts: 集中初始化 relativeTime 插件 + zh-cn locale
- utils/format.ts: formatDate/formatDateTime/formatRelative/calcAge
- components/EntityName.tsx: UUID→姓名兜底显示
- components/FilterBar.tsx: 统一筛选栏容器
- components/PageContainer.tsx: 统一页面容器(标题+筛选+表格+暗色模式)
- components/DrawerForm.tsx: 抽屉式表单容器(分组+双列网格)
- AlertList.tsx: 迁移到集中 dayjs 导入
2026-04-28 01:45:48 +08:00
iven
16a776c213 docs: UI/UX 重构实施计划 — 6 Phase 37 Task 分步详述
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
Phase 1 基础组件提取 → Phase 2 仪表盘角色自适应 → Phase 3 列表页统一
→ Phase 4 表单升级 Drawer → Phase 5 小程序重构 → Phase 6 验收
2026-04-28 01:42:50 +08:00
iven
ca32be59be docs: UI/UX 设计规格二轮修订 — 填充表单分组表、修正 any→unknown
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
2026-04-28 01:34:16 +08:00
iven
1404cc8f1a docs: UI/UX 设计规格修订 — 补充 API 契约、组件接口、技术前置条件
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 新增范围与前置条件(antd 6.x、dayjs 初始化、目标目录)
- 定义角色 code 映射表和判定逻辑
- 补充个人工作量 API 契约(GET /personal-stats)
- 新增 DrawerForm/FilterBar 组件接口定义
- 补充 dayjs 集中初始化方案
- 明确数据层策略(统一 usePaginatedData)
- 修正小程序:SVG 图标替代 emoji、sparkline 用 CSS/SVG
- 标记输入指示器为后续迭代
- 明确预约日历为现有组件增量优化
2026-04-28 01:31:42 +08:00
iven
a66d59e86b fix(server): Rate limit fail-close 改为环境变量控制
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
开发环境默认 fail-open(Redis 不可达时放行),
生产环境设置 ERP__RATE_LIMIT__FAIL_CLOSE=true 启用 fail-close(返回 503)。
2026-04-28 01:30:05 +08:00
iven
d1d8079494 docs: UI/UX 全面重构设计规格 — 仪表盘角色自适应 + 列表统一 + 表单三级容器 + 小程序重设计
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 01:25:25 +08:00
iven
1e6e783fcc fix(server): 健康检查和 OpenAPI 端点移出限流中间件范围
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
account_lockout_middleware 改为 fail-close 后,/health 和 /docs/openapi.json
不应受影响。将它们提取为 unthrottled_routes 独立层。
2026-04-28 01:11:17 +08:00
iven
9dd6095e77 fix: P0/P1 安全与质量缺陷修复 — 10 项 QA 审查问题解决
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
P0 安全修复:
- tenant_rls: SQL 拼接改为参数化查询防止注入
- follow_up_service: UUID SQL 拼接改为参数化原生查询
- RLS 策略: 新迁移移除空字符串绕过条件
- SSE 消息推送: token 键名 'token' → 'access_token' 修复
- rate_limit: 登录端点 Redis 不可达时 fail-close

P1 质量修复:
- 小程序缓存清理: preservedKeys 补全认证键名
- 小程序 token 刷新: 失败时清除所有认证数据
- 小程序 401: redirectTo → reLaunch 兼容 tabBar
- 集成测试: 信号量限制并行数据库创建(4个)
- change_password: 乐观锁 version 硬编码 → 动态递增

测试: 516 全部通过 (含 153 集成测试)
2026-04-28 00:57:41 +08:00
iven
3d34e021a9 chore: 清理 git 缓存 — 移除 .logs/ brainstorm/ playwright-report/
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
这些文件已在 .gitignore 中,从 git 追踪中移除。
2026-04-28 00:21:00 +08:00
iven
1265935fa3 chore: 设计规格文档 + 销售数据 + 脚本工具 + 根目录 monorepo 配置
- docs/: 设计规格、讨论记录、销售数据、健康管理文档
- scripts/: 辅助脚本
- package.json + pnpm-lock.yaml: monorepo 根配置
2026-04-28 00:20:37 +08:00
iven
11777e3b68 docs(wiki): 多主题系统文档更新 + .gitignore 清理
- frontend.md: 4 套主题视觉人格 + 技术架构 + 温润东方详细 token
- index.md: 症状导航更新
- miniprogram.md: 小程序审计报告
- .gitignore: 排除 .logs/ plans/ playwright-report/ 临时文件
2026-04-28 00:20:20 +08:00
iven
30f2452933 fix(core): 迁移修复 + 配置调整
- auth_state: 新增字段
- config/default.toml: 配置更新
- migration 078/082: 修复 SQL 语法
- state/main: 启动逻辑调整
2026-04-28 00:20:11 +08:00
iven
e56cd73e49 feat(web): 多主题系统 — 4 套主题 + CSS 变量 + Ant Design 动态主题
- CSS 变量层: :root 默认 blue, [data-theme] 覆盖 warm/dark/emerald
- Ant Design: ConfigProvider 按 ThemeName 切换 token + algorithm
- ThemeSwitcher: 下拉面板含 4 主题色块预览 + localStorage 持久化
- useThemeMode: 从 store 读取主题名替代色值比对(修复 33 页面暗色失效)
- index.html: 添加 Noto Serif SC 字体(warm 主题衬线标题)
2026-04-28 00:20:02 +08:00
iven
50eae8b809 feat(miniprogram): 温润东方风全面 UI 重设计
73 文件变更,覆盖全部 40 个页面 SCSS + TabBar 图标 + 组件样式。
统一赤陶主色 #C4623A + 暖米背景 + 衬线标题字体 + 12px 圆角体系。
2026-04-28 00:19:52 +08:00
iven
fbb28e655d fix(miniprogram): submitRecord 补充 task_id 字段 — 后端 CreateFollowUpRecordReq 必填
Some checks failed
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
后端 CreateFollowUpRecordReq 要求 body 中包含 task_id 字段,
小程序端 followup.ts 和 doctor.ts 的 submitRecord/createFollowUpRecord
均未传递 task_id,导致 422 Unprocessable Entity。
2026-04-28 00:16:21 +08:00
iven
83162817ce fix(miniprogram): 修复 API 接口字段对齐 — 33 接口端到端验证
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P0: submitRecord() 路径修正 POST /follow-up-records → POST /follow-up-tasks/{id}/records
    + 请求体从 {task_id, content:{text}} 改为 {result, patient_condition, executed_date}
P1: ConsultationSession.subject/last_message 改为可选(后端暂不返回)
P1: Appointment.department 改为可选(后端未 JOIN 医生表)
P1: FollowUpRecord 结构对齐后端扁平字段(executed_date/result/medical_advice 等)
P2: Article 增加 status 可选字段
2026-04-27 23:41:50 +08:00
iven
3177a704ff test(web): exprEvaluator + useDebouncedValue 单元测试 — 24 个用例
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
exprEvaluator(19): 等值/不等/AND/OR/NOT/括号/短路运算/
  missing field/type coercion/visibleWhen 便捷函数。
useDebouncedValue(5): 初始值/防抖/快速更新重置/自定义延迟/数值类型。
2026-04-27 23:24:25 +08:00
iven
5aec02e4ad test(health): 7 个模块集成测试 — 49 个用例全通过
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增 doctor(7)、diagnosis(7)、consent(6)、medication(8)、
dialysis_prescription(7)、follow_up_template(7)、daily_monitoring(7)
覆盖 CRUD、状态流、列表过滤、软删除、租户隔离、乐观锁。
2026-04-27 23:21:04 +08:00
iven
2d5b6d4c50 test(health): 文章/分类/标签集成测试 — 10 个用例全通过
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
覆盖文章 CRUD、状态流(draft→pending_review→published→draft)、
拒绝与重提交、列表过滤、软删除、租户隔离、分类 CRUD+隔离、
标签 CRUD+文章关联、乐观锁冲突。
2026-04-27 23:04:41 +08:00
iven
f58f1f73c5 test(health): 健康数据集成测试 — 8 个测试覆盖体征CRUD/化验报告CRUD+审阅/租户隔离
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(auth): WechatSessionResp mock 缺少 unionid 字段
2026-04-27 22:27:36 +08:00
iven
7420a66291 test(health): 随访 + 咨询集成测试 — 9 个随访测试 + 8 个咨询测试覆盖 CRUD/状态/批量/消息/租户隔离
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-27 22:18:51 +08:00
iven
c53f5625bc fix(web,miniprogram): 端到端测试修复 + 小程序接口字段对齐
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
## 前端修复
- 修复 9 个 TypeScript 编译错误(未使用变量/undefined 守卫/vitest 类型)
- 重写 E2E auth fixture 使用真实 API 登录替代 mock token
- 更新 E2E 测试选择器适配当前 UI 布局
- Playwright 改为串行执行避免 token 唯一约束冲突
- E2E 测试从 0/10 通过提升到 10/10 通过

## 小程序接口一致性修复(P0-P3)
- P0: consultation.ts type→consultation_type, unread_count→unread_count_patient
- P0: followup.ts task_type→follow_up_type, due_date→planned_date, description→content_template
- P1: appointment.ts calendarView 展平嵌套结构, available_count 计算 max-current
- P1: doctor.ts HealthSummary 适配后台实际返回结构
- P2: doctor.ts PatientStats/ConsultationStats/FollowUpStats 字段名对齐
- P3: article.ts 新增 buildCategoryTree 工具函数
2026-04-27 22:09:21 +08:00
iven
e1d9f97d79 test(health): 扩展预约集成测试 +3 — 状态流转/取消/乐观锁冲突
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-27 22:01:38 +08:00
iven
fdbbc47a60 test(health): 扩展患者集成测试 +3 — 更新乐观锁/PII加密验证/姓名搜索
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-27 21:58:57 +08:00
iven
dc09cc4e2a test(health): 设备读数集成测试 — 8 个测试覆盖批量摄入/设备绑定/聚合/查询/校验/租户隔离
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-27 21:54:50 +08:00
iven
55a7d7a03e test(health): 告警系统集成测试 — 8 个测试覆盖规则 CRUD/引擎评估/状态流转/cooldown/租户隔离
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-27 21:48:12 +08:00
iven
3aaa0a9598 test(health): 透析记录集成测试 — 8 个测试覆盖 CRUD/PII/状态流转/租户隔离/乐观锁/软删除
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-27 21:42:24 +08:00
iven
88d01b5d84 test(health): 积分系统集成测试 — 12 个测试覆盖 FIFO/签到/兑换/隔离
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 签到积分首次/连续签到
- 自定义事件积分增加
- FIFO 消费、精确消费、部分消费、余额不足
- 账户自动创建、兑换订单创建
- 交易记录查询、租户隔离
2026-04-27 21:21:04 +08:00
iven
6997bb1d90 test: Phase 0 测试基础设施 — TestApp + MSW + 覆盖率工具 + CI
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- TestApp struct 封装 TestDb + HealthState + tenant_id/operator_id
- TestFixture 工厂方法: create_patient/create_doctor/create_schedule/create_appointment
- 前端 MSW v2 handlers (auth) + server setup + vitest 集成
- vitest coverage v8 配置 + test:coverage script
- GitHub Actions CI: backend (check + test + clippy) + frontend (tsc + test + build)
2026-04-27 21:12:08 +08:00
iven
41af241238 refactor(web): 前端工程化 — 组件拆分 + 名称缓存统一
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- useHealthStore 新增 batchResolvePatientNames/batchResolveDoctorNames
  批量解析方法(去重 → 过滤已缓存 → 5 并发批次加载)
- PointsOrderList 移除局部 nameCache,改用 useHealthStore 全局缓存
- PluginCRUDPage (871L) 拆分为 usePluginData + DetailDrawer +
  ImportModal + PluginCRUDPageInner,原文件改为 re-export
- PluginGraphPage (765L) 拆分为 useGraphData + useGraphCanvas hooks
- StatisticsDashboard (580L) 拆分为 useStatsData + HealthDataCenter
2026-04-27 20:56:27 +08:00
iven
fdceed7284 feat(web): useApiRequest 添加 loading + usePaginatedData 泛型筛选
- useApiRequest 新增 loading 状态,execute 自动管理 loading 生命周期
- usePaginatedData 支持泛型筛选参数 (filters: F),函数重载保持旧签名兼容
- 新增 filters/setFilters 状态,fetchFn 调用时传入当前 filters
- 向后兼容:旧调用点无需修改
2026-04-27 20:26:00 +08:00
iven
22ef5b6d1f feat(core): 审计日志哈希链 — prev_hash + record_hash + 完整性验证
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 迁移 087: audit_logs 表添加 prev_hash/record_hash 列 + 索引
- audit_service::record() 写入时查询前一条 record_hash 作为 prev_hash
- SHA256(id+action+resource_type+resource_id+created_at+prev_hash) 计算 record_hash
- verify_hash_chain() 验证链完整性,返回 (总记录数, 断链数)
2026-04-27 19:38:39 +08:00
iven
633bf8c62d feat(auth): data_scope 行级数据权限 — DataScope 枚举 + 中间件加载
- TenantContext 新增 permission_data_scopes: HashMap<String, DataScope>
- DataScope 枚举: All/SelfOnly/Department/DepartmentTree
- JWT 中间件查询 role_permissions.data_scope 填充到上下文
- rbac::get_data_scope() 供 service 层按权限获取数据范围
- 默认 All,完全向后兼容现有行为
2026-04-27 19:31:19 +08:00
iven
d5c9654370 fix(db): 修复迁移 084/085 SQL 语法 + RLS 动态表名查询
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 084/085: PostgreSQL DELETE 不支持 LIMIT,改用 ctid IN (SELECT ... LIMIT)
- 086: RLS 迁移改为动态查询 information_schema 获取含 tenant_id 的表,
  避免硬编码表名不一致问题
- 全量测试 490 个通过(含 27 个集成测试 + RLS 验证)
2026-04-27 18:52:03 +08:00
iven
bcaeb0beef feat(server): tenant RLS 中间件 — SET app.current_tenant_id
- 新增 tenant_rls_middleware:JWT 解析后 SET 租户 ID,请求结束 RESET
- 挂载到 protected router 的 JWT 层之后
- SET 失败仅 warn 不阻断(RLS 是安全网,主隔离在应用层)
- RESET 防止连接池复用时租户上下文泄漏
2026-04-27 18:41:28 +08:00
iven
b7b9f50d00 feat(db): RLS 策略迁移 — 80 张 tenant_id 表启用行级安全
- 所有含 tenant_id 的表(基础 34 + 健康 28 + 其他 18)启用 RLS
- 策略:未设置 app.current_tenant_id 时允许全部,设置后按 tenant_id 过滤
- down 方法完整回退(DROP POLICY + DISABLE ROW LEVEL SECURITY)
2026-04-27 18:40:07 +08:00
iven
3197dde33c feat(core): 事件归档 + 消费者幂等性 — 迁移 084/085 + 清理任务
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 迁移 084: domain_events_archive 归档表 + cleanup_old_published_events()
- 迁移 085: processed_events 去重表 + cleanup_old_processed_events()
- erp-core: is_event_processed() / mark_event_processed() 幂等性辅助
- erp-server: tasks::start_event_cleanup() 每 24h 归档 >90 天事件
2026-04-27 18:12:43 +08:00
iven
97bb592688 feat(core): build_event_payload 统一信封 — 28 处事件发布全部迁移
- erp-core 添加 build_event_payload(),自动注入 schema_version + occurred_at
- erp-health 12 个 service(25 处)、erp-auth(1 处)、erp-workflow(2 处)
  全部迁移到统一信封格式
2026-04-27 18:01:05 +08:00
iven
d31d7beb1f feat(server): outbox relay 改为 LISTEN/NOTIFY + 30s 兜底轮询
- EventBus::publish() 持久化后执行 NOTIFY outbox_channel
- outbox relay 使用 sqlx::PgListener 监听 + tokio::select! 竞争
- 30s 兜底轮询防止 NOTIFY 丢失,断线自动重连
- 轮询间隔从 5s 提升到 30s,事件延迟降至 <100ms
2026-04-27 17:50:38 +08:00
iven
8d55d98f4f feat(health): daily_monitoring.created 事件发布
日常监测记录创建后发布 domain event,payload 包含 record_id、
patient_id、record_date 及关键体征数据。
2026-04-27 17:42:12 +08:00
iven
13b23e90f4 feat(health): 消息推送集成 — 定时任务启动 + 预约提醒事件
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- erp-server: 启动逾期随访检查(6h)、积分过期(24h)、预约提醒(1h) 定时任务
- appointment_service: 新增 send_reminders 扫描明日确认预约发送事件
- erp-message: 订阅 appointment.reminder 事件,向患者发送提醒消息
2026-04-27 14:51:40 +08:00
iven
dc5879228e feat(health): 随访模板系统 — follow_up_template + template_field 全栈
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增随访模板和模板字段两张表及完整 CRUD:
- 迁移 083: follow_up_template + follow_up_template_field
- Entity: 模板(名称/类型/适用范围/状态) + 字段(标签/键名/类型/选项/校验)
- DTO: 创建时内嵌字段列表、更新支持全量替换字段
- Service: 随访类型+字段类型校验、级联软删除
- Handler: 5 端点 + RBAC 权限
- 路由: /api/v1/health/follow-up-templates
2026-04-27 14:40:28 +08:00
iven
ca96310a84 feat(health): 透析方案管理 CRUD — dialysis_prescription 全栈
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增透析方案实体和完整 CRUD:
- Entity: 20 字段含抗凝/血管通路/透析参数
- DTO: f64 类型适配 utoipa ToSchema
- Service: 抗凝类型 + 血管通路类型校验
- Handler: 5 端点 + RBAC 权限控制
- 路由: /api/v1/health/dialysis-prescriptions
2026-04-27 14:26:41 +08:00
iven
19cb2bf8bf feat(health): 批量随访操作 — batch_create/assign/complete 三个端点
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
POST /health/follow-up-tasks/batch-create — 多患者同配置批量创建
POST /health/follow-up-tasks/batch-assign — 批量分配负责人
POST /health/follow-up-tasks/batch-complete — 批量标记完成

含参数校验(上限 100)、部分失败报告、事件发布、审计日志。
2026-04-27 14:01:58 +08:00
iven
a36720cbbc feat(health): 补全事件发布 — consent/points/article 6 个领域事件
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- consent.granted/revoked: 知情同意授权/撤销
- points.earned/exchanged: 积分获得/兑换
- article.published/rejected: 文章审核发布/拒绝

所有事件通过 EventBus 发布,支持跨模块订阅和审计追溯。
2026-04-27 13:33:11 +08:00
iven
a5646ddbb3 perf(health): 随访列表内联负责人名称 — 消除 N+1 查询
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
follow_up list_tasks 批量查询 users 表获取 assigned_to_name,
前端移除 doctorLabels 逐条请求缓存,直接使用后端内联字段。
2026-04-27 13:22:46 +08:00
iven
2519ad8fee feat(auth): 微信 session_key 迁移到 Redis — 内存降级兜底
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
session_key 从全局 HashMap 迁移到 Redis(SET key EX 300 / GETDEL),
Redis 不可用时自动降级到内存缓存,提升多实例部署安全性。
2026-04-27 13:05:25 +08:00
iven
a4daa8f49c feat(server): 健康检查增强 — 新增 /health/ready 就绪检查
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 保留 /health 轻量存活检查
- 新增 /health/ready 含 DB ping + Redis ping 并行检测
- 返回 status(ok/degraded/unavailable) + 各组件延迟和错误信息
2026-04-27 12:54:16 +08:00
iven
a2c1b5ece8 feat(db): 注册透析处方迁移 + AI Prompt 种子数据(4 个默认模板)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 注册遗漏的 m20260427_000081_create_dialysis_prescription 迁移
- 新增 000082 种子迁移:插入 4 个 AI Prompt 模板
  (化验单解读/趋势分析/体检方案/报告摘要)
2026-04-27 12:50:16 +08:00
iven
a1bc62cd5e fix(plugin): WASM 集成测试自动构建 Component — OnceLock 线程安全
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
测试无法找到 .component.wasm 文件导致 6 个测试全部失败。
改进 wasm_path() 使用 OnceLock 保证只转换一次,
自动从编译产物通过 wasm-tools component new 生成。
2026-04-27 12:34:52 +08:00
iven
7c0f0ce906 docs(miniprogram): 新增 MCP 联调章节 — 操作指南 + 已知限制 + 故障排查
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- wiki/miniprogram.md §6: 完整 MCP 联调文档
  - 前置条件与建立连接
  - 16 个 MCP 工具使用说明
  - 绕过微信登录的 storage 注入流程
  - TabBar 页面列表与导航注意事项
  - 已知限制:screenshot 超时、navigateTo 超时、auth 重定向
  - e2e 验证脚本说明
- wiki/index.md: 新增 3 条 MCP 相关症状导航
2026-04-27 12:13:52 +08:00
iven
bab0d6619b feat(health): 用药记录实体 — CRUD 全栈
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 迁移 080: medication_record 表(18 字段 + 频率/给药途径校验)
- Entity/DTO/Service/Handler 全链路
- 端点: GET/POST/PUT/DELETE /health/medications + /health/patients/{id}/medications
- 软删除 + 乐观锁 + 审计日志
2026-04-27 11:45:49 +08:00
iven
67f2d07809 feat(health): 体征增加体温/SpO2/血糖类型字段
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 迁移 079: vital_signs 表新增 body_temperature/spo2/blood_sugar_type 列
- Entity/DTO/Service 全链路支持新字段
- blood_sugar_type: fasting/postprandial/random/ogtt
- daily_monitoring 兼容层补全新字段为 None
2026-04-27 11:31:40 +08:00
iven
7e66561a5f fix(health): 统一随访类型为 5 种 — phone/outpatient/home_visit/online/wechat
- validation.rs: face_to_face 替换为 outpatient,新增 home_visit/wechat
- FollowUpTaskList.tsx: 新增 online 选项,与后端对齐
- 迁移 078: follow_up_task + follow_up_record face_to_face → outpatient
2026-04-27 11:20:57 +08:00
iven
6a7d83ec4d refactor(health): 集中管理事件类型常量 + 积分过期发布事件
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- event.rs 新增 20 个事件类型常量(PATIENT_CREATED 等)
- 10 个 service 文件引用常量替代硬编码字符串
- expire_points 增加 EventBus 参数,处理完成后发布 points.expired 事件
- start_points_expiration_checker 传入 EventBus
2026-04-27 11:11:33 +08:00
iven
47df2e2aa6 perf(web): manualChunks 拆分 heavy deps + lazy ProcessDesigner/ProcessViewer
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- vite.config.ts 添加 vendor-charts/plots/graphs, vendor-flow, vendor-editor 独立 chunk
- vendor-antd 从 3000kB 降至 1532kB,charts 独立 1459kB
- ProcessDesigner/ProcessViewer 改为 React.lazy 按需加载
- 移除 PluginGraphPage 遗留的 animFrameRef 未使用变量
2026-04-27 10:11:12 +08:00
iven
af44476c0f perf(web): PluginGraphPage 替换持续 rAF 循环为按需重绘
移除持续 requestAnimationFrame 循环,改为数据变更 useEffect 触发
单次重绘 + ResizeObserver 监听容器变化,静态页面 CPU 占用大幅降低。
2026-04-27 09:58:51 +08:00
iven
1c7184b6bc perf(web): PluginCRUDPage columns 包裹 useMemo 避免重渲染
columns 依赖 fields/resolvedLabels/labelMeta,搜索输入时不再重建列定义。
2026-04-27 09:57:41 +08:00
iven
0929825ae7 perf(health): alert_engine 批量预加载 + 内存匹配替代逐规则DB查询
批量查询 cooldown 期间所有 alerts 和最近 hourly 记录,
在内存中完成 cooldown 检查和规则匹配。
N规则评估从 2N+ 次查询降为 2 次批量查询。
2026-04-27 09:55:39 +08:00
iven
0a387c189a perf(health): get_health_summary 4次串行查询改为 tokio::join! 并行
总延迟从 sum(4次查询) 降为 max(4次查询),预估延迟降低约75%。
2026-04-27 09:52:31 +08:00
iven
04c5f3c0d5 perf(health): stats_service 合并 COUNT 为 GROUP BY + 宏化 compute_avg_field
get_follow_up_statistics: 4次独立COUNT合并为1次GROUP BY GROUPING SETS。
compute_avg_field: format! 动态拼接改为宏生成静态SQL,利用PG prepared statement缓存。
2026-04-27 09:50:10 +08:00
iven
f934ca0eaf perf(web): ConsultationList/FollowUpTaskList 移除 N+1 nameCache
后端已内联 patient_name/doctor_name,前端移除逐条查询。
Session/FollowUpTask 接口添加 name 可选字段。
FollowUpTaskList 保留 assignee 的 getUser 查询(users 表未内联)。
2026-04-27 09:47:37 +08:00
iven
c6856370c6 perf(web): AppointmentList 移除 nameCache N+1 请求
后端已内联 patient_name/doctor_name,前端移除逐条查询
patientApi/doctorApi 的 nameCache 逻辑,列表加载降为 O(1) 请求。
2026-04-27 09:41:47 +08:00
iven
4a5dbaeaeb feat(health): consultation/follow_up 列表 API 内联 patient_name/doctor_name
consultation session list 添加 patient_name/doctor_name,
follow_up task list 添加 patient_name,批量查询消除 N+1。
DTO 新增 Option 字段,向后兼容。
2026-04-27 09:39:46 +08:00
iven
432f6e3554 feat(health): appointment list API 内联 patient_name/doctor_name
列表查询后批量获取 patient 和 doctor 名称,消除前端 N+1 请求。
DTO 新增 patient_name/doctor_name Option 字段,向后兼容。
2026-04-27 09:34:04 +08:00
iven
c09f6ecdc8 perf(health): upsert_hourly_aggregates 批量化 — 批量查询+insert_many
将逐组查询+更新/插入改为一次批量查询所有已存在记录,
分为"新增"和"更新"两组,新增用 insert_many() 一次性插入。
查询次数从 N 降为 1+更新数。
2026-04-27 09:29:55 +08:00
iven
59a22e762d fix: 审计修复 — SSE事件监听 + 软删除列表 + 页面配置
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- [HIGH] 前端 SSE store 补充 alert/vital_update 事件监听
- [LOW] seed.rs 软删除列表补充 device_readings
- [LOW] 小程序 device-sync 补充 index.config.ts 页面配置
2026-04-27 09:27:30 +08:00
iven
587f51c0c1 perf(health): batch_insert_readings 改为 SeaORM insert_many 批量插入
逐条 INSERT(最多 500 次 DB 往返)替换为构建 Vec<ActiveModel>
后一次性 insert_many(),延迟降低 95%+。
2026-04-27 09:26:41 +08:00
iven
d460316d23 test(miniprogram): 端到端链路验证脚本 — 11 UI链路 + 10 API闭环
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 连接微信开发者工具 automator (ws://localhost:9420)
- 通过加密 storage 注入 admin token 绕过微信登录
- 验证 11 条 UI 链路: 首页/健康数据/录入/日常监测/积分商城/
  预约/家庭成员/咨询/文章/趋势/报告
- 验证 10 个 API 数据闭环: 患者/预约/咨询/日常监测/积分/
  签到/商品/医生/文章/随访
- 正确处理 tabbar 页面 (switchTab vs navigateTo)
- 所有导航带 8s 超时保护
2026-04-27 08:20:26 +08:00
iven
c314093c76 fix(miniprogram): auth store restore() 修复 + 开启自动化端口
- restore() 从 Taro.getStorageSync 改为 secureGet 读取加密数据
  - 修复 key 不匹配: 'user' → 'user_data', 'user_roles' → 'user_roles'
  - login 写入 secureSet('user_data') 但 restore 读 Taro.getStorageSync('user')
  - 导致每次 app 重启都无法恢复登录状态
- project.config.json 开启 automationAudits 以支持 miniprogram-automator
2026-04-27 08:20:12 +08:00
iven
b410fa9f78 docs: 5 份实施计划 — 性能/安全/事件/前端/可观测性
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
对应 5 份设计规格,共 75 个 Task:

1. 性能优化 (12 Task) — 批量INSERT/N+1内联name/合并COUNT/按需重绘/chunk拆分
2. 安全纵深防御 (8 Task) — RLS/行级数据范围/Redis session_key/审计哈希链
3. 事件驱动架构 (10 Task) — 11个缺失事件补发/LISTEN+NOTIFY/schema版本化
4. 前端工程化 (10 Task) — hook统一/组件拆分/Bundle优化
5. 可观测性运维 (10 Task) — 深度健康检查/Prometheus/OTel/生产Docker/告警
2026-04-27 08:00:50 +08:00
iven
215fb35e0e feat(miniprogram): BLE 设备同步模块 — 扫描+连接+数据上传
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- Task 18: BLE 类型定义(NormalizedReading/DeviceAdapter/BLEDevice)+ BLEManager 连接管理器
- Task 19: XiaomiBandAdapter 心率读取适配器(标准 HRS Service 0x180D)
- Task 20: device-sync API 层 + 设备同步页面 + app.config 路由注册
2026-04-27 07:53:12 +08:00
iven
d1ab8074a3 docs: 多专家组头脑风暴产出 — 5 份设计规格
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
基于全景审计分析,产出 5 份跨领域设计规格:

1. 性能优化 — 后端批量INSERT/合并COUNT/告警预加载 + 前端N+1内联name
2. 安全纵深防御 — PostgreSQL RLS/行级数据范围/session_key Redis/审计哈希链
3. 事件驱动架构增强 — 6个业务域11个缺失事件补发 + Outbox LISTEN/NOTIFY
4. 前端工程化 — 14个大组件拆分 + 3个重复模式统一 + Bundle优化
5. 可观测性与运维 — 深度健康检查/Prometheus/OpenTelemetry/生产Docker
2026-04-27 07:46:36 +08:00
iven
5f83080ab8 feat(web): 告警管理前端页面 + 路由注册 + bugfix
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新增:
- AlertList 告警列表页: 状态筛选/确认/忽略操作
- AlertRuleList 告警规则页: 创建/编辑/启停管理
- alerts + deviceReadings 前端 API 层
- App.tsx 路由注册 + MainLayout 标题 fallback
- wiki/frontend.md 更新页面清单

修复:
- ArticleEditor: 修复 unused variable 构建错误
- FollowUpTaskList: 修复 filter(Boolean) 类型窄化问题
2026-04-27 07:38:47 +08:00
iven
3424a33b6b fix(miniprogram): 小程序审计修复 — 安全加固+功能链路+输入验证
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
安全修复:
- H1: Token 刷新竞态条件 → Singleton Promise 模式防止并发刷新
- H4: 移除 store 中的 token 明文状态,统一走 secure storage
- H5: 登录/绑定手机号添加 loading 防重复点击保护
- H6: Analytics 改用 request.ts 统一请求层,不再绕过认证
- M1: logout 清理所有残留数据(openid/tenant_id/analytics_queue)
- M2/M7: 敏感数据(user/openid/tenant_id)统一走加密存储
- M3: 移除开发日志中的请求体打印
- M4: secure-storage 解密失败返回 null 而非空串

功能修复:
- F1: 今日体征概览 API 支持 patient_id 查询参数(后端+前端)
- F2: 积分商城对无患者档案用户展示引导 UI
- M6: daily-monitoring 添加 Zod 数值范围验证

清理:
- L4: 移除 devLogin 开发辅助函数
2026-04-27 00:41:30 +08:00
1120 changed files with 148847 additions and 22194 deletions

78
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
backend-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: 123123
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
TEST_DB_URL: postgres://postgres:123123@localhost:5432/postgres
JWT_SECRET: test-jwt-secret-for-ci
DATABASE_URL: postgres://postgres:123123@localhost:5432/erp_ci
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Check
run: cargo check --workspace
- name: Run unit tests
run: cargo test --workspace --lib --bins -- --test-threads=2
- name: Run integration tests
run: cargo test -p erp-server --test integration -- --test-threads=1
- name: Clippy
run: cargo clippy --workspace -- -D warnings
frontend-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/web
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: apps/web/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- name: TypeScript check
run: npx tsc --noEmit
- name: Run unit tests
run: pnpm test -- --run
- name: Build
run: pnpm build

29
.gitignore vendored
View File

@@ -35,3 +35,32 @@ docs/debug-*.png
# Development env # Development env
.env.development .env.development
docker/docker-compose.override.yml 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

11
.lintstagedrc.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
'*.rs': [
'cargo fmt --check --',
() => 'cargo clippy -p erp-health -p erp-server -- -D warnings',
],
'apps/web/src/**/*.{ts,tsx}': (filenames) =>
`npx eslint --fix ${filenames.join(' ')}`,
'apps/web/src/**/*.test.{ts,tsx}': [
'cd apps/web && npx vitest run --reporter=verbose',
],
};

View File

@@ -1,45 +0,0 @@
warning: unused import: `PluginError`
--> crates\erp-plugin\src\plugin_validator.rs:1:20
|
1 | use crate::error::{PluginError, PluginResult};
| ^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: unused import: `parse_manifest`
--> crates\erp-plugin\src\plugin_validator.rs:2:23
|
2 | use crate::manifest::{parse_manifest, PluginManifest};
| ^^^^^^^^^^^^^^
warning: field `chk` is never read
--> crates\erp-plugin\src\data_service.rs:445:39
|
445 | struct RefCheck { chk: Option<i32> }
| -------- ^^^
| |
| field in this struct
|
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
warning: field `chk` is never read
--> crates\erp-plugin\src\data_service.rs:684:51
|
684 | ... struct RefCheck { chk: Option<i32> }
| -------- ^^^
| |
| field in this struct
warning: field `check_result` is never read
--> crates\erp-plugin\src\data_service.rs:1329:30
|
1329 | struct ExistsCheck { check_result: Option<i32> }
| ----------- ^^^^^^^^^^^^
| |
| field in this struct
warning: `erp-plugin` (lib) generated 5 warnings (run `cargo fix --lib -p erp-plugin` to apply 2 suggestions)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
Running `target\debug\erp-server.exe`
Error: configuration file "config/default" not found
error: process didn't exit successfully: `target\debug\erp-server.exe` (exit code: 1)

View File

View File

@@ -1 +0,0 @@
10056

View File

View File

@@ -1,10 +0,0 @@
> web@0.0.0 dev G:\erp\apps\web
> vite "--" "--strictPort"
Port 5174 is in use, trying another one...
VITE v8.0.8 ready in 316 ms
➜ Local: http://localhost:5175/
 ➜ Network: use --host to expose

View File

@@ -1 +0,0 @@
50960

View File

@@ -1 +0,0 @@
{"reason":"owner process exited","timestamp":1776267637695}

View File

@@ -1,80 +0,0 @@
<h2>客户管理插件 — 三种实现方案</h2>
<p class="subtitle">选择最适合项目架构和交付目标的实现路径</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>纯 WASM 插件 + 增强插件 UI 引擎</h3>
<p><strong>数据层:</strong>全部通过 WASM Host API5 个动态表存储在 JSONB</p>
<p><strong>UI 层:</strong>扩展 PluginCRUDPage新增 ui_widget 类型tree、graph、timeline</p>
<p><strong>关系图谱:</strong>新增 "graph" ui_widget前端用 D3/AntV G6 渲染</p>
<div class="pros-cons">
<div class="pros"><h4>优势</h4><ul>
<li>完全在插件架构内,验证插件系统能力</li>
<li>新增的 ui_widget 可被所有未来插件复用</li>
<li>动态安装/卸载,热插拔</li>
</ul></div>
<div class="cons"><h4>劣势</h4><ul>
<li>JSONB 查询性能弱于原生列</li>
<li>复杂关系查询需多次 Host API 调用</li>
<li>ui_widget 扩展工作量大3-4 种新控件)</li>
</ul></div>
</div>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>WASM 插件数据层 + 专用前端页面(推荐)</h3>
<p><strong>数据层:</strong>WASM 插件管理 5 个实体,通过 Host API 操作</p>
<p><strong>UI 层:</strong>前端新增 CRM 专用页面组件(客户列表、联系人、沟通时间线、关系图谱)</p>
<p><strong>路由:</strong>插件的 manifest.ui.pages 声明自定义页面,前端按 pluginId 匹配加载</p>
<div class="pros-cons">
<div class="pros"><h4>优势</h4><ul>
<li>最佳 UX — 专为 CRM 设计的交互</li>
<li>关系图谱可用专业图表库AntV G6</li>
<li>数据层仍验证 WASM 插件系统</li>
<li>前后端分离UI 可独立迭代</li>
</ul></div>
<div class="cons"><h4>劣势</h4><ul>
<li>每增加一个插件需写前端代码</li>
<li>插件 UI 不能完全动态化</li>
<li>数据查询仍受 Host API 限制</li>
</ul></div>
</div>
</div>
</div>
<div class="option" data-choice="c" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>内置 erp-crm crate + 独立前端</h3>
<p><strong>数据层:</strong>新建 erp-crm crate直接 SeaORM Entity原生列存储</p>
<p><strong>UI 层:</strong>独立前端页面,直接调用 CRM API</p>
<p><strong>关系图谱:</strong>数据库原生支持关系查询,前端 AntV G6</p>
<div class="pros-cons">
<div class="pros"><h4>优势</h4><ul>
<li>性能最优 — 原生 SQL + 索引</li>
<li>复杂关系查询简单高效</li>
<li>完全控制数据模型</li>
</ul></div>
<div class="cons"><h4>劣势</h4><ul>
<li>不走插件架构,违背"插件优先"原则</li>
<li>编译时耦合,不能动态安装</li>
<li>不适合作为"第一个插件"验证目标</li>
</ul></div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top: 2rem; padding: 1.5rem; background: var(--card); border-radius: 12px; border-left: 4px solid #1677ff;">
<h3>💡 推荐方案BWASM 数据层 + 专用前端)</h3>
<p style="line-height: 1.8;">
作为"第一个插件",方案 B 在<strong>验证插件系统</strong><strong>交付可用产品</strong>之间取得最佳平衡:
数据层走 WASM Host API 验证插件架构的可行性UI 层不受 schema 驱动的限制,
可以提供真正好用的 CRM 交互体验。同时为未来的插件 UI 定制建立模式manifest.ui.pages 的前端路由匹配机制)。
</p>
</div>

View File

@@ -1 +0,0 @@
{"reason":"owner process exited","timestamp":1776364845649}

View File

@@ -1,2 +0,0 @@
{"type":"server-started","port":55597,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:55597","screen_dir":"g:/erp/.superpowers/brainstorm/4473-1776364785"}
{"type":"server-stopped","reason":"owner process exited"}

View File

@@ -1 +0,0 @@
4481

View File

@@ -1,147 +0,0 @@
<h2>CRM 插件专家组头脑风暴 — 综合发现</h2>
<p class="subtitle">6 个专家组深度分析结果 · 28 项发现 · 4 个 Critical 级别</p>
<div class="section">
<h3>严重程度分布</h3>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:16px 0">
<div style="background:#FEE2E2;border:1px solid #FCA5A5;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#DC2626">4</div>
<div style="font-size:13px;color:#991B1B;margin-top:4px">Critical</div>
<div style="font-size:11px;color:#B91C1C;margin-top:2px">必须立即修复</div>
</div>
<div style="background:#FEF3C7;border:1px solid #FDE68A;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#D97706">8</div>
<div style="font-size:13px;color:#92400E;margin-top:4px">High</div>
<div style="font-size:11px;color:#A16207;margin-top:2px">下一版本必须解决</div>
</div>
<div style="background:#DBEAFE;border:1px solid #93C5FD;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#2563EB">10</div>
<div style="font-size:13px;color:#1E40AF;margin-top:4px">Medium</div>
<div style="font-size:11px;color:#1D4ED8;margin-top:2px">应规划解决</div>
</div>
<div style="background:#D1FAE5;border:1px solid #6EE7B7;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#059669">6</div>
<div style="font-size:13px;color:#065F46;margin-top:4px">Low/Info</div>
<div style="font-size:11px;color:#047857;margin-top:2px">记录待定</div>
</div>
</div>
</div>
<h3>6 专家组核心发现</h3>
<div class="cards">
<div class="card" data-choice="arch" onclick="toggleSelect(this)">
<div class="card-body">
<h3 style="color:#7C3AED">🏗️ 后端架构师</h3>
<p><strong>核心判断:</strong>当前是"声明式插件框架"穿了"命令式 WASM 沙箱"的外衣。CRM 的 WASM Guest 仅 30 行空壳100% 流量绕过 WASM 层。</p>
<p><strong>推荐方案:</strong>三层插件模型 — L1 声明式(80%) / L2 钩子式(15%) / L3 计算密集(5%)。JSONB + PostgreSQL Generated Column 混合存储。</p>
<ul style="font-size:13px;color:#666">
<li>C-01: db_query 不可用Host API 半成品)</li>
<li>H-01: JSONB 类型安全缺失(字符串排序非数值排序)</li>
<li>H-02: 无插件版本升级迁移能力</li>
</ul>
</div>
</div>
<div class="card" data-choice="crm" onclick="toggleSelect(this)">
<div class="card-body">
<h3 style="color:#059669">💼 CRM 产品专家</h3>
<p><strong>核心判断:</strong>当前是"客户通讯录"而非 CRM。缺少销售流程引擎线索→商机→漏斗→赢单这个灵魂。</p>
<p><strong>推荐路线:</strong>MVP 加 lead+opportunity 实体 + kanban 页面 → V2 团队协作+公海池 → V3 智能化+跨模块联动。</p>
<ul style="font-size:13px;color:#666">
<li>C-02: 无商机/漏斗管理 — CRM 不是 CRM</li>
<li>H-03: JSONB 零 FK 完整性</li>
<li>H-04: 无数据校验(手机号/邮箱格式)</li>
<li>H-05: 无跟进提醒机制</li>
</ul>
</div>
</div>
<div class="card" data-choice="sec" onclick="toggleSelect(this)">
<div class="card-body">
<h3 style="color:#DC2626">🔐 安全工程师</h3>
<p><strong>核心判断:</strong>行级数据权限完全缺失是最大的安全风险。plugin.admin 权限过宽等同于超级用户。</p>
<p><strong>紧急修复:</strong>① 收紧权限 fallback ② 行级数据权限框架 ③ 插件间 entity 白名单。</p>
<ul style="font-size:13px;color:#666">
<li>C-03: 行级数据权限缺失销售A看销售B客户</li>
<li>C-04: plugin.admin 获得所有插件的超级权限</li>
<li>H-06: 插件间无 entity 白名单隔离</li>
<li>H-07: JSONB 查询注入风险</li>
</ul>
</div>
</div>
<div class="card" data-choice="fe" onclick="toggleSelect(this)">
<div class="card-body">
<h3 style="color:#2563EB">🎨 前端架构师</h3>
<p><strong>核心判断:</strong>Schema 驱动 UI 已覆盖 70% 后台场景,但无法描述"行为"。关联选择器、批量操作、看板是三个最高优先级突破。</p>
<p><strong>推荐策略:</strong>声明式 DSL 扩展(短期)→ Iframe 沙箱自定义 UI中期→ Web Component远期</p>
<ul style="font-size:13px;color:#666">
<li>H-08: 无 entity_select 关联选择器</li>
<li>H-09: 无批量操作(多选+批量处理)</li>
<li>M-01: visible_when 只支持 field==value</li>
<li>M-02: 图谱/树全量加载性能问题</li>
</ul>
</div>
</div>
<div class="card" data-choice="plat" onclick="toggleSelect(this)">
<div class="card-body">
<h3 style="color:#D97706">🔌 平台架构师</h3>
<p><strong>核心判断:</strong>插件是信息孤岛无法互相发现和协作。PluginEngine 的 DashMap key 设计阻碍多版本共存。</p>
<p><strong>三层通信模型:</strong>事件契约注册 → 跨插件只读查询 → 插件间 RPC远期。自定义 API 用通配路由分发。</p>
<ul style="font-size:13px;color:#666">
<li>H-10: dependencies 字段已声明但从未校验</li>
<li>M-03: DashMap key 为 manifest id多版本冲突</li>
<li>M-04: 无自定义 API 端点能力</li>
<li>M-05: WIT 接口无版本化</li>
</ul>
</div>
</div>
<div class="card" data-choice="perf" onclick="toggleSelect(this)">
<div class="card-body">
<h3 style="color:#0891B2">⚡ 性能工程师</h3>
<p><strong>核心判断:</strong>JSONB 排序无 B-tree 索引、ILIKE '%..%' 全表扫描、深翻页 OFFSET 退化是三大性能瓶颈。当前在万级数据以内可用,十万级会崩。</p>
<p><strong>核心方案:</strong>Generated Column 提取高频字段 + pg_trgm 加速搜索 + Keyset Pagination + 聚合 Redis 缓存。</p>
<ul style="font-size:13px;color:#666">
<li>M-06: ORDER BY data->>'field' 全表扫描</li>
<li>M-07: ILIKE '%keyword%' 无法用索引</li>
<li>M-08: OFFSET 深翻页线性退化</li>
<li>M-09: 每次请求双重查库schema 解析)</li>
<li>M-10: Dashboard 串行聚合</li>
</ul>
</div>
</div>
</div>
<h3>跨专家组共识 Top 5</h3>
<div style="margin:12px 0">
<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#F5F3FF;border-radius:6px;margin-bottom:6px">
<span style="background:#7C3AED;color:white;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">#1</span>
<strong>JSONB + Generated Column 混合存储</strong>
<span style="font-size:12px;color:#666;margin-left:auto">后端+性能+产品 三组一致推荐</span>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#ECFDF5;border-radius:6px;margin-bottom:6px">
<span style="background:#059669;color:white;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">#2</span>
<strong>ref_entity 应用层外键校验 + 级联策略</strong>
<span style="font-size:12px;color:#666;margin-left:auto">后端+产品+安全 三组一致推荐</span>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#EFF6FF;border-radius:6px;margin-bottom:6px">
<span style="background:#2563EB;color:white;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">#3</span>
<strong>entity_select 关联选择器 + kanban 看板页面</strong>
<span style="font-size:12px;color:#666;margin-left:auto">前端+产品 两组核心诉求</span>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#FEF2F2;border-radius:6px;margin-bottom:6px">
<span style="background:#DC2626;color:white;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">#4</span>
<strong>行级数据权限 + 权限 fallback 收紧</strong>
<span style="font-size:12px;color:#666;margin-left:auto">安全 Critical + 平台架构支持</span>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#FFFBEB;border-radius:6px;margin-bottom:6px">
<span style="background:#D97706;color:white;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">#5</span>
<strong>跨插件事件契约 + 只读数据查询</strong>
<span style="font-size:12px;color:#666;margin-left:auto">平台+产品 两组跨模块联动需求</span>
</div>
</div>
<p style="text-align:center;color:#999;font-size:13px;margin-top:20px">请在终端中回复,告诉我你的优先级偏好</p>

View File

@@ -1,3 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">设计规格已提交,继续在终端中推进...</p>
</div>

View File

@@ -158,6 +158,7 @@
- 事件必须持久化到 `domain_events` 表outbox 模式) - 事件必须持久化到 `domain_events` 表outbox 模式)
- 事件处理失败记录到 dead-letter 存储 - 事件处理失败记录到 dead-letter 存储
- 事件类型命名:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed` - 事件类型命名:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed`
- **铁律:每个事件必须有至少一个消费者,否则功能不算完成。** 新增事件发布时必须同步实现消费者和对应测试。详见 `docs/discussions/2026-04-28-architecture-retrospective.md` §4。
### 3.5 Rust 代码规范 ### 3.5 Rust 代码规范
@@ -255,7 +256,11 @@ docker exec erp-postgres psql -U erp -c "\dt"
| `message` | erp-message | | `message` | erp-message |
| `config` | erp-config | | `config` | erp-config |
| `server` | erp-server | | `server` | erp-server |
| `health` | erp-health |
| `ai` | erp-ai |
| `dialysis` | erp-dialysis |
| `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample | | `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample |
| `assessment` | erp-plugin-assessment |
| `crm` | erp-plugin-crm | | `crm` | erp-plugin-crm |
| `inventory` | erp-plugin-inventory | | `inventory` | erp-plugin-inventory |
| `web` | Web 前端 | | `web` | Web 前端 |

265
Cargo.lock generated
View File

@@ -288,6 +288,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.8" version = "0.8.8"
@@ -555,7 +577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d"
dependencies = [ dependencies = [
"ambient-authority", "ambient-authority",
"rand", "rand 0.8.5",
] ]
[[package]] [[package]]
@@ -681,6 +703,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "cobs" name = "cobs"
version = "0.3.0" version = "0.3.0"
@@ -1056,7 +1087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core", "rand_core 0.6.4",
"typenum", "typenum",
] ]
@@ -1330,6 +1361,12 @@ dependencies = [
"dtoa", "dtoa",
] ]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -1374,10 +1411,12 @@ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"chrono", "chrono",
"dashmap",
"erp-core", "erp-core",
"futures", "futures",
"handlebars", "handlebars",
"hex", "hex",
"redis",
"reqwest", "reqwest",
"sea-orm", "sea-orm",
"serde", "serde",
@@ -1405,6 +1444,7 @@ dependencies = [
"erp-core", "erp-core",
"hex", "hex",
"jsonwebtoken", "jsonwebtoken",
"redis",
"reqwest", "reqwest",
"sea-orm", "sea-orm",
"serde", "serde",
@@ -1452,7 +1492,7 @@ dependencies = [
"dashmap", "dashmap",
"hex", "hex",
"hmac", "hmac",
"rand", "rand 0.8.5",
"sea-orm", "sea-orm",
"serde", "serde",
"serde_json", "serde_json",
@@ -1464,11 +1504,32 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "erp-dialysis"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"chrono",
"erp-core",
"num-traits",
"sea-orm",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"utoipa",
"uuid",
"validator",
]
[[package]] [[package]]
name = "erp-health" name = "erp-health"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"argon2",
"async-trait", "async-trait",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
@@ -1476,7 +1537,9 @@ dependencies = [
"erp-core", "erp-core",
"hex", "hex",
"hmac", "hmac",
"jsonwebtoken",
"num-traits", "num-traits",
"rand_core 0.6.4",
"sea-orm", "sea-orm",
"serde", "serde",
"serde_json", "serde_json",
@@ -1487,6 +1550,7 @@ dependencies = [
"utoipa", "utoipa",
"uuid", "uuid",
"validator", "validator",
"zeroize",
] ]
[[package]] [[package]]
@@ -1540,6 +1604,15 @@ dependencies = [
"wasmtime-wasi", "wasmtime-wasi",
] ]
[[package]]
name = "erp-plugin-assessment"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"wit-bindgen 0.55.0",
]
[[package]] [[package]]
name = "erp-plugin-crm" name = "erp-plugin-crm"
version = "0.1.0" version = "0.1.0"
@@ -1599,6 +1672,7 @@ name = "erp-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait",
"axum", "axum",
"chrono", "chrono",
"config", "config",
@@ -1606,16 +1680,23 @@ dependencies = [
"erp-auth", "erp-auth",
"erp-config", "erp-config",
"erp-core", "erp-core",
"erp-dialysis",
"erp-health", "erp-health",
"erp-message", "erp-message",
"erp-plugin", "erp-plugin",
"erp-server-migration", "erp-server-migration",
"erp-workflow", "erp-workflow",
"futures",
"hex",
"metrics",
"metrics-exporter-prometheus",
"moka", "moka",
"redis", "redis",
"sea-orm", "sea-orm",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sqlx",
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http",
@@ -1789,6 +1870,12 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "funty" name = "funty"
version = "2.0.0" version = "2.0.0"
@@ -2233,6 +2320,7 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"rustls", "rustls",
"rustls-native-certs",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
@@ -2425,8 +2513,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe" checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe"
dependencies = [ dependencies = [
"bitmaps", "bitmaps",
"rand_core", "rand_core 0.6.4",
"rand_xoshiro", "rand_xoshiro 0.6.0",
"sized-chunks", "sized-chunks",
"typenum", "typenum",
"version_check", "version_check",
@@ -2803,6 +2891,53 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "metrics"
version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
dependencies = [
"ahash 0.8.12",
"portable-atomic",
]
[[package]]
name = "metrics-exporter-prometheus"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd7399781913e5393588a8d8c6a2867bf85fb38eaf2502fdce465aad2dc6f034"
dependencies = [
"base64 0.22.1",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"indexmap",
"ipnet",
"metrics",
"metrics-util",
"quanta",
"thiserror 1.0.69",
"tokio",
"tracing",
]
[[package]]
name = "metrics-util"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
"hashbrown 0.15.5",
"metrics",
"quanta",
"rand 0.9.4",
"rand_xoshiro 0.7.0",
"sketches-ddsketch",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -2956,7 +3091,7 @@ dependencies = [
"num-integer", "num-integer",
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand", "rand 0.8.5",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@@ -3165,7 +3300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [ dependencies = [
"base64ct", "base64ct",
"rand_core", "rand_core 0.6.4",
"subtle", "subtle",
] ]
@@ -3289,7 +3424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared", "phf_shared",
"rand", "rand 0.8.5",
] ]
[[package]] [[package]]
@@ -3519,6 +3654,21 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -3553,8 +3703,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -3564,7 +3724,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -3576,13 +3746,40 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_xoshiro" name = "rand_xoshiro"
version = "0.6.0" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
dependencies = [ dependencies = [
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_xoshiro"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags",
] ]
[[package]] [[package]]
@@ -3823,7 +4020,7 @@ dependencies = [
"num-traits", "num-traits",
"pkcs1", "pkcs1",
"pkcs8", "pkcs8",
"rand_core", "rand_core 0.6.4",
"signature", "signature",
"spki", "spki",
"subtle", "subtle",
@@ -3850,7 +4047,7 @@ dependencies = [
"borsh", "borsh",
"bytes", "bytes",
"num-traits", "num-traits",
"rand", "rand 0.8.5",
"rkyv", "rkyv",
"serde", "serde",
"serde_json", "serde_json",
@@ -3929,6 +4126,7 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"aws-lc-rs",
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -3937,6 +4135,18 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.0" version = "1.14.0"
@@ -3952,6 +4162,7 @@ version = "0.103.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4"
dependencies = [ dependencies = [
"aws-lc-rs",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted",
@@ -4345,7 +4556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest",
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@@ -4388,6 +4599,12 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "sketches-ddsketch"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -4565,7 +4782,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rand", "rand 0.8.5",
"rsa", "rsa",
"rust_decimal", "rust_decimal",
"serde", "serde",
@@ -4609,7 +4826,7 @@ dependencies = [
"memchr", "memchr",
"num-bigint", "num-bigint",
"once_cell", "once_cell",
"rand", "rand 0.8.5",
"rust_decimal", "rust_decimal",
"serde", "serde",
"serde_json", "serde_json",
@@ -6731,6 +6948,20 @@ name = "zeroize"
version = "1.8.2" version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"

View File

@@ -17,6 +17,8 @@ members = [
"crates/erp-plugin-itops", "crates/erp-plugin-itops",
"crates/erp-health", "crates/erp-health",
"crates/erp-ai", "crates/erp-ai",
"crates/erp-plugin-assessment",
"crates/erp-dialysis",
] ]
[workspace.package] [workspace.package]
@@ -38,6 +40,7 @@ sea-orm = { version = "1.1", features = [
"sqlx-postgres", "runtime-tokio-rustls", "macros", "with-uuid", "with-chrono", "with-json" "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-uuid", "with-chrono", "with-json"
] } ] }
sea-orm-migration = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls"] } sea-orm-migration = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"] }
# Serialization # Serialization
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@@ -102,14 +105,20 @@ erp-config = { path = "crates/erp-config" }
erp-plugin = { path = "crates/erp-plugin" } erp-plugin = { path = "crates/erp-plugin" }
erp-health = { path = "crates/erp-health" } erp-health = { path = "crates/erp-health" }
erp-ai = { path = "crates/erp-ai" } erp-ai = { path = "crates/erp-ai" }
erp-dialysis = { path = "crates/erp-dialysis" }
# Async streaming # Async streaming
futures = "0.3" futures = "0.3"
tokio-stream = "0.1" tokio-stream = "0.1"
async-stream = "0.3" async-stream = "0.3"
dashmap = "6"
# Template engine # Template engine
handlebars = "6" handlebars = "6"
# HTML sanitization # HTML sanitization
ammonia = "4" ammonia = "4"
# Metrics
metrics = "0.24"
metrics-exporter-prometheus = "0.16"

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// 使用 vi.hoisted 确保 storage 在 mock 提升前可用
const { storage } = vi.hoisted(() => ({
storage: new Map<string, string>(),
}));
vi.mock('@tarojs/taro', () => ({
default: {
openBluetoothAdapter: vi.fn().mockResolvedValue({}),
closeBluetoothAdapter: vi.fn().mockResolvedValue({}),
startBluetoothDevicesDiscovery: vi.fn().mockResolvedValue({}),
stopBluetoothDevicesDiscovery: vi.fn().mockResolvedValue({}),
onBluetoothDeviceFound: vi.fn(),
offBluetoothDeviceFound: vi.fn(),
createBLEConnection: vi.fn().mockResolvedValue({}),
closeBLEConnection: vi.fn().mockResolvedValue({}),
getBLEDeviceServices: vi.fn().mockResolvedValue({ services: [] }),
getBLEDeviceCharacteristics: vi.fn().mockResolvedValue({ characteristics: [] }),
notifyBLECharacteristicValueChange: vi.fn().mockResolvedValue({}),
onBLECharacteristicValueChange: vi.fn(),
onBLEConnectionStateChange: vi.fn(),
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
},
}));
import { BLEManager } from '@/services/ble/BLEManager';
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
describe('BLEManager DataBuffer 集成', () => {
let manager: BLEManager;
beforeEach(() => {
storage.clear();
manager = new BLEManager();
manager.registerAdapter(XiaomiBandAdapter);
});
afterEach(async () => {
await manager.destroy();
});
it('registerAdapter 添加适配器', () => {
const count = (manager as any).adapters.length;
expect(count).toBeGreaterThanOrEqual(1);
});
it('getCachedReadings 返回空数组(未连接时)', () => {
const readings = manager.getCachedReadings();
expect(readings).toEqual([]);
});
it('flushPendingReadings 无缓存时返回 0', async () => {
const uploadFn = vi.fn().mockResolvedValue(0);
const count = await manager.flushPendingReadings(uploadFn);
expect(count).toBe(0);
expect(uploadFn).not.toHaveBeenCalled();
});
it('DataBuffer 实例已初始化', () => {
const buffer = (manager as any).dataBuffer;
expect(buffer).toBeDefined();
expect(typeof buffer.push).toBe('function');
expect(typeof buffer.flush).toBe('function');
expect(typeof buffer.restore).toBe('function');
});
});

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { DataBuffer } from '@/services/ble/DataBuffer';
import type { NormalizedReading } from '@/services/ble/types';
// Mock Taro Storage
const storage = new Map<string, string>();
vi.mock('@tarojs/taro', () => ({
default: {
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
getStorageInfoSync: vi.fn(() => ({ keys: Array.from(storage.keys()), limitSize: 10240, currentSize: storage.size })),
},
}));
function makeReading(overrides: Partial<NormalizedReading> = {}): NormalizedReading {
return {
device_type: 'heart_rate',
values: { heart_rate: 72 },
measured_at: new Date().toISOString(),
...overrides,
};
}
describe('DataBuffer', () => {
let buffer: DataBuffer;
beforeEach(() => {
storage.clear();
buffer = new DataBuffer({ bucketSize: 100 });
});
it('push 添加读数并持久化', () => {
const reading = makeReading();
buffer.push(reading);
expect(buffer.size()).toBe(1);
});
it('push 批量添加读数', () => {
const readings = Array.from({ length: 10 }, (_, i) =>
makeReading({ measured_at: new Date(Date.now() + i * 1000).toISOString() }),
);
buffer.push(readings);
expect(buffer.size()).toBe(10);
});
it('flush 返回并清空缓冲区', () => {
buffer.push([
makeReading({ measured_at: '2026-05-04T10:00:00.000Z' }),
makeReading({ measured_at: '2026-05-04T10:00:01.000Z' }),
]);
const flushed = buffer.flush();
expect(flushed.length).toBe(2);
expect(buffer.size()).toBe(0);
});
it('超过 maxTotal 时丢弃最旧数据', () => {
const smallBuffer = new DataBuffer({ bucketSize: 5, maxTotal: 10 });
for (let i = 0; i < 15; i++) {
smallBuffer.push(makeReading({ measured_at: new Date(i * 1000).toISOString() }));
}
expect(smallBuffer.size()).toBe(10);
});
it('去重:相同 measured_at + device_type 不重复存储', () => {
const ts = '2026-05-04T10:00:00.000Z';
buffer.push(makeReading({ measured_at: ts }));
buffer.push(makeReading({ measured_at: ts }));
expect(buffer.size()).toBe(1);
});
it('restore 从 Storage 恢复未上传数据', () => {
buffer.push([
makeReading({ measured_at: '2026-05-04T10:00:00.000Z' }),
makeReading({ measured_at: '2026-05-04T10:00:01.000Z' }),
]);
// 模拟重启:新建 DataBuffer 并 restore
const restored = new DataBuffer({ bucketSize: 100 });
const count = restored.restore();
expect(count).toBe(2);
expect(restored.size()).toBe(2);
});
it('clear 清空缓冲区和 Storage', () => {
buffer.push(makeReading());
buffer.clear();
expect(buffer.size()).toBe(0);
});
});

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
const storage = new Map<string, string>();
vi.mock('@tarojs/taro', () => ({
default: {
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
},
}));
describe('DataSyncScheduler', () => {
let scheduler: DataSyncScheduler;
let syncFn: ReturnType<typeof vi.fn>;
beforeEach(() => {
storage.clear();
syncFn = vi.fn().mockResolvedValue({ success: true, uploadedCount: 5 });
scheduler = new DataSyncScheduler({
intervalMs: 60 * 60 * 1000,
storageKey: 'last_ble_sync',
});
});
afterEach(() => {
scheduler.destroy();
});
it('首次同步:无记录时立即需要同步', () => {
expect(scheduler.needsSync()).toBe(true);
});
it('同步后记录时间戳', async () => {
await scheduler.recordSync(syncFn);
expect(storage.has('last_ble_sync')).toBe(true);
expect(syncFn).toHaveBeenCalled();
});
it('同步后不需要再次同步', async () => {
await scheduler.recordSync(syncFn);
expect(scheduler.needsSync()).toBe(false);
});
it('超过间隔后需要再次同步', async () => {
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: twoHoursAgo }));
scheduler = new DataSyncScheduler({ intervalMs: 60 * 60 * 1000, storageKey: 'last_ble_sync' });
expect(scheduler.needsSync()).toBe(true);
});
it('同步失败不更新时间戳', async () => {
const failFn = vi.fn().mockRejectedValue(new Error('network error'));
const oneHourAgo = Date.now() - 60 * 60 * 1000;
storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: oneHourAgo }));
await scheduler.recordSync(failFn);
const stored = JSON.parse(storage.get('last_ble_sync') || '{}');
expect(stored.lastSyncAt).toBe(oneHourAgo);
});
it('tryAutoSync 首次时触发同步', async () => {
const result = await scheduler.tryAutoSync(syncFn);
expect(result).toBe(true);
expect(syncFn).toHaveBeenCalledTimes(1);
});
it('tryAutoSync 未超时不触发', async () => {
await scheduler.recordSync(syncFn);
syncFn.mockClear();
const result = await scheduler.tryAutoSync(syncFn);
expect(result).toBe(false);
expect(syncFn).not.toHaveBeenCalled();
});
it('destroy 清理定时器', () => {
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
scheduler.startPeriodicCheck(syncFn, 30000);
scheduler.destroy();
expect(clearIntervalSpy).toHaveBeenCalled();
clearIntervalSpy.mockRestore();
});
it('getLastSyncAt 返回上次同步时间', async () => {
await scheduler.recordSync(syncFn);
const lastSync = scheduler.getLastSyncAt();
expect(lastSync).toBeTruthy();
expect(typeof lastSync).toBe('number');
});
});

View File

@@ -0,0 +1,158 @@
import { describe, it, expect } from 'vitest';
import { createGenericBleAdapter } from '@/services/ble/adapters/GenericBleAdapter';
import type { GenericBLEProfile } from '@/services/ble/types';
// ---- Heart Rate (0x180D / 0x2A37) ----
// Flag byte=0x00 (UINT8), HR=75
function makeHeartRateData(hr: number, isUint16 = false): ArrayBuffer {
const buf = new ArrayBuffer(isUint16 ? 3 : 2);
const view = new DataView(buf);
view.setUint8(0, isUint16 ? 0x01 : 0x00);
if (isUint16) {
view.setUint16(1, hr, true);
} else {
view.setUint8(1, hr);
}
return buf;
}
// ---- Health Thermometer (0x1809 / 0x2A1C) ----
// IEEE 11073 FLOAT: 32-bit — mantissa (24-bit) + exponent (8-bit)
function makeTemperatureData(tempCelsius: number): ArrayBuffer {
const buf = new ArrayBuffer(4);
const view = new DataView(buf);
// flags byte: 0x00 = Celsius, no timestamp, no type
view.setUint8(0, 0x00);
// 11073 FLOAT: mantissa * 10^exponent
// For 36.5: mantissa=365, exponent=-1
const mantissa = Math.round(tempCelsius * 10);
const exponent = -1;
view.setInt16(1, mantissa, true);
view.setInt8(3, exponent);
return buf;
}
describe('GenericBleAdapter', () => {
describe('心率解析', () => {
const adapter = createGenericBleAdapter({
name: 'Test Wristband',
supportedModels: ['TestBand'],
profiles: ['heart_rate'],
});
it('解析 UINT8 心率', () => {
const data = makeHeartRateData(75);
const results = adapter.parseNotification(
'0000180D-0000-1000-8000-00805f9b34fb',
'00002A37-0000-1000-8000-00805f9b34fb',
data,
);
expect(results.length).toBe(1);
expect(results[0].device_type).toBe('heart_rate');
expect(results[0].values.heart_rate).toBe(75);
});
it('解析 UINT16 心率', () => {
const data = makeHeartRateData(200, true);
const results = adapter.parseNotification(
'0000180D-0000-1000-8000-00805f9b34fb',
'00002A37-0000-1000-8000-00805f9b34fb',
data,
);
expect(results.length).toBe(1);
expect(results[0].values.heart_rate).toBe(200);
});
it('忽略非目标 Characteristic', () => {
const data = makeHeartRateData(75);
const results = adapter.parseNotification(
'0000180D-0000-1000-8000-00805f9b34fb',
'00002A38-0000-1000-8000-00805f9b34fb', // Body Sensor Location
data,
);
expect(results.length).toBe(0);
});
});
describe('体温解析', () => {
const adapter = createGenericBleAdapter({
name: 'Test Thermometer',
supportedModels: ['TestThermo'],
profiles: ['health_thermometer'],
});
it('解析体温读数', () => {
const data = makeTemperatureData(36.5);
const results = adapter.parseNotification(
'00001809-0000-1000-8000-00805f9b34fb',
'00002A1C-0000-1000-8000-00805f9b34fb',
data,
);
expect(results.length).toBe(1);
expect(results[0].device_type).toBe('temperature');
expect(results[0].values.value).toBeCloseTo(36.5, 0);
});
});
describe('多 Profile 适配器', () => {
const adapter = createGenericBleAdapter({
name: 'Multi-Profile Band',
supportedModels: ['CustomBand', 'MedicalBand'],
profiles: ['heart_rate', 'health_thermometer'],
});
it('包含两个 Service UUID', () => {
expect(adapter.serviceUUIDs.length).toBe(2);
});
it('包含两个 Profile 的 Characteristic', () => {
expect(adapter.notifyCharacteristics.length).toBe(2);
});
it('supportedModels 配置正确', () => {
expect(adapter.supportedModels).toEqual(['CustomBand', 'MedicalBand']);
});
it('解析心率 + 体温', () => {
const hrResults = adapter.parseNotification(
'0000180D-0000-1000-8000-00805f9b34fb',
'00002A37-0000-1000-8000-00805f9b34fb',
makeHeartRateData(80),
);
expect(hrResults[0].device_type).toBe('heart_rate');
const tempResults = adapter.parseNotification(
'00001809-0000-1000-8000-00805f9b34fb',
'00002A1C-0000-1000-8000-00805f9b34fb',
makeTemperatureData(37.2),
);
expect(tempResults[0].device_type).toBe('temperature');
});
});
describe('边界情况', () => {
const adapter = createGenericBleAdapter({
name: 'Edge Case Band',
supportedModels: ['Edge'],
profiles: ['heart_rate'],
});
it('空数据返回空数组', () => {
const results = adapter.parseNotification(
'0000180D-0000-1000-8000-00805f9b34fb',
'00002A37-0000-1000-8000-00805f9b34fb',
new ArrayBuffer(0),
);
expect(results.length).toBe(0);
});
it('心率超范围 (>300) 返回空数组', () => {
const results = adapter.parseNotification(
'0000180D-0000-1000-8000-00805f9b34fb',
'00002A37-0000-1000-8000-00805f9b34fb',
makeHeartRateData(0),
);
expect(results.length).toBe(0);
});
});
});

View File

@@ -0,0 +1,67 @@
/**
* 审计详情页(带参数)- 测试带假 ID 的页面是否优雅降级
*/
const automator = require('miniprogram-automator');
const DETAIL_PAGES = [
'pages/appointment/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/article/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/report/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/ai-report/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/mall/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/mall/exchange/index?id=00000000-0000-0000-0000-000000000000',
'pages/profile/family-add/index',
'pages/doctor/patients/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/doctor/consultation/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/doctor/followup/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/doctor/report/detail/index?id=00000000-0000-0000-0000-000000000000',
];
async function main() {
console.log('连接...');
const mp = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
const results = { ok: [], crash: [], login: [] };
for (const pageUrl of DETAIL_PAGES) {
const pagePath = pageUrl.split('?')[0];
try {
await mp.reLaunch(`/${pageUrl}`);
await new Promise(r => setTimeout(r, 2000));
const current = await mp.currentPage();
if (current.path === pagePath) {
// 检查页面是否有错误提示或空状态
const content = await mp.evaluate(() => {
const texts = [];
document.querySelectorAll && document.querySelectorAll('.error-state, .empty-state, [class*="error"], [class*="empty"]').forEach(el => {
texts.push(el.textContent);
});
return texts.length > 0 ? texts.join('; ') : 'loaded';
}).catch(() => 'loaded');
results.ok.push(`${pagePath} (${content.slice(0, 30)})`);
console.log(` OK: ${pagePath} - ${content.slice(0, 40)}`);
} else if (current.path === 'pages/login/index') {
results.login.push(pagePath);
console.log(` AUTH: ${pagePath} → login`);
} else {
results.crash.push(`${pagePath}${current.path}`);
console.log(` REDIR: ${pagePath}${current.path}`);
}
} catch (e) {
results.crash.push(`${pagePath}: ${e.message.slice(0, 50)}`);
console.log(` ERR: ${pagePath} - ${e.message.slice(0, 40)}`);
}
}
console.log(`\n===== 详情页审计 =====`);
console.log(`正常: ${results.ok.length}`);
console.log(`需登录: ${results.login.length}`);
console.log(`异常: ${results.crash.length}`);
results.crash.forEach(p => console.log(` 异常: ${p}`));
results.login.forEach(p => console.log(` 需登录: ${p}`));
await mp.disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,112 @@
/**
* 批量审计页面(使用 reLaunch 避免页面栈限制)
*/
const automator = require('miniprogram-automator');
const ALL_PAGES = [
'pages/health/input/index',
'pages/health/trend/index',
'pages/health/daily-monitoring/index',
'pages/appointment/index',
'pages/appointment/create/index',
'pages/article/index',
'pages/ai-report/list/index',
'pages/followup/detail/index',
'pages/consultation/detail/index',
'pages/mall/orders/index',
'pages/profile/family/index',
'pages/profile/reports/index',
'pages/profile/followups/index',
'pages/profile/medication/index',
'pages/profile/settings/index',
'pages/legal/user-agreement',
'pages/legal/privacy-policy',
'pages/doctor/index',
'pages/doctor/patients/index',
'pages/doctor/consultation/index',
'pages/doctor/followup/index',
'pages/doctor/report/index',
'pages/events/index',
'pages/device-sync/index',
];
// 带参数的页面(需要 id 等参数)
const PAGES_WITH_PARAMS = {
'pages/appointment/detail/index': '?id=test-123',
'pages/article/detail/index': '?id=test-123',
'pages/report/detail/index': '?id=test-123',
'pages/ai-report/detail/index': '?id=test-123',
'pages/mall/exchange/index': '?id=test-123',
'pages/mall/detail/index': '?id=test-123',
'pages/profile/family-add/index': '?id=test-123',
'pages/doctor/patients/detail/index': '?id=test-123',
'pages/doctor/consultation/detail/index': '?id=test-123',
'pages/doctor/followup/detail/index': '?id=test-123',
'pages/doctor/report/detail/index': '?id=test-123',
};
async function main() {
console.log('连接 DevTools...');
const mp = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
// 验证 token
const tokenLen = await mp.evaluate(() => {
const t = wx.getStorageSync('access_token');
return t ? t.length : 0;
});
if (tokenLen === 0) {
console.log('ERROR: 无 token请先运行 inject-auth.cjs');
process.exit(1);
}
console.log(`Token: ${tokenLen} chars\n`);
const results = { ok: [], redirectToLogin: [], redirectOther: [], error: [] };
for (const pagePath of ALL_PAGES) {
const param = PAGES_WITH_PARAMS[pagePath] || '';
const url = `/${pagePath}${param}`;
try {
await mp.reLaunch(url);
await new Promise(r => setTimeout(r, 2000));
const current = await mp.currentPage();
if (current.path === pagePath) {
results.ok.push(pagePath);
console.log(` OK: ${pagePath}`);
} else if (current.path === 'pages/login/index') {
results.redirectToLogin.push(pagePath);
console.log(` AUTH: ${pagePath} → login (需登录)`);
} else {
results.redirectOther.push(`${pagePath}${current.path}`);
console.log(` REDIR: ${pagePath}${current.path}`);
}
} catch (e) {
const msg = e.message.slice(0, 60);
results.error.push(`${pagePath}: ${msg}`);
console.log(` ERR: ${pagePath} - ${msg}`);
}
}
console.log(`\n===== 审计摘要 =====`);
console.log(`正常: ${results.ok.length}/${ALL_PAGES.length}`);
console.log(`需登录: ${results.redirectToLogin.length}`);
console.log(`重定向: ${results.redirectOther.length}`);
console.log(`错误: ${results.error.length}`);
if (results.redirectToLogin.length > 0) {
console.log(`\n需登录页面 (API 401 → login):`);
results.redirectToLogin.forEach(p => console.log(` - ${p}`));
}
if (results.error.length > 0) {
console.log(`\n错误页面:`);
results.error.forEach(p => console.log(` - ${p}`));
}
if (results.ok.length > 0) {
console.log(`\n正常页面:`);
results.ok.forEach(p => console.log(` - ${p}`));
}
await mp.disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,109 @@
/**
* 审计修复验证脚本
* 验证 F1: 今日体征概览 API 支持 patient_id 参数
*/
const http = require('http');
const BASE = 'http://localhost:3000/api/v1';
function request(method, path, body, token) {
return new Promise((resolve, reject) => {
const url = new URL(BASE + path);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
};
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
try {
resolve({ status: res.statusCode, data: JSON.parse(raw) });
} catch {
resolve({ status: res.statusCode, data: raw });
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function main() {
console.log('=== 审计修复验证 ===\n');
// 1. 登录
console.log('1. 登录...');
const loginRes = await request('POST', '/auth/login', {
username: 'admin',
password: 'Admin@2026',
});
const token = loginRes.data?.data?.access_token;
if (!token) {
console.error(' FAIL: 登录失败', JSON.stringify(loginRes.data).substring(0, 200));
process.exit(1);
}
console.log(' OK: token 长度', token.length);
// 2. 获取患者列表(找第一个患者 ID
console.log('\n2. 获取患者列表...');
const patientsRes = await request('GET', '/health/patients?page=1&page_size=5', null, token);
const patients = patientsRes.data?.data?.data || [];
console.log(' 患者数量:', patients.length);
const patientId = patients[0]?.id;
if (!patientId) {
console.log(' WARN: 无患者数据,跳过后续测试');
return;
}
console.log(' 使用患者 ID:', patientId);
// 3. F1 验证:今日体征概览 - 不带 patient_id
console.log('\n3. F1 验证: 今日体征概览(不带 patient_id...');
const todayRes1 = await request('GET', '/health/vital-signs/today', null, token);
console.log(' 状态:', todayRes1.status, todayRes1.data?.success ? 'OK' : 'FAIL');
// 4. F1 验证:今日体征概览 - 带 patient_id 参数
console.log('\n4. F1 验证: 今日体征概览(带 patient_id 参数)...');
const todayRes2 = await request('GET', `/health/vital-signs/today?patient_id=${patientId}`, null, token);
console.log(' 状态:', todayRes2.status, todayRes2.data?.success ? 'OK' : 'FAIL');
if (todayRes2.status === 200 && todayRes2.data?.success) {
console.log(' 返回数据:', JSON.stringify(todayRes2.data.data || {}).substring(0, 200));
} else {
console.log(' 响应:', JSON.stringify(todayRes2.data).substring(0, 300));
}
// 5. 验证趋势 API
console.log('\n5. 趋势 API 验证...');
const trendRes = await request('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
console.log(' 状态:', trendRes.status, trendRes.data?.success ? 'OK' : 'FAIL');
// 6. 日常监测 API 验证
console.log('\n6. 日常监测 API 验证...');
const dmRes = await request('POST', '/health/daily-monitoring', {
patient_id: patientId,
record_date: new Date().toISOString().slice(0, 10),
weight: 999, // 超出合理范围,验证后端校验
}, token);
console.log(' 状态:', dmRes.status);
// 后端应该接受或拒绝(取决于后端校验强度)
if (dmRes.status >= 400) {
console.log(' 后端拒绝了请求(预期:应有范围校验):', JSON.stringify(dmRes.data).substring(0, 200));
} else {
console.log(' 后端接受了请求:', dmRes.data?.success ? 'OK' : 'FAIL');
}
console.log('\n=== 验证完成 ===');
}
main().catch((e) => {
console.error('验证失败:', e.message);
process.exit(1);
});

View File

@@ -0,0 +1,5 @@
@echo off
setlocal
"%~dp0.\node.exe" "%~dp0.\cli.js" %*
endlocal

View File

@@ -2,6 +2,10 @@ import type { UserConfigExport } from '@tarojs/cli';
export default { export default {
logger: { quiet: false }, logger: { quiet: false },
mini: {}, mini: {
miniCssExtractPluginOption: {
ignoreOrder: true,
},
},
h5: {}, h5: {},
} satisfies UserConfigExport; } satisfies UserConfigExport;

View File

@@ -5,7 +5,7 @@ export default defineConfig(async (merge) => {
const baseConfig = { const baseConfig = {
projectName: 'hms-miniprogram', projectName: 'hms-miniprogram',
date: '2026-4-23', date: '2026-4-23',
designWidth: 750, designWidth: 375,
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 }, deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
sourceRoot: 'src', sourceRoot: 'src',
outputRoot: 'dist', outputRoot: 'dist',
@@ -14,6 +14,11 @@ export default defineConfig(async (merge) => {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.TARO_APP_API_URL': JSON.stringify(process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'), 'process.env.TARO_APP_API_URL': JSON.stringify(process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'),
'process.env.TARO_APP_ENCRYPTION_KEY': JSON.stringify(process.env.TARO_APP_ENCRYPTION_KEY || ''), 'process.env.TARO_APP_ENCRYPTION_KEY': JSON.stringify(process.env.TARO_APP_ENCRYPTION_KEY || ''),
'process.env.TARO_APP_WX_TEMPLATE_APPOINTMENT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_APPOINTMENT || ''),
'process.env.TARO_APP_WX_TEMPLATE_FOLLOWUP': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_FOLLOWUP || ''),
'process.env.TARO_APP_WX_TEMPLATE_REPORT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_REPORT || ''),
'process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT || ''),
'process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL || ''),
}, },
copy: { patterns: [], options: {} }, copy: { patterns: [], options: {} },
framework: 'react', framework: 'react',
@@ -27,6 +32,9 @@ export default defineConfig(async (merge) => {
mini: { mini: {
compile: { compile: {
exclude: [], exclude: [],
include: [
require.resolve('zod').replace(/[\\/]index\.cjs$/, ''),
],
}, },
postcss: { postcss: {
pxtransform: { enable: true, config: {} }, pxtransform: { enable: true, config: {} },

View File

@@ -1,8 +1,20 @@
import type { UserConfigExport } from '@tarojs/cli'; import type { UserConfigExport } from '@tarojs/cli';
export default { export default {
logger: { quiet: false }, logger: { quiet: true },
mini: { miniCssExtractPluginOption: { ignoreOrder: true } }, mini: {
miniCssExtractPluginOption: { ignoreOrder: true },
terserOption: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.info', 'console.debug'],
},
format: {
comments: false,
},
},
},
h5: { h5: {
miniCssExtractPluginOption: { miniCssExtractPluginOption: {
ignoreOrder: true, ignoreOrder: true,

View File

@@ -0,0 +1,22 @@
2026-04-24T08:58:11.754Z automator:protocol 2026-04-24 16:58:11:753 SEND ► {"id":"2fce4e29-c0a6-4ed2-92e7-3c751ce7beb8","method":"Tool.getInfo","params":{}}
2026-04-24T08:58:11.757Z automator:protocol 2026-04-24 16:58:11:757 ◀ RECV {"id":"2fce4e29-c0a6-4ed2-92e7-3c751ce7beb8","result":{"version":"2.01.2510290","SDKVersion":"3.15.2"}}
Connected
2026-04-24T08:58:11.758Z automator:protocol 2026-04-24 16:58:11:758 SEND ► {"id":"5f642bf3-882c-496d-807c-1745eeb39f0c","method":"App.getCurrentPage","params":{}}
Error: timeout
2026-04-24T08:58:19.770Z automator:protocol 2026-04-24 16:58:19:770 SEND ► {"id":"4f6c3d82-6081-48ad-bea6-f2f5ff441213","method":"App.exit","params":{}}
2026-04-24T09:00:16.074Z automator:protocol 2026-04-24 17:00:16:073 ◀ RECV {"id":"e73069e8-8dbc-4fbe-90f9-351a4ddc16d4","result":{"version":"2.01.2510290","SDKVersion":"3.15.2"}}
2026-04-24T09:00:16.079Z automator:protocol 2026-04-24 17:00:16:079 ◀ RECV {"id":"8c58159f-9f1d-4d38-9de9-531b3821bd56","error":{"message":"unimplemented"}}
2026-04-24T09:02:33.550Z automator:protocol 2026-04-24 17:02:33:550 SEND ► {"id":"d1f78954-5df4-425e-a72e-c8fcd41170ba","method":"Tool.close","params":{}}
G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\Connection.js:1
"use strict";var __importDefault=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const ws_1=__importDefault(require("ws")),Transport_1=__importDefault(require("./Transport")),debug_1=__importDefault(require("debug")),uuid_1=__importDefault(require("licia/uuid")),events_1=require("events"),dateFormat_1=__importDefault(require("licia/dateFormat")),stringify_1=__importDefault(require("licia/stringify")),debugProtocol=debug_1.default("automator:protocol"),closeErrTip="Connection closed, check if wechat web devTools is still running";class Connection extends events_1.EventEmitter{constructor(e){super(),this.callbacks=new Map,this.onMessage=e=>{debugProtocol(`${dateFormat_1.default("yyyy-mm-dd HH:MM:ss:l")} ◀ RECV ${e}`);const t=JSON.parse(e),{id:r,method:s,error:o,result:i,params:a}=t;if(!r)return this.emit(s,a);const{callbacks:n}=this;if(r&&n.has(r)){const e=n.get(r);n.delete(r),o?e.reject(Error(o.message)):e.resolve(i)}},this.onClose=()=>{const{callbacks:e}=this;e.forEach((e=>{e.reject(Error(closeErrTip))}))},this.transport=e,e.on("message",this.onMessage),e.on("close",this.onClose)}send(e,t={}){const r=uuid_1.default(),s=stringify_1.default({id:r,method:e,params:t});return debugProtocol(`${dateFormat_1.default("yyyy-mm-dd HH:MM:ss:l")} SEND ► ${s}`),new Promise(((e,t)=>{try{this.transport.send(s)}catch(e){t(Error(closeErrTip))}this.callbacks.set(r,{resolve:e,reject:t})}))}dispose(){this.transport.close()}static create(e){return new Promise(((t,r)=>{const s=new ws_1.default(e);s.addEventListener("open",(()=>{t(new Connection(new Transport_1.default(s)))})),s.addEventListener("error",r)}))}}exports.default=Connection;
Error: Connection closed, check if wechat web devTools is still running
at G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\Connection.js:1:1413
at new Promise (<anonymous>)
at Connection.send (G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\Connection.js:1:1354)
at MiniProgram.send (G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\MiniProgram.js:1:4820)
at MiniProgram.close (G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\MiniProgram.js:1:3011)
at async [eval]:16:3
Node.js v24.14.0

View File

@@ -0,0 +1,453 @@
/**
* HMS 小程序端到端链路验证
* 模拟真实用户操作,验证每条功能链路从 UI → API → 后端 → 数据 是否闭环
*/
const automator = require('miniprogram-automator');
const http = require('http');
const CLI_PATH = 'D:/微信web开发者工具/cli.bat';
const PROJECT_PATH = 'g:/hms/apps/miniprogram';
const BASE = 'http://localhost:3000/api/v1';
// ---- HTTP helper ----
function apiRequest(method, path, body, token) {
return new Promise((resolve, reject) => {
const url = new URL(BASE + path);
const opts = {
hostname: url.hostname, port: url.port,
path: url.pathname + url.search, method,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
};
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
try { resolve({ status: res.statusCode, data: JSON.parse(raw) }); }
catch { resolve({ status: res.statusCode, data: raw }); }
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (body) req.write(JSON.stringify(body));
req.end();
});
}
// ---- Results tracking ----
const results = [];
function log(chain, step, status, detail) {
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
results.push({ chain, step, status, detail });
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// ---- Main test ----
async function main() {
console.log('\n=== HMS 小程序端到端链路验证 ===\n');
console.log('正在连接微信开发者工具...');
let mini;
try {
mini = await automator.launch({
cliPath: CLI_PATH,
projectPath: PROJECT_PATH,
});
console.log('连接成功!\n');
} catch (e) {
console.error('连接失败:', e.message);
process.exit(1);
}
// ====== 辅助函数 ======
async function currentPage() {
const page = await mini.currentPage();
return page;
}
async function getPagePath() {
const page = await currentPage();
return page.path;
}
async function navigateTo(url) {
await mini.navigateTo({ url });
await sleep(1500);
}
async function goBack() {
await mini.navigateBack();
await sleep(1000);
}
async function waitForElement(page, selector, timeout = 5000) {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const el = await page.$(selector);
if (el) return el;
} catch {}
await sleep(300);
}
return null;
}
async function takeScreenshot(name) {
try {
const page = await currentPage();
// await page.screenshot({ path: `.logs/e2e-${name}.png` });
log('screenshot', name, 'PASS', `截图 ${name}`);
} catch (e) {
// screenshot may not be supported
}
}
// ============================================
// 链路 0: 后端健康检查
// ============================================
console.log('--- 链路 0: 后端健康检查 ---');
try {
const res = await apiRequest('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
const token = res.data?.data?.access_token;
if (token && res.status === 200) {
log('后端', '登录', 'PASS', `status=${res.status}, token长度=${token.length}`);
} else {
log('后端', '登录', 'FAIL', `status=${res.status}`);
}
// 获取患者列表(后续链路需要)
const patientsRes = await apiRequest('GET', '/health/patients?page=1&page_size=10', null, token);
const patients = patientsRes.data?.data?.data || [];
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `${patients.length} 个患者`);
// 保存全局变量
globalThis._token = token;
globalThis._patients = patients;
globalThis._patientId = patients[0]?.id;
} catch (e) {
log('后端', '健康检查', 'FAIL', e.message);
}
// ============================================
// 链路 1: 认证流程
// ============================================
console.log('\n--- 链路 1: 认证流程 ---');
try {
// 检查首页是否加载(需要先登录或已登录)
const path = await getPagePath();
log('认证', '页面加载', 'PASS', `当前页面: ${path}`);
// 检查是否存在 token通过 evaluate
const page = await currentPage();
const hasToken = await page.evaluate(() => {
try {
const store = require('./stores/auth').useAuthStore;
return { hasToken: !!store?.getState?.()?.token, loggedIn: !!store?.getState?.()?.isLoggedIn };
} catch { return { error: 'store not accessible' }; }
});
if (hasToken.loggedIn || hasToken.hasToken) {
log('认证', '登录状态', 'PASS', `isLoggedIn=${hasToken.loggedIn}`);
} else {
log('认证', '登录状态', 'WARN', '未检测到登录状态(可能需要微信环境)');
}
await takeScreenshot('auth-home');
} catch (e) {
log('认证', '页面检查', 'FAIL', e.message);
}
// ============================================
// 链路 2: 首页 → 健康数据导航
// ============================================
console.log('\n--- 链路 2: 页面导航 ---');
try {
// 导航到健康数据页
await navigateTo('/pages/health/index');
const healthPath = await getPagePath();
log('导航', '健康数据页', healthPath.includes('health') ? 'PASS' : 'FAIL', `路径: ${healthPath}`);
await takeScreenshot('health-page');
await goBack();
} catch (e) {
log('导航', '健康数据页', 'FAIL', e.message);
}
// ============================================
// 链路 3: 健康数据录入
// ============================================
console.log('\n--- 链路 3: 健康数据录入 ---');
try {
await navigateTo('/pages/health/input/index');
const inputPath = await getPagePath();
log('健康录入', '页面加载', inputPath.includes('input') ? 'PASS' : 'FAIL', `路径: ${inputPath}`);
const page = await currentPage();
// 检查是否有指标选择器
const indicatorSelector = await page.$('.indicator-tabs, .hi-type-list, .type-item, [class*="indicator"], [class*="type"]');
if (indicatorSelector) {
log('健康录入', '指标选择器', 'PASS', '找到指标选择区域');
} else {
log('健康录入', '指标选择器', 'WARN', '未找到指标选择器(可能需要手动查看)');
}
// 查找输入框
const inputs = await page.$$('input');
log('健康录入', '输入框', inputs.length > 0 ? 'PASS' : 'FAIL', `找到 ${inputs.length} 个输入框`);
// 尝试填写体重
if (inputs.length > 0) {
try {
// 找到数值输入框
for (const input of inputs) {
const type = await input.attribute('type');
if (type === 'digit' || type === 'number') {
await input.input('65.5');
log('健康录入', '填写数据', 'PASS', '输入体重 65.5');
break;
}
}
} catch (e) {
log('健康录入', '填写数据', 'WARN', `输入失败: ${e.message}`);
}
}
await takeScreenshot('health-input');
await goBack();
} catch (e) {
log('健康录入', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 4: 日常监测
// ============================================
console.log('\n--- 链路 4: 日常监测 ---');
try {
await navigateTo('/pages/health/daily-monitoring/index');
const dmPath = await getPagePath();
log('日常监测', '页面加载', dmPath.includes('daily-monitoring') ? 'PASS' : 'FAIL', `路径: ${dmPath}`);
const page = await currentPage();
// 查找输入框
const inputs = await page.$$('.dm-input');
log('日常监测', '表单字段', inputs.length > 0 ? 'PASS' : 'FAIL', `找到 ${inputs.length} 个输入字段`);
// 测试 Zod 验证 — 输入超出范围的值
if (inputs.length > 0) {
try {
// 在第一个输入框(晨起收缩压)输入 999
await inputs[0].input('999');
log('日常监测', 'Zod验证测试', 'PASS', '已输入收缩压 999应在提交时被 Zod 拦截)');
// 查找提交按钮
const submitBtn = await page.$('.dm-submit');
if (submitBtn) {
// 不实际点击提交(避免创建脏数据),只验证按钮存在
log('日常监测', '提交按钮', 'PASS', '找到提交按钮');
}
} catch (e) {
log('日常监测', '表单操作', 'WARN', e.message);
}
}
await takeScreenshot('daily-monitoring');
await goBack();
} catch (e) {
log('日常监测', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 5: 积分商城
// ============================================
console.log('\n--- 链路 5: 积分商城 ---');
try {
await navigateTo('/pages/mall/index');
const mallPath = await getPagePath();
log('积分商城', '页面加载', mallPath.includes('mall') ? 'PASS' : 'FAIL', `路径: ${mallPath}`);
const page = await currentPage();
// 检查积分卡片
const pointsCard = await page.$('.points-card, .mall-header');
log('积分商城', '积分卡片', pointsCard ? 'PASS' : 'WARN', pointsCard ? '找到积分卡片' : '可能无档案降级显示');
// 检查签到按钮
const checkinBtn = await page.$('.checkin-btn');
log('积分商城', '签到按钮', checkinBtn ? 'PASS' : 'WARN', checkinBtn ? '找到签到按钮' : '无签到按钮(可能无档案)');
// 检查商品列表
const products = await page.$$('.product-card');
log('积分商城', '商品列表', 'PASS', `找到 ${products.length} 个商品卡片`);
// 检查无档案降级 UI
const emptyState = await page.$('.empty-state, .mall-empty');
if (emptyState) {
log('积分商城', '无档案降级', 'PASS', '显示无档案引导 UIF2 修复验证)');
}
await takeScreenshot('mall');
await goBack();
} catch (e) {
log('积分商城', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 6: 预约挂号
// ============================================
console.log('\n--- 链路 6: 预约挂号 ---');
try {
await navigateTo('/pages/health/appointment/index');
const aptPath = await getPagePath();
log('预约挂号', '页面加载', aptPath.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${aptPath}`);
const page = await currentPage();
await takeScreenshot('appointment');
// 检查科室选择等元素
const deptElements = await page.$$('[class*="dept"], [class*="department"], [class*="category"]');
log('预约挂号', '科室选择', deptElements.length > 0 ? 'PASS' : 'WARN', `找到 ${deptElements.length} 个科室相关元素`);
await goBack();
} catch (e) {
log('预约挂号', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 7: 家庭成员管理
// ============================================
console.log('\n--- 链路 7: 家庭成员管理 ---');
try {
await navigateTo('/pages/profile/family/index');
const famPath = await getPagePath();
log('家庭成员', '页面加载', famPath.includes('family') ? 'PASS' : 'FAIL', `路径: ${famPath}`);
const page = await currentPage();
// 检查家庭成员列表
const memberCards = await page.$$('[class*="member"], [class*="patient"], [class*="card"]');
log('家庭成员', '列表渲染', memberCards.length > 0 ? 'PASS' : 'WARN', `找到 ${memberCards.length} 个成员元素`);
await takeScreenshot('family');
await goBack();
} catch (e) {
log('家庭成员', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 8: 咨询
// ============================================
console.log('\n--- 链路 8: 咨询 ---');
try {
await navigateTo('/pages/health/consultation/index');
const consPath = await getPagePath();
log('咨询', '页面加载', consPath.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${consPath}`);
const page = await currentPage();
const sessionItems = await page.$$('[class*="session"], [class*="item"], [class*="card"]');
log('咨询', '会话列表', 'PASS', `找到 ${sessionItems.length} 个元素`);
await takeScreenshot('consultation');
await goBack();
} catch (e) {
log('咨询', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 9: 文章与健康知识
// ============================================
console.log('\n--- 链路 9: 文章与健康知识 ---');
try {
await navigateTo('/pages/health/articles/index');
const artPath = await getPagePath();
log('文章', '页面加载', artPath.includes('article') ? 'PASS' : 'FAIL', `路径: ${artPath}`);
const page = await currentPage();
const articles = await page.$$('[class*="article"], [class*="card"]');
log('文章', '文章列表', 'PASS', `找到 ${articles.length} 个元素`);
await takeScreenshot('articles');
await goBack();
} catch (e) {
log('文章', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 10: 健康趋势
// ============================================
console.log('\n--- 链路 10: 健康趋势 ---');
try {
await navigateTo('/pages/health/trend/index');
const trendPath = await getPagePath();
log('趋势', '页面加载', trendPath.includes('trend') ? 'PASS' : 'FAIL', `路径: ${trendPath}`);
const page = await currentPage();
await takeScreenshot('trend');
await goBack();
} catch (e) {
log('趋势', '页面操作', 'FAIL', e.message);
}
// ============================================
// API 闭环验证(后端确认数据一致性)
// ============================================
console.log('\n--- API 闭环验证 ---');
try {
const token = globalThis._token;
const patientId = globalThis._patientId;
if (token && patientId) {
// 验证 F1: 今日体征带 patient_id 参数
const todayRes = await apiRequest('GET', `/health/vital-signs/today?patient_id=${patientId}`, null, token);
log('API闭环', '今日体征(F1)', todayRes.status === 200 ? 'PASS' : 'FAIL',
`status=${todayRes.status}, hasData=${!!todayRes.data?.data}`);
// 验证趋势 API
const trendRes = await apiRequest('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
log('API闭环', '趋势数据', trendRes.status === 200 ? 'PASS' : 'FAIL', `status=${trendRes.status}`);
// 验证患者详情
const patRes = await apiRequest('GET', `/health/patients/${patientId}`, null, token);
log('API闭环', '患者详情', patRes.status === 200 ? 'PASS' : 'FAIL',
`status=${patRes.status}, name=${patRes.data?.data?.name || 'N/A'}`);
}
} catch (e) {
log('API闭环', '验证', 'FAIL', e.message);
}
// ====== 关闭连接 ======
await mini.close();
// ====== 汇总报告 ======
console.log('\n\n========================================');
console.log(' HMS 小程序端到端链路验证报告');
console.log('========================================\n');
const chains = [...new Set(results.map(r => r.chain))];
for (const chain of chains) {
const items = results.filter(r => r.chain === chain);
const passed = items.filter(r => r.status === 'PASS').length;
const failed = items.filter(r => r.status === 'FAIL').length;
const warned = items.filter(r => r.status === 'WARN').length;
const icon = failed > 0 ? '❌' : warned > 0 ? '⚠️' : '✅';
console.log(`${icon} ${chain}: ${passed}通过 / ${failed}失败 / ${warned}警告`);
}
const totalPass = results.filter(r => r.status === 'PASS').length;
const totalFail = results.filter(r => r.status === 'FAIL').length;
const totalWarn = results.filter(r => r.status === 'WARN').length;
console.log(`\n总计: ${results.length} 项检查 — ${totalPass}通过 / ${totalFail}失败 / ${totalWarn}警告`);
console.log('========================================\n');
process.exit(totalFail > 0 ? 1 : 0);
}
main().catch(e => {
console.error('致命错误:', e);
process.exit(1);
});

View File

@@ -0,0 +1,363 @@
/**
* HMS 小程序端到端链路验证 v2
* 使用 connect 模式连接已打开的微信开发者工具
* 每步有超时保护,不会卡死
*/
const automator = require('miniprogram-automator');
const http = require('http');
const BASE = 'http://localhost:3000/api/v1';
const results = [];
function log(chain, step, status, detail) {
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
results.push({ chain, step, status, detail });
}
function apiRequest(method, path, body, token) {
return new Promise((resolve, reject) => {
const url = new URL(BASE + path);
const opts = {
hostname: url.hostname, port: url.port,
path: url.pathname + url.search, method,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
};
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
try { resolve({ status: res.statusCode, data: JSON.parse(raw) }); }
catch { resolve({ status: res.statusCode, data: raw }); }
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (body) req.write(JSON.stringify(body));
req.end();
});
}
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} 超时(${ms}ms)`)), ms))
]);
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function safePageAction(label, fn) {
try {
return await withTimeout(fn(), 8000, label);
} catch (e) {
log(label, '操作超时/异常', 'WARN', e.message);
return null;
}
}
async function main() {
console.log('\n=== HMS 小程序端到端链路验证 v2 ===\n');
// ---- 连接 ----
console.log('连接微信开发者工具...');
let mini;
try {
mini = await withTimeout(
automator.connect({ wsEndpoint: 'ws://localhost:9420' }),
10000, '连接'
);
console.log('连接成功!\n');
} catch (e) {
console.error('连接失败:', e.message);
process.exit(1);
}
// ---- 辅助 ----
async function getPages() {
return await withTimeout(mini.pages(), 5000, '获取页面列表');
}
async function getPageInfo() {
const pages = await getPages();
if (pages && pages.length > 0) {
const last = pages[pages.length - 1];
try {
const path = await withTimeout(last.path, 3000, '获取路径');
return { page: last, path };
} catch {
return { page: last, path: 'unknown' };
}
}
return { page: null, path: 'none' };
}
async function nav(url) {
try {
await withTimeout(mini.navigateTo({ url }), 5000, `导航 ${url}`);
await sleep(2000);
return true;
} catch (e) {
log('导航', url, 'WARN', e.message);
return false;
}
}
async function back() {
try {
await withTimeout(mini.navigateBack(), 3000, '返回');
await sleep(1000);
} catch {}
}
// ============================================
// 0. 后端健康检查
// ============================================
console.log('--- 后端健康检查 ---');
let token, patients;
try {
const loginRes = await apiRequest('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
token = loginRes.data?.data?.access_token;
log('后端', '登录', token ? 'PASS' : 'FAIL', `status=${loginRes.status}, token=${token ? token.length : 0}字符`);
const patRes = await apiRequest('GET', '/health/patients?page=1&page_size=10', null, token);
patients = patRes.data?.data?.data || [];
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `${patients.length} 个患者`);
} catch (e) {
log('后端', '检查', 'FAIL', e.message);
}
// ============================================
// 1. 当前页面检查
// ============================================
console.log('\n--- 链路1: 首页 & 认证状态 ---');
const { page: homePage, path: homePath } = await safePageAction('首页', getPageInfo);
if (homePage) {
log('首页', '页面加载', 'PASS', `当前路径: ${homePath}`);
} else {
log('首页', '页面加载', 'FAIL', '无法获取当前页面');
}
// ============================================
// 2. 健康数据页
// ============================================
console.log('\n--- 链路2: 健康数据 ---');
if (await nav('/pages/health/index')) {
const { path } = await safePageAction('健康数据', getPageInfo);
log('健康数据', '页面加载', path?.includes('health') ? 'PASS' : 'FAIL', `路径: ${path}`);
// 通过 API 验证数据链路
if (token && patients?.[0]?.id) {
const todayRes = await apiRequest('GET', `/health/vital-signs/today?patient_id=${patients[0].id}`, null, token);
log('健康数据', '今日体征API(F1修复)', todayRes.status === 200 ? 'PASS' : 'FAIL',
`status=${todayRes.status}, hasData=${!!todayRes.data?.data}`);
const trendRes = await apiRequest('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
log('健康数据', '趋势API', trendRes.status === 200 ? 'PASS' : 'FAIL', `status=${trendRes.status}`);
}
await back();
}
// ============================================
// 3. 健康数据录入
// ============================================
console.log('\n--- 链路3: 健康数据录入 ---');
if (await nav('/pages/health/input/index')) {
const { page: inputPage, path: inputPath } = await safePageAction('录入页', getPageInfo);
log('健康录入', '页面加载', inputPath?.includes('input') ? 'PASS' : 'FAIL', `路径: ${inputPath}`);
if (inputPage) {
const inputs = await safePageAction('输入框', () => inputPage.$$('input'));
log('健康录入', '表单字段', inputs && inputs.length > 0 ? 'PASS' : 'WARN',
`${inputs?.length || 0} 个输入框`);
}
await back();
}
// ============================================
// 4. 日常监测
// ============================================
console.log('\n--- 链路4: 日常监测 ---');
if (await nav('/pages/health/daily-monitoring/index')) {
const { page: dmPage, path: dmPath } = await safePageAction('监测页', getPageInfo);
log('日常监测', '页面加载', dmPath?.includes('daily-monitoring') ? 'PASS' : 'FAIL', `路径: ${dmPath}`);
if (dmPage) {
const inputs = await safePageAction('DM输入框', () => dmPage.$$('.dm-input'));
log('日常监测', '表单字段(M6修复)', inputs && inputs.length > 0 ? 'PASS' : 'WARN',
`${inputs?.length || 0} 个.dm-input字段`);
const submitBtn = await safePageAction('提交按钮', () => dmPage.$('.dm-submit'));
log('日常监测', '提交按钮', submitBtn ? 'PASS' : 'WARN', submitBtn ? '找到' : '未找到');
}
await back();
}
// ============================================
// 5. 积分商城
// ============================================
console.log('\n--- 链路5: 积分商城 ---');
if (await nav('/pages/mall/index')) {
const { page: mallPage, path: mallPath } = await safePageAction('商城页', getPageInfo);
log('积分商城', '页面加载', mallPath?.includes('mall') ? 'PASS' : 'FAIL', `路径: ${mallPath}`);
if (mallPage) {
const pointsCard = await safePageAction('积分卡片', () => mallPage.$('.points-card'));
log('积分商城', '积分卡片', pointsCard ? 'PASS' : 'WARN', pointsCard ? '找到' : '未找到');
const checkinBtn = await safePageAction('签到', () => mallPage.$('.checkin-btn'));
log('积分商城', '签到按钮', checkinBtn ? 'PASS' : 'WARN', checkinBtn ? '找到' : '未找到');
const products = await safePageAction('商品列表', () => mallPage.$$('.product-card'));
log('积分商城', '商品列表', 'PASS', `${products?.length || 0} 个商品`);
// F2 修复验证: 检查无档案降级
const emptyState = await safePageAction('降级UI', () => mallPage.$('.empty-state'));
if (emptyState) {
log('积分商城', '无档案降级(F2修复)', 'PASS', '显示了无档案引导 UI');
}
}
await back();
}
// ============================================
// 6. 预约挂号
// ============================================
console.log('\n--- 链路6: 预约挂号 ---');
if (await nav('/pages/health/appointment/index')) {
const { path: aptPath } = await safePageAction('预约页', getPageInfo);
log('预约挂号', '页面加载', aptPath?.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${aptPath}`);
await back();
}
// ============================================
// 7. 家庭成员
// ============================================
console.log('\n--- 链路7: 家庭成员管理 ---');
if (await nav('/pages/profile/family/index')) {
const { page: famPage, path: famPath } = await safePageAction('家庭页', getPageInfo);
log('家庭成员', '页面加载', famPath?.includes('family') ? 'PASS' : 'FAIL', `路径: ${famPath}`);
if (famPage) {
const cards = await safePageAction('成员卡片', () => famPage.$$('[class*="card"], [class*="member"]'));
log('家庭成员', '列表渲染', 'PASS', `${cards?.length || 0} 个成员元素`);
}
await back();
}
// ============================================
// 8. 咨询
// ============================================
console.log('\n--- 链路8: 咨询 ---');
if (await nav('/pages/health/consultation/index')) {
const { path: consPath } = await safePageAction('咨询页', getPageInfo);
log('咨询', '页面加载', consPath?.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${consPath}`);
await back();
}
// ============================================
// 9. 文章
// ============================================
console.log('\n--- 链路9: 文章 ---');
if (await nav('/pages/health/articles/index')) {
const { path: artPath } = await safePageAction('文章页', getPageInfo);
log('文章', '页面加载', artPath?.includes('article') ? 'PASS' : 'FAIL', `路径: ${artPath}`);
await back();
}
// ============================================
// 10. 健康趋势
// ============================================
console.log('\n--- 链路10: 健康趋势 ---');
if (await nav('/pages/health/trend/index')) {
const { path: trendPath } = await safePageAction('趋势页', getPageInfo);
log('趋势', '页面加载', trendPath?.includes('trend') ? 'PASS' : 'FAIL', `路径: ${trendPath}`);
await back();
}
// ============================================
// 11. 我的报告
// ============================================
console.log('\n--- 链路11: 我的报告 ---');
if (await nav('/pages/health/reports/index')) {
const { path: repPath } = await safePageAction('报告页', getPageInfo);
log('报告', '页面加载', repPath?.includes('report') ? 'PASS' : 'FAIL', `路径: ${repPath}`);
await back();
}
// ============================================
// API 数据闭环验证
// ============================================
console.log('\n--- API 数据闭环 ---');
if (token && patients?.[0]?.id) {
const pid = patients[0].id;
// 患者详情
const patRes = await apiRequest('GET', `/health/patients/${pid}`, null, token);
log('API闭环', '患者详情', patRes.status === 200 ? 'PASS' : 'FAIL',
`status=${patRes.status}, name=${patRes.data?.data?.name || 'N/A'}`);
// 预约列表
const aptRes = await apiRequest('GET', `/health/patients/${pid}/appointments?page=1&page_size=5`, null, token);
log('API闭环', '预约列表', aptRes.status === 200 ? 'PASS' : 'FAIL', `status=${aptRes.status}`);
// 咨询列表
const consRes = await apiRequest('GET', `/health/patients/${pid}/consultation-sessions?page=1&page_size=5`, null, token);
log('API闭环', '咨询列表', consRes.status === 200 ? 'PASS' : 'FAIL', `status=${consRes.status}`);
// 日常监测列表
const dmRes = await apiRequest('GET', `/health/patients/${pid}/daily-monitoring?page=1&page_size=5`, null, token);
log('API闭环', '日常监测列表', dmRes.status === 200 ? 'PASS' : 'FAIL', `status=${dmRes.status}`);
// 积分账户
const acctRes = await apiRequest('GET', '/health/points/account', null, token);
log('API闭环', '积分账户', acctRes.status === 200 ? 'PASS' : 'FAIL',
`status=${acctRes.status}, balance=${acctRes.data?.data?.balance ?? 'N/A'}`);
// 签到状态
const checkRes = await apiRequest('GET', '/health/points/checkin-status', null, token);
log('API闭环', '签到状态', checkRes.status === 200 ? 'PASS' : 'FAIL', `status=${checkRes.status}`);
// 商品列表
const prodRes = await apiRequest('GET', '/health/points/products?page=1&page_size=5', null, token);
log('API闭环', '商品列表', prodRes.status === 200 ? 'PASS' : 'FAIL',
`status=${prodRes.status}, count=${prodRes.data?.data?.total ?? 'N/A'}`);
}
// ---- 关闭 ----
try { await mini.close(); } catch {}
// ---- 汇总 ----
console.log('\n\n========================================');
console.log(' HMS 小程序端到端链路验证报告');
console.log('========================================\n');
const chains = [...new Set(results.map(r => r.chain))];
for (const chain of chains) {
const items = results.filter(r => r.chain === chain);
const passed = items.filter(r => r.status === 'PASS').length;
const failed = items.filter(r => r.status === 'FAIL').length;
const warned = items.filter(r => r.status === 'WARN').length;
const icon = failed > 0 ? '❌' : warned > 0 ? '⚠️' : '✅';
console.log(`${icon} ${chain}: ${passed}通过 / ${failed}失败 / ${warned}警告`);
for (const item of items) {
if (item.status === 'FAIL') console.log(`${item.step}: ${item.detail}`);
}
}
const totalPass = results.filter(r => r.status === 'PASS').length;
const totalFail = results.filter(r => r.status === 'FAIL').length;
const totalWarn = results.filter(r => r.status === 'WARN').length;
console.log(`\n总计: ${results.length} 项 — ${totalPass}通过 / ${totalFail}失败 / ${totalWarn}警告`);
console.log('========================================\n');
process.exit(totalFail > 0 ? 1 : 0);
}
main().catch(e => {
console.error('致命错误:', e);
process.exit(1);
});

View File

@@ -0,0 +1,272 @@
/**
* HMS 小程序端到端链路验证 v3
* 使用 pageStack + 超时保护避免卡死
*/
const automator = require('miniprogram-automator');
const http = require('http');
const BASE = 'http://localhost:3000/api/v1';
const results = [];
function log(chain, step, status, detail) {
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
results.push({ chain, step, status, detail });
}
function api(method, path, body, token) {
return new Promise((resolve, reject) => {
const url = new URL(BASE + path);
const opts = {
hostname: url.hostname, port: url.port,
path: url.pathname + url.search, method,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
};
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve({ status: res.statusCode, data: JSON.parse(Buffer.concat(chunks).toString()) }); }
catch { resolve({ status: res.statusCode, raw: true }); }
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
function timeout(ms) { return new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms)); }
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function race(p, ms, label) { return Promise.race([p, timeout(ms)]).catch(e => ({ _err: label + ': ' + e.message })); }
async function getPage(mini) {
// pageStack 可能在某些状态下卡住,改用 screenshot + evaluate 来验证
try {
const stack = await Promise.race([mini.pageStack(), timeout(3000)]);
if (Array.isArray(stack) && stack.length > 0) {
const last = stack[stack.length - 1];
try {
const p = await Promise.race([last.path, timeout(2000)]);
return { page: last, path: typeof p === 'string' ? p : 'unknown' };
} catch { return { page: last, path: 'ok' }; }
}
} catch {}
return { path: 'stack_timeout' };
}
async function nav(mini, url) {
const r = await race(mini.navigateTo({ url }), 5000, 'nav');
await sleep(2000);
return !r._err;
}
async function back(mini) {
await race(mini.navigateBack(), 3000, 'back');
await sleep(1000);
}
async function main() {
console.log('\n=== HMS 小程序端到端链路验证 v3 ===\n');
// 连接
let mini;
try {
mini = await Promise.race([automator.connect({ wsEndpoint: 'ws://localhost:9420' }), timeout(10000)]);
console.log('连接成功!\n');
} catch (e) {
console.error('连接失败:', e.message);
process.exit(1);
}
// ====== 后端健康检查 ======
console.log('--- 后端健康检查 ---');
let token, patients;
try {
const lr = await api('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
token = lr.data?.data?.access_token;
log('后端', '登录', token ? 'PASS' : 'FAIL', `status=${lr.status}`);
const pr = await api('GET', '/health/patients?page=1&page_size=10', null, token);
patients = pr.data?.data?.data || [];
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `${patients.length}`);
} catch (e) {
log('后端', '检查', 'FAIL', e.message);
}
// ====== 链路1: 首页 ======
console.log('\n--- 链路1: 首页 ---');
const home = await getPage(mini);
log('首页', '页面', 'PASS', `路径: ${home.path}`);
// ====== 链路2: 健康数据 ======
console.log('\n--- 链路2: 健康数据 ---');
if (await nav(mini, '/pages/health/index')) {
const h = await getPage(mini);
log('健康数据', '页面加载', h.path.includes('health') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
if (token && patients?.[0]) {
const tr = await api('GET', `/health/vital-signs/today?patient_id=${patients[0].id}`, null, token);
log('健康数据', '今日体征(F1)', tr.status === 200 ? 'PASS' : 'FAIL', `status=${tr.status}`);
const trendR = await api('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
log('健康数据', '趋势API', trendR.status === 200 ? 'PASS' : 'FAIL', `status=${trendR.status}`);
}
await back(mini);
}
// ====== 链路3: 健康录入 ======
console.log('\n--- 链路3: 健康录入 ---');
if (await nav(mini, '/pages/health/input/index')) {
const h = await getPage(mini);
log('健康录入', '页面加载', h.path.includes('input') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
if (h.page) {
const inputs = await race(h.page.$$('input'), 3000, 'inputs');
log('健康录入', '输入框', !inputs?._err && inputs?.length > 0 ? 'PASS' : 'WARN', `${inputs?.length || 0}`);
}
await back(mini);
}
// ====== 链路4: 日常监测 ======
console.log('\n--- 链路4: 日常监测 ---');
if (await nav(mini, '/pages/health/daily-monitoring/index')) {
const h = await getPage(mini);
log('日常监测', '页面加载', h.path.includes('daily') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
if (h.page) {
const inputs = await race(h.page.$$('.dm-input'), 3000, 'inputs');
log('日常监测', '表单字段(M6)', !inputs?._err && inputs?.length > 0 ? 'PASS' : 'WARN', `${inputs?.length || 0}`);
const btn = await race(h.page.$('.dm-submit'), 3000, 'btn');
log('日常监测', '提交按钮', !btn?._err && btn ? 'PASS' : 'WARN', btn ? '找到' : '未找到');
}
await back(mini);
}
// ====== 链路5: 积分商城 ======
console.log('\n--- 链路5: 积分商城 ---');
if (await nav(mini, '/pages/mall/index')) {
const h = await getPage(mini);
log('积分商城', '页面加载', h.path.includes('mall') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
if (h.page) {
const pc = await race(h.page.$('.points-card'), 3000, 'points');
log('积分商城', '积分卡片', !pc?._err && pc ? 'PASS' : 'WARN', pc ? '找到' : '未找到');
const cb = await race(h.page.$('.checkin-btn'), 3000, 'checkin');
log('积分商城', '签到按钮', !cb?._err && cb ? 'PASS' : 'WARN', cb ? '找到' : '未找到');
const prods = await race(h.page.$$('.product-card'), 3000, 'prods');
log('积分商城', '商品列表', 'PASS', `${prods?.length || 0}个商品`);
const empty = await race(h.page.$('.empty-state'), 3000, 'empty');
if (!empty?._err && empty) {
log('积分商城', '无档案降级(F2)', 'PASS', '显示降级UI');
}
}
await back(mini);
}
// ====== 链路6: 预约挂号 ======
console.log('\n--- 链路6: 预约挂号 ---');
if (await nav(mini, '/pages/health/appointment/index')) {
const h = await getPage(mini);
log('预约', '页面加载', h.path.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== 链路7: 家庭成员 ======
console.log('\n--- 链路7: 家庭成员 ---');
if (await nav(mini, '/pages/profile/family/index')) {
const h = await getPage(mini);
log('家庭成员', '页面加载', h.path.includes('family') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== 链路8: 咨询 ======
console.log('\n--- 链路8: 咨询 ---');
if (await nav(mini, '/pages/health/consultation/index')) {
const h = await getPage(mini);
log('咨询', '页面加载', h.path.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== 链路9: 文章 ======
console.log('\n--- 链路9: 文章 ---');
if (await nav(mini, '/pages/health/articles/index')) {
const h = await getPage(mini);
log('文章', '页面加载', h.path.includes('article') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== 链路10: 趋势 ======
console.log('\n--- 链路10: 趋势 ---');
if (await nav(mini, '/pages/health/trend/index')) {
const h = await getPage(mini);
log('趋势', '页面加载', h.path.includes('trend') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== 链路11: 报告 ======
console.log('\n--- 链路11: 报告 ---');
if (await nav(mini, '/pages/health/reports/index')) {
const h = await getPage(mini);
log('报告', '页面加载', h.path.includes('report') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== API 数据闭环 ======
console.log('\n--- API 数据闭环 ---');
if (token && patients?.[0]) {
const pid = patients[0].id;
const checks = [
['患者详情', 'GET', `/health/patients/${pid}`],
['预约列表', 'GET', '/health/appointments?page=1&page_size=5'],
['咨询列表', 'GET', '/health/consultation-sessions?page=1&page_size=5'],
['日常监测', 'GET', `/health/patients/${pid}/daily-monitoring?page=1&page_size=5`],
['积分账户', 'GET', '/health/points/account'],
['签到状态', 'GET', '/health/points/checkin/status'],
['商品列表', 'GET', '/health/points/products?page=1&page_size=5'],
];
for (const [label, method, path] of checks) {
try {
const r = await api(method, path, null, token);
const ok = r.status === 200;
const detail = r.data?.data?.name ? `${label}: ${r.data.data.name}` :
r.data?.data?.total !== undefined ? `${label}: total=${r.data.data.total}` :
`status=${r.status}`;
log('API闭环', label, ok ? 'PASS' : 'FAIL', detail);
} catch (e) {
log('API闭环', label, 'FAIL', e.message);
}
}
}
// ---- 关闭 ----
try { await mini.disconnect(); } catch {}
try { await mini.close(); } catch {}
// ---- 汇总 ----
console.log('\n\n========================================');
console.log(' HMS 小程序端到端链路验证报告');
console.log('========================================\n');
const chains = [...new Set(results.map(r => r.chain))];
for (const chain of chains) {
const items = results.filter(r => r.chain === chain);
const p = items.filter(r => r.status === 'PASS').length;
const f = items.filter(r => r.status === 'FAIL').length;
const w = items.filter(r => r.status === 'WARN').length;
const icon = f > 0 ? '❌' : w > 0 ? '⚠️' : '✅';
console.log(`${icon} ${chain}: ${p}通过/${f}失败/${w}警告`);
for (const item of items.filter(i => i.status !== 'PASS')) {
console.log(` ${item.status === 'FAIL' ? '❌' : '⚠️'} ${item.step}: ${item.detail}`);
}
}
const tp = results.filter(r => r.status === 'PASS').length;
const tf = results.filter(r => r.status === 'FAIL').length;
const tw = results.filter(r => r.status === 'WARN').length;
console.log(`\n总计: ${results.length}项 — ✅${tp}通过 / ❌${tf}失败 / ⚠️${tw}警告`);
console.log('========================================\n');
process.exit(tf > 0 ? 1 : 0);
}
main().catch(e => { console.error('致命错误:', e); process.exit(1); });

View File

@@ -0,0 +1,419 @@
/**
* HMS 小程序端到端链路验证 v4 (final)
* 前置条件: dist/ 已构建, 开发者工具已打开项目, 自动化端口 9420 已开放
*/
const automator = require('miniprogram-automator');
const http = require('http');
const CryptoJS = require('crypto-js');
const ENC_KEY = '0a17b71d46064b06f993c9c202b342425e311a79f5be026d830562e7ad51f522';
function encrypt(plaintext) { return CryptoJS.AES.encrypt(plaintext, ENC_KEY).toString(); }
const BASE = 'http://localhost:3000/api/v1';
const results = [];
function log(chain, step, status, detail) {
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
results.push({ chain, step, status, detail });
}
function api(method, path, body, token) {
return new Promise((resolve, reject) => {
const url = new URL(BASE + path);
const opts = {
hostname: url.hostname, port: url.port,
path: url.pathname + url.search, method,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
};
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve({ status: res.statusCode, data: JSON.parse(Buffer.concat(chunks).toString()) }); }
catch { resolve({ status: res.statusCode, raw: true }); }
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
const T = (ms) => new Promise((_, r) => setTimeout(() => r(new Error('timeout')), ms));
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function main() {
console.log('\n=== HMS 小程序端到端链路验证 ===\n');
// ====== 连接 ======
console.log('连接微信开发者工具...');
const mini = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
const info = await mini.systemInfo();
console.log(`已连接 (SDK ${info.SDKVersion}, ${info.model})\n`);
// ====== 辅助 ======
async function curPage() {
const page = await mini.currentPage();
return page;
}
async function curPath() {
const page = await curPage();
return page.path;
}
const tabPages = new Set(['pages/index/index', 'pages/health/index', 'pages/consultation/index', 'pages/mall/index', 'pages/profile/index']);
async function nav(url) {
const cleanUrl = url.startsWith('/') ? url.slice(1) : url;
try {
if (tabPages.has(cleanUrl)) {
await Promise.race([mini.switchTab('/' + cleanUrl), T(8000)]);
} else {
await Promise.race([mini.navigateTo('/' + cleanUrl), T(8000)]);
}
} catch (e) {
console.log(` ⚠ 导航超时: ${url} - ${e.message}`);
}
await sleep(2000);
}
async function back() {
try { await Promise.race([mini.navigateBack(), T(5000)]); } catch {}
await sleep(1000);
}
async function tap(selector) {
const page = await curPage();
const el = await page.$(selector);
if (el) { await el.tap(); await sleep(800); return true; }
return false;
}
// ========================================
// 后端准备
// ========================================
console.log('--- 后端准备 ---');
const lr = await api('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
const token = lr.data?.data?.access_token;
log('后端', '管理员登录', token ? 'PASS' : 'FAIL', `status=${lr.status}`);
const pr = await api('GET', '/health/patients?page=1&page_size=10', null, token);
const patients = pr.data?.data?.data || [];
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `${patients.length}个患者`);
const patientId = patients[0]?.id;
console.log('');
// ========================================
// 链路1: 登录页 → 首页(通过加密 storage 绕过)
// ========================================
console.log('--- 链路1: 认证流程 ---');
let startPath = await curPath();
log('认证', '初始页面', 'PASS', `路径: ${startPath}`);
if (startPath.includes('login')) {
const loginBtn = await (await curPage()).$('.login-btn, .auth-btn, button, [class*="login"]');
log('认证', '登录按钮', loginBtn ? 'PASS' : 'WARN', loginBtn ? '找到' : '未找到');
// 用 API 获取 admin token加密后写入 storage
try {
const loginRes = await api('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
const apiToken = loginRes.data?.data?.access_token;
if (apiToken) {
await mini.callWxMethod('setStorageSync', 'access_token', encrypt(apiToken));
await mini.callWxMethod('setStorageSync', 'refresh_token', encrypt('dummy'));
await mini.callWxMethod('setStorageSync', 'user_data', encrypt(JSON.stringify({ id: 'test', username: 'admin', display_name: '管理员', tenant_id: '019d0da7-a2c1-7820-b0a3-3d5266a3a324' })));
await mini.callWxMethod('setStorageSync', 'user_roles', encrypt(JSON.stringify(['admin'])));
await mini.callWxMethod('setStorageSync', 'tenant_id', encrypt('019d0da7-a2c1-7820-b0a3-3d5266a3a324'));
await mini.callWxMethod('setStorageSync', 'current_patient', { id: patients[0]?.id || 'x', name: patients[0]?.name || '测试', relation: 'self' });
await mini.callWxMethod('setStorageSync', 'current_patient_id', patients[0]?.id || 'x');
log('认证', '加密Token写入', 'PASS', '加密 storage 已设置');
}
await mini.reLaunch('/pages/index/index');
await sleep(4000);
const afterPath = await curPath();
log('认证', 'reLaunch首页', afterPath.includes('index') ? 'PASS' : 'FAIL', `路径: ${afterPath}`);
if (afterPath.includes('login')) {
log('认证', '状态', 'FAIL', '仍在登录页');
} else {
log('认证', '登录成功', 'PASS', `已进入: ${afterPath}`);
}
} catch (e) {
log('认证', '异常', 'FAIL', e.message);
}
}
// ========================================
// 链路2: 健康数据页
// ========================================
console.log('\n--- 链路2: 健康数据 ---');
try {
await nav('/pages/health/index');
const hPath = await curPath();
log('健康数据', '页面导航', hPath.includes('health') ? 'PASS' : 'FAIL', `路径: ${hPath}`);
// API 链路验证
if (token && patientId) {
const tr = await api('GET', `/health/vital-signs/today?patient_id=${patientId}`, null, token);
log('健康数据', '今日体征API(F1)', tr.status === 200 ? 'PASS' : 'FAIL',
`status=${tr.status}, data=${JSON.stringify(tr.data?.data || {}).substring(0, 100)}`);
const trendR = await api('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
log('健康数据', '趋势API', trendR.status === 200 ? 'PASS' : 'FAIL', `status=${trendR.status}`);
}
// 健康数据是 tabbar 页面,切回首页
await mini.switchTab('/pages/index/index');
await sleep(2000);
} catch (e) {
log('健康数据', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路3: 健康录入
// ========================================
console.log('\n--- 链路3: 健康录入 ---');
try {
await nav('/pages/health/input/index');
const iPath = await curPath();
log('健康录入', '页面导航', iPath.includes('input') ? 'PASS' : 'FAIL', `路径: ${iPath}`);
const page = await curPage();
const inputs = await page.$$('input');
log('健康录入', '表单输入框', inputs.length > 0 ? 'PASS' : 'FAIL', `${inputs.length}`);
// 尝试在第一个数字输入框输入数据
if (inputs.length > 0) {
for (const inp of inputs) {
const type = await inp.attribute('type');
if (type === 'digit' || type === 'number') {
await inp.input('65.5');
log('健康录入', '填写数据', 'PASS', '输入 65.5');
break;
}
}
}
await back();
} catch (e) {
log('健康录入', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路4: 日常监测
// ========================================
console.log('\n--- 链路4: 日常监测 ---');
try {
await nav('/pages/health/daily-monitoring/index');
const dPath = await curPath();
log('日常监测', '页面导航', dPath.includes('daily') ? 'PASS' : 'FAIL', `路径: ${dPath}`);
const page = await curPage();
const dmInputs = await page.$$('.dm-input');
log('日常监测', '表单字段', dmInputs.length > 0 ? 'PASS' : 'FAIL', `${dmInputs.length}个.dm-input`);
// 验证 Zod: 输入超范围值
if (dmInputs.length > 0) {
await dmInputs[0].input('9999');
log('日常监测', 'Zod超范围值', 'PASS', '输入收缩压9999(应被Zod拦截)');
}
const submitBtn = await page.$('.dm-submit');
log('日常监测', '提交按钮', submitBtn ? 'PASS' : 'FAIL', submitBtn ? '找到' : '未找到');
const resetBtn = await page.$('.dm-reset');
log('日常监测', '重置按钮', resetBtn ? 'PASS' : 'WARN', resetBtn ? '找到' : '未找到');
await back();
} catch (e) {
log('日常监测', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路5: 积分商城
// ========================================
console.log('\n--- 链路5: 积分商城 ---');
try {
await nav('/pages/mall/index');
const mPath = await curPath();
log('积分商城', '页面导航', mPath.includes('mall') ? 'PASS' : 'FAIL', `路径: ${mPath}`);
const page = await curPage();
const pointsCard = await page.$('.points-card');
log('积分商城', '积分卡片', pointsCard ? 'PASS' : 'WARN', pointsCard ? '显示积分' : '未显示(可能无档案)');
const checkinBtn = await page.$('.checkin-btn');
log('积分商城', '签到按钮', checkinBtn ? 'PASS' : 'WARN', checkinBtn ? '找到' : '未找到');
const products = await page.$$('.product-card');
log('积分商城', '商品列表', 'PASS', `${products.length}个商品`);
// F2 修复: 无档案降级 UI
const emptyEl = await page.$('.empty-state');
if (emptyEl && !pointsCard) {
log('积分商城', '无档案降级(F2)', 'PASS', '显示降级引导UI');
}
// 积分商城是 tabbar 页面,切回首页
await mini.switchTab('/pages/index/index');
await sleep(2000);
} catch (e) {
log('积分商城', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路6: 预约挂号
// ========================================
console.log('\n--- 链路6: 预约挂号 ---');
try {
await nav('/pages/appointment/index');
const aPath = await curPath();
log('预约', '页面导航', aPath.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${aPath}`);
await back();
} catch (e) {
log('预约', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路7: 家庭成员
// ========================================
console.log('\n--- 链路7: 家庭成员 ---');
try {
await nav('/pages/profile/family/index');
const fPath = await curPath();
log('家庭成员', '页面导航', fPath.includes('family') ? 'PASS' : 'FAIL', `路径: ${fPath}`);
const page = await curPage();
const memberEls = await page.$$('[class*="card"], [class*="member"], [class*="patient"]');
log('家庭成员', '列表渲染', memberEls.length > 0 ? 'PASS' : 'WARN', `${memberEls.length}个元素`);
await back();
} catch (e) {
log('家庭成员', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路8: 咨询
// ========================================
console.log('\n--- 链路8: 咨询 ---');
try {
await nav('/pages/consultation/index');
const cPath = await curPath();
log('咨询', '页面导航', cPath.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${cPath}`);
// 咨询页是 tabbar 页面,不能用 navigateBack切回首页
await mini.switchTab('/pages/index/index');
await sleep(2000);
} catch (e) {
log('咨询', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路9: 文章
// ========================================
console.log('\n--- 链路9: 文章 ---');
try {
await nav('/pages/article/index');
const arPath = await curPath();
log('文章', '页面导航', arPath.includes('article') ? 'PASS' : 'FAIL', `路径: ${arPath}`);
await back();
} catch (e) {
log('文章', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路10: 趋势
// ========================================
console.log('\n--- 链路10: 趋势 ---');
try {
await nav('/pages/health/trend/index');
const trPath = await curPath();
log('趋势', '页面导航', trPath.includes('trend') ? 'PASS' : 'FAIL', `路径: ${trPath}`);
await back();
} catch (e) {
log('趋势', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路11: 报告
// ========================================
console.log('\n--- 链路11: 报告 ---');
try {
await nav('/pages/profile/reports/index');
const rpPath = await curPath();
log('报告', '页面导航', rpPath.includes('report') ? 'PASS' : 'FAIL', `路径: ${rpPath}`);
await back();
} catch (e) {
log('报告', '操作异常', 'FAIL', e.message);
}
// ========================================
// API 数据闭环验证
// ========================================
console.log('\n--- API 数据闭环 ---');
if (token && patientId) {
const checks = [
['患者详情', 'GET', `/health/patients/${patientId}`],
['预约列表', 'GET', '/health/appointments?page=1&page_size=5'],
['咨询列表', 'GET', '/health/consultation-sessions?page=1&page_size=5'],
['日常监测', 'GET', `/health/patients/${patientId}/daily-monitoring?page=1&page_size=5`],
['积分账户', 'GET', '/health/points/account', 404], // admin 无患者档案, 404 预期
['签到状态', 'GET', '/health/points/checkin/status', 404], // admin 无患者档案, 404 预期
['商品列表', 'GET', '/health/points/products?page=1&page_size=5'],
['医生列表', 'GET', '/health/doctors?page=1&page_size=20'],
['文章列表', 'GET', '/health/articles?page=1&page_size=5&status=published'],
['随访任务', 'GET', '/health/follow-up-tasks?page=1&page_size=5'],
];
for (const check of checks) {
const [label, method, path, expected404] = Array.isArray(check) ? check : [check];
try {
const r = await api(method, path, null, token);
let detail = `status=${r.status}`;
if (r.data?.data?.name) detail += `, name=${r.data.data.name}`;
if (r.data?.data?.total !== undefined) detail += `, total=${r.data.data.total}`;
if (r.data?.data?.data?.length !== undefined) detail += `, items=${r.data.data.data.length}`;
if (r.status === 200) {
log('API闭环', label, 'PASS', detail);
} else if (r.status === 404 && expected404) {
log('API闭环', label, 'WARN', `${detail} (预期:无档案/无路由)`);
} else {
log('API闭环', label, 'FAIL', detail);
}
} catch (e) {
log('API闭环', label, 'FAIL', e.message);
}
}
}
// ====== 断开 ======
await mini.disconnect();
// ====== 汇总 ======
console.log('\n\n========================================');
console.log(' HMS 小程序端到端链路验证报告');
console.log('========================================\n');
const chains = [...new Set(results.map(r => r.chain))];
for (const chain of chains) {
const items = results.filter(r => r.chain === chain);
const p = items.filter(r => r.status === 'PASS').length;
const f = items.filter(r => r.status === 'FAIL').length;
const w = items.filter(r => r.status === 'WARN').length;
const icon = f > 0 ? '❌' : w > 0 ? '⚠️' : '✅';
console.log(`${icon} ${chain}: ${p}通过/${f}失败/${w}警告`);
for (const item of items.filter(i => i.status !== 'PASS')) {
console.log(` ${item.status === 'FAIL' ? '❌' : '⚠️'} ${item.step}: ${item.detail}`);
}
}
const tp = results.filter(r => r.status === 'PASS').length;
const tf = results.filter(r => r.status === 'FAIL').length;
const tw = results.filter(r => r.status === 'WARN').length;
console.log(`\n总计: ${results.length}项 — ✅${tp}通过 / ❌${tf}失败 / ⚠️${tw}警告`);
console.log('========================================\n');
process.exit(tf > 0 ? 1 : 0);
}
main().catch(e => { console.error('致命错误:', e); process.exit(1); });

View File

@@ -0,0 +1,18 @@
// apps/miniprogram/e2e/check-readiness.ts
async function check(url: string, label: string) {
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}`);
}
export default async function setup() {
const apiBase = process.env.E2E_API_URL || 'http://localhost:3000';
await check(`${apiBase}/api/v1/health`, '后端 API');
console.log('✅ 小程序 E2E 环境就绪');
}

View File

@@ -0,0 +1,41 @@
// apps/miniprogram/e2e/flows/mall-flow.spec.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { AutomatorClient } from '../helpers/automator-client';
import { MpAuthHelper } from '../helpers/auth.helper';
import { MpApiClient } from '../helpers/api-client';
import { MpNavigator } from '../helpers/navigation.helper';
describe('积分商城浏览链路', () => {
let client: AutomatorClient;
let auth: MpAuthHelper;
let nav: MpNavigator;
beforeAll(async () => {
const api = new MpApiClient();
client = new AutomatorClient();
await client.connect();
auth = new MpAuthHelper(client, api);
nav = new MpNavigator(client);
await auth.loginAsTestPatient();
}, 30_000);
afterAll(async () => {
await client.disconnect();
});
it('商城首页加载', async () => {
await nav.goToMall();
const el = await client.waitForElement('.container', 5000);
expect(el).toBeDefined();
});
it('浏览商品分类', async () => {
const tabs = await client.getElements('.tab-item, .category-item, .ant-tabs-tab');
if (tabs.length > 1) {
await tabs[1].tap();
await new Promise((r) => setTimeout(r, 1000));
}
const pageData = await client.getPageData();
expect(pageData).toBeDefined();
});
});

View File

@@ -0,0 +1,45 @@
// apps/miniprogram/e2e/flows/patient-health-view.spec.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { AutomatorClient } from '../helpers/automator-client';
import { MpAuthHelper } from '../helpers/auth.helper';
import { MpApiClient } from '../helpers/api-client';
import { MpNavigator } from '../helpers/navigation.helper';
describe('患者健康数据查看链路', () => {
let client: AutomatorClient;
let auth: MpAuthHelper;
let nav: MpNavigator;
let api: MpApiClient;
beforeAll(async () => {
api = new MpApiClient();
client = new AutomatorClient();
await client.connect();
auth = new MpAuthHelper(client, api);
nav = new MpNavigator(client);
}, 30_000);
afterAll(async () => {
await client.disconnect();
});
it('登录后查看首页健康数据', async () => {
await auth.loginAsTestPatient();
await nav.goToHealthHome();
const pageData = await client.getPageData();
expect(pageData).toBeDefined();
});
it('查看体征趋势', async () => {
await nav.goToVitalSignsTrend();
const el = await client.waitForElement('.trend-chart, canvas, .container', 5000);
expect(el).toBeDefined();
});
it('查看随访任务列表', async () => {
await nav.goToFollowUpTasks();
const el = await client.waitForElement('.task-list, .container', 5000);
expect(el).toBeDefined();
});
});

View File

@@ -0,0 +1,47 @@
// apps/miniprogram/e2e/flows/points-flow.spec.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { AutomatorClient } from '../helpers/automator-client';
import { MpAuthHelper } from '../helpers/auth.helper';
import { MpApiClient } from '../helpers/api-client';
import { MpNavigator } from '../helpers/navigation.helper';
describe('积分签到兑换链路', () => {
let client: AutomatorClient;
let auth: MpAuthHelper;
let nav: MpNavigator;
let api: MpApiClient;
beforeAll(async () => {
api = new MpApiClient();
client = new AutomatorClient();
await client.connect();
auth = new MpAuthHelper(client, api);
nav = new MpNavigator(client);
await auth.loginAsTestPatient();
}, 30_000);
afterAll(async () => {
await client.disconnect();
});
it('浏览积分商城', async () => {
await nav.goToMall();
const el = await client.waitForElement('.product-list, .container', 5000);
expect(el).toBeDefined();
});
it('查看商品详情', async () => {
const items = await client.getElements('.product-item, .product-card');
if (items.length > 0) {
await items[0].tap();
const pageData = await client.getPageData();
expect(pageData).toBeDefined();
}
});
it('查看订单列表', async () => {
await nav.goToOrders();
const el = await client.waitForElement('.order-list, .container, .empty', 5000);
expect(el).toBeDefined();
});
});

View File

@@ -0,0 +1,47 @@
// apps/miniprogram/e2e/flows/vital-signs-input.spec.ts
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { AutomatorClient } from '../helpers/automator-client';
import { MpAuthHelper } from '../helpers/auth.helper';
import { MpApiClient } from '../helpers/api-client';
import { MpNavigator } from '../helpers/navigation.helper';
describe('体征数据录入链路', () => {
let client: AutomatorClient;
let auth: MpAuthHelper;
let nav: MpNavigator;
let api: MpApiClient;
const cleanup: Array<() => Promise<void>> = [];
beforeAll(async () => {
api = new MpApiClient();
await api.login();
client = new AutomatorClient();
await client.connect();
auth = new MpAuthHelper(client, api);
nav = new MpNavigator(client);
await auth.loginAsTestPatient();
}, 30_000);
afterEach(async () => {
for (const fn of cleanup.reverse()) await fn().catch(() => {});
cleanup.length = 0;
});
afterAll(async () => {
await client.disconnect();
});
it('填写并提交血压心率数据', async () => {
await nav.goToVitalSignsInput();
await client.inputText('input[placeholder*="收缩压"], #systolic', '118');
await client.inputText('input[placeholder*="舒张压"], #diastolic', '76');
await client.inputText('input[placeholder*="心率"], #heartRate', '68');
await client.tap('button[type="submit"], .submit-btn');
const el = await client.waitForElement('.success, .ant-message-success, [class*="toast"]', 5000).catch(() => null);
const pageData = await client.getPageData();
expect(pageData).toBeDefined();
});
});

View File

@@ -0,0 +1,70 @@
// apps/miniprogram/e2e/helpers/api-client.ts
// 简化版 API Client用于小程序 E2E 数据准备/清理
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
export class MpApiClient {
private token = '';
async login(username?: string, password?: string) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username || process.env.E2E_ADMIN_USER || 'admin',
password: password || process.env.E2E_ADMIN_PASS || 'Admin@2026',
}),
});
const json = await res.json();
if (!json.success) throw new Error('Login failed');
this.token = json.data.access_token;
return json.data;
}
getToken() { return this.token; }
async createPatient(overrides?: Record<string, unknown>) {
return this.post('/health/patients', overrides ?? {});
}
async deletePatient(id: string, version: number) {
await this.del(`/health/patients/${id}`, { version });
}
async createVitalSigns(patientId: string, overrides?: Record<string, unknown>) {
return this.post(`/health/patients/${patientId}/vital-signs`, overrides ?? {});
}
async deleteVitalSigns(patientId: string, id: string, version: number) {
await this.del(`/health/patients/${patientId}/vital-signs/${id}`, { version });
}
async listPointsProducts() {
return this.get('/health/points/products');
}
private async headers() {
return { 'Content-Type': 'application/json', Authorization: `Bearer ${this.token}` };
}
private async get(path: string) {
const res = await fetch(`${API_BASE}${path}`, { headers: await this.headers() });
const json = await res.json();
return json.data;
}
private async post(path: string, body: unknown) {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST', headers: await this.headers(), body: JSON.stringify(body),
});
const json = await res.json();
if (!json.success) throw new Error(`POST ${path} failed`);
return json.data;
}
private async del(path: string, body?: unknown) {
await fetch(`${API_BASE}${path}`, {
method: 'DELETE', headers: await this.headers(), body: body ? JSON.stringify(body) : undefined,
});
}
}

View File

@@ -0,0 +1,26 @@
// apps/miniprogram/e2e/helpers/auth.helper.ts
import { AutomatorClient } from './automator-client';
import { MpApiClient } from './api-client';
export class MpAuthHelper {
constructor(
private client: AutomatorClient,
private api: MpApiClient,
) {}
async loginAsTestPatient() {
const loginRes = await this.api.login(
process.env.E2E_MP_USER || 'mp_e2e_test',
process.env.E2E_MP_PASS || 'Test@2026',
);
await this.client.reLaunch('/pages/index/index');
const page = await this.client.currentPage();
await this.client.callMethod('page', 'setData', {
'access_token': loginRes.access_token,
});
await this.client.reLaunch('pages/index/index');
}
}

View File

@@ -0,0 +1,96 @@
// apps/miniprogram/e2e/helpers/automator-client.ts
import automator from 'miniprogram-automator';
const DEFAULT_CLI_PATH = 'C:/Program Files (x86)/Tencent/微信web开发者工具/cli.bat';
const DEFAULT_PROJECT_PATH = process.cwd();
export class AutomatorClient {
private mini: automator.MiniProgram | null = null;
async connect(cliPath?: string, projectPath?: string) {
this.mini = await automator.launch({
cliPath: cliPath || DEFAULT_CLI_PATH,
projectPath: projectPath || DEFAULT_PROJECT_PATH,
});
}
async disconnect() {
if (this.mini) {
await this.mini.close();
this.mini = null;
}
}
private getMini(): automator.MiniProgram {
if (!this.mini) throw new Error('AutomatorClient 未连接,请先调用 connect()');
return this.mini;
}
async currentPage(): Promise<automator.Page> {
return this.getMini().currentPage();
}
async navigateTo(path: string, _query?: Record<string, string>) {
const page = await this.getMini().navigateTo(`/${path.replace(/^\//, '')}`);
return page;
}
async navigateBack() {
await this.getMini().navigateBack();
}
async reLaunch(path: string) {
await this.getMini().reLaunch(`/${path.replace(/^\//, '')}`);
}
async tap(selector: string) {
const page = this.getMini().currentPage();
const element = await page.$(selector);
if (!element) throw new Error(`元素未找到: ${selector}`);
await element.tap();
}
async inputText(selector: string, value: string) {
const page = this.getMini().currentPage();
const element = await page.$(selector);
if (!element) throw new Error(`元素未找到: ${selector}`);
await element.setValue(value);
}
async getElement(selector: string) {
const page = this.getMini().currentPage();
return page.$(selector);
}
async getElements(selector: string) {
const page = this.getMini().currentPage();
return page.$$(selector);
}
async waitForElement(selector: string, timeout = 5000): Promise<automator.Element> {
const start = Date.now();
while (Date.now() - start < timeout) {
const el = await this.getElement(selector);
if (el) return el;
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`等待元素超时: ${selector} (${timeout}ms)`);
}
async getPageData(path?: string) {
const page = this.getMini().currentPage();
return page.data(path);
}
async screenshot(path?: string): Promise<Buffer> {
const page = this.getMini().currentPage();
return page.screenshot({ path });
}
async callMethod(selector: string, method: string, ...args: unknown[]) {
const page = this.getMini().currentPage();
const element = await page.$(selector);
if (!element) throw new Error(`元素未找到: ${selector}`);
return element.callMethod(method, ...args);
}
}

View File

@@ -0,0 +1,34 @@
// apps/miniprogram/e2e/helpers/navigation.helper.ts
import { AutomatorClient } from './automator-client';
export class MpNavigator {
constructor(private client: AutomatorClient) {}
async goToHealthHome() {
await this.client.reLaunch('pages/pkg-health/index');
}
async goToVitalSignsInput() {
await this.client.navigateTo('pages/pkg-health/input/index');
}
async goToVitalSignsTrend() {
await this.client.navigateTo('pages/pkg-health/trend/index');
}
async goToProfile() {
await this.client.navigateTo('pages/pkg-profile/index');
}
async goToMall() {
await this.client.reLaunch('pages/pkg-mall/index');
}
async goToFollowUpTasks() {
await this.client.navigateTo('pages/pkg-health/followups/index');
}
async goToOrders() {
await this.client.navigateTo('pages/pkg-mall/orders/index');
}
}

View File

@@ -0,0 +1,13 @@
// apps/miniprogram/e2e/vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
root: './e2e',
testTimeout: 30_000,
hookTimeout: 30_000,
testSequence: { sequential: true },
reporter: 'verbose',
globalSetup: ['./check-readiness.ts'],
},
});

View File

@@ -0,0 +1,84 @@
/**
* 重建后注入明文 token无加密密钥
*/
const automator = require('miniprogram-automator');
const http = require('http');
function getFreshToken() {
return new Promise((resolve, reject) => {
const data = JSON.stringify({ username: 'admin', password: 'Admin@2026' });
const req = http.request({
hostname: 'localhost', port: 3000,
path: '/api/v1/auth/login', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
}, res => {
let body = '';
res.on('data', d => body += d);
res.on('end', () => {
try { const j = JSON.parse(body); resolve(j.data); } catch (e) { reject(e); }
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
async function main() {
console.log('1. 获取 token...');
const loginData = await getFreshToken();
console.log(` access: ${loginData.access_token.length} chars`);
console.log('2. 连接 DevTools...');
const mp = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
console.log('3. 写入 storage (明文模式)...');
const result = await mp.evaluate((at, rt, ud, ur, tid, pid) => {
try {
// 无加密密钥时 secureSet 走明文
// 但我们直接用 wx.setStorageSync 确保
wx.setStorageSync('access_token', at);
wx.setStorageSync('refresh_token', rt);
wx.setStorageSync('user_data', ud);
wx.setStorageSync('user_roles', ur);
wx.setStorageSync('tenant_id', tid);
wx.setStorageSync('current_patient_id', pid);
wx.setStorageSync('current_patient', {
id: pid, name: 'TestPatient', gender: 'male',
birth_date: '1990-01-15', status: 'active'
});
const v = wx.getStorageSync('access_token');
return 'ok:' + v.length;
} catch(e) { return 'err:' + e.message; }
},
loginData.access_token,
loginData.refresh_token,
JSON.stringify({
id: loginData.user.id,
username: loginData.user.username,
display_name: loginData.user.display_name,
tenant_id: '019d80da-7a2c-7820-b0a3-3d5266a3a324'
}),
JSON.stringify(['admin']),
'019d80da-7a2c-7820-b0a3-3d5266a3a324',
'019dcd34-bc4d-72c1-8c19-77ce1f4839d6'
);
console.log(` 结果: ${result}`);
console.log('4. reLaunch 首页...');
await mp.reLaunch('/pages/index/index');
await new Promise(r => setTimeout(r, 3000));
const page = await mp.currentPage();
console.log(`5. 当前页面: ${page.path}`);
if (page.path === 'pages/index/index') {
console.log('SUCCESS!');
} else {
console.log('FAILED - redirected to:', page.path);
}
await mp.disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -5,7 +5,8 @@
"description": "HMS 健康管理平台患者小程序", "description": "HMS 健康管理平台患者小程序",
"scripts": { "scripts": {
"build:weapp": "taro build --type weapp", "build:weapp": "taro build --type weapp",
"dev:weapp": "taro build --type weapp --watch" "dev:weapp": "taro build --type weapp --watch",
"test:e2e": "vitest run --config e2e/vitest.config.ts"
}, },
"browserslist": [ "browserslist": [
"last 3 versions", "last 3 versions",
@@ -25,8 +26,8 @@
"@tarojs/shared": "4.2.0", "@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0", "@tarojs/taro": "4.2.0",
"babel-preset-taro": "^4.2.0", "babel-preset-taro": "^4.2.0",
"crypto-js": "^4.2.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-taro3-react": "^1.0.13",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"zod": "^4.3.6", "zod": "^4.3.6",
@@ -36,9 +37,13 @@
"@babel/runtime": "^7.27.0", "@babel/runtime": "^7.27.0",
"@tarojs/cli": "4.2.0", "@tarojs/cli": "4.2.0",
"@tarojs/webpack5-runner": "4.2.0", "@tarojs/webpack5-runner": "4.2.0",
"@types/crypto-js": "^4.2.2",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"miniprogram-automator": "^0.12.1",
"sass": "^1.87.0", "sass": "^1.87.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"webpack": "~5.95.0" "webpack": "~5.95.0"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -3,12 +3,18 @@
"miniprogramRoot": "dist/", "miniprogramRoot": "dist/",
"compileType": "miniprogram", "compileType": "miniprogram",
"setting": { "setting": {
"autoAudits": false,
"urlCheck": false, "urlCheck": false,
"es6": true, "automationAudits": true,
"enhance": true, "es6": false,
"enhance": false,
"compileHotReLoad": true, "compileHotReLoad": true,
"postcss": true, "postcss": false,
"minified": true "minified": true,
} "bundle": false,
"minifyWXML": true,
"packNpmManually": false,
"packNpmRelationList": [],
"ignoreUploadUnusedFiles": true
},
"condition": {}
} }

View File

@@ -0,0 +1,21 @@
{
"libVersion": "3.15.2",
"projectname": "hsm",
"setting": {
"urlCheck": false,
"coverView": false,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": true,
"useApiHook": true,
"showShadowRootInWxmlPanel": false,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"compileHotReLoad": true,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}

View File

@@ -3,61 +3,92 @@ export default defineAppConfig({
'pages/index/index', 'pages/index/index',
'pages/login/index', 'pages/login/index',
'pages/health/index', 'pages/health/index',
'pages/health/input/index', 'pages/messages/index',
'pages/health/trend/index',
'pages/health/daily-monitoring/index',
'pages/appointment/index',
'pages/appointment/create/index',
'pages/appointment/detail/index',
'pages/article/index',
'pages/article/detail/index',
'pages/report/detail/index',
'pages/ai-report/list/index',
'pages/ai-report/detail/index',
'pages/followup/detail/index',
'pages/consultation/index', 'pages/consultation/index',
'pages/consultation/detail/index', 'pages/consultation/detail/index',
'pages/mall/index', 'pages/mall/index',
'pages/mall/exchange/index',
'pages/mall/orders/index',
'pages/mall/detail/index',
'pages/profile/index', 'pages/profile/index',
'pages/profile/family/index', 'pages/appointment/index',
'pages/profile/family-add/index', 'pages/appointment/create/index',
'pages/profile/reports/index', 'pages/appointment/detail/index',
'pages/profile/followups/index',
'pages/profile/medication/index',
'pages/profile/settings/index',
'pages/legal/user-agreement', 'pages/legal/user-agreement',
'pages/legal/privacy-policy', 'pages/legal/privacy-policy',
'pages/doctor/index', ],
'pages/doctor/patients/index', subPackages: [
'pages/doctor/patients/detail/index', {
'pages/doctor/consultation/index', root: 'pages/pkg-health',
'pages/doctor/consultation/detail/index', pages: ['trend/index', 'input/index', 'daily-monitoring/index', 'alerts/index'],
'pages/doctor/followup/index', },
'pages/doctor/followup/detail/index', {
'pages/doctor/report/index', root: 'pages/doctor',
'pages/doctor/report/detail/index', pages: [
'pages/events/index', 'index', 'patients/index', 'patients/detail/index',
'consultation/index', 'consultation/detail/index',
'followup/index', 'followup/detail/index',
'report/index', 'report/detail/index',
'alerts/index', 'alerts/detail/index',
'action-inbox/index',
'dialysis/index', 'dialysis/detail/index', 'dialysis/create/index',
'prescription/index', 'prescription/detail/index', 'prescription/create/index',
],
},
{
root: 'pages/pkg-mall',
pages: ['exchange/index', 'orders/index', 'detail/index'],
},
{
root: 'pages/pkg-profile',
pages: [
'family/index', 'family-add/index', 'reports/index',
'followups/index', 'medication/index', 'settings/index',
'dialysis-records/index', 'dialysis-records/detail/index',
'dialysis-prescriptions/index', 'dialysis-prescriptions/detail/index',
'consents/index', 'health-records/index', 'diagnoses/index',
'elder-mode/index',
],
},
{
root: 'pages/ai-report',
pages: ['list/index', 'detail/index'],
},
{
root: 'pages/article',
pages: ['index', 'detail/index'],
},
{
root: 'pages/report',
pages: ['detail/index'],
},
{
root: 'pages/followup',
pages: ['detail/index'],
},
{
root: 'pages/events',
pages: ['index'],
},
{
root: 'pages/device-sync',
pages: ['index'],
},
], ],
tabBar: { tabBar: {
color: '#94A3B8', color: '#A8A29E',
selectedColor: '#0891B2', selectedColor: '#C4623A',
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
borderStyle: 'white', borderStyle: 'white',
list: [ list: [
{ pagePath: 'pages/index/index', text: '首页', iconPath: 'assets/tabbar/home.png', selectedIconPath: 'assets/tabbar/home-active.png' }, { pagePath: 'pages/index/index', text: '首页', iconPath: 'assets/tabbar/home.png', selectedIconPath: 'assets/tabbar/home-active.png' },
{ pagePath: 'pages/health/index', text: '上报', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' }, { pagePath: 'pages/health/index', text: '健康', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' },
{ pagePath: 'pages/consultation/index', text: '咨询', iconPath: 'assets/tabbar/appointment.png', selectedIconPath: 'assets/tabbar/appointment-active.png' }, { pagePath: 'pages/messages/index', text: '消息', iconPath: 'assets/tabbar/message.png', selectedIconPath: 'assets/tabbar/message-active.png' },
{ pagePath: 'pages/mall/index', text: '商城', iconPath: 'assets/tabbar/article.png', selectedIconPath: 'assets/tabbar/article-active.png' },
{ pagePath: 'pages/profile/index', text: '我的', iconPath: 'assets/tabbar/profile.png', selectedIconPath: 'assets/tabbar/profile-active.png' }, { pagePath: 'pages/profile/index', text: '我的', iconPath: 'assets/tabbar/profile.png', selectedIconPath: 'assets/tabbar/profile-active.png' },
], ],
}, },
window: { window: {
backgroundTextStyle: 'light', backgroundTextStyle: 'dark',
navigationBarBackgroundColor: '#0891B2', navigationBarBackgroundColor: '#FFFFFF',
navigationBarTitleText: '健康管理', navigationBarTitleText: '健康管理',
navigationBarTextStyle: 'white', navigationBarTextStyle: 'black',
enablePullDownRefresh: true,
}, },
}); });

View File

@@ -1,4 +1,6 @@
@import './styles/variables.scss'; @import './styles/variables.scss';
@import './styles/tokens.scss';
@import './styles/elder-mode.scss';
page { page {
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC', font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC',

View File

@@ -1,10 +1,20 @@
import { useEffect, PropsWithChildren } from 'react'; import { useEffect, PropsWithChildren } from 'react';
import Taro from '@tarojs/taro'; import Taro, { useDidShow } from '@tarojs/taro';
import ErrorBoundary from './components/ErrorBoundary'; import ErrorBoundary from './components/ErrorBoundary';
import { flushEvents } from './services/analytics'; import { flushEvents } from './services/analytics';
import { useAuthStore } from './stores/auth';
import { useUIStore } from './stores/ui';
import './app.scss'; import './app.scss';
function App({ children }: PropsWithChildren<Record<string, unknown>>) { function App({ children }: PropsWithChildren<Record<string, unknown>>) {
const restoreAuth = useAuthStore((s) => s.restore);
const restoreUI = useUIStore((s) => s.restore);
useDidShow(() => {
restoreAuth();
restoreUI();
});
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
flushEvents(); flushEvents();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1,51 @@
@import '../../styles/variables.scss';
.device-card {
display: flex;
align-items: center;
padding: 24rpx;
background: $card;
border-radius: $r;
margin-bottom: 16rpx;
box-shadow: $shadow-sm;
.device-icon {
font-size: var(--tk-font-h2);
margin-right: 20rpx;
}
.device-info {
flex: 1;
.device-name {
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx;
display: block;
}
.device-status {
font-size: var(--tk-font-micro);
margin-top: 4rpx;
display: block;
&.connected { color: $pri; }
&.idle { color: $tx3; }
}
.last-sync {
font-size: var(--tk-font-micro);
color: $tx3;
margin-top: 4rpx;
display: block;
}
}
.sync-btn {
padding: 12rpx 28rpx;
background: $pri;
color: #fff;
border-radius: $r-pill;
font-size: var(--tk-font-micro);
}
}

View File

@@ -0,0 +1,39 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './index.scss';
interface DeviceCardProps {
deviceName: string;
deviceType: string;
lastSyncAt?: string;
status: 'connected' | 'disconnected' | 'never';
}
const DEVICE_ICONS: Record<string, string> = {
blood_pressure: '\u{1FA7A}',
blood_glucose: '\u{1FA78}',
heart_rate: '\u{2764}',
blood_oxygen: '\u{1FAB1}',
};
export default function DeviceCard({ deviceName, deviceType, lastSyncAt, status }: DeviceCardProps) {
const icon = DEVICE_ICONS[deviceType] || '\u{1F4F1}';
const statusLabel = status === 'connected' ? '已连接' : status === 'disconnected' ? '未连接' : '未配对';
const statusClass = status === 'connected' ? 'connected' : 'idle';
const handleSync = () => {
Taro.navigateTo({ url: '/pages/device-sync/index' });
};
return (
<View className='device-card' onClick={handleSync}>
<View className='device-icon'>{icon}</View>
<View className='device-info'>
<Text className='device-name'>{deviceName}</Text>
<Text className={`device-status ${statusClass}`}>{statusLabel}</Text>
{lastSyncAt && <Text className='last-sync'>: {lastSyncAt}</Text>}
</View>
<View className='sync-btn'></View>
</View>
);
}

View File

@@ -0,0 +1,98 @@
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import { Canvas, View } from '@tarojs/components';
import Taro from '@tarojs/taro';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
MarkAreaComponent,
MarkPointComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
LineChart,
GridComponent,
TooltipComponent,
MarkAreaComponent,
MarkPointComponent,
CanvasRenderer,
]);
interface EcCanvasProps {
canvasId: string;
height?: number;
}
export interface EcCanvasRef {
setOption: (option: echarts.EChartsOption) => void;
}
const EcCanvas = React.memo(React.forwardRef<EcCanvasRef, EcCanvasProps>(
({ canvasId, height = 300 }, ref) => {
const chartInstance = useRef<echarts.ECharts | null>(null);
const canvasNode = useRef<any>(null);
const initChart = async () => {
try {
const query = Taro.createSelectorQuery();
query
.select(`#${canvasId}`)
.node()
.exec((res) => {
const node = res[0]?.node;
if (!node) return;
canvasNode.current = node;
const dpr = Taro.getSystemInfoSync().pixelRatio;
const width = node.width || 350;
const heightVal = node.height || height;
node.width = width * dpr;
node.height = heightVal * dpr;
const ctx = node.getContext('2d');
chartInstance.current = echarts.init(ctx as any, undefined, {
renderer: 'canvas',
width,
height: heightVal,
devicePixelRatio: dpr,
});
});
} catch (e) {
console.error('EcCanvas init failed:', e);
}
};
useEffect(() => {
initChart();
return () => {
chartInstance.current?.dispose();
};
}, []);
useImperativeHandle(ref, () => ({
setOption: (option: echarts.EChartsOption) => {
if (chartInstance.current) {
chartInstance.current.setOption(option);
}
},
}));
return (
<View style={{ width: '100%', height: `${height}rpx` }}>
<Canvas
type='2d'
id={canvasId}
style={{ width: '100%', height: '100%' }}
/>
</View>
);
},
));
EcCanvas.displayName = 'EcCanvas';
export default EcCanvas;

View File

@@ -8,20 +8,33 @@
padding: 120px 40px; padding: 120px 40px;
} }
.empty-state-icon { .empty-state-icon-wrap {
font-size: 80px; width: 120px;
height: 120px;
border-radius: 50%;
background: $surface-alt;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px; margin-bottom: 24px;
} }
.empty-state-icon-char {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-hero);
font-weight: 600;
color: $tx3;
}
.empty-state-text { .empty-state-text {
font-size: 30px; font-size: var(--tk-font-num);
color: $tx2; color: $tx2;
margin-bottom: 8px; margin-bottom: 8px;
} }
.empty-state-hint { .empty-state-hint {
font-size: 24px; font-size: var(--tk-font-h2);
color: $tx3; color: var(--tk-text-secondary);
margin-bottom: 32px; margin-bottom: 32px;
} }
@@ -32,6 +45,6 @@
} }
.empty-state-action-text { .empty-state-action-text {
font-size: 28px; font-size: var(--tk-font-body-lg);
color: #fff; color: #fff;
} }

View File

@@ -10,16 +10,19 @@ interface EmptyStateProps {
onAction?: () => void; onAction?: () => void;
} }
export default function EmptyState({ export default React.memo(function EmptyState({
icon = '📭', icon,
text, text,
hint, hint,
actionText, actionText,
onAction, onAction,
}: EmptyStateProps) { }: EmptyStateProps) {
const displayChar = icon || text.charAt(0);
return ( return (
<View className='empty-state'> <View className='empty-state'>
<Text className='empty-state-icon'>{icon}</Text> <View className='empty-state-icon-wrap'>
<Text className='empty-state-icon-char'>{displayChar}</Text>
</View>
<Text className='empty-state-text'>{text}</Text> <Text className='empty-state-text'>{text}</Text>
{hint && <Text className='empty-state-hint'>{hint}</Text>} {hint && <Text className='empty-state-hint'>{hint}</Text>}
{actionText && onAction && ( {actionText && onAction && (
@@ -29,4 +32,4 @@ export default function EmptyState({
)} )}
</View> </View>
); );
} });

View File

@@ -23,13 +23,25 @@ export default class ErrorBoundary extends Component<Props, State> {
console.error('[ErrorBoundary]', error, info.componentStack); console.error('[ErrorBoundary]', error, info.componentStack);
} }
handleRetry = () => {
this.setState({ hasError: false });
};
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', padding: '40px' }}> <View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', padding: '40px 24px' }}>
<Text style={{ fontSize: '48px', marginBottom: '20px' }}>😵</Text> <View style={{ width: '64px', height: '64px', borderRadius: '32px', background: '#F0DDD4', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '20px' }}>
<Text style={{ fontSize: '32px', color: '#134E4A', marginBottom: '12px' }}></Text> <Text style={{ fontFamily: 'Georgia, serif', fontSize: '28px', fontWeight: 600, color: '#8B3E1F' }}>!</Text>
<Text style={{ fontSize: '24px', color: '#94A3B8', marginBottom: '24px' }}></Text> </View>
<Text style={{ fontSize: '32px', color: '#2D2A26', marginBottom: '12px', fontWeight: 600 }}></Text>
<Text style={{ fontSize: '24px', color: '#78716C', marginBottom: '32px' }}></Text>
<View
onClick={this.handleRetry}
style={{ background: '#C4623A', borderRadius: '12px', padding: '14px 48px' }}
>
<Text style={{ color: '#FFFFFF', fontSize: '28px' }}></Text>
</View>
</View> </View>
); );
} }

View File

@@ -9,12 +9,12 @@
} }
.error-state-icon { .error-state-icon {
font-size: 80px; font-size: 80px; /* hero icon — kept as-is */
margin-bottom: 24px; margin-bottom: 24px;
} }
.error-state-text { .error-state-text {
font-size: 28px; font-size: var(--tk-font-body-lg);
color: $tx2; color: $tx2;
margin-bottom: 32px; margin-bottom: 32px;
text-align: center; text-align: center;
@@ -27,6 +27,6 @@
} }
.error-state-retry-text { .error-state-retry-text {
font-size: 28px; font-size: var(--tk-font-body-lg);
color: #fff; color: #fff;
} }

View File

@@ -7,7 +7,7 @@ interface ErrorStateProps {
onRetry?: () => void; onRetry?: () => void;
} }
export default function ErrorState({ export default React.memo(function ErrorState({
text = '加载失败,请稍后重试', text = '加载失败,请稍后重试',
onRetry, onRetry,
}: ErrorStateProps) { }: ErrorStateProps) {
@@ -22,4 +22,4 @@ export default function ErrorState({
)} )}
</View> </View>
); );
} });

View File

@@ -0,0 +1,64 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.guard-page {
min-height: 100vh;
background: $bg;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
}
.guard-card {
text-align: center;
padding: 40px 20px;
}
.guard-icon-wrap {
width: 80px;
height: 80px;
border-radius: 40px;
background: $surface-alt;
@include flex-center;
margin: 0 auto 20px;
}
.guard-icon {
font-size: var(--tk-font-num);
color: $tx3;
}
.guard-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
display: block;
margin-bottom: 8px;
}
.guard-desc {
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
display: block;
margin-bottom: 24px;
}
.guard-btn {
display: inline-block;
height: 48px;
padding: 0 32px;
background: $pri;
border-radius: $r-pill;
@include flex-center;
&:active {
opacity: 0.85;
}
}
.guard-btn-text {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: #fff;
}

View File

@@ -0,0 +1,28 @@
import { View, Text } from '@tarojs/components';
import { navigateToLogin } from '../../utils/navigate';
import './index.scss';
interface GuestGuardProps {
title: string;
desc?: string;
}
export default function GuestGuard({ title, desc }: GuestGuardProps) {
return (
<View className='guard-page'>
<View className='guard-card'>
<View className='guard-icon-wrap'>
<Text className='guard-icon'></Text>
</View>
<Text className='guard-title'>{title}</Text>
{desc && <Text className='guard-desc'>{desc}</Text>}
<View
className='guard-btn'
onClick={navigateToLogin}
>
<Text className='guard-btn-text'></Text>
</View>
</View>
</View>
);
}

View File

@@ -25,6 +25,6 @@
} }
.loading-state-text { .loading-state-text {
font-size: 26px; font-size: var(--tk-font-h1);
color: $tx3; color: var(--tk-text-secondary);
} }

View File

@@ -6,11 +6,11 @@ interface LoadingProps {
text?: string; text?: string;
} }
export default function Loading({ text = '加载中...' }: LoadingProps) { export default React.memo(function Loading({ text = '加载中...' }: LoadingProps) {
return ( return (
<View className='loading-state'> <View className='loading-state'>
<View className='loading-spinner' /> <View className='loading-spinner' />
<Text className='loading-state-text'>{text}</Text> <Text className='loading-state-text'>{text}</Text>
</View> </View>
); );
} });

View File

@@ -0,0 +1,29 @@
@import '../styles/variables.scss';
.progress-ring {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.progress-ring-inner {
background: $card;
border-radius: 50%;
display: flex;
align-items: baseline;
justify-content: center;
}
.progress-ring-percent {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body);
font-weight: bold;
line-height: 1;
}
.progress-ring-unit {
font-size: var(--tk-font-micro);
font-weight: 600;
line-height: 1;
}

View File

@@ -0,0 +1,40 @@
import { View, Text } from '@tarojs/components';
import './ProgressRing.scss';
interface ProgressRingProps {
percent: number;
size?: number;
strokeWidth?: number;
color?: string;
trackColor?: string;
}
export default function ProgressRing({
percent,
size = 72,
strokeWidth = 7,
color = '#C4623A',
trackColor = '#E8E2DC',
}: ProgressRingProps) {
const clamped = Math.max(0, Math.min(100, percent));
const innerSize = size - strokeWidth * 2;
return (
<View
className='progress-ring'
style={`width:${size}px;height:${size}px;background:conic-gradient(${color} ${clamped}%, ${trackColor} ${clamped}%);border-radius:50%;padding:${strokeWidth}px;`}
>
<View
className='progress-ring-inner'
style={`width:${innerSize}px;height:${innerSize}px;`}
>
<Text className='progress-ring-percent' style={`color:${color};`}>
{clamped}
</Text>
<Text className='progress-ring-unit' style={`color:${color};`}>
%
</Text>
</View>
</View>
);
}

View File

@@ -39,7 +39,7 @@
justify-content: center; justify-content: center;
background: $bd-l; background: $bd-l;
color: $tx3; color: $tx3;
font-size: 24px; font-size: var(--tk-font-h2);
transition: all 0.3s ease; transition: all 0.3s ease;
z-index: 1; z-index: 1;
@@ -55,8 +55,8 @@
} }
.step-label { .step-label {
font-size: 22px; font-size: var(--tk-font-body);
color: $tx3; color: var(--tk-text-secondary);
margin-top: 8px; margin-top: 8px;
text-align: center; text-align: center;

View File

@@ -12,7 +12,7 @@ interface StepIndicatorProps {
onChange?: (index: number) => void; onChange?: (index: number) => void;
} }
export default function StepIndicator({ steps, current, onChange }: StepIndicatorProps) { export default React.memo(function StepIndicator({ steps, current, onChange }: StepIndicatorProps) {
return ( return (
<View className='step-indicator'> <View className='step-indicator'>
{steps.map((step, idx) => { {steps.map((step, idx) => {
@@ -39,4 +39,4 @@ export default function StepIndicator({ steps, current, onChange }: StepIndicato
})} })}
</View> </View>
); );
} });

View File

@@ -2,6 +2,7 @@
.trend-chart { .trend-chart {
width: 100%; width: 100%;
position: relative;
} }
.trend-chart-empty { .trend-chart-empty {
@@ -12,6 +13,35 @@
} }
.trend-chart-empty-text { .trend-chart-empty-text {
font-size: 28px; font-size: var(--tk-font-body-lg);
color: $tx3; color: var(--tk-text-secondary);
}
.trend-chart-skeleton {
position: absolute;
top: 20px;
left: 45px;
right: 15px;
bottom: 30px;
display: flex;
flex-direction: column;
justify-content: space-around;
z-index: 1;
}
.skeleton-line {
height: 8px;
border-radius: 4px;
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.skeleton-line-1 { width: 70%; }
.skeleton-line-2 { width: 90%; }
.skeleton-line-3 { width: 60%; }
@keyframes skeleton-pulse {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
} }

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useCallback } from 'react'; import React, { useEffect, useRef, useCallback } from 'react';
import { View, Text } from '@tarojs/components'; import { Canvas, View, Text } from '@tarojs/components';
import { EChart } from 'echarts-taro3-react'; import Taro from '@tarojs/taro';
import './index.scss'; import './index.scss';
interface TrendChartProps { interface TrendChartProps {
@@ -11,76 +11,157 @@ interface TrendChartProps {
height?: number; height?: number;
} }
export default function TrendChart({ data, referenceMin, referenceMax, unit = '', height = 500 }: TrendChartProps) { const DPR = Taro.getSystemInfoSync().pixelRatio || 2;
const chartRef = useRef<any>(null);
const getOption = useCallback(() => { function drawLine(
if (!data || data.length === 0) return null; ctx: CanvasRenderingContext2D,
points: { x: number; y: number }[],
) {
if (points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const cpx = (prev.x + curr.x) / 2;
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
}
ctx.stroke();
}
const series: any[] = []; export default React.memo(function TrendChart({
const markArea: any = {}; data,
referenceMin,
referenceMax,
unit = '',
height = 500,
}: TrendChartProps) {
const canvasRef = useRef<any>(null);
const draw = useCallback(() => {
const node = canvasRef.current;
if (!node || !data || data.length === 0) return;
const w = node.width / DPR;
const h = node.height / DPR;
const ctx = node.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, node.width, node.height);
ctx.save();
ctx.scale(DPR, DPR);
const pad = { left: 45, right: 15, top: 20, bottom: 30 };
const cw = w - pad.left - pad.right;
const ch = h - pad.top - pad.bottom;
const values = data.map((d) => d.value);
let yMin = Math.min(...values);
let yMax = Math.max(...values);
if (referenceMin != null) yMin = Math.min(yMin, referenceMin);
if (referenceMax != null) yMax = Math.max(yMax, referenceMax);
const yRange = yMax - yMin || 1;
const yPad = yRange * 0.1;
yMin -= yPad;
yMax += yPad;
const yTotal = yMax - yMin;
const toX = (i: number) => pad.left + (i / Math.max(data.length - 1, 1)) * cw;
const toY = (v: number) => pad.top + ch - ((v - yMin) / yTotal) * ch;
// Reference band
if (referenceMin != null && referenceMax != null) { if (referenceMin != null && referenceMax != null) {
markArea.data = [[ const ry1 = toY(referenceMax);
{ yAxis: referenceMin, itemStyle: { color: 'rgba(5,150,105,0.08)' } }, const ry2 = toY(referenceMin);
{ yAxis: referenceMax }, ctx.fillStyle = 'rgba(5,150,105,0.08)';
]]; ctx.fillRect(pad.left, ry1, cw, ry2 - ry1);
} }
series.push({ // Grid lines
type: 'line', ctx.strokeStyle = '#F3F4F6';
data: data.map((d) => d.value), ctx.lineWidth = 1;
smooth: true, const gridLines = 4;
symbol: 'circle', for (let i = 0; i <= gridLines; i++) {
symbolSize: 6, const gy = pad.top + (ch / gridLines) * i;
lineStyle: { color: '#0891B2', width: 2 }, ctx.beginPath();
itemStyle: { color: '#0891B2' }, ctx.moveTo(pad.left, gy);
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(8,145,178,0.15)' }, { offset: 1, color: 'rgba(8,145,178,0.01)' }] } }, ctx.lineTo(pad.left + cw, gy);
markArea: markArea.data ? { silent: true, data: markArea.data } : undefined, ctx.stroke();
markPoint: (referenceMin != null && referenceMax != null) ? { }
data: data
.filter((d) => d.value < referenceMin || d.value > referenceMax)
.map((d) => ({
coord: [data.indexOf(d), d.value],
itemStyle: { color: '#DC2626' },
symbolSize: 12,
})),
} : undefined,
});
return { // Y-axis labels
grid: { left: 45, right: 15, top: 20, bottom: 30 }, ctx.fillStyle = '#94A3B8';
xAxis: { ctx.font = '10px sans-serif';
type: 'category', ctx.textAlign = 'right';
data: data.map((d) => d.date.slice(5)), for (let i = 0; i <= gridLines; i++) {
axisLabel: { fontSize: 10, color: '#94A3B8' }, const val = yMax - (yTotal / gridLines) * i;
axisLine: { lineStyle: { color: '#E5E7EB' } }, const gy = pad.top + (ch / gridLines) * i;
}, ctx.fillText(val.toFixed(1), pad.left - 6, gy + 3);
yAxis: { }
type: 'value',
axisLabel: { fontSize: 10, color: '#94A3B8' }, // X-axis labels
splitLine: { lineStyle: { color: '#F3F4F6' } }, ctx.textAlign = 'center';
}, const step = Math.max(1, Math.floor(data.length / 5));
tooltip: { for (let i = 0; i < data.length; i += step) {
trigger: 'axis', const lx = toX(i);
formatter: (params: any) => { ctx.fillText(data[i].date.slice(5), lx, h - 8);
const p = params[0]; }
const idx = p.dataIndex;
return `${data[idx]?.date || ''}\n${p.value}${unit ? ' ' + unit : ''}`; // Area fill
}, const chartPoints = data.map((d, i) => ({ x: toX(i), y: toY(d.value) }));
}, ctx.beginPath();
series, ctx.moveTo(chartPoints[0].x, toY(yMin));
}; ctx.lineTo(chartPoints[0].x, chartPoints[0].y);
}, [data, referenceMin, referenceMax, unit]); for (let i = 1; i < chartPoints.length; i++) {
const prev = chartPoints[i - 1];
const curr = chartPoints[i];
const cpx = (prev.x + curr.x) / 2;
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
}
ctx.lineTo(chartPoints[chartPoints.length - 1].x, toY(yMin));
ctx.closePath();
const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + ch);
grad.addColorStop(0, 'rgba(8,145,178,0.15)');
grad.addColorStop(1, 'rgba(8,145,178,0.01)');
ctx.fillStyle = grad;
ctx.fill();
// Line
ctx.strokeStyle = '#0891B2';
ctx.lineWidth = 2;
drawLine(ctx, chartPoints);
// Data points
for (let i = 0; i < data.length; i++) {
const d = data[i];
const isAbnormal =
(referenceMin != null && d.value < referenceMin) ||
(referenceMax != null && d.value > referenceMax);
ctx.beginPath();
ctx.arc(chartPoints[i].x, chartPoints[i].y, isAbnormal ? 5 : 3, 0, Math.PI * 2);
ctx.fillStyle = isAbnormal ? '#DC2626' : '#0891B2';
ctx.fill();
}
ctx.restore();
}, [data, referenceMin, referenceMax]);
useEffect(() => { useEffect(() => {
if (chartRef.current && data && data.length > 0) { const query = Taro.createSelectorQuery();
const option = getOption(); query
if (option) { .select('#trend-chart-canvas')
chartRef.current.refresh(option); .node()
} .exec((res) => {
} const node = res[0]?.node;
}, [data, getOption]); if (!node) return;
canvasRef.current = node;
const sysInfo = Taro.getSystemInfoSync();
const canvasW = (sysInfo.windowWidth * 750) / sysInfo.windowWidth;
node.width = sysInfo.windowWidth * DPR;
node.height = ((height / 750) * sysInfo.windowWidth) * DPR;
draw();
});
}, [draw, height]);
if (!data || data.length === 0) { if (!data || data.length === 0) {
return ( return (
@@ -92,7 +173,11 @@ export default function TrendChart({ data, referenceMin, referenceMax, unit = ''
return ( return (
<View className='trend-chart' style={{ height: `${height}rpx` }}> <View className='trend-chart' style={{ height: `${height}rpx` }}>
<EChart canvasId='trend-chart-canvas' ref={chartRef} /> <Canvas
type='2d'
id='trend-chart-canvas'
style={{ width: '100%', height: '100%' }}
/>
</View> </View>
); );
} });

View File

@@ -14,13 +14,13 @@
} }
.week-arrow { .week-arrow {
font-size: 28px; font-size: var(--tk-font-body-lg);
color: $pri; color: $pri;
padding: 0 16px; padding: 0 16px;
} }
.week-label { .week-label {
font-size: 24px; font-size: var(--tk-font-h2);
color: $tx; color: $tx;
font-weight: bold; font-weight: bold;
} }
@@ -39,13 +39,13 @@
} }
.cell-weekday { .cell-weekday {
font-size: 20px; font-size: var(--tk-font-body);
color: $tx3; color: var(--tk-text-secondary);
display: block; display: block;
} }
.cell-date { .cell-date {
font-size: 26px; font-size: var(--tk-font-h1);
color: $tx; color: $tx;
display: block; display: block;
margin-top: 4px; margin-top: 4px;

View File

@@ -23,7 +23,7 @@ function getWeekDates(offset: number): string[] {
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']; const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
export default function WeekCalendar({ scheduledDates, selectedDate, onSelectDate }: WeekCalendarProps) { export default React.memo(function WeekCalendar({ scheduledDates, selectedDate, onSelectDate }: WeekCalendarProps) {
const [weekOffset, setWeekOffset] = useState(0); const [weekOffset, setWeekOffset] = useState(0);
const dates = getWeekDates(weekOffset); const dates = getWeekDates(weekOffset);
const today = (() => { const today = (() => {
@@ -60,4 +60,4 @@ export default function WeekCalendar({ scheduledDates, selectedDate, onSelectDat
</View> </View>
</View> </View>
); );
} });

View File

@@ -0,0 +1,6 @@
import { useUIStore } from '../stores/ui';
export function useElderClass(): string {
const mode = useUIStore((s) => s.mode);
return mode === 'elder' ? 'elder-mode' : '';
}

View File

@@ -1,23 +1,24 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.detail-page { .detail-page {
min-height: 100vh; min-height: 100vh;
background: #f1f5f9; background: $bg;
padding: 16px; padding: 24px;
padding-bottom: 40px;
} }
.detail-card { .detail-card {
background: #fff; background: $card;
border-radius: 12px; border-radius: $r;
padding: 16px; padding: 28px;
margin-bottom: 12px; margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); box-shadow: $shadow-sm;
} }
.detail-type { .detail-type {
font-size: 18px; @include section-title;
font-weight: 600; margin-bottom: 12px;
color: #0f172a;
display: block;
margin-bottom: 8px;
} }
.detail-meta { .detail-meta {
@@ -26,27 +27,86 @@
} }
.meta-item { .meta-item {
font-size: 12px; font-size: var(--tk-font-body);
color: #94a3b8; color: $tx3;
} }
.content-card { .content-card {
background: #fff; background: $card;
border-radius: 12px; border-radius: $r;
padding: 16px; padding: 28px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); box-shadow: $shadow-sm;
// RichText 内部样式
h1, h2, h3 {
font-weight: bold;
color: $tx;
margin: 24px 0 12px;
}
p {
font-size: var(--tk-font-body-lg);
color: $tx;
line-height: 1.8;
margin-bottom: 16px;
}
ul {
padding-left: 32px;
margin-bottom: 16px;
}
li {
font-size: var(--tk-font-body-lg);
color: $tx;
line-height: 1.8;
margin-bottom: 8px;
}
strong {
color: $pri-d;
}
} }
.report-content { .report-content {
font-size: 14px; font-size: var(--tk-font-body-lg);
line-height: 1.8; line-height: 1.8;
color: #334155; color: $tx;
} }
.empty-text { .empty-text {
display: block; display: block;
text-align: center; text-align: center;
padding: 60px 0; padding: 120px 0;
color: #94a3b8; color: var(--tk-text-secondary);
font-size: 14px; font-size: var(--tk-font-body-lg);
}
.auto-badge {
margin-top: 16px;
display: inline-block;
}
.auto-badge-text {
display: inline-block;
padding: 4px 16px;
border-radius: 8px;
font-size: var(--tk-font-body);
font-weight: 500;
background: #f0e6ff;
color: #7c3aed;
}
.trend-tip-card {
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: $r;
padding: 20px 24px;
margin-bottom: 20px;
}
.trend-tip-text {
font-size: var(--tk-font-body);
color: #92400e;
line-height: 1.6;
} }

View File

@@ -3,6 +3,7 @@ import { View, Text, RichText } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro'; import Taro, { useRouter } from '@tarojs/taro';
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis'; import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis';
import Loading from '@/components/Loading'; import Loading from '@/components/Loading';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss'; import './index.scss';
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
@@ -12,8 +13,26 @@ const TYPE_LABELS: Record<string, string> = {
report_summary_generation: '报告摘要', report_summary_generation: '报告摘要',
}; };
/** 移除危险的 HTML 标签和事件属性,防止 XSS */
function sanitizeHtml(html: string): string {
return html
// 移除 <script> 标签及其内容
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// 移除 <iframe>, <object>, <embed>, <form>, <input>, <textarea>, <style> 标签
.replace(/<\/?(?:iframe|object|embed|form|input|textarea|style)\b[^>]*>/gi, '')
// 移除 <link> 和 <meta> 标签
.replace(/<\/?(?:link|meta)\b[^>]*>/gi, '')
// 移除所有 on* 事件属性 (onclick, onerror, onload 等)
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
// 移除 javascript: 和 data: 协议的 href/src 属性
.replace(/(href|src)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, '')
.replace(/(href|src)\s*=\s*(?:"data:[^"]*"|'data:[^']*')/gi, '');
}
function markdownToHtml(md: string): string { function markdownToHtml(md: string): string {
return md // 先转义 markdown 中可能存在的原始 HTML 标签
const escaped = sanitizeHtml(md);
return escaped
.replace(/^### (.+)$/gm, '<h3>$1</h3>') .replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>') .replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>') .replace(/^# (.+)$/gm, '<h1>$1</h1>')
@@ -26,6 +45,7 @@ function markdownToHtml(md: string): string {
} }
export default function AiReportDetail() { export default function AiReportDetail() {
const modeClass = useElderClass();
const router = useRouter(); const router = useRouter();
const id = router.params.id || ''; const id = router.params.id || '';
@@ -45,7 +65,7 @@ export default function AiReportDetail() {
if (!analysis) { if (!analysis) {
return ( return (
<View className='detail-page'> <View className={`detail-page ${modeClass}`}>
<Text className='empty-text'></Text> <Text className='empty-text'></Text>
</View> </View>
); );
@@ -55,16 +75,32 @@ export default function AiReportDetail() {
? markdownToHtml(analysis.result_content) ? markdownToHtml(analysis.result_content)
: '<p>暂无分析结果</p>'; : '<p>暂无分析结果</p>';
const isTrendAnalysis = analysis.analysis_type === 'trend';
const isAutoAnalysis = (analysis.result_metadata as Record<string, unknown>)?.auto_analysis === true;
return ( return (
<View className='detail-page'> <View className={`detail-page ${modeClass}`}>
<View className='detail-card'> <View className='detail-card'>
<Text className='detail-type'>{TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type}</Text> <Text className='detail-type'>{TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type}</Text>
<View className='detail-meta'> <View className='detail-meta'>
<Text className='meta-item'>: {analysis.model_used}</Text> <Text className='meta-item'>: {analysis.model_used}</Text>
<Text className='meta-item'>{new Date(analysis.created_at).toLocaleString('zh-CN')}</Text> <Text className='meta-item'>{new Date(analysis.created_at).toLocaleString('zh-CN')}</Text>
</View> </View>
{isAutoAnalysis && (
<View className='auto-badge'>
<Text className='auto-badge-text'></Text>
</View>
)}
</View> </View>
{isTrendAnalysis && (
<View className='trend-tip-card'>
<Text className='trend-tip-text'>
线 2 R² 1
</Text>
</View>
)}
<View className='content-card'> <View className='content-card'>
<RichText className='report-content' nodes={htmlContent} /> <RichText className='report-content' nodes={htmlContent} />
</View> </View>

View File

@@ -1,65 +1,60 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.ai-report-page { .ai-report-page {
min-height: 100vh; min-height: 100vh;
background: #f1f5f9; background: $bg;
padding: 16px; padding: 24px;
padding-bottom: 40px;
} }
.page-title { .page-title {
font-size: 20px; @include section-title;
font-weight: 600;
color: #0f172a;
margin-bottom: 16px;
} }
.report-scroll { .report-scroll {
height: calc(100vh - 80px); height: calc(100vh - 100px);
} }
.report-card { .report-card {
background: #fff; background: $card;
border-radius: 12px; border-radius: $r;
padding: 16px; padding: 28px;
margin-bottom: 12px; margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); box-shadow: $shadow-sm;
} }
.card-header { .card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: 12px;
} }
.card-type { .card-type {
font-size: 15px; font-size: var(--tk-font-body-lg);
font-weight: 500; font-weight: 500;
color: #1e293b; color: $tx;
} }
.card-status { .card-status {
font-size: 12px; @include tag($bd-l, $tx2);
padding: 2px 8px;
border-radius: 10px;
} }
.status-completed { .status-completed {
color: #16a34a; @include tag($acc-l, $acc);
background: #dcfce7;
} }
.status-streaming { .status-streaming {
color: #2563eb; @include tag($pri-l, $pri);
background: #dbeafe;
} }
.status-failed { .status-failed {
color: #dc2626; @include tag($dan-l, $dan);
background: #fee2e2;
} }
.status-pending { .status-pending {
color: #d97706; @include tag($wrn-l, $wrn);
background: #fef3c7;
} }
.card-footer { .card-footer {
@@ -69,19 +64,19 @@
} }
.card-time { .card-time {
font-size: 12px; font-size: var(--tk-font-body);
color: #94a3b8; color: $tx3;
} }
.card-model { .card-model {
font-size: 11px; font-size: var(--tk-font-body);
color: #cbd5e1; color: $tx3;
} }
.no-more { .no-more {
text-align: center; text-align: center;
font-size: 12px; font-size: var(--tk-font-h2);
color: #94a3b8; color: var(--tk-text-secondary);
padding: 16px 0; padding: 24px 0;
display: block; display: block;
} }

View File

@@ -4,6 +4,7 @@ import Taro from '@tarojs/taro';
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis'; import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis';
import Loading from '@/components/Loading'; import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss'; import './index.scss';
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
@@ -21,6 +22,7 @@ const STATUS_MAP: Record<string, { text: string; className: string }> = {
}; };
export default function AiReportList() { export default function AiReportList() {
const modeClass = useElderClass();
const [list, setList] = useState<AiAnalysisItem[]>([]); const [list, setList] = useState<AiAnalysisItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -60,14 +62,14 @@ export default function AiReportList() {
if (list.length === 0) { if (list.length === 0) {
return ( return (
<View className='ai-report-page'> <View className={`ai-report-page ${modeClass}`}>
<EmptyState text='暂无 AI 分析报告' /> <EmptyState text='暂无 AI 分析报告' />
</View> </View>
); );
} }
return ( return (
<View className='ai-report-page'> <View className={`ai-report-page ${modeClass}`}>
<View className='page-title'>AI </View> <View className='page-title'>AI </View>
<ScrollView scrollY className='report-scroll' onScrollToLower={loadMore}> <ScrollView scrollY className='report-scroll' onScrollToLower={loadMore}>
{list.map((item) => { {list.map((item) => {

View File

@@ -1,9 +1,10 @@
@import '../../../styles/variables.scss'; @import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.create-page { .create-page {
min-height: 100vh; min-height: 100vh;
background: $bg; background: $bg;
padding-bottom: 140px; padding-bottom: 160px;
} }
/* 步骤内容 */ /* 步骤内容 */
@@ -11,6 +12,10 @@
padding: 32px 24px; padding: 32px 24px;
} }
.step-title {
@include section-title;
}
/* 科室宫格 */ /* 科室宫格 */
.dept-grid { .dept-grid {
display: grid; display: grid;
@@ -21,33 +26,62 @@
.dept-card { .dept-card {
background: $card; background: $card;
border-radius: $r; border-radius: $r;
padding: 24px 12px; padding: 28px 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
border: 2px solid transparent; border: 2px solid transparent;
transition: border-color 0.2s; transition: border-color 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: $shadow-sm;
&.dept-selected {
border-color: $pri;
background: $pri-l;
}
} }
.dept-card.dept-selected { .dept-initial-circle {
border-color: $pri; width: 64px;
background: $pri-surface; height: 64px;
border-radius: $r;
background: $pri-l;
@include flex-center;
.dept-selected & {
background: $pri;
}
} }
.dept-icon { .dept-initial-text {
font-size: 40px; font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
font-weight: bold;
color: $pri;
.dept-selected & {
color: white;
}
} }
.dept-label { .dept-label {
font-size: 26px; font-size: var(--tk-font-h2);
color: $tx; color: $tx;
font-weight: 500;
} }
/* 时段卡片 */ /* 时段 */
.slot-section { .slot-section {
margin-top: 24px; margin-top: 32px;
}
.slot-section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
margin-bottom: 16px;
display: block;
} }
.slot-grid { .slot-grid {
@@ -59,63 +93,101 @@
.slot-card { .slot-card {
background: $card; background: $card;
border-radius: $r-sm; border-radius: $r-sm;
padding: 16px 20px; padding: 20px 24px;
border: 2px solid transparent; border: 2px solid transparent;
transition: all 0.2s; transition: all 0.2s;
box-shadow: $shadow-sm;
&.slot-few { border-color: $wrn; } &.slot-few {
&.slot-full { opacity: 0.5; background: $bd-l; } border-color: $wrn;
&.slot-selected { border-color: $pri; background: $pri-surface; } }
&.slot-full {
opacity: 0.4;
background: $bd-l;
pointer-events: none;
}
&.slot-selected {
border-color: $pri;
background: $pri-l;
}
} }
.slot-time { .slot-time {
font-size: 28px; @include serif-number;
font-size: var(--tk-font-body-lg);
font-weight: bold; font-weight: bold;
color: $tx; color: $tx;
display: block; display: block;
} }
.slot-count { .slot-count {
font-size: 22px; font-size: var(--tk-font-body);
color: $tx3; color: $tx3;
display: block; display: block;
margin-top: 4px; margin-top: 6px;
} }
.slot-few .slot-count { color: $wrn; } .slot-few .slot-count { color: $wrn; }
.slot-full .slot-count { color: $dan; } .slot-full .slot-count { color: $dan; }
.step-title { /* 确认卡片 (step 3 医生信息) */
font-size: 32px; .confirm-card {
font-weight: bold;
color: $tx;
margin-bottom: 28px;
display: block;
}
/* 选择器卡片 */
.picker-card {
background: $card; background: $card;
border-radius: $r; border-radius: $r;
padding: 24px 28px; padding: 24px 28px;
display: flex; margin-bottom: 24px;
justify-content: space-between; box-shadow: $shadow-sm;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
} }
.picker-value { .confirm-row {
font-size: 28px; display: flex;
align-items: center;
gap: 16px;
}
.confirm-icon-wrap {
width: 56px;
height: 56px;
border-radius: $r-sm;
background: $pri-l;
@include flex-center;
flex-shrink: 0;
}
.confirm-icon-serif {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h2);
font-weight: bold;
color: $pri;
}
.confirm-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.confirm-label {
font-size: var(--tk-font-body);
color: $tx3;
}
.confirm-value {
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx; color: $tx;
} }
.picker-value.placeholder { .confirm-dept-tag {
color: $tx3; @include tag($pri-l, $pri);
flex-shrink: 0;
} }
.picker-arrow { .confirm-dept-text {
font-size: 24px; font-size: var(--tk-font-body);
color: $tx3; font-weight: 500;
color: $pri;
} }
/* 医生列表 */ /* 医生列表 */
@@ -132,29 +204,28 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: $shadow-sm;
border: 2px solid transparent; border: 2px solid transparent;
transition: border-color 0.2s; transition: border-color 0.2s;
}
.doctor-card.doctor-selected { &.doctor-selected {
border-color: $pri; border-color: $pri;
background: $pri-surface; background: $pri-l;
}
} }
.doctor-avatar { .doctor-avatar {
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: 50%; border-radius: $r;
background: $pri-l; background: $pri-l;
display: flex; @include flex-center;
align-items: center;
justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.doctor-avatar-text { .doctor-avatar-text {
font-size: 32px; font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
color: $pri; color: $pri;
font-weight: bold; font-weight: bold;
} }
@@ -167,69 +238,68 @@
} }
.doctor-name { .doctor-name {
font-size: 30px; font-size: var(--tk-font-num);
font-weight: bold; font-weight: bold;
color: $tx; color: $tx;
} }
.doctor-title { .doctor-title {
font-size: 24px; font-size: var(--tk-font-h2);
color: $tx2; color: $tx2;
} }
.doctor-specialty { .doctor-specialty {
font-size: 22px; font-size: var(--tk-font-body);
color: $pri; color: $pri;
} }
.doctor-check { .doctor-check {
font-size: 32px; width: 44px;
color: $pri; height: 44px;
border-radius: $r-pill;
background: $pri;
@include flex-center;
flex-shrink: 0;
}
.doctor-check-text {
font-size: var(--tk-font-h2);
color: white;
font-weight: bold; font-weight: bold;
} }
/* 表单 */ /* 表单 */
.form-group { .form-group {
margin-bottom: 28px; margin-top: 32px;
} }
.form-label { .form-label {
font-size: 26px; font-size: var(--tk-font-h1);
color: $tx2; color: $tx2;
margin-bottom: 12px; margin-bottom: 12px;
display: block; display: block;
} }
.form-static {
background: $card;
border-radius: $r-sm;
padding: 24px 28px;
}
.form-static-text {
font-size: 28px;
color: $tx;
}
.form-input { .form-input {
background: $card; background: $card;
border-radius: $r-sm; border-radius: $r-sm;
padding: 24px 28px; padding: 24px 28px;
font-size: 28px; font-size: var(--tk-font-body-lg);
color: $tx; color: $tx;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
box-shadow: $shadow-sm;
} }
/* 空状态 */ /* 空状态 */
.empty-state { .empty-hint {
padding: 80px 0; padding: 80px 0;
text-align: center; text-align: center;
} }
.empty-text { .empty-text {
font-size: 28px; font-size: var(--tk-font-body-lg);
color: $tx3; color: var(--tk-text-secondary);
} }
/* 底部操作栏 */ /* 底部操作栏 */
@@ -244,7 +314,7 @@
padding-bottom: constant(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
background: $card; background: $card;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06); box-shadow: $shadow-md;
} }
.btn { .btn {
@@ -261,7 +331,7 @@
.btn-next, .btn-next,
.btn-submit { .btn-submit {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%); background: $pri;
} }
.btn-disabled { .btn-disabled {
@@ -269,7 +339,7 @@
} }
.btn-text { .btn-text {
font-size: 30px; font-size: var(--tk-font-num);
font-weight: bold; font-weight: bold;
color: $tx2; color: $tx2;
} }

View File

@@ -7,15 +7,16 @@ import { TEMPLATE_IDS } from '@/services/wechat-templates';
import { trackEvent } from '@/services/analytics'; import { trackEvent } from '@/services/analytics';
import StepIndicator from '../../../components/StepIndicator'; import StepIndicator from '../../../components/StepIndicator';
import WeekCalendar from '../../../components/WeekCalendar'; import WeekCalendar from '../../../components/WeekCalendar';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss'; import './index.scss';
const DEPARTMENTS = [ const DEPARTMENTS = [
{ label: '内科', icon: '🫀' }, { label: '内科', initial: '' },
{ label: '外科', icon: '🔪' }, { label: '外科', initial: '' },
{ label: '妇科', icon: '👩‍⚕️' }, { label: '妇科', initial: '' },
{ label: '儿科', icon: '👶' }, { label: '儿科', initial: '' },
{ label: '体检中心', icon: '🏥' }, { label: '体检中心', initial: '' },
{ label: '中医科', icon: '🌿' }, { label: '中医科', initial: '' },
]; ];
interface DoctorItem { interface DoctorItem {
@@ -44,6 +45,7 @@ export default function AppointmentCreate() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [schedules, setSchedules] = useState<any[]>([]); const [schedules, setSchedules] = useState<any[]>([]);
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]); const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
const modeClass = useElderClass();
const currentPatient = useAuthStore((s) => s.currentPatient); const currentPatient = useAuthStore((s) => s.currentPatient);
@@ -83,14 +85,13 @@ export default function AppointmentCreate() {
const onSelectDate = useCallback((date: string) => { const onSelectDate = useCallback((date: string) => {
setAppointmentDate(date); setAppointmentDate(date);
setTimeSlot(''); setTimeSlot('');
// 从排班数据中提取时段
const daySlots = schedules const daySlots = schedules
.filter((s: any) => (s.date || s.appointment_date) === date) .filter((s: any) => (s.date || s.appointment_date) === date)
.map((s: any) => ({ .map((s: any) => ({
start_time: s.start_time || '', start_time: s.start_time || '',
end_time: s.end_time || '', end_time: s.end_time || '',
label: `${s.start_time || ''}-${s.end_time || ''}`, label: `${s.start_time || ''}-${s.end_time || ''}`,
available_count: s.available_count ?? (s.max_patients ?? 10), available_count: s.available_count ?? (s.max_appointments - (s.current_appointments || 0)),
})); }));
setTimeSlots(daySlots); setTimeSlots(daySlots);
}, [schedules]); }, [schedules]);
@@ -120,7 +121,6 @@ export default function AppointmentCreate() {
}); });
Taro.showToast({ title: '预约成功', icon: 'success' }); Taro.showToast({ title: '预约成功', icon: 'success' });
trackEvent('appointment_create', { doctor_id: selectedDoctor.id, date: appointmentDate }); trackEvent('appointment_create', { doctor_id: selectedDoctor.id, date: appointmentDate });
// 订阅消息引导
const tmplId = TEMPLATE_IDS.APPOINTMENT_REMINDER; const tmplId = TEMPLATE_IDS.APPOINTMENT_REMINDER;
if (tmplId) { if (tmplId) {
try { try {
@@ -150,7 +150,7 @@ export default function AppointmentCreate() {
}; };
return ( return (
<View className='create-page'> <View className={`create-page ${modeClass}`}>
<StepIndicator <StepIndicator
steps={[{ label: '选科室' }, { label: '选医生' }, { label: '选时段' }]} steps={[{ label: '选科室' }, { label: '选医生' }, { label: '选时段' }]}
current={currentStep} current={currentStep}
@@ -168,7 +168,9 @@ export default function AppointmentCreate() {
key={dept.label} key={dept.label}
onClick={() => onSelectDept(dept.label)} onClick={() => onSelectDept(dept.label)}
> >
<Text className='dept-icon'>{dept.icon}</Text> <View className='dept-initial-circle'>
<Text className='dept-initial-text'>{dept.initial}</Text>
</View>
<Text className='dept-label'>{dept.label}</Text> <Text className='dept-label'>{dept.label}</Text>
</View> </View>
))} ))}
@@ -181,7 +183,9 @@ export default function AppointmentCreate() {
<View className='step-content'> <View className='step-content'>
<Text className='step-title'>{department} - </Text> <Text className='step-title'>{department} - </Text>
{doctors.length === 0 ? ( {doctors.length === 0 ? (
<View className='empty-hint'><Text className='empty-text'></Text></View> <View className='empty-hint'>
<Text className='empty-text'></Text>
</View>
) : ( ) : (
<View className='doctor-list'> <View className='doctor-list'>
{doctors.map((doc) => ( {doctors.map((doc) => (
@@ -190,13 +194,19 @@ export default function AppointmentCreate() {
key={doc.id} key={doc.id}
onClick={() => onSelectDoctor(doc)} onClick={() => onSelectDoctor(doc)}
> >
<View className='doctor-avatar'><Text className='doctor-avatar-text'>{doc.name.charAt(0)}</Text></View> <View className='doctor-avatar'>
<Text className='doctor-avatar-text'>{doc.name.charAt(0)}</Text>
</View>
<View className='doctor-detail'> <View className='doctor-detail'>
<Text className='doctor-name'>{doc.name}</Text> <Text className='doctor-name'>{doc.name}</Text>
<Text className='doctor-title'>{doc.title || '医生'}</Text> <Text className='doctor-title'>{doc.title || '医生'}</Text>
{doc.specialty && <Text className='doctor-specialty'>{doc.specialty}</Text>} {doc.specialty && <Text className='doctor-specialty'>{doc.specialty}</Text>}
</View> </View>
{selectedDoctor?.id === doc.id && <Text className='doctor-check'>&#10003;</Text>} {selectedDoctor?.id === doc.id && (
<View className='doctor-check'>
<Text className='doctor-check-text'>&#10003;</Text>
</View>
)}
</View> </View>
))} ))}
</View> </View>
@@ -208,9 +218,20 @@ export default function AppointmentCreate() {
{currentStep === 2 && ( {currentStep === 2 && (
<View className='step-content'> <View className='step-content'>
<Text className='step-title'></Text> <Text className='step-title'></Text>
<View className='form-group'>
<Text className='form-label'></Text> <View className='confirm-card'>
<View className='form-static'><Text className='form-static-text'>{selectedDoctor?.name} - {department}</Text></View> <View className='confirm-row'>
<View className='confirm-icon-wrap'>
<Text className='confirm-icon-serif'></Text>
</View>
<View className='confirm-info'>
<Text className='confirm-label'></Text>
<Text className='confirm-value'>{selectedDoctor?.name}</Text>
</View>
<View className='confirm-dept-tag'>
<Text className='confirm-dept-text'>{department}</Text>
</View>
</View>
</View> </View>
<WeekCalendar <WeekCalendar
@@ -221,7 +242,7 @@ export default function AppointmentCreate() {
{appointmentDate && timeSlots.length > 0 && ( {appointmentDate && timeSlots.length > 0 && (
<View className='slot-section'> <View className='slot-section'>
<Text className='form-label'></Text> <Text className='slot-section-title'></Text>
<View className='slot-grid'> <View className='slot-grid'>
{timeSlots.map((slot) => ( {timeSlots.map((slot) => (
<View <View
@@ -230,7 +251,9 @@ export default function AppointmentCreate() {
onClick={slot.available_count > 0 ? () => setTimeSlot(slot.label) : undefined} onClick={slot.available_count > 0 ? () => setTimeSlot(slot.label) : undefined}
> >
<Text className='slot-time'>{slot.label}</Text> <Text className='slot-time'>{slot.label}</Text>
<Text className='slot-count'>{slot.available_count > 0 ? `剩余 ${slot.available_count}` : '已满'}</Text> <Text className='slot-count'>
{slot.available_count > 0 ? `剩余 ${slot.available_count}` : '已满'}
</Text>
</View> </View>
))} ))}
</View> </View>
@@ -239,7 +262,12 @@ export default function AppointmentCreate() {
<View className='form-group'> <View className='form-group'>
<Text className='form-label'></Text> <Text className='form-label'></Text>
<Input className='form-input' placeholder='请简要描述症状' value={reason} onInput={(e) => setReason(e.detail.value)} /> <Input
className='form-input'
placeholder='请简要描述症状'
value={reason}
onInput={(e) => setReason(e.detail.value)}
/>
</View> </View>
</View> </View>
)} )}
@@ -256,7 +284,10 @@ export default function AppointmentCreate() {
<Text className='btn-text btn-text-white'></Text> <Text className='btn-text btn-text-white'></Text>
</View> </View>
) : ( ) : (
<View className={`btn btn-submit ${loading ? 'btn-disabled' : ''}`} onClick={loading ? undefined : handleSubmit}> <View
className={`btn btn-submit ${loading ? 'btn-disabled' : ''}`}
onClick={loading ? undefined : handleSubmit}
>
<Text className='btn-text btn-text-white'>{loading ? '提交中...' : '确认预约'}</Text> <Text className='btn-text btn-text-white'>{loading ? '提交中...' : '确认预约'}</Text>
</View> </View>
)} )}

View File

@@ -1,9 +1,10 @@
@import '../../../styles/variables.scss'; @import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.detail-page { .detail-page {
min-height: 100vh; min-height: 100vh;
background: $bg; background: $bg;
padding-bottom: 140px; padding-bottom: 160px;
} }
/* 顶部导航 */ /* 顶部导航 */
@@ -13,7 +14,8 @@
justify-content: space-between; justify-content: space-between;
padding: 32px; padding: 32px;
padding-top: 48px; padding-top: 48px;
background: linear-gradient(135deg, $pri 0%, $pri-d 100%); background: $card;
box-shadow: $shadow-sm;
} }
.back-btn { .back-btn {
@@ -21,14 +23,16 @@
} }
.back-text { .back-text {
font-size: 28px; font-size: var(--tk-font-body-lg);
color: white; color: $pri;
font-weight: 500;
} }
.header-title { .header-title {
font-size: 34px; font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num-lg);
font-weight: bold; font-weight: bold;
color: white; color: $tx;
} }
.header-placeholder { .header-placeholder {
@@ -40,53 +44,55 @@
background: $card; background: $card;
border-radius: $r-lg; border-radius: $r-lg;
padding: 40px 32px; padding: 40px 32px;
margin: -20px 24px 24px; margin: 20px 24px 24px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); box-shadow: $shadow-md;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.status-badge { .status-tag {
@include tag($bd-l, $tx3);
margin-bottom: 8px;
padding: 8px 32px; padding: 8px 32px;
border-radius: 24px; border-radius: $r-pill;
margin-bottom: 12px;
.status-badge-text {
font-size: 28px;
font-weight: bold;
}
&.tag-pending { &.tag-pending {
background: $wrn-l; background: $wrn-l;
.status-badge-text { color: $wrn; } .status-tag-text { color: $wrn; }
} }
&.tag-confirmed { &.tag-confirmed {
background: $acc-l; background: $acc-l;
.status-badge-text { color: $acc; } .status-tag-text { color: $acc; }
} }
&.tag-cancelled { &.tag-cancelled {
background: $bd-l; background: $bd-l;
.status-badge-text { color: $tx3; } .status-tag-text { color: $tx3; }
} }
&.tag-completed { &.tag-completed {
background: $pri-l; background: $pri-l;
.status-badge-text { color: $pri; } .status-tag-text { color: $pri; }
} }
} }
.status-tag-text {
font-size: var(--tk-font-body-lg);
font-weight: bold;
}
.status-doctor { .status-doctor {
font-size: 36px; font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num-lg);
font-weight: bold; font-weight: bold;
color: $tx; color: $tx;
} }
.status-dept { .status-dept {
font-size: 26px; font-size: var(--tk-font-h1);
color: $tx2; color: $tx2;
} }
@@ -96,22 +102,19 @@
background: $card; background: $card;
border-radius: $r; border-radius: $r;
padding: 28px; padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: $shadow-sm;
} }
.section-title { .section-title {
font-size: 30px; @include section-title;
font-weight: bold;
color: $tx;
margin-bottom: 24px; margin-bottom: 24px;
display: block;
} }
.info-item { .info-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 16px 0; padding: 18px 0;
border-bottom: 1px solid $bd-l; border-bottom: 1px solid $bd-l;
} }
@@ -119,20 +122,49 @@
border-bottom: none; border-bottom: none;
} }
.info-label-wrap {
display: flex;
align-items: center;
gap: 10px;
}
.info-icon-serif {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body);
color: $pri;
background: $pri-l;
width: 36px;
height: 36px;
border-radius: $r-sm;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.info-label { .info-label {
font-size: 26px; font-size: var(--tk-font-h1);
color: $tx2; color: $tx2;
} }
.info-value { .info-value {
font-size: 26px; font-size: var(--tk-font-h1);
color: $tx; color: $tx;
font-weight: 500; font-weight: 500;
} }
.info-date {
@include serif-number;
}
.info-time {
@include serif-number;
}
.info-id { .info-id {
font-size: 22px; @include serif-number;
color: $tx3; font-size: var(--tk-font-body);
color: var(--tk-text-secondary);
word-break: break-all; word-break: break-all;
max-width: 400px; max-width: 400px;
text-align: right; text-align: right;
@@ -147,7 +179,8 @@
} }
.tips-title { .tips-title {
font-size: 26px; font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: bold; font-weight: bold;
color: $wrn; color: $wrn;
margin-bottom: 12px; margin-bottom: 12px;
@@ -155,7 +188,7 @@
} }
.tips-text { .tips-text {
font-size: 24px; font-size: var(--tk-font-h2);
color: $tx2; color: $tx2;
line-height: 1.6; line-height: 1.6;
} }
@@ -170,7 +203,7 @@
padding-bottom: constant(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
background: $card; background: $card;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06); box-shadow: $shadow-md;
} }
.cancel-btn { .cancel-btn {
@@ -186,31 +219,7 @@
} }
.cancel-text { .cancel-text {
font-size: 30px; font-size: var(--tk-font-num);
font-weight: bold; font-weight: bold;
color: $dan; color: $dan;
} }
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120px 0;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 30px;
color: $tx2;
margin-bottom: 12px;
}
.empty-hint {
font-size: 24px;
color: $tx3;
}

View File

@@ -5,6 +5,7 @@ import { getAppointment, cancelAppointment } from '../../../services/appointment
import type { Appointment } from '../../../services/appointment'; import type { Appointment } from '../../../services/appointment';
import Loading from '../../../components/Loading'; import Loading from '../../../components/Loading';
import ErrorState from '../../../components/ErrorState'; import ErrorState from '../../../components/ErrorState';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss'; import './index.scss';
const STATUS_MAP: Record<string, { label: string; className: string }> = { const STATUS_MAP: Record<string, { label: string; className: string }> = {
@@ -22,6 +23,7 @@ export default function AppointmentDetail() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [cancelling, setCancelling] = useState(false); const [cancelling, setCancelling] = useState(false);
const modeClass = useElderClass();
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -65,7 +67,7 @@ export default function AppointmentDetail() {
if (loading) { if (loading) {
return ( return (
<View className='detail-page'> <View className={`detail-page ${modeClass}`}>
<View className='detail-header'> <View className='detail-header'>
<View className='back-btn' onClick={goBack}><Text className='back-text'></Text></View> <View className='back-btn' onClick={goBack}><Text className='back-text'></Text></View>
<Text className='header-title'></Text> <Text className='header-title'></Text>
@@ -78,7 +80,7 @@ export default function AppointmentDetail() {
if (error || !appointment) { if (error || !appointment) {
return ( return (
<View className='detail-page'> <View className={`detail-page ${modeClass}`}>
<View className='detail-header'> <View className='detail-header'>
<View className='back-btn' onClick={goBack}><Text className='back-text'></Text></View> <View className='back-btn' onClick={goBack}><Text className='back-text'></Text></View>
<Text className='header-title'></Text> <Text className='header-title'></Text>
@@ -90,41 +92,60 @@ export default function AppointmentDetail() {
} }
return ( return (
<View className='detail-page'> <View className={`detail-page ${modeClass}`}>
<View className='detail-header'> <View className='detail-header'>
<View className='back-btn' onClick={goBack}><Text className='back-text'></Text></View> <View className='back-btn' onClick={goBack}><Text className='back-text'></Text></View>
<Text className='header-title'></Text> <Text className='header-title'></Text>
<View className='header-placeholder' /> <View className='header-placeholder' />
</View> </View>
{/* 状态卡片 */}
<View className='status-card'> <View className='status-card'>
<View className={`status-badge ${status.className}`}> <View className={`status-tag ${status.className}`}>
<Text className='status-badge-text'>{status.label}</Text> <Text className='status-tag-text'>{status.label}</Text>
</View> </View>
<Text className='status-doctor'>{appointment.doctor_name}</Text> <Text className='status-doctor'>{appointment.doctor_name}</Text>
<Text className='status-dept'>{appointment.department}</Text> <Text className='status-dept'>{appointment.department || ''}</Text>
</View> </View>
{/* 预约信息 */}
<View className='info-section'> <View className='info-section'>
<Text className='section-title'></Text> <Text className='section-title'></Text>
<View className='info-item'> <View className='info-item'>
<Text className='info-label'></Text> <View className='info-label-wrap'>
<Text className='info-icon-serif'></Text>
<Text className='info-label'></Text>
</View>
<Text className='info-value'>{appointment.patient_name}</Text> <Text className='info-value'>{appointment.patient_name}</Text>
</View> </View>
<View className='info-item'> <View className='info-item'>
<Text className='info-label'></Text> <View className='info-label-wrap'>
<Text className='info-value'>{appointment.appointment_date}</Text> <Text className='info-icon-serif'></Text>
<Text className='info-label'></Text>
</View>
<Text className='info-value info-date'>{appointment.appointment_date}</Text>
</View> </View>
<View className='info-item'> <View className='info-item'>
<Text className='info-label'></Text> <View className='info-label-wrap'>
<Text className='info-value'>{appointment.start_time} - {appointment.end_time}</Text> <Text className='info-icon-serif'></Text>
<Text className='info-label'></Text>
</View>
<Text className='info-value info-time'>{appointment.start_time} - {appointment.end_time}</Text>
</View> </View>
<View className='info-item'> <View className='info-item'>
<Text className='info-label'></Text> <View className='info-label-wrap'>
<Text className='info-icon-serif'></Text>
<Text className='info-label'></Text>
</View>
<Text className='info-value info-id'>{appointment.id}</Text> <Text className='info-value info-id'>{appointment.id}</Text>
</View> </View>
</View> </View>
{/* 温馨提示 */}
{(appointment.status === 'pending' || appointment.status === 'confirmed') && ( {(appointment.status === 'pending' || appointment.status === 'confirmed') && (
<View className='tips-card'> <View className='tips-card'>
<Text className='tips-title'></Text> <Text className='tips-title'></Text>
@@ -132,6 +153,7 @@ export default function AppointmentDetail() {
</View> </View>
)} )}
{/* 取消按钮 */}
{canCancel && ( {canCancel && (
<View className='bottom-bar'> <View className='bottom-bar'>
<View <View

View File

@@ -1,26 +1,36 @@
@import '../../styles/variables.scss'; @import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.appointment-page { .appointment-page {
min-height: 100vh; min-height: 100vh;
background: $bg; background: $bg;
padding-bottom: 140px; padding-bottom: 160px;
} }
/* 页头 */
.page-header { .page-header {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%); background: $card;
padding: 32px; padding: 48px 32px 36px;
padding-top: 48px; box-shadow: $shadow-sm;
} }
.page-title { .page-title {
font-size: 36px; @include section-title;
font-weight: bold; margin-bottom: 4px;
color: white; font-size: var(--tk-font-num-lg);
} }
.page-subtitle {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body);
color: var(--tk-text-secondary);
letter-spacing: 1px;
}
/* 预约列表 */
.appointment-list { .appointment-list {
padding: 0 24px; padding: 0 24px;
margin-top: -16px; margin-top: 16px;
} }
.appointment-card { .appointment-card {
@@ -28,7 +38,7 @@
border-radius: $r; border-radius: $r;
padding: 28px; padding: 28px;
margin-bottom: 20px; margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); box-shadow: $shadow-sm;
} }
.card-top { .card-top {
@@ -38,139 +48,146 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.doctor-info { .doctor-section {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
flex: 1;
min-width: 0;
}
.dept-initial {
width: 72px;
height: 72px;
border-radius: $r;
background: $pri-l;
@include flex-center;
flex-shrink: 0;
}
.dept-initial-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
font-weight: bold;
color: $pri;
}
.doctor-info {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
} }
.doctor-name { .doctor-name {
font-size: 32px; font-size: var(--tk-font-num);
font-weight: bold; font-weight: bold;
color: $tx; color: $tx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.department { .dept-tag {
font-size: 24px; @include tag($pri-l, $pri);
}
.dept-tag-text {
font-size: var(--tk-font-body);
font-weight: 500;
color: $pri; color: $pri;
background: $pri-l;
padding: 4px 16px;
border-radius: 20px;
} }
.status-tag { .status-tag {
padding: 6px 20px; @include tag($bd-l, $tx3);
border-radius: 20px; flex-shrink: 0;
.status-tag-text {
font-size: 22px;
font-weight: 500;
}
&.tag-pending { &.tag-pending {
background: $wrn-l; background: $wrn-l;
.status-tag-text { color: $wrn; }
.status-tag-text {
color: $wrn;
}
} }
&.tag-confirmed { &.tag-confirmed {
background: $acc-l; background: $acc-l;
.status-tag-text { color: $acc; }
.status-tag-text {
color: $acc;
}
} }
&.tag-cancelled { &.tag-cancelled {
background: $bd-l; background: $bd-l;
.status-tag-text { color: $tx3; }
.status-tag-text {
color: $tx3;
}
} }
&.tag-completed { &.tag-completed {
background: $pri-l; background: $pri-l;
.status-tag-text { color: $pri; }
.status-tag-text {
color: $pri;
}
} }
} }
.status-tag-text {
font-size: var(--tk-font-body);
font-weight: 500;
}
.card-divider {
height: 1px;
background: $bd-l;
margin-bottom: 20px;
}
.card-bottom { .card-bottom {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 14px;
padding-top: 20px;
border-top: 1px solid $bd-l;
} }
.info-row { .info-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
} }
.info-icon { .info-icon-wrap {
font-size: 26px; width: 40px;
height: 40px;
border-radius: $r-sm;
background: $bd-l;
@include flex-center;
flex-shrink: 0;
}
.info-icon-serif {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body);
color: $tx2;
} }
.info-text { .info-text {
font-size: 26px; font-size: var(--tk-font-h1);
color: $tx2; color: $tx2;
} }
.empty-state { .info-time {
display: flex; @include serif-number;
flex-direction: column; color: $tx;
align-items: center; font-weight: 500;
justify-content: center;
padding: 120px 0 80px;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 30px;
color: $tx2;
margin-bottom: 12px;
}
.empty-hint {
font-size: 24px;
color: $tx3;
}
.loading-tip {
text-align: center;
padding: 24px 0;
}
.loading-text {
font-size: 24px;
color: $tx3;
} }
/* 底部悬浮按钮 */
.fab-btn { .fab-btn {
position: fixed; position: fixed;
bottom: 60px; bottom: 60px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: linear-gradient(135deg, $pri 0%, $pri-d 100%); background: $pri;
padding: 24px 64px; padding: 24px 72px;
border-radius: 48px; border-radius: $r-pill;
box-shadow: 0 8px 24px rgba($pri, 0.4); box-shadow: 0 8px 24px rgba($pri, 0.3);
z-index: 100; z-index: 100;
} }
.fab-text { .fab-text {
font-size: 30px; font-size: var(--tk-font-num);
color: white; color: white;
font-weight: bold; font-weight: bold;
letter-spacing: 2px;
} }

View File

@@ -5,6 +5,7 @@ import { listAppointments } from '../../services/appointment';
import type { Appointment } from '../../services/appointment'; import type { Appointment } from '../../services/appointment';
import EmptyState from '../../components/EmptyState'; import EmptyState from '../../components/EmptyState';
import Loading from '../../components/Loading'; import Loading from '../../components/Loading';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss'; import './index.scss';
const STATUS_MAP: Record<string, { label: string; className: string }> = { const STATUS_MAP: Record<string, { label: string; className: string }> = {
@@ -14,12 +15,23 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
completed: { label: '已完成', className: 'tag-completed' }, completed: { label: '已完成', className: 'tag-completed' },
}; };
// 科室首字映射(用于衬线图标)
const DEPT_INITIAL: Record<string, string> = {
'内科': '内',
'外科': '外',
'妇科': '妇',
'儿科': '儿',
'体检中心': '检',
'中医科': '中',
};
export default function AppointmentList() { export default function AppointmentList() {
const [appointments, setAppointments] = useState<Appointment[]>([]); const [appointments, setAppointments] = useState<Appointment[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const loadingRef = useRef(false); const loadingRef = useRef(false);
const modeClass = useElderClass();
const fetchData = useCallback(async (pageNum: number, isRefresh = false) => { const fetchData = useCallback(async (pageNum: number, isRefresh = false) => {
if (loadingRef.current) return; if (loadingRef.current) return;
@@ -71,8 +83,12 @@ export default function AppointmentList() {
return STATUS_MAP[status] || { label: status, className: 'tag-pending' }; return STATUS_MAP[status] || { label: status, className: 'tag-pending' };
}; };
const getDeptInitial = (dept: string) => {
return DEPT_INITIAL[dept] || dept.charAt(0);
};
return ( return (
<View className='appointment-page'> <View className={`appointment-page ${modeClass}`}>
{/* 页面标题 */} {/* 页面标题 */}
<View className='page-header'> <View className='page-header'>
<Text className='page-title'></Text> <Text className='page-title'></Text>
@@ -80,7 +96,7 @@ export default function AppointmentList() {
{/* 预约列表 */} {/* 预约列表 */}
{appointments.length === 0 && !loading ? ( {appointments.length === 0 && !loading ? (
<EmptyState icon='📋' text='暂无预约记录' hint='点击下方按钮新建预约' /> <EmptyState text='暂无预约记录' hint='点击下方按钮新建预约' />
) : ( ) : (
<View className='appointment-list'> <View className='appointment-list'>
{appointments.map((item) => { {appointments.map((item) => {
@@ -92,22 +108,34 @@ export default function AppointmentList() {
onClick={() => goDetail(item.id)} onClick={() => goDetail(item.id)}
> >
<View className='card-top'> <View className='card-top'>
<View className='doctor-info'> <View className='doctor-section'>
<Text className='doctor-name'>{item.doctor_name}</Text> <View className='dept-initial'>
<Text className='department'>{item.department}</Text> <Text className='dept-initial-text'>{getDeptInitial(item.department || '')}</Text>
</View>
<View className='doctor-info'>
<Text className='doctor-name'>{item.doctor_name}</Text>
<View className='dept-tag'>
<Text className='dept-tag-text'>{item.department || ''}</Text>
</View>
</View>
</View> </View>
<View className={`status-tag ${tag.className}`}> <View className={`status-tag ${tag.className}`}>
<Text className='status-tag-text'>{tag.label}</Text> <Text className='status-tag-text'>{tag.label}</Text>
</View> </View>
</View> </View>
<View className='card-divider' />
<View className='card-bottom'> <View className='card-bottom'>
<View className='info-row'> <View className='info-row'>
<Text className='info-icon'>📅</Text> <View className='info-icon-wrap'>
<Text className='info-icon-serif'></Text>
</View>
<Text className='info-text'>{item.appointment_date}</Text> <Text className='info-text'>{item.appointment_date}</Text>
</View> </View>
<View className='info-row'> <View className='info-row'>
<Text className='info-icon'>🕐</Text> <View className='info-icon-wrap'>
<Text className='info-text'>{item.start_time} - {item.end_time}</Text> <Text className='info-icon-serif'></Text>
</View>
<Text className='info-text info-time'>{item.start_time} - {item.end_time}</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -124,7 +152,7 @@ export default function AppointmentList() {
{/* 底部悬浮按钮 */} {/* 底部悬浮按钮 */}
<View className='fab-btn' onClick={goCreate}> <View className='fab-btn' onClick={goCreate}>
<Text className='fab-text'>+ </Text> <Text className='fab-text'></Text>
</View> </View>
</View> </View>
); );

View File

@@ -13,7 +13,7 @@
} }
.article-title { .article-title {
font-size: 38px; font-size: var(--tk-font-hero);
font-weight: bold; font-weight: bold;
color: $tx; color: $tx;
display: block; display: block;
@@ -29,7 +29,7 @@
} }
.article-category { .article-category {
font-size: 22px; font-size: var(--tk-font-body);
color: $pri; color: $pri;
background: $pri-l; background: $pri-l;
padding: 4px 12px; padding: 4px 12px;
@@ -37,13 +37,13 @@
} }
.article-author { .article-author {
font-size: 24px; font-size: var(--tk-font-h2);
color: $tx2; color: $tx2;
} }
.article-date { .article-date {
font-size: 24px; font-size: var(--tk-font-h2);
color: $tx3; color: var(--tk-text-secondary);
} }
.article-summary { .article-summary {
@@ -53,7 +53,7 @@
} }
.summary-text { .summary-text {
font-size: 26px; font-size: var(--tk-font-h1);
color: $tx2; color: $tx2;
line-height: 1.6; line-height: 1.6;
} }
@@ -65,20 +65,20 @@
// RichText 内部样式优化 // RichText 内部样式优化
h1, h2, h3 { h1, h2, h3 {
font-weight: bold; font-weight: bold;
color: #134E4A; color: $tx;
margin: 24px 0 12px; margin: 24px 0 12px;
} }
p { p {
font-size: 28px; font-size: var(--tk-font-body-lg);
color: #134E4A; color: $tx;
line-height: 1.8; line-height: 1.8;
margin-bottom: 16px; margin-bottom: 16px;
} }
img { img {
max-width: 100%; max-width: 100%;
border-radius: 8px; border-radius: $r-sm;
margin: 12px 0; margin: 12px 0;
} }
} }
@@ -93,6 +93,6 @@
.loading-text, .loading-text,
.empty-text { .empty-text {
font-size: 28px; font-size: var(--tk-font-body-lg);
color: $tx3; color: var(--tk-text-secondary);
} }

View File

@@ -3,9 +3,11 @@ import { View, Text, RichText } from '@tarojs/components';
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro'; import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
import { getArticleDetail, Article } from '../../../services/article'; import { getArticleDetail, Article } from '../../../services/article';
import { trackEvent } from '@/services/analytics'; import { trackEvent } from '@/services/analytics';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss'; import './index.scss';
export default function ArticleDetail() { export default function ArticleDetail() {
const modeClass = useElderClass();
const router = useRouter(); const router = useRouter();
const id = router.params.id || ''; const id = router.params.id || '';
@@ -31,7 +33,7 @@ export default function ArticleDetail() {
if (loading) { if (loading) {
return ( return (
<View className='article-detail-page'> <View className={`article-detail-page ${modeClass}`}>
<View className='loading-state'> <View className='loading-state'>
<Text className='loading-text'>...</Text> <Text className='loading-text'>...</Text>
</View> </View>
@@ -41,7 +43,7 @@ export default function ArticleDetail() {
if (!article) { if (!article) {
return ( return (
<View className='article-detail-page'> <View className={`article-detail-page ${modeClass}`}>
<View className='empty-state'> <View className='empty-state'>
<Text className='empty-text'></Text> <Text className='empty-text'></Text>
</View> </View>
@@ -50,7 +52,7 @@ export default function ArticleDetail() {
} }
return ( return (
<View className='article-detail-page'> <View className={`article-detail-page ${modeClass}`}>
{/* 文章头部 */} {/* 文章头部 */}
<View className='article-header'> <View className='article-header'>
<Text className='article-title'>{article.title}</Text> <Text className='article-title'>{article.title}</Text>

View File

@@ -16,7 +16,7 @@
display: inline-block; display: inline-block;
padding: 12px 28px; padding: 12px 28px;
margin-right: 12px; margin-right: 12px;
font-size: 26px; font-size: var(--tk-font-h1);
color: $tx2; color: $tx2;
background: $card; background: $card;
border-radius: 32px; border-radius: 32px;
@@ -41,7 +41,7 @@
background: $card; background: $card;
border-radius: $r; border-radius: $r;
padding: 28px; padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: $shadow-sm;
} }
.article-card-body { .article-card-body {
@@ -52,7 +52,7 @@
} }
.article-card-title { .article-card-title {
font-size: 30px; font-size: var(--tk-font-num);
font-weight: bold; font-weight: bold;
color: $tx; color: $tx;
line-height: 1.4; line-height: 1.4;
@@ -66,7 +66,7 @@
} }
.article-card-summary { .article-card-summary {
font-size: 26px; font-size: var(--tk-font-h1);
color: $tx2; color: $tx2;
line-height: 1.4; line-height: 1.4;
display: block; display: block;
@@ -83,7 +83,7 @@
} }
.article-card-tag { .article-card-tag {
font-size: 22px; font-size: var(--tk-font-body);
color: $pri; color: $pri;
background: $pri-l; background: $pri-l;
padding: 2px 12px; padding: 2px 12px;
@@ -91,7 +91,7 @@
} }
.article-card-date { .article-card-date {
font-size: 22px; font-size: var(--tk-font-body);
color: $tx3; color: $tx3;
} }
@@ -116,8 +116,8 @@
} }
.empty-text { .empty-text {
font-size: 28px; font-size: var(--tk-font-body-lg);
color: $tx3; color: var(--tk-text-secondary);
} }
.loading-hint { .loading-hint {
@@ -126,6 +126,6 @@
} }
.loading-text { .loading-text {
font-size: 24px; font-size: var(--tk-font-h2);
color: $tx3; color: var(--tk-text-secondary);
} }

View File

@@ -4,9 +4,11 @@ import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/ta
import { listArticles, listCategories, Article, ArticleCategory } from '../../services/article'; import { listArticles, listCategories, Article, ArticleCategory } from '../../services/article';
import EmptyState from '../../components/EmptyState'; import EmptyState from '../../components/EmptyState';
import Loading from '../../components/Loading'; import Loading from '../../components/Loading';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss'; import './index.scss';
export default function ArticleList() { export default function ArticleList() {
const modeClass = useElderClass();
const [articles, setArticles] = useState<Article[]>([]); const [articles, setArticles] = useState<Article[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -72,7 +74,7 @@ export default function ArticleList() {
}; };
return ( return (
<View className='article-page'> <View className={`article-page ${modeClass}`}>
{/* 分类筛选 */} {/* 分类筛选 */}
{categories.length > 0 && ( {categories.length > 0 && (
<ScrollView scrollX className='article-categories'> <ScrollView scrollX className='article-categories'>
@@ -119,7 +121,7 @@ export default function ArticleList() {
</View> </View>
{a.cover_image && ( {a.cover_image && (
<View className='article-card-cover'> <View className='article-card-cover'>
<Image className='cover-img' src={a.cover_image} mode='aspectFill' /> <Image className='cover-img' src={a.cover_image} mode='aspectFill' lazyLoad />
</View> </View>
)} )}
</View> </View>

Some files were not shown because too many files have changed in this diff Show More