Compare commits

...

72 Commits

Author SHA1 Message Date
iven
c88f1573a5 docs(wiki): B5 §45 患者数据可携权导出 — 关键数字 + 项目阶段 + 模块描述
- 关键数字:迁移 177 / 路由 386+ / 权限 142 / 测试 1037(+6) / 事件 32·52·83
- 项目阶段:B5 §45 已实现(6 测试全绿),§47 删除权待后续
- erp-health 模块导航:加个保法 §45 数据可携权导出
2026-06-26 18:01:59 +08:00
iven
15b6bec215 feat(health): B5 个保法 §45 患者数据可携权导出
GET /health/patients/{id}/export?format=json|fhir 双格式同步导出:
- json: 明文 PII(解密不脱敏,可携权本意),聚合 7 段数据
- fhir: FHIR R4 Bundle(复用现有 converter,PII 天然脱敏)
- 安全边界:consent 门控 + patient 角色 self-scope + 审计 patient.exported(不含明文 PII)+ 日志不记 payload
- 权限 health.patient.export(医护=all, patient=self),迁移 m20260626_000171
- 事件 patient.exported;6 集成测试全绿

含顺手修复 auth_tests UserService::list 签名 drift(exclude_only_roles),解锁 integration crate 编译。
§47 删除权留后续。
2026-06-26 17:58:20 +08:00
iven
5d256fbf52 docs(wiki): B1/B4 修复条目 — JWT 死代码 + alertmanager 盲飞 + 项目阶段 5 阻塞状态
- 症状导航 +2 条:JWT doc-code drift 死代码(change-me-in-production 无注入源)/ alertmanager 渠道占位盲飞
- 项目阶段行更新:上线评估 6.4/10 + 5 阻塞状态(B1/B4 代码层已修,B2/B3/B5 待 staging)
- 表头日期 2026-06-26
2026-06-26 15:19:09 +08:00
iven
984fca627b fix(docker): B1 alertmanager 告警渠道接线 ALERT_WEBHOOK_URL 环境变量注入
- config.yml url 写死 placeholder.invalid -> ${ALERT_WEBHOOK_URL}
- compose alertmanager 补 environment 注入(评估漏检:虽有 --config.expand-env=true 但容器内无变量可展开)
- 未配置时 fallback 占位 url 保 MVP 链路可启动(fail-fast 优于静默盲飞)
- .env.production.example 补 ALERT_WEBHOOK_URL 模板
- 上线评估 B1 代码层修复,真实 webhook 由 staging 填入
2026-06-26 15:18:43 +08:00
iven
288c73fd14 fix(server): B4 JWT 默认密钥 release panic 对称防护 + 修正 doc-code drift 死代码
原 config.rs 检查 'change-me-in-production',该常量仓库无注入源(dev.ps1 用 'dev-secret-key-change-in-prod',default.toml 用 '__MUST_SET_VIA_ENV__'),warn 永不命中,生产忘设 ERP__JWT__SECRET 时静默运行可伪造任意 token。改为 UNSAFE_JWT_SECRETS 集合检查,release panic 与 KEK/storage_key 对称。上线评估 B4。
2026-06-26 15:17:53 +08:00
iven
c814a4a8f3 docs(wiki): PP-01/PP-05b/PP-09 修复条目 — 死信接线/消费者/死链
症状导航新增 3 条:
- 死信重试未接线(PP-01):start_retry_dead_letters 接线 + cron_heartbeat
- AI 队列只入队不消费(PP-05b):analysis_worker 消费者通电
- 工作台死链 + 假数据(PP-09):死链校正 + offlineEventCount 接入
2026-06-26 11:24:58 +08:00
iven
a78673ef41 fix(web): PP-09 工作台死链修复 + value={0} 接入真实数据
StatisticsDashboard 在 feat 重构时引入死链 navigate(routeConfig 不存在的
路由)+ value={0} 占位假数据。影响 100% 角色首屏。

死链修复(对照 routeConfig 实际路由):
- /health/follow-ups → /health/follow-up-tasks
- /health/vital-signs → /health/daily-monitoring
- /health/lab-reports → /health/patients(无化验单独立页)
- /health/points → /health/points-rules
- /health/articles/edit/:id → /health/articles/:id/edit(参数位置)

value={0}(OperatorDashboard 线下活动): 接入真实 offlineEventCount
(useStatsData 调 listOfflineEvents 取 total),消除假数据。

含 feat 进行中的 StatisticsDashboard 重构(卡片化 navigate + useStatsData
统一数据层)+ 清理未使用 import + fetchTopArticles effect 标注。待办:
AdminDashboard「咨询待回复」仍有 value={0} 占位。
2026-06-26 11:19:07 +08:00
iven
c87760f938 feat(server+ai): PP-01 死信重试接线 + PP-05b AI 队列消费者 — 通电半成品自动化
PP-01: retry_dead_letters 已实现但全仓零调用,业务关键事件瞬时故障即永久
滞留死信表。tasks.rs 加 start_retry_dead_letters(每小时,最大重试 5 次)
+ main.rs 注册。同时落盘 feat 进行中的 cron_heartbeat 就绪门禁
(touch_heartbeat + 给 cleanup/metrics 任务加 heartbeat 参数)。

PP-05b: AnalysisQueue "只入队不消费"(两个入队源 claim_next 零调用),
违反"每个事件必须有消费者"铁律。新增 analysis_worker.rs 后台消费者:
claim_next → analysis_type 路由 → AnalysisService → mark_completed/
mark_failed。MVP 打通 trend 链路,lab_report/dialysis_risk 暂 skip
(回滚 pending,无假数据)。启动遵循 start_auto_analysis 模式(main.rs)。
2026-06-26 11:11:14 +08:00
iven
75f0dc4354 feat(ci): PP-10 软门禁 baseline — cargo-tarpaulin 覆盖率 job
在 Gitea ci.yml 加 coverage job:
- cargo-tarpaulin --workspace 生成 cobertura XML 报告
- fail-under 20% baseline + continue-on-error=true(不阻塞其他 job)
- 上传 coverage-report artifact

当前为 baseline 阶段(覆盖率可见,不阻塞)。后续根据真实覆盖率提高
fail-under(目标 service 层 ≥60%)并去掉 continue-on-error 硬化门禁。

PP-10 是「测试金字塔失衡 + 覆盖率工具缺失」根因修复(历史 24% fix 提交率)。
2026-06-26 10:29:15 +08:00
iven
1945ef3f78 docs(wiki): PP-04 可观测性修复 — DevOps 行更新 + 告警盲飞症状条目
- 关键数字 DevOps 行:加 Alertmanager 告警出口 + Grafana dashboard
  provisioning + postgres/redis exporter
- 症状导航新增「告警触发无人知晓(生产盲飞)」条目(部分修复)
2026-06-26 10:06:00 +08:00
iven
ffbe5a797f feat(docker): PP-04 完善 — Grafana HMS 概览 dashboard + postgres/redis exporter + 渠道文档
延续 PP-04 MVP,补全可观测性闭环:
- grafana/provisioning/dashboards/json/hms-overview.json: HMS 概览 dashboard
  (服务状态/DB 连接池/EventBus 积压/内存 CPU/API 5xx 错误率,基于 app metrics)
- postgres-exporter + redis-exporter 服务: 之前 prometheus.yml 配了 target 但
  服务未部署(pg_stat_activity/redis_memory 等告警永不触发),现补齐
- alertmanager 启用 --config.expand-env: 支持渠道 token 用 \${VAR} 从 .env 注入
  (避免重蹈 PP-03 Redis 密码明文入 git 覆辙)
- alertmanager/README.md: 钉钉/企微/邮件渠道配置文档(上线前填)

nginx-exporter 跳过(alerts.yml 无 nginx 规则 + 需改 nginx.conf 配 stub_status)
2026-06-26 10:03:21 +08:00
iven
6457c53d9c feat(docker): PP-04 可观测性 MVP — Alertmanager 告警出口 + Grafana provisioning
PP-04 核实属实:11 条告警规则在 prometheus 加载但无 alertmanager(告警
无通知出口),grafana provisioning 目录空,exporter 服务也未部署
("配置齐全运行为零")。

MVP 打通告警链路 + 让 grafana 可用(不依赖 exporter,基于 app metrics):
- docker-compose.production.yml 加 alertmanager 服务 + alertmanager_data 卷
- prometheus.yml 加 alerting 指向 alertmanager:9093
- alertmanager/config.yml 路由(SEV-1 critical 即时通知 + 分组)
- grafana/provisioning/datasources 自动连 prometheus
- grafana/provisioning/dashboards provider 就绪

待办(上线前):① alertmanager 占位 webhook 替换为真实渠道(钉钉/企微/邮件)
② 补 grafana dashboard JSON ③ 部署 postgres/redis/nginx exporter 让 prometheus 抓得到
2026-06-26 09:25:43 +08:00
iven
3351c68d10 docs: redact Redis 凭据明文 + 系统分析报告 + wiki 关键数字校正
PP-03 凭据泄露处置:
- 清除 wiki + 2 份历史文档中的 Redis 明文密码与公网 IP(4 文件 5 处)
- wiki 新增安全告警 + 症状导航条目
- 核实降级:泄露旧密码已失效,HMS 连本地 Redis,云端闲置;公网已关闭

系统深度分析(9 维度 + 6 主题多专家组):
- docs/discussions/2026-06-25-analysis/ 新增 7 文件
- 综合 6.8/10,4 CRITICAL,TOP 12 痛点,4 阶段路线图

wiki 关键数字校正(PP-02/05a fix 触发):
- 迁移数 175→176(m20260626_000170)
- 症状导航新增 device_readings 分区硬截止 + claim_next 注入修复条目
2026-06-26 09:07:35 +08:00
iven
57192b2ec0 fix(server): 修复 device_readings 分区硬截止 + AI 队列 claim_next SQL 注入
PP-02: m000073 只静态建了 2026_05~2026_08 分区,2026-09-01 起 INSERT
将抛错导致小程序 BLE 数据上传全线中断。新增 m20260626_000170 补建
2026_09~2027_06 共 10 个月分区,解除确定性硬截止。

PP-05a: AnalysisQueue::claim_next 用 format! 拼 tenant_id(SQL 注入)
且 SELECT+UPDATE 不在事务内、无 FOR UPDATE SKIP LOCKED。改为参数化 \$1
+ 事务内 FOR UPDATE SKIP LOCKED 原子 claim,防注入并防并发重复领取。

PP-01(死信接线)耦合 feat 分支进行中的 cron_heartbeat 工作,另行提交。
2026-06-26 09:03:53 +08:00
iven
3d683dfe82 docs(wiki): 新增上线后待办清单(6 必须项 + 4 建议项 + 数据修复 SQL) 2026-06-05 16:39:51 +08:00
iven
ee5ae9e1fb docs(wiki): 新增真机微信登录限流症状条目 + 校准日期 2026-06-05 16:36:14 +08:00
iven
01a0fffc43 fix(auth): 微信登录端点独立限流 30 次/分钟
真机调试首次登录即触发 '请求过于频繁' 错误,根因是微信登录
与密码登录共享 5 次/分钟的限制,且 extract_client_ip 在无
代理头时返回 'unknown',所有真机请求共享同一个 rate limit key。

修复:将微信登录/绑定路由从 public_routes 拆分为独立的
wechat_routes,使用 30 次/分钟的宽松限流(与 token 刷新一致)。
密码登录保持 5 次/分钟的严格限制不变。
2026-06-05 16:33:42 +08:00
iven
976b9d94a0 docs(wiki): 校准关键数字至 2026-06-05 — 用户管理过滤+患者摘要过滤 2026-06-05 11:04:08 +08:00
iven
5d61f19966 docs(wiki): 新增小程序上传数据不可见症状条目 2026-06-05 10:52:30 +08:00
iven
1982698b79 fix(health): 患者摘要列表按 user_id 过滤
小程序 loadPatients() 现在只获取当前登录用户关联的患者,
不再返回整个租户的所有患者。修复 wx_7141 上传数据写到
错误 patient 记录下的问题。

- PatientListParams 增加 user_id 可选参数
- list_summaries 增加 user_id 过滤条件
- 小程序 getPatientSummaries 传入 userId
- auth store loadPatients 传入当前 user.id
2026-06-05 10:51:17 +08:00
iven
76a89dc7de docs(wiki): 新增 wx_* 患者混入用户管理症状条目 2026-06-05 10:20:04 +08:00
iven
201a91580c feat(auth): 用户管理页面过滤纯患者用户 + fix(health): clippy 修复
后端 list_users API 新增 exclude_only_roles 参数,排除仅有指定角色的用户。
前端用户管理页面默认传 'patient' 过滤,wx_* 微信患者不再混入员工列表。
同时修复 erp-health dashboard.rs 的 clippy 警告(unused import + collapsible if)。
2026-06-05 10:17:59 +08:00
iven
a5c67d6bec docs(wiki): 校准关键数字至 2026-06-04 — 自动测量+登录修复+知识库V2
关键数字变更:
- Rust 源文件: 705→726 (+21), 代码行 ~134K
- 迁移: 165→175 (+10, 知识库 V2 + RLS)
- erp-health: 58→59 Entity, 31→33 handler, 216→217 文件
- erp-ai: 20→24 Entity, 95→105 文件 (知识库 V2)
- 全系统 Entity: 115→118
- utoipa: 94→98 文件
- 后端测试: 1030→1031 函数
- Git 提交: 1061→1065
- 小程序: 185→202 TS/TSX, 49→51 service, 5→6 store, 12→13 hooks, 103→110 SCSS

症状导航新增:
- M2 测量页仪表盘数值不可见 (gauge__center 缺背景色)
- 微信登录后显示绑定失败-登录态丢失 (login() 吞错误)
2026-06-04 16:01:59 +08:00
iven
958110cc73 fix(mp): 微信登录 API 失败时不应显示手机绑定按钮
根因:login() 的 catch 块把所有错误都返回 false,
导致 handleWechatLogin 误判为'未绑定'并显示手机绑定按钮。
用户点击绑定后因 wechat_openid 从未写入而报'登录态丢失'。

修复:
- API 失败时 throw 而非 return false,让调用方区分错误和未绑定
- 增加 resp.openid 空值校验,防止后端返回空 openid 进入绑定流程
- 现在后端不可用时用户会看到正确的错误提示而非误导性的绑定按钮
2026-06-01 18:54:03 +08:00
iven
13705a3eaf feat(mp): 连接M2手环后自动测量5项指标 + 修复仪表盘数值不可见
- 连接认证成功后自动依次测量心率→血氧→血压→体温→压力
- 列表式进度UI展示每项指标状态(等待/测量中/完成/跳过)
- 总进度条百分比实时更新
- 测量完成后保存结果并显示'查看结果并返回'按钮
- 支持取消自动测量,已测得的数据不丢失
- 修复仪表盘中心区域缺少背景色导致数值与底色混淆不可见
2026-06-01 11:08:22 +08:00
iven
92ffd8cecb feat(mp): Veepoo M2 BLE 管线扩展 — 精准睡眠数据 + 自动测量 + UI 重构
- 新增 VeepooBridge API:精准睡眠读取(readPreciseSleepData)、B3自动测量配置
  (readAutoTestConfig/setAutoTestConfig)、开关设置(setAutoHeartRate/BP/Temp)、
  体温自动数据读取(readAutoTemperatureData),共 10 个新 API
- 新增 SDK 事件类型:SDK_EVENT_SLEEP(4)、SDK_EVENT_AUTO_TEST(54)
- VeepooPipeline 新增:readSleepData/readAllSleepData(enableAutoMeasurement
  睡眠数据 Promise 化读取 + 自动测量一键开启
- VeepooHistoryReader 新增:uploadSleepReadings 睡眠数据上传
- stores/veepoo.ts 实装:注册 onSleepData 回调、syncHistory 实际读取+上传、
  readSleepData 状态管理、enableAutoMeasurement、连接后自动触发三件事
- 原生页面(native/pkg-veepoo):_onReady 后自动读取 3 天睡眠 + 开启自动测量,
  新增 _readSleepData/_handleSleepEvent/_enableAutoMeasurement
- UI 重构:测量页药丸式选择器+SVG 圆环仪表盘+健康评估标签
- 数据上传页:2 列结果卡片网格+彩色条标识+睡眠数据卡片(★评分+总时长)
- 修复上传按钮无响应 bug:patientId 增加 URL fallback + 错误提示不再静默
- 设计原型:docs/design/veepoo-measure-prototype.html(4 状态预览)
2026-05-31 21:48:06 +08:00
iven
6d073840aa docs(wiki): 记录 Veepoo M2 BLE SDK 对接踩坑和正确流程
- wiki/index.md 症状导航新增 5 条:??语法错误、useRef白屏、扫描匹配、认证超时三层根因
- wiki/index.md 关键数字更新:Git 提交 1061 次,小程序描述补充原生分包页面
- docs/discussions/ 新增 SDK 对接流程文档:
  - 连接回调 4 次触发机制(只响应 connection:true)
  - veepooWeiXinSDKNotifyMonitorValueChange 必须在连接后注册
  - VPDeviceAck vs VPDevicepassword 字段区别
  - deviceChipStatus 布尔值兼容
  - 完整踩坑清单(9 项)
2026-05-30 23:07:03 +08:00
iven
f96e88b17b fix(mp): 检查 VPDeviceAck 而非 VPDevicepassword 判断认证结果
SDK 认证回调结构:
- VPDevicepassword = "0000"(设备密码原始值,不是认证状态)
- VPDeviceAck = "successfulVerification"(认证结果)

之前代码错误检查 VPDevicepassword 的值,永远不匹配,
导致认证成功但代码未识别 → 超时。

同时修复 deviceChipStatus 轮询:SDK 可能写入布尔值 true
而非字符串。
2026-05-30 22:56:03 +08:00
iven
dc5d689d11 fix(mp): 监听器改为 connection:true 后注册,修复 notifyBLECharacteristicValueChange:not init
根因日志:
  SDK 数据事件: {errno:1500101, errMsg:"notifyBLECharacteristicValueChange:fail:not init"}

veepooWeiXinSDKNotifyMonitorValueChange 内部调用
wx.notifyBLECharacteristicValueChange,需要蓝牙适配器已初始化。
onLoad 时适配器未初始化 → 订阅失败 → 后续所有 BLE 数据丢失。

修正:从 onLoad 移除,改到 connection:true 回调中注册
(此时适配器已初始化、连接已建立、特征值已发现并订阅)。
2026-05-30 22:43:49 +08:00
iven
695b61f850 fix(mp): 数据监听器改为 onLoad 全局注册一次
重构原生页面:
- 数据监听器和连接状态监听器在 onLoad 中全局注册一次
- _listenersRegistered 防止重复注册
- 连接流程不再注册监听器,只处理连接+认证
- 增加关键节点诊断日志(SDK函数类型、连接阶段、认证调用)
- 连接回调简化为只匹配 connection:true

注意:需要清除 dist/ 后完整重建(dev 模式不监听 native/ 变更)
2026-05-30 22:32:06 +08:00
iven
8d3b3a0491 fix(mp): 数据监听器改回连接就绪后注册 + 增加轮询诊断日志
wx.onBLECharacteristicValueChange 只支持一个回调,
SDK 合并连接函数可能内部也调用它,连接前注册会被覆盖。
改为 connection:true 回调后、认证前注册(对齐 SDK 文档顺序)。

增加每 500ms 轮询时打印 deviceChipStatus 实际值便于定位。
2026-05-30 13:59:05 +08:00
iven
bc3c056c8d fix(mp): 只在 connection:true 最终回调触发认证,修复过早认证无响应
连接回调触发 4 次(createBLEConnection → services → characteristics → connection:true),
errno:0 在第一次回调就匹配了条件,导致认证指令在特征值订阅完成前发送,
BLE 通知通道未建立,认证响应无法送达。

修正:仅 result.connection === true 时触发认证流程,
这是 SDK 合并接口的最终就绪信号。
2026-05-30 13:53:31 +08:00
iven
3e36e31cf6 fix(mp): 数据监听器移到连接前注册,修复认证超时
根因:veepooWeiXinSDKNotifyMonitorValueChange 封装
wx.onBLECharacteristicValueChange,必须在连接+订阅
特征值之前注册,否则认证响应(type=1)丢失。

流程修正:registerListeners → connect → auth
原流程:connect → callback → registerListeners → auth(错误)

同时增加 SDK 事件诊断日志和认证超时时输出
deviceChipStatus 实际值便于排查。
2026-05-30 13:43:46 +08:00
iven
ec404a3e25 fix(mp): M2 设备扫描放宽名称匹配 + vibrateShort 异步 catch
1. 扫描匹配放宽:支持 M2 / VPM / VEEPOO 三种广播名前缀
2. 增加诊断日志:SDK 加载状态 + 每个扫描到的设备完整信息
3. 扫描超时从 10s 增加到 15s
4. vibrateShort 改用 .catch() 捕获异步 rejection(DevTools 不支持 type 参数)
2026-05-30 13:36:57 +08:00
iven
7924768df3 fix(mp): veepoo-measure 缺少 useRef 导入导致页面空白
import 语句仅写了 'import React from react',
组件中使用的 useRef 未被解构导入,运行时报 ReferenceError。
2026-05-30 13:28:15 +08:00
iven
ac9896d375 fix(mp): 原生页面 ?? 运算符不兼容微信小程序运行时
_formatValues 中 values.systolic ?? '--' 改为 != null 三元表达式,
微信小程序 JS 引擎不支持 ES2020 nullish coalescing。
2026-05-30 13:15:03 +08:00
iven
a86219c8a0 fix(mp): Veepoo M2 BLE 审计 C1-C5/H1-H6 全量修复
CRITICAL 修复:
- C1: 体温测量传 { switch: boolean } 参数 + 停止指令
- C2: uploadReadings 使用正确 NormalizedReading 类型替代 as any
- C3: navigatedRef 防重入避免 React 18 Strict Mode 双触发导航
- C4: WXML gauge 空闲态用 data 预计算值替代 findIndex+匿名函数
- C5: _onReady 清除 _authTimeout 防止 Timer 泄漏

HIGH 修复:
- H1: WXML 用 results[type] 替代未声明的 measureStates
- H2: handleConnect 添加 _connecting 防重入保护
- H4: 连接回调兼容 errno:0 / errCode:0 fallback
- H5: _formatValues 零值合法显示(!== undefined && !== null)

MEDIUM:
- Storage key 添加 hms: 命名空间前缀防冲突
2026-05-30 13:11:49 +08:00
iven
432c5d96f2 chore: 清理 .gitignore + 添加 wiki/permissions.md
1. .gitignore: 补充临时调试文件(_*.txt/server_*.txt/tmp_*.txt 等)、
   graphify-out/、apps/mp-native/ 的忽略规则
2. wiki/permissions.md: 角色权限体系文档
2026-05-29 17:20:45 +08:00
iven
aa6d93129d fix(security): P0 安全修复 — Access Token 吊销 + OpenAPI 保护 + RLS 补齐 + CI 加固 + 测试修复
P0-5: Access Token 吊销机制
- 新增内存 DashMap 黑名单(token_hash → exp),支持单 token 吊销
- 密码修改/登出时自动清除用户权限缓存,强制重新认证
- 惰性清理过期条目,防止内存无限增长

P0-6: OpenAPI 端点安全
- 生产构建返回 404,仅 cfg(debug_assertions) 模式可用
- 防止 385+ API 端点 schema 对外暴露

P0-4: RLS 策略补充迁移 (m000169)
- 幂等遍历所有含 tenant_id 的表,补齐缺失的 RLS 策略
- 覆盖 m000088 之后创建的约 20 张新表

P0-3: CI 安全加固
- 移除 CI 中硬编码密码 123123,改用 postgres
- 保持 cargo audit / npm-audit 严格门禁

P0-7: AI prompt 集成测试修复
- get_active_prompt 改按 analysis_type 查找而非 name
- list_prompts 过滤参数从 category 改为 analysis_type
- 167 集成测试全部通过(原 164 passed / 3 failed)
2026-05-29 11:38:38 +08:00
iven
9a67bf80c1 refactor(health): 消除双套脱敏实现 — 统一使用 erp-core Unicode 安全版本
erp-health/src/service/masking.rs 中的 mask_id_number/mask_phone 使用
字节切片(&s[..3])而非字符切片,对非 ASCII 输入会 panic。
改为 pub use erp_core::crypto 的 Unicode 安全版本(chars().collect()),
仅保留 health 业务特有的 validate_status_transition。
2026-05-29 08:09:40 +08:00
iven
03ead44385 fix(security): P0 安全修复 — 审计日志 PII 脱敏 + AI Token 计量 + backup.sh 拼写 + CI audit
1. 审计日志 PII 脱敏: audit_service.rs 中 old_value/new_value 自动 mask
   patient/consultation/follow_up 等资源类型的 PII 字段(id_number/phone/name 等)
2. AI Token 计量: chat_handler.rs 从 Provider response 和 AgentOrchestrator 提取
   实际 input_tokens/output_tokens,替代硬编码 0
3. AI display_hints: 从 AgentOrchestrator 传递 display_hints 给前端 ChatResponse
4. backup.sh: PGDATABSE 拼写错误修复为 PGDATABASE
5. CI: npm audit 移除 || true,高危漏洞阻止合并
6. 新增六维度深度分析报告 docs/discussions/2026-05-28
2026-05-29 07:56:29 +08:00
iven
ddf5c196e4 fix(mp): 健康页滚动卡死 + 文章样式丢失 — ScrollView height:0 修复 + RichArticle 18 条 tag-style 规则
- 健康页:移除冗余"健康"标题栏,ScrollView scrollY 添加 height:0 修复 flex 高度分配
- 健康页:useReachBottom(页面级)替换为 ScrollView onScrollToLower,修复模拟器卡死
- RichArticle:新增 18 条 tag-style 规则(h1-h4/p/ul/ol/li/table/th/td 等),确保文章内容在小程序中正确渲染样式
2026-05-27 19:37:25 +08:00
iven
23cd0b14a7 refactor(ai): 用知识库 V2 替换旧版 — 删除旧页面/API,菜单路径不变
- 删除旧版 AiKnowledgePage.tsx 和 api/ai/knowledge.ts
- V2 页面接管 /health/ai-knowledge 路由(不再用 -v2 后缀)
- 迁移 168 改为 UPDATE 旧菜单名称+component(而非新增菜单)
- routeConfig 和 App.tsx 路由声明同步更新
2026-05-27 11:15:17 +08:00
iven
803a27fb84 fix(db): 知识库 V2 菜单迁移 — PL/pgSQL 安全检查 sys_menu 存在性
迁移 168 引用 sys_menu 表但在无基础 ERP 数据的数据库中不存在。
改用 DO $$ block + information_schema 检查,表不存在时安全跳过。
2026-05-27 11:04:45 +08:00
iven
a4d09269a4 fix(ai): 同步集成测试 create_prompt 签名 — 补充 analysis_type 参数
Prompt 管理 Phase 2 新增了 analysis_type 参数,但集成测试未同步。
5 处调用点全部补齐第 8 个参数。
2026-05-27 01:06:16 +08:00
iven
b0323ec89c feat(ai): 知识库 V2 菜单迁移 + 文本切片器 + 前端路由权限
- 新增迁移 000168:在 AI 知识库同级添加「知识库 V2」菜单,绑定 admin 角色
- 新增 document/chunker.rs:固定大小 + overlap 文本切片器(5 单元测试)
- 前端 routeConfig 添加 /health/ai-knowledge-v2 权限声明
- App.tsx validateRouteCoverage 补充 v2 路径
2026-05-27 00:49:27 +08:00
iven
2324d770bc feat(web): 知识库 V2 管理页面 — 列表/CRUD/上传/向量搜索测试
- knowledgeV2.ts API client: 知识库/文档/搜索完整接口
- KnowledgeV2Page: 知识库列表 + 创建/编辑/删除
- 文档列表 Drawer: 按知识库查看文档(状态/切片进度)
- 上传 Modal: Multipart 文件上传(PDF/TXT/MD/DOCX/XLSX)
- 向量搜索测试 Drawer: 输入查询 → 余弦相似度结果展示

路由: /health/ai-knowledge-v2
Phase 3 Task 16-19
2026-05-27 00:38:11 +08:00
iven
823d69a3c3 feat(ai): 知识库 V2 集成 — 多知识源路由 + AI 分析自动注入
- KnowledgeV2Source: 实现 KnowledgeSource trait,自动搜索所有启用的知识库
- AnalysisService.knowledge_sources: 改 Option → Vec 支持多知识源
- 最佳匹配策略:遍历所有知识源取最高 confidence 的上下文注入 system prompt
- main.rs 共享 EmbeddingService + KnowledgeV2Service 实例

Phase 2 Task 12-15
2026-05-27 00:30:49 +08:00
iven
7d1b1f9c7c feat(ai): 向量搜索 + hit test API
- KnowledgeV2Service.vector_search: pgvector 余弦相似度搜索
- SearchHit DTO: chunk_id/document_id/similarity/metadata
- hit_test handler: POST /ai/documents/hit-test (embed query → 搜索)
- AiState 添加 embedding 字段,共享 EmbeddingService 实例
- top_k 限制最大 20

Phase 2 Task 11
2026-05-27 00:24:34 +08:00
iven
e94f5bc00c feat(ai): 文档管理 handler — CRUD + Multipart 上传
- list_documents: 分页列表(按知识库过滤)
- get_document: 文档详情
- create_manual_document: 手动输入文档
- upload_document: Multipart 文件上传(20MB 限制 + 自动解析)
- delete_document: 软删除(级联减计数)
- 5 条路由注册到 /ai/knowledge-bases/{kb_id}/documents + /ai/documents/*

Phase 2 Task 10
2026-05-27 00:17:43 +08:00
iven
0a1f4cb9a9 feat(ai): 文档解析管线 — PDF 解析 + 切片 + 嵌入管线
- 简化版 parser:PDF(pdf-extract) + 纯文本 + 二进制兜底
- 固定窗口切片器(500 字符/50 重叠),5 个单元测试全通过
- DocumentService:手动/上传文档创建 → 切片 → 嵌入 → 存储
- UploadDocumentParams 结构体避免过多参数
- 移除未使用的 docx-rs/calamine 依赖

Phase 2 Task 7-9
2026-05-27 00:13:08 +08:00
iven
23c5bbdb40 feat(ai): 知识库 V2 Handler + 路由注册 + State 初始化
5 个端点:GET/POST /ai/knowledge-bases, GET/PUT/DELETE /ai/knowledge-bases/{id}
AiState 新增 knowledge_v2 字段,main.rs 初始化。
2026-05-26 23:25:38 +08:00
iven
2ccf0801b7 feat(ai): 新增 KnowledgeV2Service — 知识库 CRUD + 原子计数器
知识库列表/创建/更新/删除/单条查询,含分页和过滤。
increment_document_count / increment_chunk_count 使用 SQL 原子递增。
2026-05-26 23:19:09 +08:00
iven
86dbd74f3f feat(ai): 新增知识库 V2 Entity(bases/documents/chunks)
三张新表对应的 SeaORM Entity,embedding 字段标记 #[sea_orm(ignore)]
通过 raw SQL 操作 pgvector 列。
2026-05-26 23:13:42 +08:00
iven
0edb475638 feat(db): 创建 ai_knowledge_documents + ai_knowledge_chunks 表迁移
文档表支持多种来源(上传/URL/手动)、处理状态追踪、嵌入进度计数。
切片表含 vector(1536) 列 + HNSW 索引用于向量相似搜索。
2026-05-26 23:10:57 +08:00
iven
a7526455b4 feat(db): 创建 ai_knowledge_bases 表迁移
知识库 V2 第一张表:支持多类型知识库(规则/参考资料/临床指南/FAQ),
含意图关键词 JSONB、切片策略 JSONB、文档/切片计数器。
2026-05-26 23:07:28 +08:00
iven
dda8be9079 docs(ai): AI 知识库 V2 实施计划 — 7 Phase / 26 Tasks
Phase 0: 数据库迁移 + Entity (3 Tasks)
Phase 1: 后端 Service + Handler (4 Tasks)
Phase 2: 文档处理管线 (5 Tasks)
Phase 3: 混合意图路由 (3 Tasks)
Phase 4: 前端管理 UI (4 Tasks)
Phase 5: AI 客服集成 + 数据迁移 (4 Tasks)
Phase 6: 测试 + 验收 (3 Tasks)
2026-05-26 23:00:46 +08:00
iven
af2484e63b docs(ai): 知识库 V2 设计规格 — review 修复
修复 3 CRITICAL + 4 HIGH 问题:
- C1: 明确禁止 format! 拼接 SQL,所有 pgvector 查询参数化
- C2: 迁移 SQL 改用临时映射表精确关联,防数据重复/丢失
- C3: SSRF 防护细化(DNS rebinding + 超时 + 重定向校验)
- H1: document_count/chunk_count 改为原子 SQL 增量
- H2: embedding 部分失败:NULL embedding 写入 + embedded_count 统计
- H3: chunks 表补充 updated_at/updated_by 审计字段
- H4: 新增 validator crate 依赖说明
- M1: 空知识库边界返回空 context 不报错
- M2: 向量索引改用 HNSW(无需预热)
2026-05-26 22:18:28 +08:00
iven
10c28df152 docs(ai): AI 知识库 V2 设计规格 — 统一知识管理平台
替换旧三层模型(rules/references/guides)为 Dify 风格统一知识库:
- 3 张新表:knowledge_bases / documents / chunks(切片+向量)
- 文档处理管线:PDF/Word/Excel/URL/Markdown/手动录入 → 智能切片 → embedding
- 混合意图路由:关键词粗筛 → 向量检索 → LLM 兜底
- AI 客服集成:RAG context 注入 + [ref:xxx] 引用溯源
2026-05-26 22:11:29 +08:00
iven
3c7b48b6f6 feat(ai): Prompt 管理 Phase 2 — analysis_type 后端选择键 + 筛选修复
- 新增 ai_prompt.analysis_type 列作为后端按链路选择 Prompt 的唯一键
- name 回归显示标识符用途,不再承担选择键角色
- 迁移 000164: 新增 analysis_type 列 + 从 name 回填 + 索引
- 迁移 000165: 修复旧数据从 category 错误回填的问题
- AiPromptList 页面重构: 分析类型/调用链路列、详情抽屉、新建表单
- DrawerForm 组件新增 onValuesChange 回调支持跨字段联动
- 新建表单选择分析类型后自动填充标识符
- 筛选过滤器改为按 analysis_type 而非 category 过滤(后端+前端同步)
- 停用/激活/回滚/删除操作完整可用
2026-05-26 17:04:26 +08:00
iven
3972db4f98 fix(web): 积分商品保存 — 去掉 image_url 中的临时 token 参数
MediaPicker 返回含 ?token= 的 URL 用于预览认证,但保存到数据库时应只存纯路径,
避免 JWT token 持久化。同时改进错误提示显示后端返回的具体消息。
2026-05-26 10:38:30 +08:00
iven
9d6a92e1d7 fix(web): 积分商品图片预览 — 改用 React state 驱动替代 antd Form shouldUpdate
ant Form shouldUpdate + setFieldValue 在 DrawerForm 上下文中无法正确触发重渲染,
改用独立 imageUrl state 管理,Input/预览/MediaPicker/Upload 全部通过 state 同步
2026-05-26 10:30:22 +08:00
iven
42299a6722 fix(web): 积分商品图片选择器 + 更新 422 修复
- 图片字段改用 shouldUpdate 自定义渲染,值正确绑定到 Input 和预览
- updateProduct API 改为嵌套 { data, version } 格式,匹配后端 DTO
- 隐藏 Form.Item 保存 image_url 值到 form store
2026-05-26 10:12:04 +08:00
iven
a2864713d6 feat(web): 积分商品图片选择器 — 媒体库 + 上传替代手动 URL
- PointsProductList: 图片字段改为 Input + 媒体库按钮 + 上传按钮 + 图片预览
- DrawerForm: 新增可选 form prop,允许外部控制表单实例
2026-05-26 10:04:28 +08:00
iven
ba93e6585c fix(web): 文章编辑修复 + ESLint 合规
- ArticleEditor: tag_ids 过滤 null/undefined 值,修复 422 错误
- ArticleCategoryManage: 删除分类传递 version 字段,修复 enforce_version 校验
- articles API: ArticleCategory 接口增加 version 字段
- usePaginatedData: ref 赋值移入 useEffect,修复 react-hooks/refs 规则
- ArticleCategoryManage/ArticleManageList: 函数用 useCallback 包裹,修复 exhaustive-deps
2026-05-26 01:13:59 +08:00
iven
d7fb5da873 feat(health): 积分规则查重 — 同租户同事件类型不可重复创建
- 新增迁移 m20260526_000163:points_rule (tenant_id, event_type) 部分唯一索引(排除软删除行)
- 后端 create_rule 添加 event_type 查重,重复时返回 400 Validation 错误
- 前端 PointsRuleList 提取后端错误消息展示给用户
2026-05-26 01:09:21 +08:00
iven
8027cdd1d9 docs(graphify): 添加知识图谱使用指南
- CLAUDE.md: graphify 从英文规则扩展为中文开发流程指南
  - 定义 5 种使用场景(接手任务/排查bug/理解模块/代码更新/架构审查)
  - 融入闭环工作法,优先级:graphify query > path > Grep/Read
- wiki/index.md: 关键数字添加 Graphify 行 + 模块导航新增开发工具分区
- wiki/infrastructure.md: §3 常用命令添加 Graphify 子节
2026-05-25 14:03:50 +08:00
iven
8ad4329632 chore(mp): 配置优化 + 文档更新
- config: virtualHost + native-components 拷贝配置
- project.config: skylineRenderEnable=false 调试用
- app.config: 移除 lazyCodeLoading 注释(已在 config/index.ts 控制)
- dev.ps1: WECHAT_DEV_MODE=false(真机测试用)
- wiki: 更新 DevTools 卡死根因 + 构建模式说明
- CLAUDE.md: 添加 graphify 知识图谱规则
2026-05-25 13:45:46 +08:00
iven
1a376a255d fix(mp): 导航/请求健壮性 — reLaunch 去重 + 失败降级
- navigateToLogin 添加去重 + reLaunch 失败降级 redirectTo
- request.ts safeReLaunch 添加目标页检测 + 失败降级
- 退出登录 reLaunch 失败降级 redirectTo
- DoctorTabBar / 首页医生端跳转 reLaunch 失败降级
- 网络恢复时正确清理 toast 状态和定时器
2026-05-25 13:45:12 +08:00
iven
485b9bb926 feat(mp): 登录页 UX 优化 — 协议区域就近显示
- 协议勾选移至对应操作区域(微信登录 + 绑定区)更直觉
- 统一 SHOW_DEV_LOGIN 常量控制开发模式入口
- 抽取 requireAgreement() 复用协议检查逻辑
2026-05-25 13:44:35 +08:00
iven
185f411495 feat(mp): 文章详情页改用 mp-html 原生富文本组件
- 引入 mp-html 替代 RichText,支持图文混排、表格等复杂内容
- 新建 RichArticle 组件封装 sanitizeHtml + mp-html
- 通过 native-components 拷贝原生组件到 dist
- 优化文章排版样式(字号、间距、分隔线、底栏安全区)
- sanitize-html 扩展允许 style/data-w-e-type 属性
2026-05-25 13:44:00 +08:00
iven
a24c18155f feat(mp): BLE 血氧仪支持 + 服务发现增强
- 新增 Pulse Oximeter Service (0x1822) 支持,含 SFLOAT 解析
- 连接后自动扫描全部服务,发现并订阅已知健康 UUID
- 设备同步页展示已发现的服务和可用数据类型标签
- 新增 BLEDiscoveredService / BLEDiscoveredCharacteristic 类型
2026-05-25 13:43:16 +08:00
165 changed files with 16530 additions and 1982 deletions

View File

@@ -46,6 +46,47 @@ jobs:
ERP__JWT__SECRET: ci-test-secret ERP__JWT__SECRET: ci-test-secret
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026 ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
# PP-10: 覆盖率 baseline软门禁阶段
# 当前 continue-on-error=true先让覆盖率可见、生成报告 artifact。
# 后续根据 baseline 真实数据提高 fail-under 阈值(目标 service 层 ≥60%)并去掉
# continue-on-error 硬化门禁。见 docs/discussions/2026-06-25-analysis/ PP-10。
coverage:
runs-on: ubuntu-latest
continue-on-error: true
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: erp_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Install cargo-tarpaulin
run: cargo install cargo-tarpaulin --locked
- name: Run coverage (fail-under 20% baseline)
run: cargo tarpaulin --workspace --out Xml --output-dir coverage --fail-under 20 -- --test-threads=2
env:
ERP__DATABASE__URL: postgres://test:test@localhost:5432/erp_test
ERP__JWT__SECRET: ci-test-secret
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
if-no-files-found: warn
frontend-build: frontend-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@@ -17,7 +17,7 @@ jobs:
image: postgres:16 image: postgres:16
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: 123123 POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres POSTGRES_DB: postgres
ports: ports:
- 5432:5432 - 5432:5432
@@ -28,9 +28,9 @@ jobs:
--health-retries 5 --health-retries 5
env: env:
TEST_DB_URL: postgres://postgres:123123@localhost:5432/postgres TEST_DB_URL: postgres://postgres:postgres@localhost:5432/postgres
JWT_SECRET: test-jwt-secret-for-ci JWT_SECRET: test-jwt-secret-for-ci
DATABASE_URL: postgres://postgres:123123@localhost:5432/erp_ci DATABASE_URL: postgres://postgres:postgres@localhost:5432/erp_ci
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -81,7 +81,7 @@ jobs:
run: pnpm build run: pnpm build
- name: Security audit (npm) - name: Security audit (npm)
run: npx npm-audit --audit-level=high || true run: npx npm-audit --audit-level=high
miniprogram-test: miniprogram-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest

22
.gitignore vendored
View File

@@ -82,6 +82,28 @@ tmp/
screenshots/ screenshots/
server-log.txt server-log.txt
snapshot_*.txt snapshot_*.txt
_*.txt
_server_*.txt
tmp_*.txt
direct_*.txt
server_*.txt
server_combined.txt
out.txt
_wx_login.json
.claude/settings.json
# Trace/debug JSON
trace-*.json
# Graphify knowledge graph (regenerated locally)
graphify-out/
# Native miniprogram (separate project)
apps/mp-native/
# Misc untracked
err.txt
uploads/g:/hms/.superpowers/ uploads/g:/hms/.superpowers/
.claude/skills/design-handoff/node_modules/ .claude/skills/design-handoff/node_modules/
.design/config.yml .design/config.yml
.superpowers/

View File

@@ -504,3 +504,32 @@ chore(docker): 添加 PostgreSQL 健康检查
| 设计文档索引 | `wiki/index.md` | | 设计文档索引 | `wiki/index.md` |
| 开发进度、模块状态 | `wiki/index.md` 关键数字 | | 开发进度、模块状态 | `wiki/index.md` 关键数字 |
| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 | | 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 |
## graphify — 代码知识图谱
> 项目知识图谱位于 `graphify-out/`当前规模18,517 节点 / 22,666 边 / 1,841 社区(纯 AST 解析,无 API 成本)。
> 工具:`python -m graphify`(已安装 graphifyy 0.8.18)。
### 开发流程中的使用场景
| 时机 | 命令 | 目的 |
|------|------|------|
| **接手新任务,理解代码关系** | `graphify query "概念名"` | 搜索相关节点,比 Grep 更精准(按调用/引用/包含关系) |
| **排查 bug追踪调用链** | `graphify path "A" "B"` | 查找两个模块/函数间的最短路径 |
| **理解某个模块的职责** | `graphify explain "模块名"` | 自然语言解释节点及其邻居 |
| **代码改动后** | `graphify update .` | 增量更新图谱AST-only秒级完成 |
| **宏观架构审查** | 读 `graphify-out/GRAPH_REPORT.md` | 全局社区结构、跨文件关系概览 |
### 使用优先级(融入 §2.5 闭环工作法)
在 §2.5 步骤 1「现状确认」中**优先使用 graphify 替代盲目 Grep**
1. **先 `graphify query`** — 精确定位相关节点和社区(比 Grep 返回更结构化的结果)
2. **再 `graphify path`** — 确认模块间依赖路径(避免遗漏间接依赖)
3. **最后 Grep/Glob/Read** — 确认 graphify 发现的具体文件内容
### 注意事项
- `graphify update .` 纯本地 AST 解析,不消耗 LLM token每次代码改动后都可以运行
- 查询结果比 GRAPH_REPORT.md 更精准,优先使用 query/path/explain仅在需要全局视图时读报告
- 首次生成需几分钟1712 文件),后续增量更新秒级完成

View File

@@ -120,6 +120,9 @@ handlebars = "6"
# HTML sanitization # HTML sanitization
ammonia = "4" ammonia = "4"
# Document parsing
pdf-extract = "0.7"
# Metrics # Metrics
metrics = "0.24" metrics = "0.24"
metrics-exporter-prometheus = "0.16" metrics-exporter-prometheus = "0.16"

View File

@@ -44,6 +44,13 @@ export default defineConfig(async (merge) => {
resource: ['src/styles/variables.scss'], resource: ['src/styles/variables.scss'],
}, },
mini: { mini: {
virtualHost: true,
copy: {
patterns: [
{ from: 'src/native-components/', to: 'dist/native-components/', ignore: ['*.ts'] },
],
options: {},
},
compile: { compile: {
exclude: [], exclude: [],
include: [], include: [],

View File

@@ -0,0 +1,863 @@
/**
* Veepoo M2 原生小程序页面 — 连接 + 测量
*
* 完全脱离 Taro 框架,直接使用微信原生 API + Veepoo SDK。
* 流程严格对齐官方 Demo
* onLoad 注册全局监听器
* → scan → stopScan → connect(等待 connection:true)
* → delay 500ms → authenticate
* → SDK 事件(type=1) / Storage 轮询 → ready
*/
// eslint-disable-next-line no-undef
const { veepooBle, veepooFeature, veepooLogger } = require('./libs/veepoo-sdk');
// ── 常量 ──
var SDK_EVENT_AUTH = 1;
var SDK_EVENT_BATTERY = 2;
var SDK_EVENT_SLEEP = 4;
var SDK_EVENT_DAILY = 5;
var SDK_EVENT_TEMPERATURE = 6;
var SDK_EVENT_BLOOD_PRESSURE = 18;
var SDK_EVENT_BLOOD_OXYGEN = 31;
var SDK_EVENT_HEART_RATE = 51;
var SDK_EVENT_PRESSURE = 58;
var SDK_EVENT_AUTO_TEST = 54;
var MEASURE_TYPES = [
{ type: 'heart_rate', label: '心率', unit: 'bpm', icon: '♥', color: '#EF4444', sdkType: SDK_EVENT_HEART_RATE },
{ type: 'blood_oxygen', label: '血氧', unit: '%', icon: 'O₂', color: '#3B82F6', sdkType: SDK_EVENT_BLOOD_OXYGEN },
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', icon: '↕', color: '#8B5CF6', sdkType: SDK_EVENT_BLOOD_PRESSURE },
{ type: 'temperature', label: '体温', unit: '°C', icon: 'T', color: '#F59E0B', sdkType: SDK_EVENT_TEMPERATURE },
{ type: 'pressure', label: '压力', unit: '', icon: '~', color: '#6366F1', sdkType: SDK_EVENT_PRESSURE },
];
var MEASURE_TIMEOUTS = {
heart_rate: 60000,
blood_oxygen: 60000,
blood_pressure: 120000,
temperature: 60000,
pressure: 90000,
};
var MEASURE_SETTLE_DELAY = 1500;
function _findConfig(type) {
for (var i = 0; i < MEASURE_TYPES.length; i++) {
if (MEASURE_TYPES[i].type === type) return MEASURE_TYPES[i];
}
return MEASURE_TYPES[0];
}
// ── Page ──
// eslint-disable-next-line no-undef
Page({
data: {
phase: 'idle',
deviceId: '',
deviceName: 'M2',
batteryLevel: null,
error: '',
selectedType: 'heart_rate',
selectedIcon: '♥',
selectedColor: '#EF4444',
selectedLabel: '心率',
selectedUnit: 'bpm',
measureTypes: MEASURE_TYPES,
measurePhase: 'idle',
measureProgress: 0,
measureDisplayValue: '',
measureError: '',
results: {},
hasResults: false,
// 自动测量状态
autoMeasuring: false,
autoMeasureDone: false,
autoMeasureStatus: {},
autoMeasureValues: {},
autoMeasureProgress: 0,
},
_authTimer: null,
_authTimeout: null,
_scanTimer: null,
_scanFound: null,
_measureTimer: null,
_settleTimer: null,
_lastValues: null,
_connected: false,
_eventChannel: null,
_connecting: false,
_listenersRegistered: false,
_autoQueue: null,
_autoQueueIndex: 0,
// ── 生命周期 ──
onLoad: function () {
// eslint-disable-next-line no-undef
this._eventChannel = this.getOpenerEventChannel();
// eslint-disable-next-line no-undef
wx.setNavigationBarTitle({ title: 'M2 手环测量' });
this._updateSelectedDisplay('heart_rate');
// 注意:不在 onLoad 注册 veepooWeiXinSDKNotifyMonitorValueChange
// 该函数内部会调用 wx.notifyBLECharacteristicValueChange需要蓝牙适配器已初始化。
// onLoad 时适配器未初始化 → 返回 "notifyBLECharacteristicValueChange:fail:not init"
// 监听器改在 _doConnect 的 connection:true 回调中注册。
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 页面已加载');
},
onUnload: function () {
this._cleanup();
},
_cleanup: function () {
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
if (this._scanTimer) { clearTimeout(this._scanTimer); this._scanTimer = null; }
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
this._connecting = false;
if (this._connected) {
try { veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(function () {}); } catch (e) {
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 断开异常:', e);
}
this._connected = false;
}
},
// ── 全局监听器onLoad 注册一次) ──
_registerGlobalListeners: function () {
if (this._listenersRegistered) return;
this._listenersRegistered = true;
var self = this;
// SDK 数据监听 — 接收所有解析后的事件auth/measure/battery 等)
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(function (data) {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] SDK 数据事件:', JSON.stringify(data).substring(0, 500));
self._handleSdkEvent(data);
});
// BLE 连接状态变化
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(function (res) {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 连接状态变化:', JSON.stringify(res));
if (!res.connected) {
self._connected = false;
self._connecting = false;
self._cancelPendingMeasure();
self.setData({ phase: 'disconnected' });
}
});
// eslint-disable-next-line no-undef
console.log('[veepoo-native] SDK 函数类型:', {
scanFn: typeof veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice,
connectFn: typeof veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager,
dataFn: typeof veepooBle.veepooWeiXinSDKNotifyMonitorValueChange,
authFn: typeof veepooFeature.veepooBlePasswordCheckManager,
});
},
_updateSelectedDisplay: function (type) {
var cfg = _findConfig(type);
this.setData({
selectedType: type,
selectedIcon: cfg.icon,
selectedColor: cfg.color,
selectedLabel: cfg.label,
selectedUnit: cfg.unit,
});
},
// ── 连接流程 ──
handleConnect: function () {
if (this.data.phase !== 'idle' && this.data.phase !== 'error' && this.data.phase !== 'disconnected') return;
if (this._connecting) return;
this._connecting = true;
this.setData({ phase: 'scanning', error: '' });
veepooLogger.setLevel(0);
var self = this;
self._scanFound = null;
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(function (res) {
var device = Array.isArray(res) ? res[0] : res;
if (!device) return;
var name = (device.localName || device.name || '').toUpperCase();
var deviceId = device.deviceId || device.mac || '';
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 扫描到:', name, deviceId);
if (!self._scanFound && (name.indexOf('M2') !== -1 || name.indexOf('VPM') !== -1 || name.indexOf('VEEPOO') !== -1)) {
self._scanFound = device;
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {
self._doConnect(device);
});
}
});
this._scanTimer = setTimeout(function () {
if (!self._scanFound) {
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {});
self._connecting = false;
self.setData({ phase: 'error', error: '未找到 M2 设备,请确保手环已开机且蓝牙已开启' });
}
}, 15000);
},
_doConnect: function (device) {
this.setData({ phase: 'connecting' });
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 开始连接:', device.deviceId || device.mac);
var self = this;
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(device, function (result) {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 连接阶段回调:', JSON.stringify(result).substring(0, 300));
// 只响应最终就绪回调connection:true
if (result.connection === true) {
self._connected = true;
self._connecting = false;
self.setData({
deviceId: device.deviceId || device.mac || '',
});
// 关键:在连接就绪后注册数据监听器
// veepooWeiXinSDKNotifyMonitorValueChange 内部会调用
// wx.notifyBLECharacteristicValueChange需要蓝牙适配器已初始化+已连接
self._registerGlobalListeners();
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 连接就绪监听器已注册500ms 后发送认证');
setTimeout(function () {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 调用 veepooBlePasswordCheckManager');
veepooFeature.veepooBlePasswordCheckManager();
self.setData({ phase: 'authenticating' });
}, 500);
// Storage 轮询兜底
self._authTimer = setInterval(function () {
try {
// eslint-disable-next-line no-undef
var status = wx.getStorageSync('deviceChipStatus');
// SDK 可能写入字符串或布尔值 true
if (status === 'successfulVerification' || status === 'passTheVerification' || status === true) {
clearInterval(self._authTimer);
self._authTimer = null;
self._onReady();
}
} catch (_) { /* ignore */ }
}, 500);
self._authTimeout = setTimeout(function () {
if (self._authTimer) {
clearInterval(self._authTimer);
self._authTimer = null;
self._connecting = false;
// eslint-disable-next-line no-undef
console.error('[veepoo-native] 认证超时 deviceChipStatus=', wx.getStorageSync('deviceChipStatus'));
self.setData({ phase: 'error', error: '设备认证超时,请重新连接' });
}
}, 8000);
}
});
},
_onReady: function () {
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
this._connecting = false;
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 认证成功,设备就绪');
this.setData({ phase: 'ready' });
veepooFeature.veepooReadElectricQuantityManager();
// 认证成功后自动读取 3 天睡眠数据 + 开启自动测量
this._readSleepData();
this._enableAutoMeasurement();
// 自动依次测量所有指标(面向中老年人,减少操作)
this._startAutoMeasureQueue();
},
// ── SDK 事件路由 ──
_handleSdkEvent: function (data) {
if (!data || data.type === undefined) return;
var type = data.type;
if (type === SDK_EVENT_AUTH) {
var content = data.content || {};
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 认证事件: VPDeviceAck=' + content.VPDeviceAck + ' VPDevicepassword=' + content.VPDevicepassword);
// VPDeviceAck 是认证结果successfulVerification/passTheVerification
// VPDevicepassword 是设备密码原始值(如 "0000"),不是认证结果
if (content.VPDeviceAck === 'successfulVerification' || content.VPDeviceAck === 'passTheVerification') {
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
this._onReady();
}
return;
}
if (type === SDK_EVENT_BATTERY) {
var pct = (data.content || {}).VPDeviceElectricPercent;
if (pct !== undefined && pct !== null) {
this.setData({ batteryLevel: Number(pct) });
}
return;
}
// 睡眠数据回调type=4
if (type === SDK_EVENT_SLEEP) {
this._handleSleepEvent(data);
return;
}
// 日常数据回调type=5
if (type === SDK_EVENT_DAILY) {
// 日常数据用于历史同步,原生页面暂不处理
return;
}
// 自动测量配置回调type=54
if (type === SDK_EVENT_AUTO_TEST) {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 自动测量配置回调');
return;
}
for (var i = 0; i < MEASURE_TYPES.length; i++) {
if (MEASURE_TYPES[i].sdkType === type) {
this._handleMeasureEvent(MEASURE_TYPES[i].type, data);
return;
}
}
},
// ── 测量流程 ──
handleSelectType: function (e) {
var type = e.currentTarget.dataset.type;
if (this.data.measurePhase === 'measuring') return;
this._updateSelectedDisplay(type);
this.setData({ measureError: '' });
},
handleStartMeasure: function () {
var type = this.data.selectedType;
if (this.data.measurePhase === 'measuring') return;
if (!this._connected) {
this.setData({ measureError: '设备未连接' });
return;
}
var self = this;
self._lastValues = null;
self.setData({
measurePhase: 'measuring',
measureProgress: 0,
measureDisplayValue: '',
measureError: '',
});
self._sendMeasureCommand(type, true);
self._measureTimer = setTimeout(function () {
self._onMeasureError('测量超时,请重试');
}, MEASURE_TIMEOUTS[type] || 60000);
},
handleCancelMeasure: function () {
this._cancelPendingMeasure();
this.setData({
measurePhase: 'idle',
measureProgress: 0,
measureDisplayValue: '',
});
},
handleDisconnect: function () {
this._cleanup();
this.setData({ phase: 'idle', deviceId: '', batteryLevel: null, error: '' });
},
handleBack: function () {
var results = this.data.results;
if (Object.keys(results).length > 0) {
try {
// eslint-disable-next-line no-undef
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
} catch (_) { /* ignore */ }
}
if (this._eventChannel) {
this._eventChannel.emit('measureComplete', { results: results, count: Object.keys(results).length });
}
// eslint-disable-next-line no-undef
wx.navigateBack({ delta: 1 });
},
handleResetResult: function () {
var type = this.data.selectedType;
var newResults = Object.assign({}, this.data.results);
delete newResults[type];
this.setData({
measurePhase: 'idle',
measureProgress: 0,
measureDisplayValue: '',
measureError: '',
results: newResults,
hasResults: Object.keys(newResults).length > 0,
});
},
// ── 测量事件处理 ──
_handleMeasureEvent: function (type, data) {
// 自动测量模式下,路由到自动测量处理器
if (this.data.autoMeasuring) {
this._handleAutoMeasureEvent(type, data);
return;
}
// 手动测量模式
if (this.data.selectedType !== type || this.data.measurePhase !== 'measuring') return;
var content = data.content || {};
var self = this;
if (content.deviceBusy === true) { self._onMeasureError('设备正忙,请稍后重试'); return; }
if (content.notWear === true || data.state === 6) { self._onMeasureError('请将手环佩戴到手腕上'); return; }
if (data.state === 7) { self._onMeasureError('设备正在充电'); return; }
if (data.state === 8) { self._onMeasureError('设备电量不足'); return; }
if (type === 'pressure' && data.ack === 2) { self._onMeasureError('设备电量不足'); return; }
if (type === 'pressure' && data.ack === 3) { self._onMeasureError('设备正在测量其他数据'); return; }
if (type === 'pressure' && data.ack === 4) { self._onMeasureError('佩戴检测未通过'); return; }
var values = self._extractValues(type, content);
if (!values) return;
self._lastValues = values;
var displayVal = self._formatValues(type, values);
var progress = data.Progress !== undefined ? data.Progress : 0;
self.setData({
measureDisplayValue: displayVal,
measureProgress: Math.max(progress, 0),
});
if (progress >= 100) {
self._onMeasureSuccess(type, values);
return;
}
if (!self._settleTimer) {
self._settleTimer = setTimeout(function () {
if (self._lastValues && self.data.measurePhase === 'measuring') {
self._onMeasureSuccess(type, self._lastValues);
}
}, MEASURE_SETTLE_DELAY);
}
},
_extractValues: function (type, content) {
switch (type) {
case 'heart_rate': {
var hr = Number(content.heartRate);
return (hr >= 30 && hr <= 250) ? { heart_rate: hr } : null;
}
case 'blood_oxygen': {
var bo = Number(content.bloodOxygen);
return (bo >= 70 && bo <= 100) ? { blood_oxygen: bo } : null;
}
case 'blood_pressure': {
var high = Number(content.bloodPressureHigh);
var low = Number(content.bloodPressureLow);
return (high > 0 && low > 0) ? { systolic: high, diastolic: low } : null;
}
case 'temperature': {
var temp = Number(content.bodyTemperature);
return (temp > 30 && temp < 45) ? { temperature: temp } : null;
}
case 'pressure': {
var p = Number(content.pressure);
return (p >= 0 && p <= 100) ? { pressure: p } : null;
}
default: return null;
}
},
_formatValues: function (type, values) {
if (type === 'blood_pressure') {
return (values.systolic != null ? values.systolic : '--') + '/' + (values.diastolic != null ? values.diastolic : '--');
}
var v = Object.values(values)[0];
return (v !== undefined && v !== null) ? String(v) : '--';
},
_onMeasureSuccess: function (type, values) {
this._cancelPendingMeasure();
var result = { type: type, values: values, measuredAt: Date.now() };
var newResults = Object.assign({}, this.data.results);
newResults[type] = result;
this.setData({
measurePhase: 'success',
measureProgress: 100,
measureDisplayValue: this._formatValues(type, values),
results: newResults,
hasResults: true,
});
if (this._eventChannel) {
this._eventChannel.emit('measureResult', result);
}
},
_onMeasureError: function (msg) {
this._cancelPendingMeasure();
this.setData({ measurePhase: 'error', measureError: msg });
},
_cancelPendingMeasure: function () {
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
this._lastValues = null;
var type = this.data.selectedType;
if (type) this._sendMeasureCommand(type, false);
},
_sendMeasureCommand: function (type, on) {
switch (type) {
case 'heart_rate':
veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: !!on });
break;
case 'blood_oxygen':
veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: on ? 'start' : 'stop' });
break;
case 'blood_pressure':
veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: on ? 'start' : 'stop' });
break;
case 'temperature':
veepooFeature.veepooSendTemperatureMeasurementSwitchManager({ switch: !!on });
break;
case 'pressure':
veepooFeature.veepooSendPressureTestManager({ switch: !!on });
break;
}
},
// ── 睡眠数据读取 ──
_sleepResults: null,
_sleepDay: 0,
_readSleepData: function () {
this._sleepResults = [];
this._sleepDay = 0;
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 开始读取睡眠数据3天');
// 依次读取 3 天睡眠
var self = this;
veepooFeature.veepooSendReadPreciseSleepManager({ day: 0 });
// 延迟读取后续天(避免并发冲突)
// eslint-disable-next-line no-undef
setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 1 }); }, 3000);
// eslint-disable-next-line no-undef
setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 2 }); }, 6000);
},
_handleSleepEvent: function (data) {
var progress = data.Progress || 0;
if (progress < 100) return;
var content = data.content || {};
var readDay = data.readDay || 0;
var totalTime = Number(content.sleepTotalTime || 0);
if (totalTime <= 0) return;
var sleepResult = {
day: readDay,
deepSleepMinutes: Number(content.deepSleepTime || 0),
lightSleepMinutes: Number(content.lightSleepTime || 0),
totalSleepMinutes: totalTime,
qualityScore: Number(content.sleepQuality || 0),
fallAsleepTime: String(content.fallAsleepTime || ''),
exitSleepTime: String(content.exitSleepTime || ''),
};
if (!this._sleepResults) this._sleepResults = [];
this._sleepResults.push(sleepResult);
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 睡眠数据 day=' + readDay + ' 总时长=' + totalTime + '分钟 质量=' + sleepResult.qualityScore + '星');
// 保存到 Storage 供 Taro 页面读取
try {
// eslint-disable-next-line no-undef
wx.setStorageSync('hms:veepoo_sleep_results', JSON.stringify(this._sleepResults));
} catch (_) { /* ignore */ }
},
// ── 自动测量 ──
_enableAutoMeasurement: function () {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 开启自动测量功能');
// 开启心率自动监测
try {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticHRTest: 'open',
});
} catch (e) {
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 开启心率自动监测失败:', e);
}
// 开启血压自动监测
try {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticBPTest: 'open',
});
} catch (e) {
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 开启血压自动监测失败:', e);
}
// 开启体温自动监测
try {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticTemperatureTest: 'open',
});
} catch (e) {
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 开启体温自动监测失败:', e);
}
},
// ── 自动测量队列 ──
_startAutoMeasureQueue: function () {
var types = [];
var status = {};
for (var i = 0; i < MEASURE_TYPES.length; i++) {
types.push(MEASURE_TYPES[i].type);
status[MEASURE_TYPES[i].type] = 'pending';
}
this._autoQueue = types;
this._autoQueueIndex = 0;
this.setData({
autoMeasuring: true,
autoMeasureDone: false,
autoMeasureStatus: status,
autoMeasureValues: {},
autoMeasureProgress: 0,
});
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 启动自动测量队列,共 ' + types.length + ' 项');
var self = this;
setTimeout(function () {
self._startNextAutoMeasure();
}, 800);
},
_startNextAutoMeasure: function () {
if (!this._autoQueue || this._autoQueueIndex >= this._autoQueue.length) {
this._onAutoMeasureComplete();
return;
}
var type = this._autoQueue[this._autoQueueIndex];
this._updateSelectedDisplay(type);
var status = Object.assign({}, this.data.autoMeasureStatus);
status[type] = 'measuring';
this.setData({
autoMeasureStatus: status,
measurePhase: 'measuring',
measureProgress: 0,
measureDisplayValue: '',
measureError: '',
});
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 自动测量 [' + (this._autoQueueIndex + 1) + '/' + this._autoQueue.length + ']: ' + type);
this._lastValues = null;
this._sendMeasureCommand(type, true);
var self = this;
var timeout = MEASURE_TIMEOUTS[type] || 60000;
this._measureTimer = setTimeout(function () {
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 自动测量超时: ' + type);
self._onAutoMeasureError(type, '测量超时');
}, timeout);
},
_handleAutoMeasureEvent: function (type, data) {
if (!this._autoQueue || this._autoQueueIndex >= this._autoQueue.length) return;
if (type !== this._autoQueue[this._autoQueueIndex]) return;
var content = data.content || {};
var self = this;
if (content.deviceBusy === true) { self._onAutoMeasureError(type, '设备正忙'); return; }
if (content.notWear === true || data.state === 6) { self._onAutoMeasureError(type, '未佩戴'); return; }
if (data.state === 7) { self._onAutoMeasureError(type, '设备充电中'); return; }
if (data.state === 8) { self._onAutoMeasureError(type, '电量不足'); return; }
if (type === 'pressure' && data.ack === 2) { self._onAutoMeasureError(type, '电量不足'); return; }
if (type === 'pressure' && data.ack === 3) { self._onAutoMeasureError(type, '设备正忙'); return; }
if (type === 'pressure' && data.ack === 4) { self._onAutoMeasureError(type, '未佩戴'); return; }
var values = self._extractValues(type, content);
if (!values) return;
self._lastValues = values;
var progress = data.Progress !== undefined ? data.Progress : 0;
self.setData({ measureProgress: Math.max(progress, 0) });
if (progress >= 100) {
self._onAutoMeasureSuccess(type, values);
return;
}
if (!self._settleTimer) {
self._settleTimer = setTimeout(function () {
if (self._lastValues && self.data.autoMeasuring) {
self._onAutoMeasureSuccess(type, self._lastValues);
}
}, MEASURE_SETTLE_DELAY);
}
},
_onAutoMeasureSuccess: function (type, values) {
this._cancelPendingMeasure();
var result = { type: type, values: values, measuredAt: Date.now() };
var newResults = Object.assign({}, this.data.results);
newResults[type] = result;
var status = Object.assign({}, this.data.autoMeasureStatus);
status[type] = 'done';
var newValues = Object.assign({}, this.data.autoMeasureValues);
newValues[type] = this._formatValues(type, values);
var doneCount = 0;
var keys = Object.keys(status);
for (var i = 0; i < keys.length; i++) {
if (status[keys[i]] === 'done' || status[keys[i]] === 'error') doneCount++;
}
var progress = Math.round((doneCount / this._autoQueue.length) * 100);
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 自动测量完成: ' + type + ' = ' + newValues[type] + ' (' + progress + '%)');
this.setData({
results: newResults,
hasResults: true,
autoMeasureStatus: status,
autoMeasureValues: newValues,
autoMeasureProgress: progress,
measurePhase: 'idle',
});
this._autoQueueIndex++;
var self = this;
setTimeout(function () {
self._startNextAutoMeasure();
}, 800);
},
_onAutoMeasureError: function (type, msg) {
this._cancelPendingMeasure();
var status = Object.assign({}, this.data.autoMeasureStatus);
status[type] = 'error';
var doneCount = 0;
var keys = Object.keys(status);
for (var i = 0; i < keys.length; i++) {
if (status[keys[i]] === 'done' || status[keys[i]] === 'error') doneCount++;
}
var progress = Math.round((doneCount / this._autoQueue.length) * 100);
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 自动测量失败: ' + type + ' - ' + msg + ' (' + progress + '%)');
this.setData({
autoMeasureStatus: status,
autoMeasureProgress: progress,
measurePhase: 'idle',
});
this._autoQueueIndex++;
var self = this;
setTimeout(function () {
self._startNextAutoMeasure();
}, 500);
},
_onAutoMeasureComplete: function () {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 自动测量全部完成');
var results = this.data.results;
if (Object.keys(results).length > 0) {
try {
// eslint-disable-next-line no-undef
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
} catch (_) { /* ignore */ }
}
this.setData({
autoMeasureDone: true,
autoMeasuring: false,
autoMeasureProgress: 100,
measurePhase: 'idle',
});
},
handleCancelAutoMeasure: function () {
this._cancelPendingMeasure();
this._autoQueue = null;
var results = this.data.results;
if (Object.keys(results).length > 0) {
try {
// eslint-disable-next-line no-undef
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
} catch (_) { /* ignore */ }
}
this.setData({
autoMeasuring: false,
autoMeasureDone: false,
measurePhase: 'idle',
});
},
});

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "M2 手环测量",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"backgroundColor": "#F5F5F4"
}

View File

@@ -0,0 +1,215 @@
<!--
Veepoo M2 原生小程序页面 — 连接 + 测量
设计原型: docs/design/veepoo-measure-prototype.html
完全匹配 SDK 官方 Demo 流程,不依赖 Taro
-->
<!-- ═══ 未连接 / 错误 / 断开 ═══ -->
<block wx:if="{{phase === 'idle' || phase === 'error' || phase === 'disconnected'}}">
<view class="connect-screen">
<view class="connect-anim">
<view class="connect-ring"></view>
<view class="connect-center">
<text class="connect-bt">BT</text>
</view>
</view>
<text class="connect-title">M2 手环健康测量</text>
<text class="connect-hint">请确保手环已开机且蓝牙已开启</text>
<view wx:if="{{error}}" class="connect-error">
<text class="connect-error-text">{{error}}</text>
</view>
<view class="connect-btn-wrap">
<view class="btn-primary" bindtap="handleConnect">
{{phase === 'error' ? '重新连接' : phase === 'disconnected' ? '重新连接' : '连接 M2 手环'}}
</view>
</view>
<view wx:if="{{hasResults}}" class="connect-back">
<view class="btn-text" bindtap="handleBack">查看测量结果并返回</view>
</view>
</view>
</block>
<!-- ═══ 连接中(扫描/连接/认证) ═══ -->
<block wx:elif="{{phase === 'scanning' || phase === 'connecting' || phase === 'authenticating'}}">
<view class="connect-screen">
<view class="connect-anim">
<view class="connect-ring connect-ring--active"></view>
<view class="connect-center">
<text class="connect-bt">BT</text>
</view>
</view>
<text class="connect-title">
{{phase === 'scanning' ? '正在搜索 M2 手环...' : phase === 'connecting' ? '正在连接...' : '正在认证...'}}
</text>
<text class="connect-hint">请确保手环已开机且靠近手机</text>
</view>
</block>
<!-- ═══ 自动测量中 / 自动测量完成 ═══ -->
<block wx:elif="{{phase === 'ready' && (autoMeasuring || autoMeasureDone)}}">
<view class="measure-page">
<!-- 设备状态栏 -->
<view class="device-bar">
<view class="device-bar__left">
<view class="device-bar__dot"></view>
<text class="device-bar__name">{{deviceName}}</text>
<text wx:if="{{batteryLevel !== null}}" class="device-bar__battery">{{batteryLevel}}%</text>
</view>
<view wx:if="{{autoMeasuring}}" class="device-bar__disconnect" bindtap="handleCancelAutoMeasure">取消</view>
</view>
<view class="auto-measure">
<!-- 标题 -->
<view class="auto-measure__header">
<text class="auto-measure__title">{{autoMeasureDone ? '✓ 测量完成!' : '正在自动测量...'}}</text>
<text wx:if="{{!autoMeasureDone}}" class="auto-measure__subtitle">请保持手环佩戴,无需任何操作</text>
</view>
<!-- 指标列表 -->
<view class="auto-measure__list">
<view
wx:for="{{measureTypes}}"
wx:key="type"
class="auto-item {{autoMeasureStatus[item.type] === 'done' ? 'auto-item--done' : autoMeasureStatus[item.type] === 'measuring' ? 'auto-item--active' : autoMeasureStatus[item.type] === 'error' ? 'auto-item--error' : ''}}"
>
<view class="auto-item__left">
<view class="auto-item__icon-wrap" style="background: {{autoMeasureStatus[item.type] === 'done' ? item.color : autoMeasureStatus[item.type] === 'error' ? '#ccc' : item.color}}">
<text class="auto-item__icon">{{autoMeasureStatus[item.type] === 'done' ? '✓' : autoMeasureStatus[item.type] === 'error' ? '✕' : item.icon}}</text>
</view>
<text class="auto-item__label">{{item.label}}</text>
</view>
<view class="auto-item__right">
<block wx:if="{{autoMeasureStatus[item.type] === 'done'}}">
<text class="auto-item__value" style="color: {{item.color}}">{{autoMeasureValues[item.type]}}</text>
<text wx:if="{{item.unit}}" class="auto-item__unit">{{item.unit}}</text>
</block>
<text wx:elif="{{autoMeasureStatus[item.type] === 'measuring'}}" class="auto-item__status auto-item__status--active">测量中...</text>
<text wx:elif="{{autoMeasureStatus[item.type] === 'error'}}" class="auto-item__status auto-item__status--error">已跳过</text>
<text wx:else class="auto-item__status">等待中</text>
</view>
</view>
</view>
<!-- 进度条 -->
<view class="auto-progress">
<view class="auto-progress__bar">
<view class="auto-progress__fill" style="width: {{autoMeasureProgress}}%"></view>
</view>
<text class="auto-progress__text">{{autoMeasureProgress}}%</text>
</view>
<!-- 免责声明 -->
<view class="disclaimer">
<text class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</text>
</view>
<!-- 操作按钮 -->
<view class="actions">
<block wx:if="{{autoMeasureDone}}">
<view class="btn btn--primary" bindtap="handleBack">查看结果并返回</view>
</block>
<block wx:elif="{{autoMeasuring}}">
<view class="btn btn--text" bindtap="handleCancelAutoMeasure">取消自动测量</view>
</block>
</view>
</view>
</view>
</block>
<!-- ═══ 就绪 + 手动测量 ═══ -->
<block wx:elif="{{phase === 'ready'}}">
<view class="measure-page">
<!-- 设备状态栏 -->
<view class="device-bar">
<view class="device-bar__left">
<view class="device-bar__dot"></view>
<text class="device-bar__name">{{deviceName}}</text>
<text wx:if="{{batteryLevel !== null}}" class="device-bar__battery">{{batteryLevel}}%</text>
</view>
<view class="device-bar__disconnect" bindtap="handleDisconnect">断开</view>
</view>
<!-- 指标选择器 — 药丸式 -->
<scroll-view class="selector" scroll-x enhanced show-scrollbar="{{false}}">
<view
wx:for="{{measureTypes}}"
wx:key="type"
class="selector__pill {{selectedType === item.type ? 'selector__pill--active' : ''}} {{results[item.type] ? 'selector__pill--done' : ''}}"
data-type="{{item.type}}"
bindtap="handleSelectType"
>
<view class="selector__icon-wrap" style="background: {{item.color}}">
<text class="selector__icon">{{item.icon}}</text>
</view>
<text class="selector__label">{{item.label}}</text>
</view>
</scroll-view>
<!-- 仪表盘区域 -->
<view class="gauge-section">
<view class="gauge {{measurePhase === 'measuring' ? 'gauge--measuring' : ''}}">
<!-- SVG 圆环 -->
<view class="gauge__ring-wrap">
<view class="gauge__ring-bg"></view>
<view class="gauge__ring-progress" style="background: conic-gradient({{selectedColor}} {{measureProgress * 3.6}}deg, #E8E2DC 0deg);"></view>
<view class="gauge__center">
<!-- 空闲 -->
<block wx:if="{{measurePhase === 'idle'}}">
<text class="gauge__icon-lg" style="color: {{selectedColor}}">{{selectedIcon}}</text>
<text class="gauge__hint">点击下方按钮开始</text>
</block>
<!-- 测量中 -->
<block wx:elif="{{measurePhase === 'measuring'}}">
<text wx:if="{{measureDisplayValue}}" class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
<text wx:else class="gauge__loading">测量中...</text>
<text wx:if="{{measureDisplayValue}}" class="gauge__unit">{{selectedUnit}}</text>
</block>
<!-- 成功 -->
<block wx:elif="{{measurePhase === 'success'}}">
<text class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
<text class="gauge__unit">{{selectedUnit}}</text>
</block>
<!-- 错误 -->
<block wx:elif="{{measurePhase === 'error'}}">
<text class="gauge__err">!</text>
<text class="gauge__err-text">{{measureError}}</text>
</block>
</view>
</view>
</view>
<!-- 进度条 -->
<view wx:if="{{measurePhase === 'measuring' && measureProgress > 0}}" class="progress-bar">
<view class="progress-bar__fill" style="width: {{measureProgress}}%; background: {{selectedColor}};"></view>
</view>
</view>
<!-- 免责声明 -->
<view class="disclaimer">
<text class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</text>
</view>
<!-- 操作按钮 -->
<view class="actions">
<block wx:if="{{measurePhase === 'idle'}}">
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">
开始测量{{selectedLabel}}
</view>
</block>
<block wx:elif="{{measurePhase === 'measuring'}}">
<view class="btn btn--secondary" bindtap="handleCancelMeasure">停止测量</view>
<view class="btn btn--text" bindtap="handleBack">完成并查看结果</view>
</block>
<block wx:elif="{{measurePhase === 'success'}}">
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleResetResult">重新测量</view>
<view class="btn btn--secondary" bindtap="handleBack">完成并查看结果</view>
</block>
<block wx:elif="{{measurePhase === 'error'}}">
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">重新测量</view>
</block>
</view>
</view>
</block>

View File

@@ -0,0 +1,619 @@
/**
* Veepoo M2 原生页面样式
* 设计原型: docs/design/veepoo-measure-prototype.html
* 复刻小程序 design token
*/
page {
--pri: #C4623A;
--pri-l: #F0DDD4;
--bg: #F5F0EB;
--card: #FFFFFF;
--tx: #2D2A26;
--tx2: #5A554F;
--tx3: #78716C;
--bd: #E8E2DC;
--acc: #5B7A5E;
--acc-l: #E8F0E8;
--dan: #B54A4A;
--dan-l: #FDEAEA;
background: var(--bg);
min-height: 100vh;
}
/* ═══════════════════════════════════════
连接页面(未连接/连接中/错误)
═══════════════════════════════════════ */
.connect-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 0 40px;
}
.connect-anim {
position: relative;
width: 120px;
height: 120px;
margin-bottom: 28px;
}
.connect-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid var(--pri);
animation: pulse-ring 2s ease-out infinite;
}
.connect-ring--active {
border-color: var(--pri);
animation: pulse-ring 1.5s ease-out infinite;
}
.connect-center {
position: absolute;
inset: 20px;
border-radius: 50%;
background: var(--pri);
display: flex;
align-items: center;
justify-content: center;
}
.connect-bt {
color: #fff;
font-size: 20px;
font-weight: 700;
}
.connect-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 22px;
font-weight: 700;
color: var(--tx);
margin-bottom: 8px;
line-height: 1.3;
}
.connect-hint {
font-size: 14px;
color: var(--tx3);
margin-bottom: 32px;
text-align: center;
}
.connect-error {
margin-bottom: 16px;
text-align: center;
}
.connect-error-text {
font-size: 14px;
color: var(--dan);
}
.connect-btn-wrap {
width: 200px;
}
.connect-back {
margin-top: 16px;
}
@keyframes pulse-ring {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.4); opacity: 0; }
}
/* ═══════════════════════════════════════
测量页面(就绪态)
═══════════════════════════════════════ */
/* ═══ 自动测量进度 ═══ */
.auto-measure {
padding: 24px 20px 40px;
}
.auto-measure__header {
text-align: center;
margin-bottom: 28px;
}
.auto-measure__title {
display: block;
font-family: Georgia, 'Times New Roman', serif;
font-size: 24px;
font-weight: 700;
color: var(--tx);
margin-bottom: 8px;
}
.auto-measure__subtitle {
display: block;
font-size: 14px;
color: var(--tx3);
}
.auto-measure__list {
background: var(--card);
border-radius: 16px;
box-shadow: 0 2px 12px rgba(45,42,38,0.06);
overflow: hidden;
}
.auto-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--bd);
transition: background 200ms ease;
}
.auto-item:last-child {
border-bottom: none;
}
.auto-item--active {
background: rgba(196,98,58,0.04);
}
.auto-item--done {
background: rgba(91,122,94,0.04);
}
.auto-item--error {
opacity: 0.6;
}
.auto-item__left {
display: flex;
align-items: center;
gap: 14px;
}
.auto-item__icon-wrap {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.auto-item--done .auto-item__icon-wrap {
background: var(--acc) !important;
}
.auto-item--error .auto-item__icon-wrap {
background: var(--tx3) !important;
}
.auto-item__icon {
font-size: 18px;
color: #fff;
font-weight: 700;
}
.auto-item__label {
font-size: 16px;
font-weight: 600;
color: var(--tx);
}
.auto-item__right {
display: flex;
align-items: baseline;
gap: 4px;
}
.auto-item__value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 22px;
font-weight: 700;
}
.auto-item__unit {
font-size: 13px;
color: var(--tx3);
}
.auto-item__status {
font-size: 14px;
color: var(--tx3);
}
.auto-item__status--active {
color: var(--pri);
font-weight: 600;
}
.auto-item__status--error {
color: var(--tx3);
}
/* ── 自动测量进度条 ── */
.auto-progress {
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
padding: 0 4px;
}
.auto-progress__bar {
flex: 1;
height: 6px;
background: var(--bd);
border-radius: 3px;
overflow: hidden;
}
.auto-progress__fill {
height: 100%;
background: var(--pri);
border-radius: 3px;
transition: width 0.5s ease-out;
}
.auto-progress__text {
font-size: 14px;
font-weight: 600;
color: var(--pri);
min-width: 36px;
text-align: right;
}
.measure-page {
min-height: 100vh;
padding-bottom: 40px;
}
/* ── 设备状态栏 ── */
.device-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
background: var(--card);
border-bottom: 1px solid var(--bd);
}
.device-bar__left {
display: flex;
align-items: center;
gap: 8px;
}
.device-bar__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--acc);
}
.device-bar__name {
font-size: 16px;
font-weight: 600;
color: var(--tx);
}
.device-bar__battery {
font-size: 13px;
color: var(--tx3);
margin-left: 4px;
}
.device-bar__disconnect {
font-size: 13px;
color: var(--tx3);
padding: 6px 12px;
background: transparent;
border: 1px solid var(--bd);
border-radius: 999px;
}
/* ── 指标选择器(药丸式) ── */
.selector {
display: flex;
white-space: nowrap;
padding: 16px 20px;
gap: 8px;
}
.selector__pill {
flex-shrink: 0;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 14px;
border-radius: 16px;
position: relative;
min-width: 64px;
transition: all 200ms ease;
}
.selector__pill--active {
background: var(--card);
box-shadow: 0 2px 12px rgba(45,42,38,0.10);
}
.selector__pill--done::after {
content: '✓';
position: absolute;
top: 4px;
right: 6px;
font-size: 10px;
color: #fff;
background: var(--acc);
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.selector__icon-wrap {
width: 36px;
height: 36px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 200ms ease;
}
.selector__pill--active .selector__icon-wrap {
transform: scale(1.15);
}
.selector__icon {
font-size: 18px;
color: #fff;
}
.selector__label {
font-size: 13px;
color: var(--tx3);
}
.selector__pill--active .selector__label {
color: var(--tx);
font-weight: 600;
}
/* ── 仪表盘 ── */
.gauge-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0 24px;
}
.gauge {
position: relative;
}
.gauge--measuring {
animation: gauge-breathe 2s ease-in-out infinite;
}
@keyframes gauge-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
.gauge__ring-wrap {
position: relative;
width: 220px;
height: 220px;
}
.gauge__ring-bg {
position: absolute;
inset: 0;
border-radius: 50%;
border: 10px solid var(--bd);
box-sizing: border-box;
}
.gauge__ring-progress {
position: absolute;
inset: 0;
border-radius: 50%;
}
.gauge__center {
position: absolute;
inset: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg);
border-radius: 50%;
}
.gauge__icon-lg {
font-size: 40px;
margin-bottom: 8px;
}
.gauge__hint {
font-size: 13px;
color: var(--tx3);
text-align: center;
}
.gauge__value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 52px;
font-weight: 700;
line-height: 1;
}
.gauge__unit {
font-size: 14px;
color: var(--tx3);
margin-top: 4px;
}
.gauge__loading {
font-size: 16px;
color: var(--tx2);
}
.gauge__err {
font-size: 36px;
color: var(--dan);
font-weight: 700;
}
.gauge__err-text {
font-size: 13px;
color: var(--tx2);
text-align: center;
}
/* ── 进度条 ── */
.progress-bar {
width: 240px;
height: 4px;
background: var(--bd);
border-radius: 2px;
margin-top: 16px;
overflow: hidden;
}
.progress-bar__fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease-out;
}
/* ── 免责声明 ── */
.disclaimer {
text-align: center;
padding: 0 20px;
margin-bottom: 16px;
}
.disclaimer__text {
font-size: 11px;
color: var(--tx3);
line-height: 1.5;
}
/* ── 操作按钮 ── */
.actions {
padding: 0 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.btn {
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
text-align: center;
transition: opacity 150ms;
}
.btn:active {
opacity: 0.85;
}
.btn--primary {
background: var(--pri);
color: #fff;
box-shadow: 0 4px 16px rgba(196,98,58,0.3);
}
.btn--secondary {
background: var(--card);
color: var(--tx);
border: 1px solid var(--bd);
}
.btn--text {
background: transparent;
color: var(--tx3);
height: 44px;
font-size: 14px;
}
/* ═══ 旧版兼容样式 ═══ */
.btn-primary {
background: var(--pri);
color: #fff;
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
box-shadow: 0 4px 16px rgba(196,98,58,0.3);
}
.btn-primary:active { opacity: 0.85; }
.btn-secondary {
background: var(--card);
color: var(--tx);
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
border: 1px solid var(--bd);
}
.btn-secondary:active { opacity: 0.85; }
.btn-text {
background: transparent;
color: var(--tx3);
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.btn-large { margin: 0; }
/* 旧版 header/selector/gauge 兼容 */
.header { display: none; }
.header-device { display: none; }
.header-dot { display: none; }
.header-name { display: none; }
.header-battery { display: none; }
.header-disconnect { display: none; }
.selector-item { display: none; }
.selector-icon { display: none; }
.selector-label { display: none; }
.selector-check { display: none; }
.gauge-circle { display: none; }
.gauge-icon { display: none; }
.gauge-hint { display: none; }
.gauge-value { display: none; }
.gauge-loading { display: none; }
.gauge-err { display: none; }
.gauge-err-text { display: none; }
.gauge-progress-bar { display: none; }
.gauge-progress-fill { display: none; }
.assessment { display: none; }
.assessment-text { display: none; }
.disclaimer-text { display: none; }
.measure-error { display: none; }
.measure-error-text { display: none; }

View File

@@ -34,6 +34,7 @@
"@tarojs/runtime": "4.2.0", "@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0", "@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0", "@tarojs/taro": "4.2.0",
"mp-html": "^2.5.2",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"zustand": "^5.0.0" "zustand": "^5.0.0"

View File

@@ -38,6 +38,9 @@ importers:
'@tarojs/taro': '@tarojs/taro':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)) version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
mp-html:
specifier: ^2.5.2
version: 2.5.2
react: react:
specifier: ^18.3.0 specifier: ^18.3.0
version: 18.3.1 version: 18.3.1
@@ -4435,6 +4438,9 @@ packages:
mobile-detect@1.4.5: mobile-detect@1.4.5:
resolution: {integrity: sha512-yc0LhH6tItlvfLBugVUEtgawwFU2sIe+cSdmRJJCTMZ5GEJyLxNyC/NIOAOGk67Fa8GNpOttO3Xz/1bHpXFD/g==} resolution: {integrity: sha512-yc0LhH6tItlvfLBugVUEtgawwFU2sIe+cSdmRJJCTMZ5GEJyLxNyC/NIOAOGk67Fa8GNpOttO3Xz/1bHpXFD/g==}
mp-html@2.5.2:
resolution: {integrity: sha512-45e8c32Qgux4YU4iC3qCSFsOh3y+RwPwZ+iz/vvLkDgSGWk+1zsL4WUzWWQc9w3AsAfkaD/QR0oIufIDngBmXA==}
ms@2.0.0: ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@@ -11011,6 +11017,8 @@ snapshots:
mobile-detect@1.4.5: {} mobile-detect@1.4.5: {}
mp-html@2.5.2: {}
ms@2.0.0: {} ms@2.0.0: {}
ms@2.1.3: {} ms@2.1.3: {}

View File

@@ -14,7 +14,8 @@
"minifyWXML": true, "minifyWXML": true,
"packNpmManually": false, "packNpmManually": false,
"packNpmRelationList": [], "packNpmRelationList": [],
"ignoreUploadUnusedFiles": true "ignoreUploadUnusedFiles": true,
"skylineRenderEnable": false
}, },
"condition": {} "condition": {}
} }

View File

@@ -1,5 +1,4 @@
export default defineAppConfig({ export default defineAppConfig({
// 仅生产构建启用dev 模式下 lazyCodeLoading 导致 DevTools / 真机调试卡死
...(process.env.NODE_ENV === 'production' ? { lazyCodeLoading: 'requiredComponents' as const } : {}), ...(process.env.NODE_ENV === 'production' ? { lazyCodeLoading: 'requiredComponents' as const } : {}),
pages: [ pages: [
'pages/index/index', 'pages/index/index',

View File

@@ -0,0 +1,54 @@
import { memo, useMemo } from 'react';
import { View } from '@tarojs/components';
import { sanitizeHtml } from '@/utils/sanitize-html';
interface RichArticleProps {
html: string;
className?: string;
}
const TAG_STYLE = JSON.stringify({
h1: 'font-size:20px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
h2: 'font-size:18px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
h3: 'font-size:16px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
h4: 'font-size:15px;font-weight:600;color:#2D2A26;margin:12px 0 6px',
p: 'font-size:16px;color:#2D2A26;line-height:1.85;margin-bottom:12px',
ul: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
ol: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
li: 'margin-bottom:4px',
blockquote: 'border-left:3px solid #C4623A;padding:6px 12px;color:#5A554F;margin:12px 0',
strong: 'font-weight:700;color:#2D2A26',
em: 'font-style:italic',
code: 'background:#F5F0EB;padding:2px 6px;border-radius:4px;font-size:14px;color:#C4623A',
pre: 'background:#F5F0EB;padding:12px;border-radius:8px;margin:14px 0;overflow-x:auto',
table: 'width:100%;border-collapse:collapse;margin:8px 0;font-size:14px',
th: 'border:1px solid #E8E2DC;padding:6px 8px;background:#FAF8F5;font-weight:600;text-align:left',
td: 'border:1px solid #E8E2DC;padding:6px 8px',
hr: 'border:none;border-top:1px dashed #D1D5DB;margin:14px 0',
img: 'max-width:100%;border-radius:8px;margin:8px 0;display:block',
a: 'color:#C4623A;text-decoration:none',
});
function prepareHtml(raw: string): string {
return sanitizeHtml(raw);
}
function RichArticle({ html, className }: RichArticleProps) {
const content = useMemo(() => prepareHtml(html), [html]);
if (!content) return null;
return (
<View className={className}>
<mp-html
content={content}
lazy-load
selectable
container-style="font-size:16px;color:#5A554F;line-height:1.8;word-break:break-word"
tag-style={TAG_STYLE}
/>
</View>
);
}
export default memo(RichArticle);

View File

@@ -27,7 +27,9 @@ export default function DoctorTabBar({ active }: DoctorTabBarProps) {
const handleTab = (tab: TabItem) => { const handleTab = (tab: TabItem) => {
if (tab.key === activeKey) return; if (tab.key === activeKey) return;
Taro.reLaunch({ url: tab.url }); Taro.reLaunch({ url: tab.url }).catch(() => {
Taro.redirectTo({ url: tab.url }).catch(() => {});
});
}; };
return ( return (

View File

@@ -0,0 +1,8 @@
"use strict";function e(t){"@babel/helpers - typeof";return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(t)}function t(e,t,o){return(t=n(t))in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e}function n(t){var n=o(t,"string");return"symbol"==e(n)?n:n+""}function o(t,n){if("object"!=e(t)||!t)return t;var o=t[Symbol.toPrimitive];if(void 0!==o){var i=o.call(t,n||"default");if("object"!=e(i))return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===n?String:Number)(t)}/*!
* mp-html v2.5.2
* https://github.com/jin-yufeng/mp-html
*
* Released under the MIT license
* Author: Jin Yufeng
*/
var i=require("./parser"),r=[];Component({data:{nodes:[]},properties:{containerStyle:String,content:{type:String,value:"",observer:function(e){this.setContent(e)}},copyLink:{type:Boolean,value:!0},domain:String,errorImg:String,lazyLoad:Boolean,loadingImg:String,pauseVideo:{type:Boolean,value:!0},previewImg:{type:null,value:!0},scrollTable:Boolean,selectable:null,setTitle:{type:Boolean,value:!0},showImgMenu:{type:Boolean,value:!0},tagStyle:Object,useAnchor:null},created:function(){this.plugins=[];for(var e=r.length;e--;)this.plugins.push(new r[e](this))},detached:function(){this._hook("onDetached")},methods:{in:function(e,t,n){e&&t&&n&&(this._in={page:e,selector:t,scrollTop:n})},navigateTo:function(e,n){var o=this;return new Promise(function(i,r){if(!o.data.useAnchor)return void r(Error("Anchor is disabled"));var a=wx.createSelectorQuery().in(o._in?o._in.page:o).select((o._in?o._in.selector:"._root")+(e?"".concat(">>>","#").concat(e):"")).boundingClientRect();o._in?a.select(o._in.selector).scrollOffset().select(o._in.selector).boundingClientRect():a.selectViewport().scrollOffset(),a.exec(function(e){if(!e[0])return void r(Error("Label not found"));var a=e[1].scrollTop+e[0].top-(e[2]?e[2].top:0)+(n||parseInt(o.data.useAnchor)||0);o._in?o._in.page.setData(t({},o._in.scrollTop,a)):wx.pageScrollTo({scrollTop:a,duration:300}),i()})})},getText:function(e){var t="";return function e(n){for(var o=0;o<n.length;o++){var i=n[o];if("text"===i.type)t+=i.text.replace(/&amp;/g,"&");else if("br"===i.name)t+="\n";else{var r="p"===i.name||"div"===i.name||"tr"===i.name||"li"===i.name||"h"===i.name[0]&&i.name[1]>"0"&&i.name[1]<"7";r&&t&&"\n"!==t[t.length-1]&&(t+="\n"),i.children&&e(i.children),r&&"\n"!==t[t.length-1]?t+="\n":"td"!==i.name&&"th"!==i.name||(t+="\t")}}}(e||this.data.nodes),t},getRect:function(){var e=this;return new Promise(function(t,n){wx.createSelectorQuery().in(e).select("._root").boundingClientRect().exec(function(e){return e[0]?t(e[0]):n(Error("Root label not found"))})})},pauseMedia:function(){for(var e=(this._videos||[]).length;e--;)this._videos[e].pause()},setPlaybackRate:function(e){this.playbackRate=e;for(var t=(this._videos||[]).length;t--;)this._videos[t].playbackRate(e)},setContent:function(e,t){var n=this;this.imgList&&t||(this.imgList=[]),this._videos=[];var o={},r=new i(this).parse(e);if(t)for(var a=this.data.nodes.length,s=r.length;s--;)o["nodes[".concat(a+s,"]")]=r[s];else o.nodes=r;if(this.setData(o,function(){n._hook("onLoad"),n.triggerEvent("load")}),this.data.lazyLoad||this.imgList._unloadimgs<this.imgList.length/2){var l=0,c=function(e){e&&e.height||(e={}),e.height===l?n.triggerEvent("ready",e):(l=e.height,setTimeout(function(){n.getRect().then(c).catch(c)},350))};this.getRect().then(c).catch(c)}else this.imgList._unloadimgs||this.getRect().then(function(e){n.triggerEvent("ready",e)}).catch(function(){n.triggerEvent("ready",{})})},_hook:function(e){for(var t=r.length;t--;)this.plugins[t][e]&&this.plugins[t][e]()},_add:function(e){e.detail.root=this}}});

View File

@@ -0,0 +1 @@
{"component":true,"usingComponents":{"node":"./node/node"}}

View File

@@ -0,0 +1 @@
<view class="_root {{selectable?'_select':''}}" style="{{containerStyle}}"><slot wx:if="{{!nodes[0]}}"/><node id="_root" childs="{{nodes}}" opts="{{[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]}}" catchadd="_add"/></view>

View File

@@ -0,0 +1 @@
._root{padding:1px 0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}._select{-webkit-user-select:text;user-select:text}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"component":true,"usingComponents":{"node":"./node"}}

View File

@@ -0,0 +1 @@
<wxs module="isInline">var e={abbr:!0,b:!0,big:!0,code:!0,del:!0,em:!0,i:!0,ins:!0,label:!0,q:!0,small:!0,span:!0,strong:!0,sub:!0,sup:!0};module.exports=function(n,i){return e[n]||-1!==(i||"").indexOf("inline")};</wxs><template name="el"><block wx:if="{{n.name==='img'}}"><rich-text wx:if="{{n.t}}" style="display:{{n.t}}" nodes="<img class='_img' style='{{n.attrs.style}}' src='{{n.attrs.src}}'>" data-i="{{i}}" catchtap="imgTap"/><block wx:else><image wx:if="{{(opts[1]&&!ctrl[i])||ctrl[i]<0}}" class="_img" style="{{n.attrs.style}}" src="{{ctrl[i]<0?opts[2]:opts[1]}}" mode="widthFix"/><image id="{{n.attrs.id}}" class="_img {{n.attrs.class}}" style="{{ctrl[i]===-1?'display:none;':''}}width:{{ctrl[i]||1}}px;height:1px;{{n.attrs.style}}" src="{{n.attrs.src}}" mode="{{!n.h?'widthFix':(!n.w?'heightFix':(n.m||'scaleToFill'))}}" lazy-load="{{opts[0]}}" webp="{{n.webp}}" show-menu-by-longpress="{{opts[3]&&!n.attrs.ignore}}" data-i="{{i}}" bindload="imgLoad" binderror="mediaError" catchtap="imgTap" bindlongpress="noop"/></block></block><text wx:elif="{{n.text}}" user-select="{{opts[4]=='force'&&isiOS}}" decode>{{n.text}}</text><text wx:elif="{{n.name==='br'}}">{{'\n'}}</text><view wx:elif="{{n.name==='a'}}" id="{{n.attrs.id}}" class="{{n.attrs.href?'_a ':''}}{{n.attrs.class}}" hover-class="_hover" style="display:inline;{{n.attrs.style}}" data-i="{{i}}" catchtap="linkTap"><node childs="{{n.children}}" opts="{{opts}}" style="display:inherit"/></view><video wx:elif="{{n.name==='video'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" autoplay="{{n.attrs.autoplay}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" muted="{{n.attrs.muted}}" object-fit="{{n.attrs['object-fit']}}" poster="{{n.attrs.poster}}" src="{{n.src[ctrl[i]||0]}}" data-i="{{i}}" bindplay="play" bindpause="mediaEvent" bindfullscreenchange="mediaEvent" binderror="mediaError"/><audio wx:elif="{{n.name==='audio'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" author="{{n.attrs.author}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" name="{{n.attrs.name}}" poster="{{n.attrs.poster}}" src="{{n.src[ctrl[i]||0]}}" data-i="{{i}}" bindplay="play" bindpause="mediaEvent" binderror="mediaError"/><rich-text wx:else id="{{n.attrs.id}}" style="{{n.f}}" user-select="{{opts[4]}}" nodes="{{[n]}}"/></template><block wx:for="{{nodes}}" wx:for-item="n1" wx:for-index="i1" wx:key="i1"><template wx:if="{{!n1.c&&(!n1.children||n1.name==='a'||!isInline(n1.name,n1.attrs.style))}}" is="el" data="{{n:n1,i:''+i1,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n1.attrs.id}}" class="_{{n1.name}} {{n1.attrs.class}}" style="{{n1.attrs.style}}"><block wx:for="{{n1.children}}" wx:for-item="n2" wx:for-index="i2" wx:key="i2"><template wx:if="{{!n2.c&&(!n2.children||n2.name==='a'||!isInline(n2.name,n2.attrs.style))}}" is="el" data="{{n:n2,i:i1+'_'+i2,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n2.attrs.id}}" class="_{{n2.name}} {{n2.attrs.class}}" style="{{n2.attrs.style}}"><block wx:for="{{n2.children}}" wx:for-item="n3" wx:for-index="i3" wx:key="i3"><template wx:if="{{!n3.c&&(!n3.children||n3.name==='a'||!isInline(n3.name,n3.attrs.style))}}" is="el" data="{{n:n3,i:i1+'_'+i2+'_'+i3,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n3.attrs.id}}" class="_{{n3.name}} {{n3.attrs.class}}" style="{{n3.attrs.style}}"><block wx:for="{{n3.children}}" wx:for-item="n4" wx:for-index="i4" wx:key="i4"><template wx:if="{{!n4.c&&(!n4.children||n4.name==='a'||!isInline(n4.name,n4.attrs.style))}}" is="el" data="{{n:n4,i:i1+'_'+i2+'_'+i3+'_'+i4,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n4.attrs.id}}" class="_{{n4.name}} {{n4.attrs.class}}" style="{{n4.attrs.style}}"><block wx:for="{{n4.children}}" wx:for-item="n5" wx:for-index="i5" wx:key="i5"><template wx:if="{{!n5.c&&(!n5.children||n5.name==='a'||!isInline(n5.name,n5.attrs.style))}}" is="el" data="{{n:n5,i:i1+'_'+i2+'_'+i3+'_'+i4+'_'+i5,opts:opts,ctrl:ctrl}}"/><node wx:else id="{{n5.attrs.id}}" class="_{{n5.name}} {{n5.attrs.class}}" style="{{n5.attrs.style}}" childs="{{n5.children}}" opts="{{opts}}"/></block></view></block></view></block></view></block></view></block>

View File

@@ -0,0 +1 @@
._a{padding:1.5px 0 1.5px 0;color:#366092;word-break:break-all}._hover{text-decoration:underline;opacity:.7}._img{max-width:100%;-webkit-touch-callout:none}._b,._strong{font-weight:700}._code{font-family:monospace}._del{text-decoration:line-through}._em,._i{font-style:italic}._h1{font-size:2em}._h2{font-size:1.5em}._h3{font-size:1.17em}._h5{font-size:.83em}._h6{font-size:.67em}._h1,._h2,._h3,._h4,._h5,._h6{display:block;font-weight:700}._ins{text-decoration:underline}._li{display:list-item}._ol{list-style-type:decimal}._ol,._ul{display:block;padding-left:40px;margin:1em 0}._q::before{content:'"'}._q::after{content:'"'}._sub{font-size:smaller;vertical-align:sub}._sup{font-size:smaller;vertical-align:super}._tbody,._tfoot,._thead{display:table-row-group}._tr{display:table-row}._td,._th{display:table-cell;vertical-align:middle}._th{font-weight:700;text-align:center}._ul{list-style-type:disc}._ul ._ul{margin:0;list-style-type:circle}._ul ._ul ._ul{list-style-type:square}._abbr,._b,._code,._del,._em,._i,._ins,._label,._q,._span,._strong,._sub,._sup{display:inline}._blockquote,._div,._p{display:block}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '文章详情',
usingComponents: {
'mp-html': '../../../native-components/mp-html/index',
},
});

View File

@@ -1,19 +1,21 @@
@import '../../../styles/variables.scss'; @import '../../../styles/variables.scss';
// 文章详情页 — 对齐原型 docs/design/mp-04-article-report.html → ArticleDetail // 文章详情页 — 阅读优化排版
.article-detail-page { .article-detail-page {
padding-bottom: 80px; padding-bottom: 100px;
background: $card;
} }
.article-title { .article-title {
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-h2); font-size: var(--tk-font-h1);
font-weight: 700; font-weight: 700;
color: $tx; color: $tx;
display: block; display: block;
line-height: 1.4; line-height: 1.35;
margin-bottom: var(--tk-gap-sm); margin-bottom: var(--tk-gap-md);
letter-spacing: 0.5px;
} }
.article-meta { .article-meta {
@@ -21,36 +23,38 @@
gap: var(--tk-gap-md); gap: var(--tk-gap-md);
font-size: var(--tk-font-cap); font-size: var(--tk-font-cap);
color: $tx3; color: $tx3;
margin-bottom: var(--tk-gap-md); margin-bottom: var(--tk-gap-lg);
align-items: center;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.meta-icon {
font-size: 13px;
line-height: 1;
} }
.article-divider { .article-divider {
height: 1px; height: 1px;
background: $bd-l; background: linear-gradient(90deg, $bd-l, $bd, $bd-l);
margin-bottom: var(--tk-section-gap); margin-bottom: var(--tk-section-gap);
} }
.article-body { .article-body {
font-size: 15px; // RichText 内部样式由 formatArticleHtml 内联注入
// 这里只控制容器间距
font-size: var(--tk-font-body);
color: $tx2; color: $tx2;
line-height: 1.8; line-height: 1.8;
// RichText 内部样式 // 兜底:万一内联样式未生效的标签
h1, h2, h3 { h1, h2, h3, h4, h5, h6 {
font-weight: bold;
color: $tx; color: $tx;
margin: var(--tk-gap-lg) 0 var(--tk-gap-sm); line-height: 1.4;
}
p {
margin-bottom: var(--tk-gap-md);
text-indent: 2em;
}
img {
max-width: 100%;
border-radius: $r-sm;
margin: var(--tk-gap-sm) 0;
} }
} }
@@ -59,31 +63,40 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 60px; height: 64px;
background: $card; background: $card;
border-top: 1px solid $bd-l; border-top: 1px solid $bd-l;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 32px; gap: 48px;
padding: 0 var(--tk-page-padding); padding: 0 var(--tk-page-padding);
z-index: 10; z-index: 10;
// 安全区适配
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -2px 12px rgba(45, 42, 38, 0.06);
} }
.article-action-btn { .article-action-btn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 2px; gap: 4px;
min-width: $touch-min;
min-height: $touch-min;
justify-content: center;
border-radius: $r-sm;
transition: background var(--tk-duration-fast);
&:active { &:active {
background: $surface-alt;
opacity: var(--tk-touch-feedback-opacity); opacity: var(--tk-touch-feedback-opacity);
} }
} }
.article-action-icon { .article-action-icon {
font-size: 20px; font-size: 22px;
color: $tx3; color: $tx2;
line-height: 1; line-height: 1;
} }
@@ -105,3 +118,29 @@
font-size: var(--tk-font-body); font-size: var(--tk-font-body);
color: $tx3; color: $tx3;
} }
// ─── 关怀模式覆盖 ───
.elder-mode {
.article-title {
font-size: var(--tk-font-h1);
line-height: 1.3;
}
.article-body {
font-size: var(--tk-font-body);
line-height: 2;
}
.article-bottom-bar {
height: 72px;
gap: 56px;
}
.article-action-icon {
font-size: 26px;
}
.article-action-text {
font-size: var(--tk-font-body-sm);
}
}

View File

@@ -1,13 +1,13 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { View, Text, RichText } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro'; import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData'; import { usePageData } from '@/hooks/usePageData';
import { getArticleDetail, getPublicArticleDetail, Article } from '../../../services/article'; import { getArticleDetail, getPublicArticleDetail, Article } from '../../../services/article';
import { trackEvent } from '@/services/analytics'; import { trackEvent } from '@/services/analytics';
import { sanitizeHtml } from '@/utils/sanitize-html';
import { useElderClass } from '../../../hooks/useElderClass'; import { useElderClass } from '../../../hooks/useElderClass';
import { useAuthStore } from '../../../stores/auth'; import { useAuthStore } from '../../../stores/auth';
import PageShell from '@/components/ui/PageShell'; import PageShell from '@/components/ui/PageShell';
import RichArticle from '@/components/RichArticle';
import './index.scss'; import './index.scss';
export default function ArticleDetail() { export default function ArticleDetail() {
@@ -77,14 +77,24 @@ export default function ArticleDetail() {
<Text className='article-title'>{article.title}</Text> <Text className='article-title'>{article.title}</Text>
<View className='article-meta'> <View className='article-meta'>
{article.author && <Text>{article.author}</Text>} {article.author && (
{article.published_at && <Text>{article.published_at.slice(0, 10)}</Text>} <View className='meta-item'>
<Text className='meta-icon'></Text>
<Text>{article.author}</Text>
</View>
)}
{article.published_at && (
<View className='meta-item'>
<Text className='meta-icon'>📅</Text>
<Text>{article.published_at.slice(0, 10)}</Text>
</View>
)}
</View> </View>
<View className='article-divider' /> <View className='article-divider' />
<View className='article-body'> <View className='article-body'>
<RichText nodes={sanitizeHtml(article.content || '')} /> <RichArticle html={article.content || ''} />
</View> </View>
<View className='article-bottom-bar'> <View className='article-bottom-bar'>

View File

@@ -2,515 +2,131 @@
@import '../../styles/mixins.scss'; @import '../../styles/mixins.scss';
.health-page { .health-page {
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
}
/* ─── 页头 ─── */
.health-header {
margin-bottom: var(--tk-gap-sm);
display: flex;
align-items: baseline;
justify-content: space-between;
}
.health-title {
@include serif-number;
font-size: var(--tk-font-h1);
font-weight: 700;
color: $tx;
letter-spacing: -0.02em;
}
.health-date {
font-size: var(--tk-font-cap);
color: $tx3;
}
/* ─── 今日体征 hero 卡片 ─── */
.vitals-grid {
margin-bottom: var(--tk-section-gap);
background: linear-gradient(135deg, $card 60%, $pri-l);
border-radius: var(--tk-card-radius);
box-shadow: $shadow-md;
padding: var(--tk-card-padding);
/* 覆盖 ContentCard 默认 padding/margin */
&.content-card {
padding: var(--tk-card-padding);
margin-bottom: var(--tk-section-gap);
}
}
.vitals-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--tk-gap-md);
}
.vitals-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx2;
letter-spacing: 0.04em;
}
.vitals-badge {
font-size: var(--tk-font-micro);
color: $acc;
background: $acc-l;
padding: 3px 10px;
border-radius: $r-pill;
font-weight: 500;
}
.vitals-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--tk-gap-sm);
}
.vital-cell {
text-align: center;
padding: var(--tk-gap-md) var(--tk-gap-sm);
border-radius: $r-sm;
background: $bg;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.vital-value {
@include serif-number;
font-size: var(--tk-font-num);
font-weight: 700;
color: $tx;
font-variant-numeric: tabular-nums;
display: block;
line-height: 1.1;
}
.vital-unit {
font-size: var(--tk-font-micro);
color: $tx3;
display: block;
margin-top: 2px;
}
.vital-label {
font-size: var(--tk-font-cap);
color: $tx2;
font-weight: 500;
display: block;
margin-top: 6px;
}
.vital-cell.vital-warn {
background: $wrn-l;
.vital-value {
color: $wrn;
}
}
.vital-cell.vital-ok {
.vital-value {
color: $acc;
}
}
/* ─── 快捷入口 — 横排 4 格图标 ─── */
.quick-entries {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--tk-gap-xs);
margin-bottom: var(--tk-section-gap);
}
.quick-entry {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh;
}
/* ─── 分类标签 ─── */
.health-categories {
white-space: nowrap;
padding: var(--tk-gap-xs) var(--tk-page-padding);
margin-bottom: var(--tk-gap-xs);
flex-shrink: 0;
}
.health-cat-tab {
display: inline-flex;
align-items: center; align-items: center;
gap: var(--tk-gap-xs);
min-height: var(--tk-touch-min);
justify-content: center; justify-content: center;
padding: var(--tk-gap-sm) 0; padding: 8px 18px;
margin-right: 8px;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.quick-icon {
width: 44px;
height: 44px;
border-radius: $r-sm;
@include flex-center;
}
.quick-icon-text {
font-size: 18px;
font-weight: 600;
}
.quick-icon--input {
background: $pri-l;
.quick-icon-text {
color: $pri;
}
}
.quick-icon--trend {
background: $doc-pri-l;
.quick-icon-text {
color: $doc-pri;
}
}
.quick-icon--report {
background: $acc-l;
.quick-icon-text {
color: $acc;
}
}
.quick-icon--med {
background: $wrn-l;
.quick-icon-text {
color: $wrn;
}
}
.quick-label {
font-size: var(--tk-font-cap);
color: $tx2;
font-weight: 500;
}
/* ─── 告警横幅 ─── */
.alert-hint {
display: flex;
align-items: center;
gap: var(--tk-gap-sm);
margin-bottom: var(--tk-section-gap);
background: $dan-l;
border-radius: $r-sm;
/* 覆盖 ContentCard 默认样式 */
&.content-card {
background: $dan-l;
box-shadow: none;
border: none;
}
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.alert-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: $dan;
flex-shrink: 0;
}
.alert-text {
flex: 1;
font-size: var(--tk-font-body-sm); font-size: var(--tk-font-body-sm);
font-weight: 500; font-weight: 400;
color: $dan;
}
.alert-arrow {
font-size: var(--tk-font-body);
color: $dan;
flex-shrink: 0;
opacity: 0.6;
}
/* ─── 趋势图 ─── */
.trend-section {
margin-bottom: var(--tk-gap-sm);
}
.section-title {
@include section-title;
}
.trend-empty {
text-align: center;
}
.trend-empty-text {
font-size: var(--tk-font-cap);
color: $tx2; color: $tx2;
background: $surface-alt;
border-radius: 20px;
transition: all 0.2s;
&--active {
background: var(--tk-pri);
color: $white;
font-weight: 600;
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.2);
}
} }
.trend-chart { /* ─── 可滚动内容区 ─── */
padding: var(--tk-gap-md); .health-scroll {
}
.trend-bars {
display: flex;
align-items: flex-end;
height: 120px;
background: $bg;
border-radius: $r-sm;
padding: var(--tk-gap-sm) var(--tk-gap-xs);
gap: 0;
position: relative;
}
.trend-threshold-line {
position: absolute;
left: 8px;
right: 8px;
border-top: 1.5px dashed $wrn;
opacity: 0.5;
pointer-events: none;
}
.trend-threshold-label {
position: absolute;
right: 0;
top: -16px;
font-size: var(--tk-font-micro);
color: $wrn;
opacity: 0.7;
}
.trend-bar-col {
flex: 1; flex: 1;
overflow: hidden;
/* 微信小程序 ScrollView scrollY 需要显式高度 */
height: 0; /* flex:1 + height:0 让 flex 布局正确分配剩余高度 */
}
/* ─── 文章列表 ─── */
.health-article-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
}
.trend-bar {
width: 24px;
border-radius: $r-xs $r-xs 0 0;
min-height: 6px;
&.trend-bar-normal {
background: var(--tk-pri);
opacity: 0.75;
}
&.trend-bar-warn {
background: $wrn;
opacity: 0.85;
}
}
.trend-bar-label {
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
margin-top: var(--tk-gap-2xs);
}
/* ─── BLE 设备卡片 ─── */
.device-section {
margin-bottom: var(--tk-gap-sm);
}
.device-card {
display: flex;
align-items: center;
gap: var(--tk-gap-sm); gap: var(--tk-gap-sm);
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
&:active { .content-card {
opacity: var(--tk-touch-feedback-opacity); display: flex;
gap: 14px;
} }
} }
.device-icon { .health-article-body {
width: 48px;
height: 48px;
border-radius: $r-sm;
background: var(--tk-pri-l);
@include flex-center;
flex-shrink: 0;
}
.device-icon-text {
font-size: var(--tk-font-body);
}
.device-info {
flex: 1; flex: 1;
display: flex;
min-width: 0; min-width: 0;
} }
.device-name { .health-article-content {
font-size: var(--tk-font-cap);
font-weight: 500;
color: $tx;
display: block;
}
.device-desc {
font-size: var(--tk-font-cap);
color: $acc;
display: block;
}
.device-arrow {
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
flex-shrink: 0;
}
/* ─── 健康资讯入口 ─── */
.article-entry {
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.article-entry-text {
font-size: var(--tk-font-body-sm);
color: $tx;
font-weight: 500;
}
/* ─── AI 建议卡片 ─── */
.ai-suggestion-card {
background: linear-gradient(135deg, #F0F7F0 0%, $acc-l 100%);
border-radius: $r;
padding: var(--tk-card-padding);
margin-bottom: var(--tk-section-gap);
box-shadow: none;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, $acc, $acc 60%, transparent);
border-radius: 3px 3px 0 0;
}
}
.ai-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-md);
}
.ai-card-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $acc;
}
.ai-card-count {
font-size: var(--tk-font-micro);
color: $acc;
opacity: 0.7;
}
.ai-suggestion-item {
padding: var(--tk-gap-sm) 0;
border-bottom: 1px solid rgba($acc, 0.12);
&:last-child {
border-bottom: none;
}
}
.ai-suggestion-main {
display: flex;
align-items: flex-start;
gap: var(--tk-gap-xs);
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.ai-risk-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
&.ai-risk-high {
background: $dan;
}
&.ai-risk-medium {
background: $wrn;
}
&.ai-risk-low {
background: $acc;
}
}
.ai-suggestion-text {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.6;
flex: 1; flex: 1;
}
/* ─── AI 建议反馈按钮 ─── */
.ai-feedback-row {
display: flex; display: flex;
gap: var(--tk-gap-xs); flex-direction: column;
margin-top: var(--tk-gap-xs); justify-content: space-between;
padding-left: 20px; min-width: 0;
} }
.ai-feedback-btn { .health-article-title {
height: 36px; font-family: Georgia, 'Times New Roman', serif;
min-height: 36px; font-size: var(--tk-font-body);
border-radius: $r-xs; font-weight: 700;
@include flex-center; color: $tx;
padding: 0 var(--tk-gap-sm); line-height: 1.35;
margin-bottom: 4px;
&:active { overflow: hidden;
opacity: var(--tk-touch-feedback-opacity); text-overflow: ellipsis;
} display: -webkit-box;
-webkit-line-clamp: 2;
&.ai-feedback-adopt { -webkit-box-orient: vertical;
background: rgba($acc, 0.15);
}
&.ai-feedback-ignore {
background: $surface-alt;
}
&.ai-feedback-consult {
background: var(--tk-pri-l);
}
} }
.ai-feedback-btn-text { .health-article-summary {
font-size: var(--tk-font-micro); font-size: var(--tk-font-cap);
font-weight: 500;
color: $tx2; color: $tx2;
line-height: 1.4;
display: block;
margin-bottom: var(--tk-gap-2xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.ai-feedback-adopt .ai-feedback-btn-text { .health-article-meta {
color: $acc; display: flex;
gap: var(--tk-gap-sm);
font-size: var(--tk-font-micro);
color: $tx3;
align-items: center;
} }
.ai-feedback-consult .ai-feedback-btn-text { .health-article-tag {
font-size: var(--tk-font-micro);
color: var(--tk-pri); color: var(--tk-pri);
background: var(--tk-pri-l);
padding: 2px 8px;
border-radius: $r-xs;
}
.health-article-date {
font-size: var(--tk-font-micro);
color: $tx3;
}
// 长者模式
.elder-mode .health-page {
.health-cat-tab {
padding: 10px 20px;
font-size: 15px;
}
.health-article-title {
font-size: 18px;
}
.health-article-summary {
font-size: 15px;
}
} }

View File

@@ -1,244 +1,152 @@
import { View, Text } from '@tarojs/components'; import { useState, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { safeNavigateTo } from '@/utils/navigate'; import { safeNavigateTo } from '@/utils/navigate';
import { usePageData } from '@/hooks/usePageData';
import { useAuthStore } from '../../stores/auth'; import { useAuthStore } from '../../stores/auth';
import { useElderClass } from '../../hooks/useElderClass'; import {
import GuestGuard from '../../components/GuestGuard'; listArticles,
import Loading from '../../components/Loading'; listCategories,
listPublicArticles,
listPublicCategories,
type Article,
type ArticleCategory,
} from '../../services/article';
import PageShell from '@/components/ui/PageShell'; import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard'; import ContentCard from '@/components/ui/ContentCard';
import SegmentTabs from '../../components/SegmentTabs'; import EmptyState from '../../components/EmptyState';
import { useHealthOverview, VITAL_TABS, type VitalType } from './useHealthOverview'; import ErrorState from '../../components/ErrorState';
import { submitSuggestionFeedback } from '../../services/ai-analysis'; import Loading from '../../components/Loading';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss'; import './index.scss';
const QUICK_ENTRIES = [
{ label: '录入体征', icon: '✏', color: 'input', path: '/pages/pkg-health/input/index' },
{ label: '健康趋势', icon: '📈', color: 'trend', path: '/pages/pkg-health/trend/index' },
{ label: '我的报告', icon: '📋', color: 'report', path: '/pages/pkg-profile/reports/index' },
{ label: '健康档案', icon: '健', color: 'med', path: '/pages/pkg-profile/health-records/index' },
] as const;
function statusClass(status?: string): string {
if (!status) return '';
if (status === 'high' || status === 'abnormal') return 'vital-warn';
if (status === 'low') return 'vital-warn';
return 'vital-ok';
}
function formatDate(): string {
const d = new Date();
const month = d.getMonth() + 1;
const day = d.getDate();
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
return `${month}${day}日 周${weekDays[d.getDay()]}`;
}
export default function Health() { export default function Health() {
const user = useAuthStore((s) => s.user);
const modeClass = useElderClass(); const modeClass = useElderClass();
const { const isLoggedIn = !!useAuthStore((s) => s.user);
todaySummary, loading, error, activeTab, trendData, trendLoading, const [articles, setArticles] = useState<Article[]>([]);
aiSuggestions, thresholds, alertCount, handleTabChange, fetchData, const [page, setPage] = useState(1);
} = useHealthOverview(); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [categories, setCategories] = useState<ArticleCategory[]>([]);
const [activeCategory, setActiveCategory] = useState<string | null>(null);
if (!user) { const fetchData = useCallback(async (p: number, append = false, categoryId?: string | null) => {
return <GuestGuard title='请先登录' desc='登录后即可查看健康数据' />; setLoading(true);
} setError(false);
try {
if (error) { const cid = categoryId !== undefined ? categoryId : activeCategory;
return ( const res = isLoggedIn
<PageShell padding="md" safeBottom={false} scroll={false} className={`health-page ${modeClass}`}> ? await listArticles({ page: p, category_id: cid || undefined })
<View className='health-header'> : await listPublicArticles({ page: p, category_id: cid || undefined });
<Text className='health-title'></Text> const list = res.data || [];
</View> setArticles(append ? (prev) => [...prev, ...list] : list);
<Loading /> setTotal(res.total);
</PageShell> setPage(p);
); } catch (err) {
} console.warn('[health] 加载文章列表失败:', err);
setError(true);
const maxTrendValue = trendData.reduce((max, d) => Math.max(max, d.value), 1); Taro.showToast({ title: '加载失败', icon: 'none' });
const dayLabels = ['日', '一', '二', '三', '四', '五', '六']; } finally {
setLoading(false);
const summary = todaySummary || {};
const vitals = [
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status },
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status },
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status },
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status },
];
const recordedCount = vitals.filter((v) => v.value !== '—').length;
const getThresholdValue = (type: VitalType): number | null => {
if (!thresholds.length) return null;
const th = thresholds;
if (type === 'blood_pressure') {
const v = th.find((t) => t.indicator === 'systolic_bp' && t.level === 'high');
return v?.threshold_value ?? 140;
} }
if (type === 'heart_rate') { }, [activeCategory, isLoggedIn]);
const v = th.find((t) => t.indicator === 'heart_rate' && t.level === 'high');
return v?.threshold_value ?? 100; usePageData(
useCallback(async () => {
try {
const cats = isLoggedIn
? await listCategories()
: await listPublicCategories();
setCategories(cats || []);
} catch (err) {
console.warn('[health] 加载分类失败:', err);
setCategories([]);
}
await fetchData(1);
}, [fetchData, isLoggedIn]),
{ throttleMs: 10000, enablePullDown: true },
);
const loadMore = useCallback(() => {
if (!loading && articles.length < total) {
fetchData(page + 1, true);
} }
if (type === 'blood_sugar') { }, [loading, articles.length, total, page, fetchData]);
const v = th.find((t) => t.indicator === 'blood_sugar_fasting' && t.level === 'high');
return v?.threshold_value ?? 6.1; const handleCategoryChange = (categoryId: string | null) => {
} setActiveCategory(categoryId);
return null; fetchData(1, false, categoryId);
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return '';
const d = new Date(dateStr);
const month = d.getMonth() + 1;
const day = d.getDate();
return `${month}${day}`;
}; };
return ( return (
<PageShell padding="md" safeBottom={false} scroll className={`health-page ${modeClass}`}> <PageShell safeBottom={false} padding="none" scroll={false} className={`health-page ${modeClass}`}>
<View className='health-header'> {/* 分类标签 */}
<Text className='health-title'></Text> {categories.length > 0 && (
<Text className='health-date'>{formatDate()}</Text> <ScrollView scrollX className='health-categories'>
</View> <View
className={`health-cat-tab ${!activeCategory ? 'health-cat-tab--active' : ''}`}
onClick={() => handleCategoryChange(null)}
>
<Text></Text>
</View>
{categories.map((cat) => (
<View
key={cat.id}
className={`health-cat-tab ${activeCategory === cat.id ? 'health-cat-tab--active' : ''}`}
onClick={() => handleCategoryChange(cat.id)}
>
<Text>{cat.name}</Text>
</View>
))}
</ScrollView>
)}
{/* 今日体征 hero 卡片 */} {/* 文章列表 */}
<View className='vitals-grid'> <ScrollView scrollY className='health-scroll' onScrollToLower={loadMore} lowerThreshold={200}>
<View className='vitals-header'> {error ? (
<Text className='vitals-title'></Text> <ErrorState onRetry={() => fetchData(1, false, null)} />
{recordedCount > 0 && ( ) : articles.length === 0 && !loading ? (
<Text className='vitals-badge'> {recordedCount} </Text> <EmptyState text='暂无健康资讯' />
)} ) : (
</View> <View className='health-article-list'>
{loading ? <Loading /> : ( {articles.map((a) => (
<View className='vitals-row'> <ContentCard
{vitals.map((v) => ( key={a.id}
<View className={`vital-cell ${statusClass(v.status)}`} key={v.label}> padding='sm'
<Text className='vital-value'>{v.value}</Text> margin='none'
<Text className='vital-unit'>{v.unit}</Text> onPress={() => safeNavigateTo(`/pages/article/detail/index?id=${a.id}`)}
<Text className='vital-label'>{v.label}</Text> >
</View> <View className='health-article-body'>
<View className='health-article-content'>
<Text className='health-article-title'>{a.title}</Text>
{a.summary && (
<Text className='health-article-summary'>{a.summary}</Text>
)}
<View className='health-article-meta'>
{(a.category_name || a.category) && (
<Text className='health-article-tag'>{a.category_name || a.category}</Text>
)}
{a.published_at && (
<Text className='health-article-date'>{formatDate(a.published_at)}</Text>
)}
</View>
</View>
</View>
</ContentCard>
))} ))}
</View> </View>
)} )}
</View> {loading && <Loading />}
</ScrollView>
{/* 快捷入口 — 横排 4 格图标 */}
<View className='quick-entries'>
{QUICK_ENTRIES.map((e) => (
<View
key={e.label}
className='quick-entry'
onClick={() => safeNavigateTo(e.path)}
>
<View className={`quick-icon quick-icon--${e.color}`}>
<Text className='quick-icon-text'>{e.icon}</Text>
</View>
<Text className='quick-label'>{e.label}</Text>
</View>
))}
</View>
{/* 告警横幅 */}
{alertCount > 0 && (
<ContentCard
variant="default"
padding="sm"
margin="none"
className='alert-hint'
onPress={() => safeNavigateTo('/pages/pkg-health/alerts/index')}
>
<View className='alert-dot' />
<Text className='alert-text'>{alertCount} </Text>
<Text className='alert-arrow'></Text>
</ContentCard>
)}
{/* AI 建议 */}
{aiSuggestions.length > 0 && (
<View className='ai-suggestion-card'>
<View className='ai-card-header'>
<Text className='ai-card-title'>AI </Text>
<Text className='ai-card-count'>{aiSuggestions.length} </Text>
</View>
{aiSuggestions.map((s) => {
const riskCls = s.risk_level === 'high' ? 'ai-risk-high' : s.risk_level === 'medium' ? 'ai-risk-medium' : 'ai-risk-low';
const params = s.params as Record<string, unknown> | null;
const reason = (params?.reason as string) || (params?.message as string) || '健康建议';
return (
<View key={s.id} className='ai-suggestion-item'>
<View className='ai-suggestion-main' onClick={() => {
if (s.suggestion_type === 'appointment') safeNavigateTo('/pages/appointment/create/index');
else if (s.suggestion_type === 'followup') safeNavigateTo('/pages/pkg-profile/followups/index');
}}>
<View className={`ai-risk-dot ${riskCls}`} />
<Text className='ai-suggestion-text'>{reason.slice(0, 50)}</Text>
</View>
<View className='ai-feedback-row'>
<View className='ai-feedback-btn ai-feedback-adopt' onClick={async () => {
try { await submitSuggestionFeedback(s.id, 'adopt'); Taro.showToast({ title: '已采纳', icon: 'success' }); fetchData(); }
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
<View className='ai-feedback-btn ai-feedback-ignore' onClick={async () => {
try { await submitSuggestionFeedback(s.id, 'ignore'); Taro.showToast({ title: '已忽略', icon: 'success' }); fetchData(); }
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
<View className='ai-feedback-btn ai-feedback-consult' onClick={async () => {
try { await submitSuggestionFeedback(s.id, 'consult'); safeNavigateTo('/pages/consultation/index'); }
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
</View>
</View>
);
})}
</View>
)}
{/* 7天趋势 */}
<View className='trend-section'>
<Text className='section-title'> 7 </Text>
<SegmentTabs tabs={VITAL_TABS} activeKey={activeTab} onChange={handleTabChange} variant="pill" />
{trendLoading ? <Loading /> : trendData.length === 0 ? (
<ContentCard padding="md">
<Text className='trend-empty-text'></Text>
</ContentCard>
) : (
<ContentCard padding="md">
<View className='trend-bars'>
{(() => {
const tv = getThresholdValue(activeTab);
if (tv) {
const pct = Math.min(95, (tv / maxTrendValue) * 100);
return (
<View className='trend-threshold-line' style={`bottom:${((12 + pct * 1.08) / 120 * 100).toFixed(1)}%;`}>
<Text className='trend-threshold-label'>{tv}</Text>
</View>
);
}
return null;
})()}
{trendData.map((point, i) => {
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
const tv = getThresholdValue(activeTab);
const isAbnormal = tv ? point.value >= tv : false;
const dayOfWeek = new Date(point.date).getDay();
return (
<View className='trend-bar-col' key={i}>
<View
className={`trend-bar ${isAbnormal ? 'trend-bar-warn' : 'trend-bar-normal'}`}
style={`height:${heightPct}%;`}
/>
<Text className='trend-bar-label'>{dayLabels[dayOfWeek]}</Text>
</View>
);
})}
</View>
</ContentCard>
)}
</View>
{/* 健康资讯入口 */}
<ContentCard onPress={() => safeNavigateTo('/pages/article/index')}>
<Text className='article-entry-text'> </Text>
</ContentCard>
</PageShell> </PageShell>
); );
} }

View File

@@ -370,7 +370,8 @@ export default function Index() {
url: target, url: target,
fail: () => { fail: () => {
redirectingRef.current = false; redirectingRef.current = false;
console.warn('跳转医生端失败,停留患者首页'); console.warn('跳转医生端失败,降级为 redirectTo');
Taro.redirectTo({ url: target }).catch(() => {});
}, },
}); });
} }

View File

@@ -9,6 +9,7 @@ declare const __wxConfig: Record<string, unknown> | undefined;
const IS_DEV = process.env.NODE_ENV !== 'production'; const IS_DEV = process.env.NODE_ENV !== 'production';
const IS_SIMULATOR = typeof __wxConfig !== 'undefined' && (__wxConfig as Record<string, unknown>)?.envVersion === 'develop'; const IS_SIMULATOR = typeof __wxConfig !== 'undefined' && (__wxConfig as Record<string, unknown>)?.envVersion === 'develop';
const SHOW_DEV_LOGIN = (IS_DEV || IS_SIMULATOR) && !!(process.env.TARO_APP_DEV_USER && process.env.TARO_APP_DEV_PASS);
export default function Login() { export default function Login() {
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
@@ -40,11 +41,16 @@ export default function Login() {
} }
}; };
const handleWechatLogin = async () => { const requireAgreement = () => {
if (!agreed) { if (!agreed) {
Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' }); Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' });
return; return false;
} }
return true;
};
const handleWechatLogin = async () => {
if (!requireAgreement()) return;
try { try {
const { code } = await Taro.login(); const { code } = await Taro.login();
const result = await login(code); const result = await login(code);
@@ -60,23 +66,6 @@ export default function Login() {
} }
}; };
const handleDevQuickLogin = async () => {
const devUser = process.env.TARO_APP_DEV_USER || '';
const devPass = process.env.TARO_APP_DEV_PASS || '';
if (!devUser || !devPass) {
Taro.showToast({ title: '未配置开发账号', icon: 'none' });
return;
}
try {
const success = await credentialLogin(devUser, devPass);
if (success) {
navigateAfterLogin();
}
} catch (err: unknown) {
Taro.showToast({ title: err instanceof Error ? err.message : '登录失败', icon: 'none' });
}
};
const handleGetPhone = async (e: { detail: { errMsg: string; encryptedData: string; iv: string } }) => { const handleGetPhone = async (e: { detail: { errMsg: string; encryptedData: string; iv: string } }) => {
if (e.detail.errMsg !== 'getPhoneNumber:ok') { if (e.detail.errMsg !== 'getPhoneNumber:ok') {
Taro.showToast({ title: '需要授权手机号', icon: 'none' }); Taro.showToast({ title: '需要授权手机号', icon: 'none' });
@@ -100,6 +89,7 @@ export default function Login() {
}; };
// DevTools 中 getPhoneNumber 不可用,直接传 mock 数据绕过微信 SDK // DevTools 中 getPhoneNumber 不可用,直接传 mock 数据绕过微信 SDK
// 仅在后端 wechat_dev_mode=true 时有效,后端会生成 mock 手机号
const handleDevBindPhone = async () => { const handleDevBindPhone = async () => {
try { try {
const success = await bindPhone('dev_mock', 'dev_mock'); const success = await bindPhone('dev_mock', 'dev_mock');
@@ -117,6 +107,21 @@ export default function Login() {
} }
}; };
const handleDevQuickLogin = async () => {
if (!requireAgreement()) return;
const devUser = process.env.TARO_APP_DEV_USER || '';
const devPass = process.env.TARO_APP_DEV_PASS || '';
if (!devUser || !devPass) return;
try {
const success = await credentialLogin(devUser, devPass);
if (success) {
navigateAfterLogin();
}
} catch (err: unknown) {
Taro.showToast({ title: err instanceof Error ? err.message : '登录失败', icon: 'none' });
}
};
return ( return (
<View className="login-page"> <View className="login-page">
{/* 品牌区 */} {/* 品牌区 */}
@@ -130,16 +135,32 @@ export default function Login() {
{!needBind ? ( {!needBind ? (
<> <>
{/* 微信一键登录 */} {/* 微信一键登录(主按钮) */}
<View className="login-wechat-btn" onClick={handleWechatLogin}> <View className="login-wechat-btn" onClick={handleWechatLogin}>
<Text className="login-wechat-icon"></Text> <Text className="login-wechat-icon"></Text>
<Text className="login-wechat-text"></Text> <Text className="login-wechat-text"></Text>
</View> </View>
{/* 协议 */}
<View className="agreement-row">
<View
className={`agreement-check ${agreed ? 'checked' : ''}`}
onClick={() => setAgreed(!agreed)}
>
{agreed && <Text className="agreement-check-mark">&#10003;</Text>}
</View>
<Text className="agreement-text">
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/user-agreement')}></Text>
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/privacy-policy')}></Text>
</Text>
</View>
</> </>
) : ( ) : (
<View className="login-bind-section"> <View className="login-bind-section">
{/* 真机:微信手机号授权 */} {/* 真机:微信手机号授权 */}
{!(IS_DEV || IS_SIMULATOR) && ( {!SHOW_DEV_LOGIN && (
<Button <Button
className="login-btn-bind" className="login-btn-bind"
openType="getPhoneNumber" openType="getPhoneNumber"
@@ -151,7 +172,7 @@ export default function Login() {
</Button> </Button>
)} )}
{/* DevTools跳过微信 SDK 直接调后端(后端 wechat_dev_mode 会用 mock 手机号) */} {/* DevTools跳过微信 SDK 直接调后端(后端 wechat_dev_mode 会用 mock 手机号) */}
{(IS_DEV || IS_SIMULATOR) && ( {SHOW_DEV_LOGIN && (
<Button <Button
className="login-btn-bind" className="login-btn-bind"
onClick={handleDevBindPhone} onClick={handleDevBindPhone}
@@ -161,29 +182,28 @@ export default function Login() {
</Button> </Button>
)} )}
{/* 协议 */}
<View className="agreement-row" style={{ marginTop: '16px' }}>
<View
className={`agreement-check ${agreed ? 'checked' : ''}`}
onClick={() => setAgreed(!agreed)}
>
{agreed && <Text className="agreement-check-mark">&#10003;</Text>}
</View>
<Text className="agreement-text">
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/user-agreement')}></Text>
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/privacy-policy')}></Text>
</Text>
</View>
</View> </View>
)} )}
{/* 协议 */}
<View className="agreement-row">
<View
className={`agreement-check ${agreed ? 'checked' : ''}`}
onClick={() => setAgreed(!agreed)}
>
{agreed && <Text className="agreement-check-mark">&#10003;</Text>}
</View>
<Text className="agreement-text">
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/user-agreement')}></Text>
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/privacy-policy')}></Text>
</Text>
</View>
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
{/* 开发模式 */} {/* 开发模式快速登录 — 仅 dev 构建 + DevTools 中显示 */}
{(IS_DEV || IS_SIMULATOR) && ( {SHOW_DEV_LOGIN && (
<View className="login-dev-btn" onClick={handleDevQuickLogin}> <View className="login-dev-btn" onClick={handleDevQuickLogin}>
<Text className="login-dev-btn-text"> </Text> <Text className="login-dev-btn-text"> </Text>
</View> </View>

View File

@@ -927,3 +927,62 @@
font-size: var(--tk-font-body-lg); font-size: var(--tk-font-body-lg);
color: $tx2; color: $tx2;
} }
// ─── 服务发现信息 ───
.ds-services-info {
margin-bottom: var(--tk-gap-md) !important;
}
.ds-services-info__title {
display: block;
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
margin-bottom: var(--tk-gap-sm);
}
.ds-services-info__caps {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ds-cap-tag {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: $r-xs;
font-size: var(--tk-font-cap);
&--on {
background: rgba($acc, 0.08);
color: $acc;
}
&--off {
background: $surface-alt;
color: $tx3;
}
}
.ds-cap-tag__dot {
font-size: 10px;
}
.ds-cap-tag__text {
font-size: var(--tk-font-cap);
}
.ds-services-info__hint {
margin-top: var(--tk-gap-sm);
background: $wrn-l;
border-radius: $r-xs;
padding: 8px 12px;
}
.ds-services-info__hint-text {
font-size: var(--tk-font-cap);
color: $wrn;
line-height: 1.5;
}

View File

@@ -10,7 +10,7 @@ import { CustomBandAdapter, HuaweiBandAdapter, FallbackAdapter } from '@/service
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler'; import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
import { uploadReadings } from '@/services/device-sync'; import { uploadReadings } from '@/services/device-sync';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import type { BLEDevice, NormalizedReading } from '@/services/ble/types'; import type { BLEDevice, NormalizedReading, BLEDiscoveredService } from '@/services/ble/types';
import { useElderClass } from '@/hooks/useElderClass'; import { useElderClass } from '@/hooks/useElderClass';
import PageShell from '@/components/ui/PageShell'; import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard'; import ContentCard from '@/components/ui/ContentCard';
@@ -80,6 +80,7 @@ export default function DeviceSync() {
const [errorMsg, setErrorMsg] = useState(''); const [errorMsg, setErrorMsg] = useState('');
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null); const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
const [pendingCount, setPendingCount] = useState(0); const [pendingCount, setPendingCount] = useState(0);
const [discoveredServices, setDiscoveredServices] = useState<BLEDiscoveredService[]>([]);
const scheduler = useMemo(() => new DataSyncScheduler({ intervalMs: 60 * 60 * 1000 }), []); const scheduler = useMemo(() => new DataSyncScheduler({ intervalMs: 60 * 60 * 1000 }), []);
@@ -163,6 +164,8 @@ export default function DeviceSync() {
setErrorMsg(''); setErrorMsg('');
try { try {
await getBleManager().connect(device); await getBleManager().connect(device);
const conn = getBleManager().getConnection();
setDiscoveredServices(conn?.discoveredServices ?? []);
setPageState('connected'); setPageState('connected');
} catch (e: unknown) { } catch (e: unknown) {
setErrorMsg(e instanceof Error ? e.message : '连接失败'); setErrorMsg(e instanceof Error ? e.message : '连接失败');
@@ -217,6 +220,7 @@ export default function DeviceSync() {
setLiveReadings([]); setLiveReadings([]);
setSyncCount(0); setSyncCount(0);
setErrorMsg(''); setErrorMsg('');
setDiscoveredServices([]);
}, []); }, []);
const latestReading = liveReadings.length > 0 ? liveReadings[liveReadings.length - 1] : null; const latestReading = liveReadings.length > 0 ? liveReadings[liveReadings.length - 1] : null;
@@ -359,6 +363,54 @@ export default function DeviceSync() {
</View> </View>
); );
/** 渲染 BLE 服务发现信息 */
const renderServiceDiscovery = () => {
if (discoveredServices.length === 0) return null;
// 检查各类健康数据是否在已发现的 UUID 中可用
const hasCharShort = (short: string) =>
discoveredServices.some((s) =>
s.characteristics.some((c) =>
c.uuid.toUpperCase().replace(/-/g, '').slice(-4) === short,
),
);
const capabilities = [
{ key: '2A37', label: '心率', available: hasCharShort('2A37') },
{ key: '2A5F', label: '血氧(实时)', available: hasCharShort('2A5F') },
{ key: '2A5E', label: '血氧(单次)', available: hasCharShort('2A5E') },
{ key: '2A1C', label: '体温', available: hasCharShort('2A1C') },
{ key: '2A35', label: '血压', available: hasCharShort('2A35') },
];
const availableCount = capabilities.filter((c) => c.available).length;
return (
<ContentCard variant="outlined" padding="md" margin="none" className="ds-services-info">
<Text className="ds-services-info__title">
({discoveredServices.length} , {availableCount} )
</Text>
<View className="ds-services-info__caps">
{capabilities.map((cap) => (
<View key={cap.key} className={`ds-cap-tag ${cap.available ? 'ds-cap-tag--on' : 'ds-cap-tag--off'}`}>
<Text className="ds-cap-tag__dot">{cap.available ? '●' : '○'}</Text>
<Text className="ds-cap-tag__text">{cap.label}</Text>
</View>
))}
</View>
{availableCount <= 1 && (
<View className="ds-services-info__hint">
<Text className="ds-services-info__hint-text">
{selectedDevice?.name?.includes('HUAWEI') || selectedDevice?.name?.includes('HW')
? '华为手环的睡眠/步数/压力数据使用私有协议,需要华为运动健康 App 同步'
: '此设备仅暴露少量标准健康服务,更多数据请使用设备官方 App 同步'}
</Text>
</View>
)}
</ContentCard>
);
};
const renderLatestReading = () => { const renderLatestReading = () => {
if (!latestReading) return null; if (!latestReading) return null;
return ( return (
@@ -525,6 +577,7 @@ export default function DeviceSync() {
{pageState === 'connected' && ( {pageState === 'connected' && (
<View className="ds-body"> <View className="ds-body">
{renderConnectedStatus()} {renderConnectedStatus()}
{renderServiceDiscovery()}
{renderLatestReading()} {renderLatestReading()}
{renderReadingsHistory()} {renderReadingsHistory()}
{renderConnectedActions()} {renderConnectedActions()}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '健康测量',
});

View File

@@ -0,0 +1,256 @@
// Veepoo 测量结果 + 上传页样式
// 设计原型: docs/design/veepoo-measure-prototype.html
@import '../../../styles/variables.scss';
.vm-page {
min-height: 100vh;
background: var(--tk-bg-primary, $bg);
}
// ── 连接中(等待跳转态) ──
.vm-connect {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 0 32px;
&__anim {
position: relative;
width: 120px;
height: 120px;
margin-bottom: 24px;
}
&__ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid $pri;
animation: vm-pulse-ring 2s ease-out infinite;
}
&__center {
position: absolute;
inset: 20px;
border-radius: 50%;
background: $pri;
display: flex;
align-items: center;
justify-content: center;
}
&__bt {
color: #fff;
font-size: 20px;
font-weight: 700;
}
&__title {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-h2, 22px);
font-weight: 700;
color: $tx;
margin-bottom: 8px;
}
&__hint {
font-size: var(--tk-font-body-sm, 14px);
color: $tx3;
}
}
@keyframes vm-pulse-ring {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.4); opacity: 0; }
}
// ── 上传页面 ──
.vm-upload {
min-height: 100vh;
padding-bottom: 40px;
&__header {
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-md, 16px);
}
&__title {
display: block;
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-h2, 22px);
font-weight: 700;
color: $tx;
line-height: 1.3;
}
&__subtitle {
display: block;
font-size: var(--tk-font-body-sm, 14px);
color: $tx3;
margin-top: 4px;
}
}
// ── 结果卡片网格 ──
.vm-results-grid {
padding: 0 var(--tk-page-padding, 20px);
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--tk-gap-sm, 12px);
}
.vm-result-card {
background: $card;
border-radius: var(--tk-card-radius, 16px);
padding: var(--tk-gap-md, 16px);
box-shadow: $shadow-sm;
position: relative;
overflow: hidden;
&--full {
grid-column: 1 / -1;
}
&--empty {
opacity: 0.5;
}
&__badge {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
border-radius: 0 4px 4px 0;
}
&__label {
display: block;
font-size: var(--tk-font-cap, 13px);
color: $tx2;
margin-bottom: 8px;
padding-left: 8px;
}
&__row {
display: flex;
align-items: baseline;
gap: 4px;
padding-left: 8px;
}
&__value {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-num-lg, 34px);
font-weight: 700;
color: $tx;
line-height: 1;
}
&__unit {
font-size: var(--tk-font-cap, 13px);
color: $tx3;
}
&__tag {
display: inline-flex;
align-items: center;
gap: 3px;
margin-top: 8px;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
font-size: var(--tk-font-micro, 11px);
font-weight: 500;
&--normal {
background: $acc-l;
color: $acc;
}
&--warning {
background: $wrn-l;
color: $wrn;
}
&--danger {
background: $dan-l;
color: $dan;
}
}
&__placeholder {
padding-left: 8px;
font-size: var(--tk-font-body-sm, 14px);
color: $tx3;
}
&--sleep {
padding-bottom: 12px;
}
}
// ── 睡眠数据行 ──
.vm-sleep-row {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 8px;
margin-left: 8px;
&__day {
font-size: var(--tk-font-body-sm, 14px);
color: $tx2;
min-width: 40px;
}
&__time {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-body, 16px);
font-weight: 600;
color: $tx;
}
&__quality {
font-size: 12px;
color: $wrn;
margin-left: auto;
}
}
// ── 底部上传播区 ──
.vm-upload-footer {
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-xl, 32px);
&__hint {
display: block;
font-size: var(--tk-font-cap, 13px);
color: $tx3;
text-align: center;
margin-bottom: var(--tk-gap-sm, 12px);
}
&__btn {
width: 100%;
}
&__time {
display: block;
font-size: var(--tk-font-micro, 11px);
color: $tx3;
text-align: center;
margin-top: var(--tk-gap-sm, 12px);
}
}
// ── 长者模式 ──
.elder-mode {
.vm-results-grid {
grid-template-columns: 1fr;
}
.vm-result-card__value {
font-size: var(--tk-font-num-lg, 40px);
}
}

View File

@@ -0,0 +1,306 @@
import React, { useRef } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import { useAuthStore } from '@/stores/auth';
import { uploadReadings } from '@/services/device-sync';
import type { NormalizedReading } from '@/services/ble/types';
import { useElderClass } from '@/hooks/useElderClass';
import PageShell from '@/components/ui/PageShell';
import PrimaryButton from '@/components/ui/PrimaryButton';
import './index.scss';
/** 原生页面返回的测量结果格式 */
interface NativeMeasureResult {
type: string;
values: Record<string, number>;
measuredAt: number;
}
/** 原生页面返回的睡眠数据格式 */
interface NativeSleepResult {
day: number;
deepSleepMinutes: number;
lightSleepMinutes: number;
totalSleepMinutes: number;
qualityScore: number;
fallAsleepTime: string;
exitSleepTime: string;
}
/** 指标配置 */
const METRIC_CONFIG = [
{ type: 'heart_rate', label: '心率', unit: 'bpm', color: '#EF4444', icon: '♥' },
{ type: 'blood_oxygen', label: '血氧', unit: '%', color: '#3B82F6', icon: 'O₂' },
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', color: '#8B5CF6', icon: '↕' },
{ type: 'temperature', label: '体温', unit: '°C', color: '#F59E0B', icon: 'T' },
{ type: 'pressure', label: '压力', unit: '', color: '#6366F1', icon: '~' },
] as const;
/** 健康评估 */
function assessHealth(type: string, values: Record<string, number>): { level: 'normal' | 'warning' | 'danger'; text: string } {
switch (type) {
case 'heart_rate': {
const v = values.heart_rate ?? 0;
if (v >= 60 && v <= 100) return { level: 'normal', text: '心率正常' };
if (v < 50 || v > 120) return { level: 'danger', text: '心率异常' };
return { level: 'warning', text: '心率偏离正常范围' };
}
case 'blood_oxygen': {
const v = values.blood_oxygen ?? 0;
if (v >= 95) return { level: 'normal', text: '血氧正常' };
if (v >= 90) return { level: 'warning', text: '血氧偏低' };
return { level: 'danger', text: '血氧过低' };
}
case 'blood_pressure': {
const sys = values.systolic ?? 0;
const dia = values.diastolic ?? 0;
if (sys >= 90 && sys <= 140 && dia >= 60 && dia <= 90) return { level: 'normal', text: '血压正常' };
if (sys > 160 || dia > 100) return { level: 'danger', text: '血压过高' };
return { level: 'warning', text: '血压偏高' };
}
case 'temperature': {
const v = values.temperature ?? 0;
if (v >= 36.0 && v <= 37.3) return { level: 'normal', text: '体温正常' };
if (v > 38.0) return { level: 'danger', text: '发热' };
return { level: 'warning', text: '体温偏离正常' };
}
case 'pressure': {
const v = values.pressure ?? 0;
if (v >= 1 && v <= 40) return { level: 'normal', text: '压力正常' };
if (v > 60) return { level: 'danger', text: '压力过高' };
return { level: 'warning', text: '压力偏高' };
}
default:
return { level: 'normal', text: '' };
}
}
/** 格式化显示值 */
function formatValue(type: string, values: Record<string, number>): string {
if (type === 'blood_pressure') {
return `${values.systolic ?? '--'}/${values.diastolic ?? '--'}`;
}
const v = Object.values(values)[0];
return v !== undefined ? String(v) : '--';
}
export default function VeepooMeasure() {
const modeClass = useElderClass();
const router = useRouter();
const patient = useAuthStore((s) => s.currentPatient);
const navigatedRef = useRef(false);
const [results, setResults] = React.useState<Record<string, NativeMeasureResult>>({});
const [sleepData, setSleepData] = React.useState<NativeSleepResult[]>([]);
const [uploadStatus, setUploadStatus] = React.useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
// 从 URL 或 store 获取 patientId
const patientId = patient?.id || router.params.patientId || '';
// C3 修复:用 ref 防重入,避免 React Strict Mode 双触发
if (!navigatedRef.current) {
navigatedRef.current = true;
// 延迟到下一个微任务,确保页面渲染完成后再跳转
setTimeout(() => {
Taro.navigateTo({
url: `/pkg-veepoo/index?patientId=${patientId}`,
events: {
measureResult: (data: NativeMeasureResult) => {
setResults((prev) => ({ ...prev, [data.type]: data }));
},
measureComplete: (data: { results: Record<string, NativeMeasureResult>; count: number }) => {
if (data.results) setResults(data.results);
},
},
});
}, 50);
}
// 页面恢复时读取原生页面返回的测量结果 + 睡眠数据
useDidShow(() => {
try {
const raw = Taro.getStorageSync('hms:veepoo_measure_results');
if (raw) {
const parsed = JSON.parse(raw) as Record<string, NativeMeasureResult>;
setResults(parsed);
Taro.removeStorageSync('hms:veepoo_measure_results');
}
} catch { /* ignore */ }
try {
const rawSleep = Taro.getStorageSync('hms:veepoo_sleep_results');
if (rawSleep) {
const parsedSleep = JSON.parse(rawSleep) as NativeSleepResult[];
if (parsedSleep.length > 0) {
setSleepData(parsedSleep);
}
Taro.removeStorageSync('hms:veepoo_sleep_results');
}
} catch { /* ignore */ }
});
const handleUpload = async () => {
// 修复:添加明确的错误提示,不再静默退出
if (!patientId) {
console.warn('[veepoo-measure] 上传失败:未获取到患者 ID');
Taro.showToast({ title: '请先绑定患者档案', icon: 'none' });
return;
}
const allResults = Object.values(results);
const hasMeasureData = allResults.length > 0;
const hasSleep = sleepData.length > 0;
if (!hasMeasureData && !hasSleep) {
console.warn('[veepoo-measure] 上传失败:无数据');
Taro.showToast({ title: '暂无测量数据', icon: 'none' });
return;
}
setUploadStatus('uploading');
try {
const allReadings: NormalizedReading[] = [];
// 测量结果
if (hasMeasureData) {
console.log('[veepoo-measure] 上传测量数据', allResults.length, '项');
allReadings.push(...allResults.map((r) => ({
device_type: r.type as NormalizedReading['device_type'],
values: r.values,
measured_at: new Date(r.measuredAt).toISOString(),
})));
}
// 睡眠数据
if (hasSleep) {
const now = new Date();
console.log('[veepoo-measure] 上传睡眠数据', sleepData.length, '天');
allReadings.push(...sleepData.map((s) => {
const baseDate = new Date(now.getTime() - s.day * 86400000);
return {
device_type: 'sleep' as const,
values: {
deep_sleep_minutes: s.deepSleepMinutes,
light_sleep_minutes: s.lightSleepMinutes,
total_sleep_minutes: s.totalSleepMinutes,
quality_score: s.qualityScore,
},
measured_at: baseDate.toISOString(),
};
}));
}
await uploadReadings(patientId, 'veepoo_m2', 'Veepoo M2', allReadings);
setUploadStatus('success');
Taro.showToast({ title: '数据已上传', icon: 'success' });
} catch (err) {
console.error('[veepoo-measure] 上传失败:', err);
setUploadStatus('error');
Taro.showToast({ title: '上传失败,请重试', icon: 'none' });
}
};
const hasResults = Object.keys(results).length > 0;
const measuredCount = Object.keys(results).length;
const measuredAt = hasResults
? new Date(Object.values(results)[0].measuredAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
: '';
return (
<PageShell padding="none" className={`vm-page ${modeClass}`}>
{hasResults ? (
<View className="vm-upload">
{/* 页面标题 */}
<View className="vm-upload__header">
<Text className="vm-upload__title"></Text>
<Text className="vm-upload__subtitle">Veepoo M2 · </Text>
</View>
{/* 结果卡片网格 */}
<View className="vm-results-grid">
{METRIC_CONFIG.map((metric) => {
const result = results[metric.type];
if (result) {
const assessment = assessHealth(metric.type, result.values);
return (
<View
key={metric.type}
className={`vm-result-card ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
>
<View className="vm-result-card__badge" style={{ background: metric.color }} />
<Text className="vm-result-card__label">{metric.label}</Text>
<View className="vm-result-card__row">
<Text className="vm-result-card__value">{formatValue(metric.type, result.values)}</Text>
<Text className="vm-result-card__unit">{metric.unit}</Text>
</View>
<View className={`vm-result-card__tag vm-result-card__tag--${assessment.level}`}>
<Text> {assessment.text}</Text>
</View>
</View>
);
}
// 未测量占位
return (
<View
key={metric.type}
className={`vm-result-card vm-result-card--empty ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
>
<View className="vm-result-card__badge" style={{ background: metric.color, opacity: 0.3 }} />
<Text className="vm-result-card__label">{metric.label}</Text>
<Text className="vm-result-card__placeholder"></Text>
</View>
);
})}
{/* 睡眠数据卡片 */}
{sleepData.length > 0 && (
<View className="vm-result-card vm-result-card--full vm-result-card--sleep">
<View className="vm-result-card__badge" style={{ background: '#5B7A5E' }} />
<Text className="vm-result-card__label">{sleepData.length} </Text>
{sleepData.map((sleep, idx) => {
const hours = Math.floor(sleep.totalSleepMinutes / 60);
const mins = sleep.totalSleepMinutes % 60;
const dayLabel = sleep.day === 0 ? '昨晚' : sleep.day === 1 ? '前晚' : '大前晚';
return (
<View key={idx} className="vm-sleep-row">
<Text className="vm-sleep-row__day">{dayLabel}</Text>
<Text className="vm-sleep-row__time">{hours}h{mins > 0 ? ` ${mins}min` : ''}</Text>
<View className="vm-sleep-row__quality">
{'★'.repeat(Math.min(sleep.qualityScore, 5))}{'☆'.repeat(Math.max(5 - sleep.qualityScore, 0))}
</View>
</View>
);
})}
<View className="vm-result-card__tag vm-result-card__tag--normal">
<Text> </Text>
</View>
</View>
)}
</View>
{/* 底部上传播区 */}
<View className="vm-upload-footer">
<Text className="vm-upload-footer__hint"></Text>
<View className="vm-upload-footer__btn">
<PrimaryButton onClick={handleUpload} disabled={uploadStatus === 'uploading'}>
{uploadStatus === 'uploading'
? '上传中...'
: uploadStatus === 'success'
? '✓ 已上传'
: `上传数据(${measuredCount} 项测量${sleepData.length > 0 ? ' + ' + sleepData.length + ' 天睡眠' : ''}`}
</PrimaryButton>
</View>
{measuredAt && <Text className="vm-upload-footer__time">{measuredAt}</Text>}
</View>
</View>
) : (
<View className="vm-connect">
<View className="vm-connect__anim">
<View className="vm-connect__ring" />
<View className="vm-connect__center"><Text className="vm-connect__bt">BT</Text></View>
</View>
<Text className="vm-connect__title">M2 </Text>
<Text className="vm-connect__hint">...</Text>
</View>
)}
</PageShell>
);
}

View File

@@ -68,7 +68,9 @@ export interface PatientSummary {
} }
/** 获取患者摘要列表(字段最小化,替代 getPatients */ /** 获取患者摘要列表(字段最小化,替代 getPatients */
export async function getPatientSummaries() { export async function getPatientSummaries(userId?: string) {
const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary'); const params: Record<string, string> = {};
if (userId) params.user_id = userId;
const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary', { params });
return Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []); return Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
} }

View File

@@ -7,6 +7,8 @@ import type {
BLEConnectionChangeResult, BLEConnectionChangeResult,
BLECharacteristicChangeResult, BLECharacteristicChangeResult,
BLEServiceItem, BLEServiceItem,
BLEDiscoveredService,
BLEDiscoveredCharacteristic,
} from './types'; } from './types';
/** BLE 连接管理 — 封装连接/断开/服务发现/通知订阅/数据监听 */ /** BLE 连接管理 — 封装连接/断开/服务发现/通知订阅/数据监听 */
@@ -121,10 +123,24 @@ export class BLEConnection {
} }
} }
/** 发现服务并启用通知 */ /** 已知的健康相关 Characteristic UUID用于自动发现和订阅 */
private static readonly HEALTH_CHAR_UUIDS: Record<string, string> = {
'2A37': 'heart_rate', // Heart Rate Measurement
'2A38': 'heart_rate_loc', // Body Sensor Location
'2A1C': 'temperature', // Temperature Measurement
'2A35': 'blood_pressure', // Blood Pressure Measurement
'2A5F': 'blood_oxygen', // PLX Continuous Measurement
'2A5E': 'blood_oxygen_spot',// PLX Spot-Check Measurement
};
/** 发现服务并启用通知 — 先订阅适配器指定的,再扫描全部服务尝试自动发现 */
private async discoverServices(device: BLEDevice): Promise<void> { private async discoverServices(device: BLEDevice): Promise<void> {
const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId }); const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId });
const services = servicesRes.services || []; const services = servicesRes.services || [];
const discoveredServices: BLEDiscoveredService[] = [];
// ── 第一轮:订阅适配器预定义的 Characteristic保持向后兼容 ──
const subscribedCharUUIDs = new Set<string>();
for (const { service: svcUUID, characteristic: charUUID } of device.adapter!.notifyCharacteristics) { for (const { service: svcUUID, characteristic: charUUID } of device.adapter!.notifyCharacteristics) {
const svc = services.find((s: BLEServiceItem) => const svc = services.find((s: BLEServiceItem) =>
@@ -137,13 +153,90 @@ export class BLEConnection {
serviceId: svc.uuid, serviceId: svc.uuid,
}); });
await Taro.notifyBLECharacteristicValueChange({ try {
deviceId: device.deviceId, await Taro.notifyBLECharacteristicValueChange({
serviceId: svc.uuid, deviceId: device.deviceId,
characteristicId: charUUID, serviceId: svc.uuid,
state: true, characteristicId: charUUID,
state: true,
});
subscribedCharUUIDs.add(charUUID.toUpperCase().replace(/-/g, '').slice(-4));
console.log(`[ble] 已订阅适配器预定义: ${svcUUID} / ${charUUID}`);
} catch (err) {
console.warn(`[ble] 订阅失败 (预定义): ${charUUID}`, err);
}
}
// ── 第二轮:扫描全部服务,发现并订阅健康相关 Characteristic ──
for (const svc of services) {
const svcUUID = svc.uuid.toUpperCase();
const discoveredChars: BLEDiscoveredCharacteristic[] = [];
let charsRes: Taro.getBLEDeviceCharacteristics.SuccessCallbackResult;
try {
charsRes = await Taro.getBLEDeviceCharacteristics({
deviceId: device.deviceId,
serviceId: svc.uuid,
});
} catch (err) {
console.warn(`[ble] 读取特征列表失败: ${svcUUID}`, err);
continue;
}
const characteristics = charsRes.characteristics || [];
for (const char of characteristics) {
const charUUIDShort = char.uuid.toUpperCase().replace(/-/g, '').slice(-4);
const props = char.properties || {};
const discoveredChar: BLEDiscoveredCharacteristic = {
uuid: char.uuid,
properties: {
read: !!props.read,
write: !!props.write,
notify: !!props.notify,
indicate: !!props.indicate,
},
};
discoveredChars.push(discoveredChar);
// 如果是已知的健康 UUID 且尚未订阅,尝试订阅
if (
BLEConnection.HEALTH_CHAR_UUIDS[charUUIDShort] &&
!subscribedCharUUIDs.has(charUUIDShort) &&
(props.notify || props.indicate)
) {
try {
await Taro.notifyBLECharacteristicValueChange({
deviceId: device.deviceId,
serviceId: svc.uuid,
characteristicId: char.uuid,
state: true,
});
subscribedCharUUIDs.add(charUUIDShort);
console.log(`[ble] 自动发现并订阅: ${BLEConnection.HEALTH_CHAR_UUIDS[charUUIDShort]} (${svcUUID} / ${char.uuid})`);
} catch (err) {
console.warn(`[ble] 自动订阅失败: ${char.uuid}`, err);
}
}
}
discoveredServices.push({
uuid: svc.uuid,
isPrimary: !!svc.isPrimary,
characteristics: discoveredChars,
}); });
} }
// 存储发现结果到连接信息
if (this.conn) {
this.conn = { ...this.conn, discoveredServices };
}
console.log(`[ble] 服务发现完成: ${discoveredServices.length} 个服务, 已订阅 ${subscribedCharUUIDs.size} 个特征`);
console.log(`[ble] 已订阅的健康特征:`, [...subscribedCharUUIDs].map(
(s) => `${s}(${BLEConnection.HEALTH_CHAR_UUIDS[s] ?? '未知'})`
).join(', '));
} }
/** 手动读取特征值 */ /** 手动读取特征值 */

View File

@@ -0,0 +1,305 @@
/**
* Veepoo SDK 桥接模块
*
* 调用顺序(基于 SDK Demo 验证):
* 1. startScan() — 初始化蓝牙 + 扫描
* 2. stopScan() — 找到设备后停止扫描
* 3. connectDevice(deviceObj) — 传入完整设备对象(非 deviceId 字符串)
* 4. registerDataListener() — 连接成功后注册数据监听
* 5. authenticate() — 延迟 500ms 后调用秘钥认证
* 6. 认证结果通过数据监听回调 type=1 返回
*/
// @ts-ignore — SDK 类型声明为 any
import { veepooBle, veepooFeature, veepooLogger } from './veepoo-sdk';
// ── SDK 事件类型常量 ──
/** 秘钥认证结果 */
export const SDK_EVENT_AUTH = 1;
/** 日常数据 */
export const SDK_EVENT_DAILY = 5;
/** 体温检测 */
export const SDK_EVENT_TEMPERATURE = 6;
/** 血压 */
export const SDK_EVENT_BLOOD_PRESSURE = 18;
/** 血氧手动测量 */
export const SDK_EVENT_BLOOD_OXYGEN = 31;
/** 心率测量 */
export const SDK_EVENT_HEART_RATE = 51;
/** 压力测量 */
export const SDK_EVENT_PRESSURE = 58;
/** 设备正忙状态枚举SDK state 字段) */
export const DEVICE_STATE = {
IDLE: 0,
MEASURING_BP: 1,
MEASURING_HR: 2,
AUTO_TEST: 3,
MEASURING_SPO2: 4,
MEASURING_FATIGUE: 5,
NOT_WORN: 6,
CHARGING: 7,
LOW_BATTERY: 8,
BUSY: 9,
} as const;
/** 连接回调中 connection 字段为 true 表示连接成功 */
export interface VeepooConnectionResult {
connection?: boolean;
errno?: number;
errCode?: number;
errMsg?: string;
}
/** SDK 事件回调数据(统一格式) */
export interface SdkEventData {
name: string;
type: number;
content: Record<string, unknown>;
Progress?: number;
state?: number;
control?: number;
ack?: number;
}
// ── 蓝牙模块 ──
/** 初始化蓝牙 + 开始扫描 */
export function startScan(onDeviceFound: (device: unknown) => void): void {
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(
(res: unknown) => {
const device = Array.isArray(res) ? res[0] : res;
if (device) onDeviceFound(device);
},
);
}
/** 停止扫描 */
export function stopScan(): Promise<void> {
return new Promise((resolve) => {
veepooBle.veepooWeiXinSDKStopSearchBleManager(() => resolve());
});
}
/** 连接设备 — 传入完整设备对象 */
export function connectDevice(device: unknown): Promise<VeepooConnectionResult> {
return new Promise((resolve) => {
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(
device,
(res: VeepooConnectionResult) => resolve(res),
);
});
}
/** 注册数据监听(必须在连接成功后调用) */
export function registerDataListener(callback: (data: SdkEventData) => void): void {
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(callback);
}
/** 监听蓝牙连接状态变化 */
export function registerConnectionListener(callback: (res: { deviceId: string; connected: boolean }) => void): void {
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(callback);
}
/** 断开连接 */
export function disconnect(): Promise<void> {
return new Promise((resolve) => {
veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(() => resolve());
});
}
// ── 功能模块:认证 ──
/** 秘钥认证(无参数无回调,结果通过数据监听 type=1 返回) */
export function authenticate(): void {
veepooFeature.veepooBlePasswordCheckManager();
}
// ── 功能模块:测量指令 ──
/** 心率测量开关true=开启false=关闭) */
export function setHeartRateMeasure(on: boolean): void {
veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: on });
}
/** 血氧测量开关('start'=开启,'stop'=关闭) */
export function setBloodOxygenMeasure(action: 'start' | 'stop'): void {
veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: action });
}
/** 血压测量开关('start'=开启,'stop'=关闭) */
export function setBloodPressureMeasure(action: 'start' | 'stop'): void {
veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: action });
}
/** 体温测量(单次触发) */
export function startTemperatureMeasure(): void {
veepooFeature.veepooSendTemperatureMeasurementSwitchManager();
}
/** 压力测量开关true=开启false=关闭) */
export function setPressureMeasure(on: boolean): void {
veepooFeature.veepooSendPressureTestManager({ switch: on });
}
// ── 功能模块:日常数据 ──
/** 读取日常数据day: 0=今天, 1=昨天, 2=前天package: 开始包序号默认1 */
export function readDailyData(day: number, pkg: number = 1): void {
veepooFeature.veepooSendReadDailyDataManager({ day, package: pkg });
}
// ── 功能模块:精准睡眠数据 ──
/** 精准睡眠事件类型 */
export const SDK_EVENT_SLEEP = 4;
/** 精准睡眠数据SDK 回调 type=4 */
export interface SleepData {
/** 入睡时间(时间戳字符串) */
fallAsleepTime: string;
/** 退出睡眠时间(时间戳字符串) */
exitSleepTime: string;
/** 起夜得分 */
nightScore: number;
/** 深睡得分 */
deepSleepScore: number;
/** 睡眠效率得分 */
sleepEfficiencyScore: number;
/** 入睡效率得分 */
fallAsleepEfficiencyScore: number;
/** 睡眠时长得分 */
sleepTimeScore: number;
/** 睡眠质量1-5 星) */
sleepQuality: number;
/** 深睡时长(分钟) */
deepSleepTime: number;
/** 浅睡时长(分钟) */
lightSleepTime: number;
/** 其他睡眠时长(分钟) */
otherSleepTime: number;
/** 睡眠总时长(分钟) */
sleepTotalTime: number;
/** 首次深睡眠时长(分钟) */
firstDeepSleepTime: number;
/** 起夜总时长(分钟) */
nightTotalTime: number;
/** 起夜到深睡均值 */
nightDeepSleepMeanValue: number;
/** 失眠得分 */
insomniaScore: number;
/** 失眠次数 */
insomniaCount: number;
/** 睡眠曲线字符串0=深睡, 1=浅睡, 2=REM, 3=失眠, 4=苏醒) */
sleepCurve: string;
}
/** 读取精准睡眠数据day: 0=今天, 1=昨天, 2=前天) */
export function readPreciseSleepData(day: number): void {
veepooFeature.veepooSendReadPreciseSleepManager({ day });
}
// ── 功能模块自动测量B3 ──
/** 自动测量事件类型 */
export const SDK_EVENT_AUTO_TEST = 54;
/** B3 自动测量功能类型枚举 */
export const AUTO_TEST_FUN_TYPES = {
PULSE_RATE: 0, // 脉率
BLOOD_PRESSURE: 1, // 血压
BLOOD_GLUCOSE: 2, // 血糖
PRESSURE: 3, // 压力
BLOOD_OXYGEN: 4, // 血氧
TEMPERATURE: 5, // 体温
LORENTZ_SCATTER: 6, // 洛伦兹散点图
HRV: 7, // HRV
BLOOD_COMPONENT: 8, // 血液成分
} as const;
export type AutoTestFunType = (typeof AUTO_TEST_FUN_TYPES)[keyof typeof AUTO_TEST_FUN_TYPES];
/** B3 自动测量配置项 */
export interface AutoTestConfig {
/** 协议类型(不可修改) */
protocolType: number;
/** 功能类型 0-8可修改 */
funTypeContent: AutoTestFunType;
/** 开关0=关闭, 1=开启 */
funSwitch: number;
/** 最小步进(分钟) */
stepUnit: number;
/** 是否支持时间段修改 */
timeSlotModify: number;
/** 是否支持时间间隔修改 */
timeIntervalModify: number;
/** 支持的测试时间段 */
supportTimeSlot: { startTime: string; stopTime: string };
/** 测量间隔(分钟,按 stepUnit 递增) */
measInterval: number;
/** 当前测试时间段 */
currentTimeSlot: { startTime: string; stopTime: string };
}
/** 读取自动测量功能配置 */
export function readAutoTestConfig(): void {
veepooFeature.veepooSendReadB3AutoTestFeatureDataManager();
}
/** 设置自动测量功能 */
export function setAutoTestConfig(config: AutoTestConfig): void {
veepooFeature.veepooSendSetupB3AutoTestFeatureDataManager({
p_protocol_type: config.protocolType,
p_fun_type_content: config.funTypeContent,
p_fun_switch: config.funSwitch,
p_step_unit: config.stepUnit,
p_time_slot_modify: config.timeSlotModify,
p_time_interval_modify: config.timeIntervalModify,
p_support_time_slot: config.supportTimeSlot,
p_meas_inv: config.measInterval,
p_cur_time_slot: config.currentTimeSlot,
});
}
// ── 功能模块:开关设置 ──
/** 自动心率监测开关 */
export function setAutoHeartRate(enabled: boolean): void {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticHRTest: enabled ? 'open' : 'close',
});
}
/** 自动血压监测开关 */
export function setAutoBloodPressure(enabled: boolean): void {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticBPTest: enabled ? 'open' : 'close',
});
}
/** 体温自动监测 */
export function setAutoTemperature(enabled: boolean): void {
veepooFeature.veepooSendSwitchSettingDataManager({
VPSettingAutomaticTemperatureTest: enabled ? 'open' : 'close',
});
}
/** 读取体温自动监测数据 */
export function readAutoTemperatureData(): void {
veepooFeature.veepooReadAutoTemperatureMeasurementDataManager({ day: 0 });
}
// ── 功能模块:设备信息 ──
/** 读取设备电量 */
export function readBatteryLevel(): void {
veepooFeature.veepooReadElectricQuantityManager();
}
// ── 日志模块 ──
/** 设置日志级别0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=NONE */
export function setLogLevel(level: number): void {
veepooLogger.setLevel(level);
}

View File

@@ -23,8 +23,44 @@ const SERVICES: Record<string, { uuid: string; chars: { notify: string; read: st
read: '00002A35-0000-1000-8000-00805F9B34FB', read: '00002A35-0000-1000-8000-00805F9B34FB',
}, },
}, },
pulse_oximeter: {
uuid: '00001822-0000-1000-8000-00805F9B34FB',
chars: {
// PLX Continuous Measurement — 实时血氧+脉率
notify: '00002A5F-0000-1000-8000-00805F9B34FB',
// PLX Spot-Check Measurement — 单次测量
read: '00002A5E-0000-1000-8000-00805F9B34FB',
},
},
}; };
// ── IEEE 11073 SFLOAT 解析Bluetooth SIG 医疗 Profile 通用格式) ──
/** 特殊 SFLOAT 值 */
const SFLOAT_NAN = 0x07FF;
const SFLOAT_NRES = 0x0800;
const SFLOAT_POS_INF = 0x07FE;
const SFLOAT_NEG_INF = 0x0802;
function parseSFLOAT(view: DataView, offset: number): number | null {
if (offset + 2 > view.byteLength) return null;
const raw = view.getUint16(offset, true);
if (raw === SFLOAT_NAN || raw === SFLOAT_NRES) return null;
if (raw === SFLOAT_POS_INF) return Infinity;
if (raw === SFLOAT_NEG_INF) return -Infinity;
const signM = (raw >> 15) & 0x01;
const exp = (raw >> 12) & 0x07;
const mantissa = raw & 0x0FFF;
// 指数用 3 位补码表示0-3 正4-7 负)
const exponent = exp >= 4 ? exp - 8 : exp;
const signedMantissa = signM ? -(mantissa ^ 0x0FFF) - 1 : mantissa;
return signedMantissa * Math.pow(10, exponent);
}
// ── 解析器 ── // ── 解析器 ──
function parseHeartRate(data: ArrayBuffer): NormalizedReading | null { function parseHeartRate(data: ArrayBuffer): NormalizedReading | null {
@@ -66,6 +102,39 @@ function parseTemperature(data: ArrayBuffer): NormalizedReading | null {
}; };
} }
/**
* 解析 Pulse Oximeter Service 数据
* PLX Continuous Measurement (0x2A5F) 和 Spot-Check (0x2A5E) 共用
* 格式: Flags(1B) + SpO2(SFLOAT 2B) + PulseRate(SFLOAT 2B) + optional...
*/
function parsePulseOximeter(data: ArrayBuffer): NormalizedReading[] {
const view = new DataView(data);
if (view.byteLength < 5) return [];
const spO2 = parseSFLOAT(view, 1);
const pulseRate = parseSFLOAT(view, 3);
const now = new Date().toISOString();
const results: NormalizedReading[] = [];
if (spO2 !== null && spO2 >= 0 && spO2 <= 100) {
results.push({
device_type: 'blood_oxygen',
values: { blood_oxygen: Math.round(spO2), unit: '%' },
measured_at: now,
});
}
if (pulseRate !== null && pulseRate > 0 && pulseRate <= 300) {
results.push({
device_type: 'heart_rate',
values: { heart_rate: Math.round(pulseRate) },
measured_at: now,
});
}
return results;
}
// ── 工厂函数 ── // ── 工厂函数 ──
export interface GenericAdapterConfig { export interface GenericAdapterConfig {
@@ -100,20 +169,23 @@ export function createGenericBleAdapter(config: GenericAdapterConfig): DeviceAda
): NormalizedReading[] { ): NormalizedReading[] {
const upper = charUUID.toUpperCase(); const upper = charUUID.toUpperCase();
// Heart Rate Measurement // Heart Rate Measurement (0x2A37)
const hrsChar = SERVICES.heart_rate.chars.notify.toUpperCase(); if (upper.includes('2A37')) {
if (upper === hrsChar || upper.includes('2A37')) {
const result = parseHeartRate(data); const result = parseHeartRate(data);
return result ? [result] : []; return result ? [result] : [];
} }
// Temperature Measurement // Temperature Measurement (0x2A1C)
const htChar = SERVICES.health_thermometer.chars.notify.toUpperCase(); if (upper.includes('2A1C')) {
if (upper === htChar || upper.includes('2A1C')) {
const result = parseTemperature(data); const result = parseTemperature(data);
return result ? [result] : []; return result ? [result] : [];
} }
// Pulse Oximeter Continuous (0x2A5F) / Spot-Check (0x2A5E)
if (upper.includes('2A5F') || upper.includes('2A5E')) {
return parsePulseOximeter(data);
}
return []; return [];
}, },
@@ -155,7 +227,7 @@ export const HuaweiBandAdapter = createGenericBleAdapter({
'华为手环', '华为手环',
'华为手表', '华为手表',
], ],
profiles: ['heart_rate', 'health_thermometer'], profiles: ['heart_rate', 'health_thermometer', 'pulse_oximeter'],
}); });
/** /**

View File

@@ -72,6 +72,8 @@ export interface BLEConnection {
adapter: DeviceAdapter; adapter: DeviceAdapter;
connectedAt?: number; connectedAt?: number;
error?: string; error?: string;
/** 连接后扫描到的全部服务(用于调试和展示) */
discoveredServices?: BLEDiscoveredService[];
} }
/** 同步操作结果 */ /** 同步操作结果 */
@@ -96,7 +98,20 @@ export interface BLEManagerConfig {
export type GenericBLEProfile = export type GenericBLEProfile =
| 'heart_rate' // Heart Rate Service (0x180D) | 'heart_rate' // Heart Rate Service (0x180D)
| 'health_thermometer' // Health Thermometer Service (0x1809) | 'health_thermometer' // Health Thermometer Service (0x1809)
| 'blood_pressure'; // Blood Pressure Service (0x1810) | 'blood_pressure' // Blood Pressure Service (0x1810)
| 'pulse_oximeter'; // Pulse Oximeter Service (0x1822)
/** BLE 服务发现结果(连接后扫描到的全部服务/特征) */
export interface BLEDiscoveredCharacteristic {
uuid: string;
properties: { read: boolean; write: boolean; notify: boolean; indicate: boolean };
}
export interface BLEDiscoveredService {
uuid: string;
isPrimary: boolean;
characteristics: BLEDiscoveredCharacteristic[];
}
/** 微信 BLE 扫描回调结果 */ /** 微信 BLE 扫描回调结果 */
export interface BLEScanResult { export interface BLEScanResult {

View File

@@ -0,0 +1,245 @@
/**
* Veepoo 历史数据读取器 — 3天日常数据分批读取 + 上传
*
* SDK 日常数据格式type=5
* - 包含计步、心率、血压、血氧、睡眠、压力、体温等
* - Progress 字段 1-100% 表示读取进度
* - 每次回调可能包含一包数据
*/
import Taro from '@tarojs/taro';
import { readDailyData } from '../VeepooBridge';
import type { SdkEventData } from '../VeepooBridge';
import type { NormalizedReading } from '../types';
import type { SleepReading } from './types';
import { uploadReadings } from '@/services/device-sync';
const CHECKPOINT_KEY = 'veepoo_history_checkpoint';
const UPLOAD_BATCH_SIZE = 20;
interface Checkpoint {
lastProgress: number;
packagesRead: number;
deviceId: string;
timestamp: number;
}
export type HistoryReadPhase = 'idle' | 'reading' | 'uploading' | 'done' | 'error';
export class VeepooHistoryReader {
private phase: HistoryReadPhase = 'idle';
private progress = 0;
private packagesRead = 0;
private buffer: NormalizedReading[] = [];
private day = 0;
private patientId = '';
private deviceId = '';
private onProgress?: (progress: number, phase: HistoryReadPhase) => void;
private uploadedCount = 0;
setCallbacks(cbs: { onProgress?: (progress: number, phase: HistoryReadPhase) => void }): void {
this.onProgress = cbs.onProgress;
}
/** 开始读取3天数据 */
async startRead(patientId: string, deviceId: string): Promise<number> {
this.patientId = patientId;
this.deviceId = deviceId;
this.buffer = [];
this.uploadedCount = 0;
this.phase = 'reading';
// 依次读取 3 天数据
for (let day = 0; day < 3; day++) {
this.day = day;
this.progress = 0;
this.onProgress?.(0, 'reading');
await this.readDay(day);
// 刷新剩余 buffer
if (this.buffer.length > 0) {
await this.flushBuffer();
}
}
this.phase = 'done';
this.onProgress?.(100, 'done');
this.clearCheckpoint();
return this.uploadedCount;
}
/** 读取单天数据 */
private readDay(day: number): Promise<void> {
return new Promise((resolve) => {
// 发送读取指令
readDailyData(day, 1);
// 进度通过 handleDailyEvent 更新
// Progress=100 时 resolve
this.dayResolve = resolve;
// 超时保护30s
this.dayTimeout = setTimeout(() => {
this.dayResolve = null;
resolve();
}, 30_000);
});
}
private dayResolve: (() => void) | null = null;
private dayTimeout: ReturnType<typeof setTimeout> | null = null;
/** 处理 SDK 日常数据回调 */
handleDailyEvent(data: SdkEventData): void {
if (this.phase !== 'reading') return;
const progress = (data.Progress ?? 0) as number;
this.progress = progress;
this.onProgress?.(progress, 'reading');
// 解析数据
const readings = this.parseDailyData(data);
if (readings.length > 0) {
this.buffer.push(...readings);
this.packagesRead++;
}
// 达到批量大小就上传
if (this.buffer.length >= UPLOAD_BATCH_SIZE) {
this.flushBuffer();
}
// 进度 100% 表示当天数据读取完成
if (progress >= 100) {
if (this.dayTimeout) clearTimeout(this.dayTimeout);
this.dayTimeout = null;
const resolve = this.dayResolve;
this.dayResolve = null;
resolve?.();
}
}
/** 解析 SDK 日常数据为 NormalizedReading */
private parseDailyData(data: SdkEventData): NormalizedReading[] {
const content = data.content ?? {};
const readings: NormalizedReading[] = [];
const now = new Date();
// 偏移到对应天
const baseDate = new Date(now.getTime() - this.day * 86400000);
const timestamp = baseDate.toISOString();
// 心率
const hr = content.heartReat ?? content.heartRate;
if (typeof hr === 'number' && hr >= 30 && hr <= 250) {
readings.push({ device_type: 'heart_rate', values: { heart_rate: hr }, measured_at: timestamp });
}
// 血氧
const bo = content.bloodOxygen;
if (typeof bo === 'number' && bo >= 70 && bo <= 100) {
readings.push({ device_type: 'blood_oxygen', values: { blood_oxygen: bo }, measured_at: timestamp });
}
// 血压
const bph = content.bloodPressureHigh;
const bpl = content.bloodPressureLow;
if (typeof bph === 'number' && typeof bpl === 'number' && bph > 0 && bpl > 0) {
readings.push({ device_type: 'blood_pressure', values: { systolic: bph, diastolic: bpl }, measured_at: timestamp });
}
// 体温
const temp = content.bodyTemperature;
if (typeof temp === 'number' && temp > 30 && temp < 45) {
readings.push({ device_type: 'temperature', values: { temperature: temp }, measured_at: timestamp });
}
// 压力
const pressure = content.pressure;
if (typeof pressure === 'number' && pressure >= 0 && pressure <= 100) {
readings.push({ device_type: 'stress', values: { value: pressure }, measured_at: timestamp });
}
// 步数
const steps = content.stepCount ?? content.steps;
if (typeof steps === 'number' && steps >= 0) {
readings.push({ device_type: 'steps', values: { value: steps }, measured_at: timestamp });
}
return readings;
}
/** 上传 buffer 中的数据 */
private async flushBuffer(): Promise<void> {
if (this.buffer.length === 0) return;
const batch = this.buffer.splice(0, this.buffer.length);
this.phase = 'uploading';
this.onProgress?.(this.progress, 'uploading');
try {
await uploadReadings(this.patientId, this.deviceId, 'Veepoo M2', batch);
this.uploadedCount += batch.length;
this.saveCheckpoint();
} catch {
// 上传失败,放回 buffer
this.buffer.unshift(...batch);
}
this.phase = 'reading';
}
private saveCheckpoint(): void {
try {
const checkpoint: Checkpoint = {
lastProgress: this.progress,
packagesRead: this.packagesRead,
deviceId: this.deviceId,
timestamp: Date.now(),
};
Taro.setStorageSync(CHECKPOINT_KEY, JSON.stringify(checkpoint));
} catch { /* ignore */ }
}
private clearCheckpoint(): void {
try { Taro.removeStorageSync(CHECKPOINT_KEY); } catch { /* ignore */ }
}
getPhase(): HistoryReadPhase { return this.phase; }
getProgress(): number { return this.progress; }
getUploadedCount(): number { return this.uploadedCount; }
// ── 睡眠数据上传 ──
/** 将睡眠数据转换为 NormalizedReading 并上传 */
async uploadSleepReadings(patientId: string, deviceId: string, sleepData: SleepReading[]): Promise<number> {
if (sleepData.length === 0) return 0;
const now = new Date();
const readings: NormalizedReading[] = sleepData.map((sleep) => {
// 根据天数偏移计算日期
const baseDate = new Date(now.getTime() - sleep.day * 86400000);
return {
device_type: 'sleep',
values: {
deep_sleep_minutes: sleep.deepSleepMinutes,
light_sleep_minutes: sleep.lightSleepMinutes,
total_sleep_minutes: sleep.totalSleepMinutes,
quality_score: sleep.qualityScore,
},
measured_at: baseDate.toISOString(),
};
});
try {
await uploadReadings(patientId, deviceId, 'Veepoo M2', readings);
this.uploadedCount += readings.length;
console.log('[veepoo-history] 睡眠数据上传成功:', readings.length, '条');
return readings.length;
} catch (err) {
console.error('[veepoo-history] 睡眠数据上传失败:', err);
return 0;
}
}
}

View File

@@ -0,0 +1,588 @@
/**
* Veepoo 管线 — SDK 事件路由 + 连接编排 + 测量 Promise 封装
*
* 职责:
* 1. 连接流程编排:扫描 → 连接 → 注册监听 → 认证 → 就绪
* 2. SDK 事件路由registerDataListener 按 type 分发
* 3. 测量 Promise 化startMeasure(type) → Promise<MeasureResult>
*/
import Taro from '@tarojs/taro';
import {
startScan,
stopScan,
connectDevice,
registerDataListener,
registerConnectionListener,
authenticate,
disconnect as veepooDisconnect,
setHeartRateMeasure,
setBloodOxygenMeasure,
setBloodPressureMeasure,
startTemperatureMeasure,
setPressureMeasure,
readBatteryLevel,
readPreciseSleepData,
readAutoTestConfig,
setAutoHeartRate,
setAutoBloodPressure,
setAutoTemperature,
setLogLevel,
SDK_EVENT_AUTH,
SDK_EVENT_HEART_RATE,
SDK_EVENT_BLOOD_OXYGEN,
SDK_EVENT_BLOOD_PRESSURE,
SDK_EVENT_TEMPERATURE,
SDK_EVENT_PRESSURE,
SDK_EVENT_DAILY,
SDK_EVENT_SLEEP,
SDK_EVENT_AUTO_TEST,
DEVICE_STATE,
} from '../VeepooBridge';
import type { SdkEventData } from '../VeepooBridge';
import type { MeasureType, MeasureResult, SleepReading } from './types';
const AUTH_TIMEOUT = 8_000;
const AUTH_POLL_INTERVAL = 500;
const MEASURE_SETTLE_DELAY = 1_500;
/** pending 测量的 resolve/reject 句柄 */
interface PendingMeasure {
type: MeasureType;
resolve: (result: MeasureResult) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
lastValue: number | null;
lastValues: Record<string, number>;
settleTimer: ReturnType<typeof setTimeout> | null;
}
/** SDK type 到 MeasureType 的映射 */
const SDK_TYPE_TO_MEASURE: Record<number, MeasureType> = {
[SDK_EVENT_HEART_RATE]: 'heart_rate',
[SDK_EVENT_BLOOD_OXYGEN]: 'blood_oxygen',
[SDK_EVENT_BLOOD_PRESSURE]: 'blood_pressure',
[SDK_EVENT_TEMPERATURE]: 'temperature',
[SDK_EVENT_PRESSURE]: 'pressure',
};
export type ConnectionChangeCallback = (connected: boolean, deviceId: string) => void;
export type AuthResultCallback = (success: boolean) => void;
export type MeasureEventCallback = (type: MeasureType, data: Record<string, unknown>) => void;
export type DailyDataCallback = (data: SdkEventData) => void;
export type SleepDataCallback = (day: number, sleep: SleepReading) => void;
export class VeepooPipeline {
private pending: PendingMeasure | null = null;
private isConnected = false;
private deviceId = '';
/** 睡眠数据读取 Promise resolve 队列 */
private sleepResolvers: Map<number, (sleep: SleepReading | null) => void> = new Map();
private sleepTimeouts: Map<number, ReturnType<typeof setTimeout>> = new Map();
private onConnectionChange?: ConnectionChangeCallback;
private onAuthResult?: AuthResultCallback;
private onMeasureEvent?: MeasureEventCallback;
private onDailyData?: DailyDataCallback;
private onSleepData?: SleepDataCallback;
/** 注册回调 */
setCallbacks(cbs: {
onConnectionChange?: ConnectionChangeCallback;
onAuthResult?: AuthResultCallback;
onMeasureEvent?: MeasureEventCallback;
onDailyData?: DailyDataCallback;
onSleepData?: SleepDataCallback;
}): void {
this.onConnectionChange = cbs.onConnectionChange;
this.onAuthResult = cbs.onAuthResult;
this.onMeasureEvent = cbs.onMeasureEvent;
this.onDailyData = cbs.onDailyData;
this.onSleepData = cbs.onSleepData;
}
/** 全流程:扫描 → 连接 → 注册监听 → 认证 */
async connect(targetName: string, debug = false): Promise<string> {
console.log('[veepoo-pipeline] connect() 开始, target:', targetName);
if (debug) setLogLevel(0);
// 1. 扫描
console.log('[veepoo-pipeline] Step 1: 扫描...');
const device = await this.scanFor(targetName);
if (!device) {
console.error('[veepoo-pipeline] 扫描未找到设备');
throw new Error(`未找到设备 ${targetName}`);
}
console.log('[veepoo-pipeline] 找到设备:', (device as Record<string, unknown>)?.deviceId);
// 2. 连接
console.log('[veepoo-pipeline] Step 2: 连接...');
const connRes = await connectDevice(device);
console.log('[veepoo-pipeline] 连接结果:', JSON.stringify(connRes));
// SDK 连接成功返回 errno=0 或 connection=true两种都要兼容
const ok = connRes?.connection === true || connRes?.errno === 0 || connRes?.errCode === 0;
if (!ok) throw new Error('连接失败');
const id = (device as Record<string, unknown>).deviceId as string;
this.deviceId = id;
this.isConnected = true;
// 3. 注册数据监听(连接成功后)
registerDataListener((data) => this.routeEvent(data));
registerConnectionListener((res) => {
this.isConnected = res.connected;
this.onConnectionChange?.(res.connected, res.deviceId);
});
// 4. 认证(延迟 500ms
await delay(500);
authenticate();
// 5. 等待认证结果
const authOk = await this.waitForAuth();
if (!authOk) throw new Error('设备认证失败,请重新连接');
// 6. 读取电量
readBatteryLevel();
return id;
}
/** 扫描指定名称的设备 */
private scanFor(targetName: string): Promise<unknown | null> {
return new Promise((resolve) => {
let found: unknown = null;
const upper = targetName.toUpperCase();
startScan((device) => {
const d = device as Record<string, unknown>;
const name = String(d.localName ?? d.name ?? '').toUpperCase();
if (name.includes(upper) && !found) {
found = device;
stopScan().then(() => resolve(found));
}
});
setTimeout(() => {
if (!found) {
stopScan().then(() => resolve(null));
}
}, 10_000);
});
}
/** 等待认证结果(轮询 deviceChipStatus */
private waitForAuth(): Promise<boolean> {
return new Promise((resolve) => {
const start = Date.now();
const poll = () => {
try {
const status = Taro.getStorageSync('deviceChipStatus');
if (status === 'successfulVerification' || status === 'passTheVerification') {
this.onAuthResult?.(true);
resolve(true);
return;
}
} catch { /* ignore */ }
if (Date.now() - start >= AUTH_TIMEOUT) {
this.onAuthResult?.(false);
resolve(false);
return;
}
setTimeout(poll, AUTH_POLL_INTERVAL);
};
poll();
});
}
/** SDK 事件路由 */
private routeEvent(data: SdkEventData): void {
const eventType = data.type;
// 认证回调
if (eventType === SDK_EVENT_AUTH) {
const content = data.content ?? {};
const password = content.VPDevicepassword;
if (password === 'passTheVerification' || password === 'successfulVerification') {
this.onAuthResult?.(true);
}
return;
}
// 日常数据
if (eventType === SDK_EVENT_DAILY) {
this.onDailyData?.(data);
return;
}
// 精准睡眠数据
if (eventType === SDK_EVENT_SLEEP) {
this.handleSleepEvent(data);
return;
}
// 自动测量功能回调
if (eventType === SDK_EVENT_AUTO_TEST) {
console.log('[veepoo-pipeline] 自动测量配置回调:', JSON.stringify(data).substring(0, 300));
return;
}
// 测量数据
const measureType = SDK_TYPE_TO_MEASURE[eventType];
if (!measureType) return;
this.handleMeasureEvent(measureType, data);
this.onMeasureEvent?.(measureType, data.content ?? {});
}
/** 处理测量事件 */
private handleMeasureEvent(type: MeasureType, data: SdkEventData): void {
if (!this.pending || this.pending.type !== type) return;
const content = data.content ?? {};
// 检查设备状态错误
const deviceBusy = content.deviceBusy === true;
const notWear = content.notWear === true;
const state = data.state;
const ack = data.ack;
if (deviceBusy) {
this.rejectPending(new Error('设备正忙,请稍后重试'));
return;
}
if (notWear || state === DEVICE_STATE.NOT_WORN) {
this.rejectPending(new Error('请将手环佩戴到手腕上'));
return;
}
if (state === DEVICE_STATE.CHARGING) {
this.rejectPending(new Error('设备正在充电,请取出后重试'));
return;
}
if (state === DEVICE_STATE.LOW_BATTERY) {
this.rejectPending(new Error('设备电量不足,请充电后重试'));
return;
}
if (type === 'pressure' && ack === 2) {
this.rejectPending(new Error('设备电量不足'));
return;
}
if (type === 'pressure' && ack === 3) {
this.rejectPending(new Error('设备正在测量其他数据'));
return;
}
if (type === 'pressure' && ack === 4) {
this.rejectPending(new Error('佩戴检测未通过'));
return;
}
// 提取数值
const values = this.extractValues(type, content);
if (!values) return;
// 更新 pending 最新值
this.pending.lastValues = values;
// 对于进度型指标,检查是否完成
const progress = data.Progress;
if (progress !== undefined && progress >= 100) {
this.resolvePending(values);
return;
}
// 对于持续测量型/单次型,收到第一个有效值后延迟 settle
if (this.pending.settleTimer === null) {
this.pending.settleTimer = setTimeout(() => {
if (this.pending && this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) {
this.resolvePending(this.pending.lastValues);
}
}, MEASURE_SETTLE_DELAY);
}
}
/** 从 SDK content 提取标准化数值 */
private extractValues(type: MeasureType, content: Record<string, unknown>): Record<string, number> | null {
switch (type) {
case 'heart_rate': {
const hr = Number(content.heartRate);
if (hr >= 30 && hr <= 250) return { heart_rate: hr };
return null;
}
case 'blood_oxygen': {
const bo = Number(content.bloodOxygen);
if (bo >= 70 && bo <= 100) return { blood_oxygen: bo };
return null;
}
case 'blood_pressure': {
const high = Number(content.bloodPressureHigh);
const low = Number(content.bloodPressureLow);
if (high > 0 && low > 0) return { systolic: high, diastolic: low };
return null;
}
case 'temperature': {
const temp = Number(content.bodyTemperature);
if (temp > 30 && temp < 45) return { temperature: temp };
return null;
}
case 'pressure': {
const p = Number(content.pressure);
if (p >= 0 && p <= 100) return { pressure: p };
return null;
}
default:
return null;
}
}
/** 发起测量 */
startMeasure(type: MeasureType): Promise<MeasureResult> {
if (this.pending) {
throw new Error(`正在测量 ${this.pending.type},请等待完成`);
}
if (!this.isConnected) {
throw new Error('设备未连接');
}
return new Promise<MeasureResult>((resolve, reject) => {
const timeout = getMeasureTimeout(type);
const timer = setTimeout(() => {
this.rejectPending(new Error('测量超时,请重试'));
}, timeout);
this.pending = {
type,
resolve,
reject,
timer,
lastValue: null,
lastValues: {},
settleTimer: null,
};
// 发送 SDK 测量指令
this.sendMeasureCommand(type);
});
}
/** 取消当前测量 */
cancelMeasure(): void {
if (!this.pending) return;
this.stopMeasureCommand(this.pending.type);
if (this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) {
this.resolvePending(this.pending.lastValues);
} else {
this.rejectPending(new Error('测量已取消'));
}
}
/** 发送 SDK 测量指令 */
private sendMeasureCommand(type: MeasureType): void {
switch (type) {
case 'heart_rate':
setHeartRateMeasure(true);
break;
case 'blood_oxygen':
setBloodOxygenMeasure('start');
break;
case 'blood_pressure':
setBloodPressureMeasure('start');
break;
case 'temperature':
startTemperatureMeasure();
break;
case 'pressure':
setPressureMeasure(true);
break;
}
}
/** 发送 SDK 停止测量指令 */
private stopMeasureCommand(type: MeasureType): void {
switch (type) {
case 'heart_rate':
setHeartRateMeasure(false);
break;
case 'blood_oxygen':
setBloodOxygenMeasure('stop');
break;
case 'blood_pressure':
setBloodPressureMeasure('stop');
break;
case 'temperature':
break; // 体温是单次触发,无法停止
case 'pressure':
setPressureMeasure(false);
break;
}
}
/** 成功 resolve pending 测量 */
private resolvePending(values: Record<string, number>): void {
if (!this.pending) return;
const p = this.pending;
this.pending = null;
clearTimeout(p.timer);
if (p.settleTimer) clearTimeout(p.settleTimer);
// 停止持续测量型指标的 SDK 指令
this.stopMeasureCommand(p.type);
p.resolve({
type: p.type,
values,
measuredAt: Date.now(),
});
}
/** 失败 reject pending 测量 */
private rejectPending(error: Error): void {
if (!this.pending) return;
const p = this.pending;
this.pending = null;
clearTimeout(p.timer);
if (p.settleTimer) clearTimeout(p.settleTimer);
// 停止 SDK 指令
this.stopMeasureCommand(p.type);
p.reject(error);
}
// ── 睡眠数据 ──
/** 读取单天精准睡眠数据,返回 Promise */
readSleepData(day: number): Promise<SleepReading | null> {
if (!this.isConnected) {
return Promise.reject(new Error('设备未连接'));
}
return new Promise<SleepReading | null>((resolve) => {
this.sleepResolvers.set(day, resolve);
// 超时保护 30s
const timer = setTimeout(() => {
this.sleepResolvers.delete(day);
this.sleepTimeouts.delete(day);
resolve(null);
}, 30_000);
this.sleepTimeouts.set(day, timer);
// 发送 SDK 读取指令
readPreciseSleepData(day);
});
}
/** 读取 3 天睡眠数据 */
async readAllSleepData(): Promise<SleepReading[]> {
const results: SleepReading[] = [];
for (let day = 0; day < 3; day++) {
const sleep = await this.readSleepData(day);
if (sleep) {
results.push(sleep);
}
}
return results;
}
/** 处理 SDK 睡眠数据回调type=4 */
private handleSleepEvent(data: SdkEventData): void {
const progress = data.Progress ?? 0;
const readDay = (data as { readDay?: number }).readDay ?? 0;
// 进度未达 100% 忽略
if (progress < 100) return;
const content = data.content ?? {};
const sleep = this.parseSleepData(readDay, content as Record<string, unknown>);
// 通知回调
if (sleep) {
this.onSleepData?.(readDay, sleep);
}
// resolve 等待中的 Promise
const resolve = this.sleepResolvers.get(readDay);
if (resolve) {
const timer = this.sleepTimeouts.get(readDay);
if (timer) clearTimeout(timer);
this.sleepResolvers.delete(readDay);
this.sleepTimeouts.delete(readDay);
resolve(sleep);
}
}
/** 从 SDK content 解析精准睡眠数据 */
private parseSleepData(day: number, content: Record<string, unknown>): SleepReading | null {
const total = Number(content.sleepTotalTime ?? 0);
if (total <= 0) return null;
return {
day,
deepSleepMinutes: Number(content.deepSleepTime ?? 0),
lightSleepMinutes: Number(content.lightSleepTime ?? 0),
otherSleepMinutes: Number(content.otherSleepTime ?? 0),
totalSleepMinutes: total,
qualityScore: Number(content.sleepQuality ?? 0),
fallAsleepTime: String(content.fallAsleepTime ?? ''),
exitSleepTime: String(content.exitSleepTime ?? ''),
};
}
// ── 自动测量 ──
/** 开启自动测量(心率 + 血压 + 血氧 + 体温) */
enableAutoMeasurement(): void {
if (!this.isConnected) return;
console.log('[veepoo-pipeline] 开启自动测量功能');
setAutoHeartRate(true);
setAutoBloodPressure(true);
setAutoTemperature(true);
// 读取当前自动测量配置
readAutoTestConfig();
}
/** 断开连接 */
async disconnect(): Promise<void> {
if (this.pending) {
this.rejectPending(new Error('设备已断开'));
}
this.isConnected = false;
this.deviceId = '';
await veepooDisconnect();
}
/** 获取连接状态 */
getConnected(): boolean {
return this.isConnected;
}
/** 获取设备 ID */
getDeviceId(): string {
return this.deviceId;
}
}
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function getMeasureTimeout(type: MeasureType): number {
const timeouts: Record<MeasureType, number> = {
heart_rate: 60_000,
blood_oxygen: 60_000,
blood_pressure: 120_000,
temperature: 60_000,
pressure: 90_000,
};
return timeouts[type];
}

View File

@@ -0,0 +1,21 @@
export { VeepooPipeline } from './VeepooPipeline';
export { VeepooHistoryReader } from './VeepooHistoryReader';
export type {
ConnectionChangeCallback,
AuthResultCallback,
MeasureEventCallback,
DailyDataCallback,
} from './VeepooPipeline';
export type {
MeasureType,
MeasurePhase,
MeasureStatus,
MeasureResult,
MeasureConfig,
ConnectionPhase,
VeepooDeviceInfo,
HistorySyncState,
SleepReading,
AutoTestSyncState,
} from './types';
export { MEASURE_TYPES, MEASURE_CONFIG } from './types';

View File

@@ -0,0 +1,152 @@
/** Veepoo 管线专用类型定义 */
/** 测量指标类型 */
export type MeasureType =
| 'heart_rate'
| 'blood_oxygen'
| 'blood_pressure'
| 'temperature'
| 'pressure';
/** 所有支持的测量指标 */
export const MEASURE_TYPES: readonly MeasureType[] = [
'heart_rate',
'blood_oxygen',
'blood_pressure',
'temperature',
'pressure',
] as const;
/** 测量指标配置 */
export interface MeasureConfig {
label: string;
unit: string;
icon: string;
color: string;
/** 正常范围 [min, max] */
normalRange: [number, number];
/** 测量超时(毫秒) */
timeout: number;
/** 测量模式 */
mode: 'continuous' | 'progress' | 'single';
}
/** 各指标配置表 */
export const MEASURE_CONFIG: Record<MeasureType, MeasureConfig> = {
heart_rate: {
label: '心率',
unit: 'bpm',
icon: '♥',
color: '#EF4444',
normalRange: [60, 100],
timeout: 60_000,
mode: 'continuous',
},
blood_oxygen: {
label: '血氧',
unit: '%',
icon: 'O₂',
color: '#3B82F6',
normalRange: [95, 100],
timeout: 60_000,
mode: 'continuous',
},
blood_pressure: {
label: '血压',
unit: 'mmHg',
icon: '↕',
color: '#8B5CF6',
normalRange: [90, 140],
timeout: 120_000,
mode: 'progress',
},
temperature: {
label: '体温',
unit: '°C',
icon: 'T',
color: '#F59E0B',
normalRange: [36.0, 37.3],
timeout: 60_000,
mode: 'single',
},
pressure: {
label: '压力',
unit: '',
icon: '~',
color: '#6366F1',
normalRange: [1, 40],
timeout: 90_000,
mode: 'progress',
},
};
/** 连接阶段 */
export type ConnectionPhase =
| 'idle'
| 'scanning'
| 'connecting'
| 'authenticating'
| 'ready'
| 'disconnected'
| 'error';
/** 测量阶段 */
export type MeasurePhase = 'idle' | 'measuring' | 'success' | 'error';
/** 单个指标的测量状态 */
export interface MeasureStatus {
phase: MeasurePhase;
progress: number;
currentValue: number | null;
result: MeasureResult | null;
error: string | null;
}
/** 测量结果 */
export interface MeasureResult {
type: MeasureType;
values: Record<string, number>;
measuredAt: number;
}
/** 设备信息 */
export interface VeepooDeviceInfo {
deviceId: string;
name: string;
batteryLevel: number | null;
}
/** 历史数据同步状态 */
export interface HistorySyncState {
phase: 'idle' | 'reading' | 'uploading' | 'done';
progress: number;
packagesRead: number;
lastCheckpoint: number;
}
/** 睡眠数据(从 SDK 精准睡眠解析) */
export interface SleepReading {
/** 读取天数0=今天, 1=昨天, 2=前天) */
day: number;
/** 深睡时长(分钟) */
deepSleepMinutes: number;
/** 浅睡时长(分钟) */
lightSleepMinutes: number;
/** 其他睡眠时长(分钟) */
otherSleepMinutes: number;
/** 睡眠总时长(分钟) */
totalSleepMinutes: number;
/** 睡眠质量评分1-5 星) */
qualityScore: number;
/** 入睡时间(时间戳字符串) */
fallAsleepTime: string;
/** 退出睡眠时间(时间戳字符串) */
exitSleepTime: string;
}
/** 自动测量同步状态 */
export interface AutoTestSyncState {
phase: 'idle' | 'reading_config' | 'configuring' | 'configured';
enabledTypes: string[];
intervalMinutes: number;
}

View File

@@ -30,6 +30,7 @@ const OFFLINE_MAX_MS = 30_000;
let offlineDetectedAt = 0; let offlineDetectedAt = 0;
let offlineSuppressMs = OFFLINE_SUPPRESS_MS; let offlineSuppressMs = OFFLINE_SUPPRESS_MS;
let networkToastShown = false; let networkToastShown = false;
let networkToastTimer: ReturnType<typeof setTimeout> | null = null;
let consecutiveNetErrors = 0; let consecutiveNetErrors = 0;
function isOffline(): boolean { function isOffline(): boolean {
@@ -44,7 +45,8 @@ function markOffline(): void {
if (!networkToastShown) { if (!networkToastShown) {
networkToastShown = true; networkToastShown = true;
Taro.showToast({ title: '网络异常,请检查连接', icon: 'none', duration: 2000 }); Taro.showToast({ title: '网络异常,请检查连接', icon: 'none', duration: 2000 });
setTimeout(() => { networkToastShown = false; }, offlineSuppressMs); if (networkToastTimer) clearTimeout(networkToastTimer);
networkToastTimer = setTimeout(() => { networkToastShown = false; networkToastTimer = null; }, offlineSuppressMs);
} }
} }
@@ -52,6 +54,8 @@ function clearOffline(): void {
offlineDetectedAt = 0; offlineDetectedAt = 0;
offlineSuppressMs = OFFLINE_SUPPRESS_MS; offlineSuppressMs = OFFLINE_SUPPRESS_MS;
consecutiveNetErrors = 0; consecutiveNetErrors = 0;
if (networkToastTimer) { clearTimeout(networkToastTimer); networkToastTimer = null; }
networkToastShown = false;
} }
function safeGet(key: string): string { function safeGet(key: string): string {
@@ -157,9 +161,15 @@ async function doRefresh(): Promise<boolean> {
let reLaunchPromise: Promise<void> | null = null; let reLaunchPromise: Promise<void> | null = null;
function safeReLaunch(url: string): void { function safeReLaunch(url: string): void {
// 已在目标页,跳过(防止 DevTools reLaunch bug
const pages = Taro.getCurrentPages();
const currentPath = pages[pages.length - 1]?.path || '';
if (currentPath.includes('pages/login')) return;
if (reLaunchPromise) return; if (reLaunchPromise) return;
reLaunchPromise = Taro.reLaunch({ url }).then(() => {}, (err) => { reLaunchPromise = Taro.reLaunch({ url }).then(() => {}, (err) => {
console.warn('[request] reLaunch failed:', err); console.warn('[request] reLaunch failed:', err);
// reLaunch 失败时降级为 redirectTo
Taro.redirectTo({ url }).catch(() => {});
}).then(() => { }).then(() => {
setTimeout(() => { reLaunchPromise = null; }, 2000); setTimeout(() => { reLaunchPromise = null; }, 2000);
}); });

View File

@@ -148,13 +148,19 @@ export const useAuthStore = create<AuthState>((set, get) => ({
get().loadPatients(); get().loadPatients();
return true; return true;
} }
// 未绑定:存储 openid 供后续绑定流程使用
if (!resp.openid) {
set({ loading: false });
throw new Error('登录失败:服务器未返回用户标识');
}
secureSet('wechat_openid', resp.openid); secureSet('wechat_openid', resp.openid);
set({ loading: false }); set({ loading: false });
return false; return false;
} catch (err) { } catch (err) {
console.warn('[auth] 微信登录失败:', err); console.warn('[auth] 微信登录失败:', err);
set({ loading: false }); set({ loading: false });
return false; // 不吞掉错误 — 让调用方区分"未绑定"和"真正的错误"
throw err;
} }
}, },
@@ -243,7 +249,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
loadPatients: async () => { loadPatients: async () => {
try { try {
const summaries = await authApi.getPatientSummaries(); const userId = get().user?.id;
const summaries = await authApi.getPatientSummaries(userId);
const patients: authApi.PatientInfo[] = summaries.map((p) => ({ const patients: authApi.PatientInfo[] = summaries.map((p) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
@@ -293,6 +300,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}); });
resetAllStores(); resetAllStores();
set({ user: null, roles: [], currentPatient: null, patients: [] }); set({ user: null, roles: [], currentPatient: null, patients: [] });
Taro.reLaunch({ url: '/pages/index/index' }); Taro.reLaunch({ url: '/pages/index/index' }).catch((err) => {
console.warn('[auth] reLaunch after logout failed:', err);
Taro.redirectTo({ url: '/pages/index/index' }).catch(() => {});
});
}, },
})); }));

View File

@@ -0,0 +1,335 @@
import { create } from 'zustand';
import { VeepooPipeline } from '@/services/ble/veepoo/VeepooPipeline';
import { VeepooHistoryReader } from '@/services/ble/veepoo/VeepooHistoryReader';
import type {
MeasureType,
MeasureStatus,
MeasureResult,
ConnectionPhase,
VeepooDeviceInfo,
HistorySyncState,
SleepReading,
} from '@/services/ble/veepoo/types';
import { MEASURE_TYPES } from '@/services/ble/veepoo/types';
import { useAuthStore } from './auth';
/** 初始化每个指标的默认状态 */
function initialMeasureStates(): Record<MeasureType, MeasureStatus> {
const states = {} as Record<MeasureType, MeasureStatus>;
for (const t of MEASURE_TYPES) {
states[t] = { phase: 'idle', progress: 0, currentValue: null, result: null, error: null };
}
return states;
}
interface VeepooState {
// 连接
connectionPhase: ConnectionPhase;
device: VeepooDeviceInfo | null;
error: string | null;
// 测量
activeMeasure: MeasureType | null;
measureStates: Record<MeasureType, MeasureStatus>;
// 历史
historySync: HistorySyncState;
// 睡眠
sleepData: SleepReading[];
sleepLoading: boolean;
// Actions
connect: (targetName?: string) => Promise<void>;
disconnect: () => Promise<void>;
startMeasure: (type: MeasureType) => Promise<MeasureResult>;
cancelMeasure: () => void;
syncHistory: (patientId: string) => Promise<void>;
readSleepData: () => Promise<SleepReading[]>;
enableAutoMeasurement: () => void;
reset: () => void;
}
let pipelineInstance: VeepooPipeline | null = null;
let historyReaderInstance: VeepooHistoryReader | null = null;
function getPipeline(): VeepooPipeline {
if (!pipelineInstance) {
pipelineInstance = new VeepooPipeline();
}
return pipelineInstance;
}
function getHistoryReader(): VeepooHistoryReader {
if (!historyReaderInstance) {
historyReaderInstance = new VeepooHistoryReader();
}
return historyReaderInstance;
}
export const useVeepooStore = create<VeepooState>((set, get) => ({
connectionPhase: 'idle',
device: null,
error: null,
activeMeasure: null,
measureStates: initialMeasureStates(),
historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 },
sleepData: [],
sleepLoading: false,
connect: async (targetName = 'M2') => {
console.log('[veepoo-store] connect() 开始, target:', targetName);
set({ connectionPhase: 'scanning', error: null });
const pipeline = getPipeline();
const historyReader = getHistoryReader();
// 注册全部回调(包含新增的 onSleepData
pipeline.setCallbacks({
onConnectionChange: (connected) => {
if (!connected) {
set({ connectionPhase: 'disconnected', device: null });
}
},
onAuthResult: (success) => {
if (success) {
set({ connectionPhase: 'ready' });
}
},
onMeasureEvent: (type, data) => {
const state = get();
if (state.activeMeasure !== type) return;
const value = extractDisplayValue(type, data);
set({
measureStates: {
...state.measureStates,
[type]: {
...state.measureStates[type],
phase: 'measuring',
progress: (data.Progress ?? data.progress ?? 0) as number,
currentValue: value,
},
},
});
},
onDailyData: (data) => {
// 转发给 HistoryReader 处理
historyReader.handleDailyEvent(data);
const progress = data.Progress ?? 0;
set((s) => ({
historySync: { ...s.historySync, progress: progress as number },
}));
},
onSleepData: (_day, sleep) => {
// 收集睡眠数据到 store
set((s) => ({
sleepData: [...s.sleepData, sleep],
}));
},
});
// 注册 HistoryReader 进度回调
historyReader.setCallbacks({
onProgress: (progress, phase) => {
set((s) => ({
historySync: {
...s.historySync,
phase: phase === 'uploading' ? 'uploading' : 'reading',
progress,
},
}));
},
});
try {
set({ connectionPhase: 'connecting' });
const deviceId = await pipeline.connect(targetName);
set({
connectionPhase: 'authenticating',
device: { deviceId, name: targetName, batteryLevel: null },
});
// 认证结果由 onAuthResult 回调设置
// 等待 ready 状态(最多 10s
await waitForState(() => get().connectionPhase === 'ready', 10_000);
// 认证通过后:自动同步历史 + 读取睡眠 + 开启自动测量
const patient = useAuthStore.getState().currentPatient;
const readyState = get().connectionPhase === 'ready';
if (patient && readyState) {
const deviceIdForReader = get().device?.deviceId ?? 'veepoo_m2';
// 并行执行三件事:
// 1. 同步日常历史数据(后台执行,进度通过回调更新)
get().syncHistory(patient.id);
// 2. 读取睡眠数据 → 完成后自动上传
get().readSleepData().then((sleepResults) => {
if (sleepResults.length > 0) {
historyReader.uploadSleepReadings(patient.id, deviceIdForReader, sleepResults);
}
});
// 3. 开启自动测量(心率+血压+体温)
pipeline.enableAutoMeasurement();
}
} catch (err) {
console.error('[veepoo-store] connect 失败:', err);
set({
connectionPhase: 'error',
error: err instanceof Error ? err.message : '连接失败',
});
}
},
disconnect: async () => {
const pipeline = getPipeline();
await pipeline.disconnect();
set({
connectionPhase: 'idle',
device: null,
error: null,
activeMeasure: null,
measureStates: initialMeasureStates(),
sleepData: [],
sleepLoading: false,
});
},
startMeasure: async (type: MeasureType) => {
const state = get();
if (state.activeMeasure) {
throw new Error(`正在测量 ${state.activeMeasure},请等待完成`);
}
if (state.connectionPhase !== 'ready') {
throw new Error('设备未就绪');
}
set({
activeMeasure: type,
measureStates: {
...state.measureStates,
[type]: { phase: 'measuring', progress: 0, currentValue: null, result: null, error: null },
},
});
const pipeline = getPipeline();
try {
const result = await pipeline.startMeasure(type);
set((s) => ({
activeMeasure: null,
measureStates: {
...s.measureStates,
[type]: { phase: 'success', progress: 100, currentValue: null, result, error: null },
},
}));
return result;
} catch (err) {
const msg = err instanceof Error ? err.message : '测量失败';
set((s) => ({
activeMeasure: null,
measureStates: {
...s.measureStates,
[type]: { phase: 'error', progress: 0, currentValue: null, result: null, error: msg },
},
}));
throw err;
}
},
cancelMeasure: () => {
const pipeline = getPipeline();
pipeline.cancelMeasure();
},
syncHistory: async (patientId: string) => {
const deviceId = get().device?.deviceId ?? 'veepoo_m2';
set((s) => ({ historySync: { ...s.historySync, phase: 'reading', progress: 0 } }));
try {
const historyReader = getHistoryReader();
const count = await historyReader.startRead(patientId, deviceId);
set((s) => ({
historySync: { ...s.historySync, phase: 'done', progress: 100, packagesRead: count },
}));
console.log('[veepoo-store] 历史数据同步完成, 上传:', count, '条');
} catch (err) {
console.error('[veepoo-store] 历史数据同步失败:', err);
set((s) => ({ historySync: { ...s.historySync, phase: 'done', progress: 100 } }));
}
},
readSleepData: async () => {
const pipeline = getPipeline();
if (!pipeline.getConnected()) {
console.warn('[veepoo-store] 设备未连接,跳过睡眠数据读取');
return [];
}
set({ sleepLoading: true, sleepData: [] });
try {
const sleepResults = await pipeline.readAllSleepData();
set({ sleepData: sleepResults, sleepLoading: false });
console.log('[veepoo-store] 睡眠数据读取完成:', sleepResults.length, '天');
return sleepResults;
} catch (err) {
console.error('[veepoo-store] 睡眠数据读取失败:', err);
set({ sleepLoading: false });
return [];
}
},
enableAutoMeasurement: () => {
const pipeline = getPipeline();
pipeline.enableAutoMeasurement();
},
reset: () => {
set({
connectionPhase: 'idle',
device: null,
error: null,
activeMeasure: null,
measureStates: initialMeasureStates(),
historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 },
sleepData: [],
sleepLoading: false,
});
},
}));
/** 从 SDK 事件 content 提取显示值 */
function extractDisplayValue(type: MeasureType, content: Record<string, unknown>): number | null {
switch (type) {
case 'heart_rate': {
const v = Number(content.heartRate);
return v >= 30 && v <= 250 ? v : null;
}
case 'blood_oxygen': {
const v = Number(content.bloodOxygen);
return v >= 70 && v <= 100 ? v : null;
}
case 'blood_pressure':
return Number(content.bloodPressureHigh) || null;
case 'temperature':
return Number(content.bodyTemperature) || null;
case 'pressure':
return Number(content.pressure) || null;
default:
return null;
}
}
/** 轮询等待状态满足条件 */
function waitForState(check: () => boolean, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const start = Date.now();
const poll = () => {
if (check()) { resolve(); return; }
if (Date.now() - start >= timeoutMs) { reject(new Error('等待超时')); return; }
setTimeout(poll, 200);
};
poll();
});
}

15
apps/miniprogram/src/types/mp-html.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
declare namespace JSX {
interface IntrinsicElements {
'mp-html': {
content?: string;
'lazy-load'?: boolean;
selectable?: boolean;
'show-img-menu'?: boolean;
domain?: string;
'tag-style'?: string;
'link-style'?: string;
'container-style'?: string;
onReady?: () => void;
};
}
}

View File

@@ -16,7 +16,7 @@ export function showToast(options: {
const duration = options.duration ?? (mode === 'elder' ? 3000 : 1500); const duration = options.duration ?? (mode === 'elder' ? 3000 : 1500);
if (mode === 'elder') { if (mode === 'elder') {
try { Taro.vibrateShort({ type: 'light' }); } catch { /* 不支持时静默 */ } Taro.vibrateShort({ type: 'light' }).catch(() => {});
} }
Taro.showToast({ ...options, duration, icon: options.icon ?? 'none' }); Taro.showToast({ ...options, duration, icon: options.icon ?? 'none' });

View File

@@ -2,21 +2,15 @@ import Taro from '@tarojs/taro';
/** 轻触反馈(按钮点击) */ /** 轻触反馈(按钮点击) */
export function hapticLight(): void { export function hapticLight(): void {
try { Taro.vibrateShort({ type: 'light' }).catch(() => { /* DevTools 不支持 type 参数,真机正常 */ });
Taro.vibrateShort({ type: 'light' });
} catch { /* 部分设备不支持 */ }
} }
/** 中等反馈(成功操作) */ /** 中等反馈(成功操作) */
export function hapticMedium(): void { export function hapticMedium(): void {
try { Taro.vibrateShort({ type: 'medium' }).catch(() => { /* ignore */ });
Taro.vibrateShort({ type: 'medium' });
} catch { /* ignore */ }
} }
/** 重度反馈(错误/警告) */ /** 重度反馈(错误/警告) */
export function hapticHeavy(): void { export function hapticHeavy(): void {
try { Taro.vibrateShort({ type: 'heavy' }).catch(() => { /* ignore */ });
Taro.vibrateShort({ type: 'heavy' });
} catch { /* ignore */ }
} }

View File

@@ -3,8 +3,26 @@ import Taro from '@tarojs/taro';
const LOGIN_PAGE = '/pages/login/index'; const LOGIN_PAGE = '/pages/login/index';
const MAX_PAGE_STACK = 9; const MAX_PAGE_STACK = 9;
// reLaunch 去重:避免 401 + 并发请求同时触发多个 reLaunch
let reLaunchPromise: Promise<void> | null = null;
export function navigateToLogin() { export function navigateToLogin() {
Taro.reLaunch({ url: LOGIN_PAGE }); // 已在登录页,跳过
const pages = Taro.getCurrentPages();
const currentPath = pages[pages.length - 1]?.path || '';
if (currentPath.includes('pages/login')) return;
// 去重:上一个 reLaunch 还没完成就跳过
if (reLaunchPromise) return;
reLaunchPromise = Taro.reLaunch({ url: LOGIN_PAGE })
.catch((err) => {
console.warn('[navigate] reLaunch to login failed:', err);
// reLaunch 失败时降级为 redirectTo
Taro.redirectTo({ url: LOGIN_PAGE }).catch(() => {});
})
.then(() => {
setTimeout(() => { reLaunchPromise = null; }, 2000);
});
} }
export function safeNavigateTo(url: string): void { export function safeNavigateTo(url: string): void {

View File

@@ -11,11 +11,12 @@ const ALLOWED_TAGS = new Set([
]); ]);
const ALLOWED_ATTRS: Record<string, Set<string>> = { const ALLOWED_ATTRS: Record<string, Set<string>> = {
'*': new Set(['class']), '*': new Set(['class', 'style', 'data-w-e-type']),
a: new Set(['href', 'title']), a: new Set(['href', 'title']),
img: new Set(['src', 'alt', 'width', 'height']), img: new Set(['src', 'alt', 'width', 'height']),
td: new Set(['colspan', 'rowspan']), td: new Set(['colspan', 'rowspan']),
th: new Set(['colspan', 'rowspan']), th: new Set(['colspan', 'rowspan']),
span: new Set(['style']),
}; };
const URL_ATTRS = new Set(['href', 'src']); const URL_ATTRS = new Set(['href', 'src']);

View File

@@ -46,7 +46,7 @@ const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList')); const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard')); const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage')); const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage'));
const AiKnowledgePage = lazy(() => import('./pages/health/AiKnowledgePage')); const KnowledgeV2Page = lazy(() => import('./pages/ai/KnowledgeV2Page'));
const AiChatPage = lazy(() => import('./pages/ai/ChatPage')); const AiChatPage = lazy(() => import('./pages/ai/ChatPage'));
const AlertList = lazy(() => import('./pages/health/AlertList')); const AlertList = lazy(() => import('./pages/health/AlertList'));
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard')); const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
@@ -330,7 +330,7 @@ export default function App() {
<Route path="/health/ai-analysis" element={<AiAnalysisList />} /> <Route path="/health/ai-analysis" element={<AiAnalysisList />} />
<Route path="/health/ai-usage" element={<AiUsageDashboard />} /> <Route path="/health/ai-usage" element={<AiUsageDashboard />} />
<Route path="/health/ai-config" element={<AiConfigPage />} /> <Route path="/health/ai-config" element={<AiConfigPage />} />
<Route path="/health/ai-knowledge" element={<AiKnowledgePage />} /> <Route path="/health/ai-knowledge" element={<KnowledgeV2Page />} />
<Route path="/ai/chat" element={<AiChatPage />} /> <Route path="/ai/chat" element={<AiChatPage />} />
<Route path="/health/alerts" element={<AlertList />} /> <Route path="/health/alerts" element={<AlertList />} />
<Route path="/health/alert-dashboard" element={<AlertDashboard />} /> <Route path="/health/alert-dashboard" element={<AlertDashboard />} />

View File

@@ -1,110 +0,0 @@
import client from '../client';
// === Types ===
export interface KnowledgeReference {
id: string;
tenant_id: string;
title: string;
analysis_type: string;
source_name: string;
content_summary: string;
tags: Record<string, unknown> | null;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface KnowledgeGuide {
id: string;
tenant_id: string;
title: string;
analysis_type: string;
content: string;
category: string | null;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface CreateReferenceReq {
title: string;
analysis_type: string;
source_name: string;
content_summary: string;
tags?: Record<string, unknown>;
is_enabled?: boolean;
}
export interface UpdateReferenceReq {
title?: string;
analysis_type?: string;
source_name?: string;
content_summary?: string;
tags?: Record<string, unknown>;
is_enabled?: boolean;
}
export interface CreateGuideReq {
title: string;
analysis_type: string;
content: string;
category?: string;
is_enabled?: boolean;
}
export interface UpdateGuideReq {
title?: string;
analysis_type?: string;
content?: string;
category?: string;
is_enabled?: boolean;
}
// === API ===
export const knowledgeApi = {
// References
listReferences: async (params?: { analysis_type?: string }) => {
const resp = await client.get('/ai/knowledge/references', { params });
return resp.data.data as { data: KnowledgeReference[]; total: number };
},
createReference: async (data: CreateReferenceReq) => {
const resp = await client.post('/ai/knowledge/references', data);
return resp.data.data as { id: string };
},
updateReference: async (id: string, data: UpdateReferenceReq) => {
const resp = await client.put(`/ai/knowledge/references/${id}`, data);
return resp.data.data as { id: string };
},
deleteReference: async (id: string) => {
const resp = await client.delete(`/ai/knowledge/references/${id}`);
return resp.data.data as { id: string };
},
reEmbedReference: async (id: string) => {
const resp = await client.post(`/ai/knowledge/references/${id}/re-embed`);
return resp.data.data as { id: string };
},
// Guides
listGuides: async (params?: { analysis_type?: string }) => {
const resp = await client.get('/ai/knowledge/guides', { params });
return resp.data.data as { data: KnowledgeGuide[]; total: number };
},
createGuide: async (data: CreateGuideReq) => {
const resp = await client.post('/ai/knowledge/guides', data);
return resp.data.data as { id: string };
},
updateGuide: async (id: string, data: UpdateGuideReq) => {
const resp = await client.put(`/ai/knowledge/guides/${id}`, data);
return resp.data.data as { id: string };
},
deleteGuide: async (id: string) => {
const resp = await client.delete(`/ai/knowledge/guides/${id}`);
return resp.data.data as { id: string };
},
reEmbedGuide: async (id: string) => {
const resp = await client.post(`/ai/knowledge/guides/${id}/re-embed`);
return resp.data.data as { id: string };
},
};

View File

@@ -0,0 +1,188 @@
import client from '../client';
// === Types ===
export interface KnowledgeBase {
id: string;
tenant_id: string;
name: string;
kb_type: string;
description: string | null;
icon: string | null;
chunk_strategy: Record<string, unknown>;
intent_keywords: Record<string, unknown>;
embedding_model: string | null;
is_enabled: boolean;
document_count: number;
chunk_count: number;
created_at: string;
updated_at: string;
}
export interface KnowledgeDocument {
id: string;
tenant_id: string;
knowledge_base_id: string;
title: string;
doc_type: string;
source_type: string;
source_url: string | null;
file_name: string | null;
file_size: number | null;
file_mime_type: string | null;
content: string | null;
status: string;
chunk_count: number;
embedded_count: number;
error_message: string | null;
processing_started_at: string | null;
processing_completed_at: string | null;
created_at: string;
updated_at: string;
}
export interface SearchHit {
chunk_id: string;
document_id: string;
chunk_index: number;
content: string;
doc_title: string;
similarity: number;
metadata: Record<string, unknown>;
}
export interface CreateKnowledgeBaseReq {
name: string;
kb_type: string;
description?: string;
icon?: string;
chunk_strategy?: Record<string, unknown>;
intent_keywords?: Record<string, unknown>;
embedding_model?: string;
is_enabled?: boolean;
}
export interface UpdateKnowledgeBaseReq {
name?: string;
kb_type?: string;
description?: string;
icon?: string;
chunk_strategy?: Record<string, unknown>;
intent_keywords?: Record<string, unknown>;
embedding_model?: string;
is_enabled?: boolean;
}
export interface CreateDocumentReq {
kb_id: string;
title: string;
doc_type?: string;
source_type?: string;
source_url?: string;
content?: string;
}
// === API ===
export const knowledgeV2Api = {
// Knowledge Bases
listKnowledgeBases: async (params?: {
kb_type?: string;
is_enabled?: boolean;
page?: number;
page_size?: number;
}) => {
const resp = await client.get('/ai/knowledge-bases', { params });
return resp.data.data as {
data: KnowledgeBase[];
total: number;
page: number;
page_size: number;
};
},
getKnowledgeBase: async (id: string) => {
const resp = await client.get(`/ai/knowledge-bases/${id}`);
return resp.data.data as KnowledgeBase;
},
createKnowledgeBase: async (data: CreateKnowledgeBaseReq) => {
const resp = await client.post('/ai/knowledge-bases', data);
return resp.data.data as { id: string };
},
updateKnowledgeBase: async (id: string, data: UpdateKnowledgeBaseReq) => {
const resp = await client.put(`/ai/knowledge-bases/${id}`, data);
return resp.data.data as { id: string };
},
deleteKnowledgeBase: async (id: string) => {
const resp = await client.delete(`/ai/knowledge-bases/${id}`);
return resp.data.data as { id: string };
},
// Documents
listDocuments: async (
kbId: string,
params?: { status?: string; page?: number; page_size?: number },
) => {
const resp = await client.get(
`/ai/knowledge-bases/${kbId}/documents`,
{ params },
);
return resp.data.data as {
data: KnowledgeDocument[];
total: number;
page: number;
page_size: number;
};
},
getDocument: async (id: string) => {
const resp = await client.get(`/ai/documents/${id}`);
return resp.data.data as KnowledgeDocument;
},
createManualDocument: async (data: CreateDocumentReq) => {
const resp = await client.post('/ai/documents/manual', data);
return resp.data.data as { id: string };
},
uploadDocument: async (
kbId: string,
file: File,
title?: string,
) => {
const formData = new FormData();
formData.append('kb_id', kbId);
formData.append('file', file);
if (title) {
formData.append('title', title);
}
const resp = await client.post('/ai/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return resp.data.data as { id: string };
},
deleteDocument: async (kbId: string, id: string) => {
const resp = await client.delete(
`/ai/knowledge-bases/${kbId}/documents/${id}`,
);
return resp.data.data as { id: string };
},
// Hit Test
hitTest: async (kbId: string, query: string, topK?: number) => {
const resp = await client.post('/ai/documents/hit-test', {
kb_id: kbId,
query,
top_k: topK,
});
return resp.data.data as {
query: string;
total: number;
hits: SearchHit[];
};
},
};

View File

@@ -11,6 +11,7 @@ export interface PromptItem {
version: number; version: number;
is_active: boolean; is_active: boolean;
category: string; category: string;
analysis_type: string;
tags: Record<string, unknown> | null; tags: Record<string, unknown> | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -23,10 +24,11 @@ export interface CreatePromptReq {
user_prompt_template: string; user_prompt_template: string;
model_config: Record<string, unknown>; model_config: Record<string, unknown>;
category: string; category: string;
analysis_type: string;
} }
export const promptApi = { export const promptApi = {
list: async (params?: { category?: string; page?: number; page_size?: number }) => { list: async (params?: { category?: string; analysis_type?: string; page?: number; page_size?: number }) => {
const resp = await client.get('/ai/prompts', { params }); const resp = await client.get('/ai/prompts', { params });
return resp.data.data as PaginatedResponse<PromptItem>; return resp.data.data as PaginatedResponse<PromptItem>;
}, },
@@ -38,8 +40,15 @@ export const promptApi = {
const resp = await client.post(`/ai/prompts/${id}/activate`); const resp = await client.post(`/ai/prompts/${id}/activate`);
return resp.data.data as PromptItem; return resp.data.data as PromptItem;
}, },
deactivate: async (id: string) => {
const resp = await client.post(`/ai/prompts/${id}/deactivate`);
return resp.data.data as PromptItem;
},
rollback: async (id: string) => { rollback: async (id: string) => {
const resp = await client.post(`/ai/prompts/${id}/rollback`); const resp = await client.post(`/ai/prompts/${id}/rollback`);
return resp.data.data as PromptItem; return resp.data.data as PromptItem;
}, },
delete: async (id: string) => {
await client.delete(`/ai/prompts/${id}`);
},
}; };

View File

@@ -82,6 +82,7 @@ export interface ArticleCategory {
description?: string; description?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
version: number;
} }
export interface CreateCategoryReq { export interface CreateCategoryReq {
@@ -236,11 +237,11 @@ export const articleCategoryApi = {
return data.data; return data.data;
}, },
delete: async (id: string) => { delete: async (id: string, version: number) => {
const { data } = await client.delete<{ const { data } = await client.delete<{
success: boolean; success: boolean;
data: null; data: null;
}>(`/health/article-categories/${id}`); }>(`/health/article-categories/${id}`, { data: { version } });
return data.data; return data.data;
}, },
}; };

View File

@@ -322,10 +322,11 @@ export const pointsApi = {
}, },
updateProduct: async (id: string, req: Partial<CreatePointsProductReq> & { is_active?: boolean; version: number }) => { updateProduct: async (id: string, req: Partial<CreatePointsProductReq> & { is_active?: boolean; version: number }) => {
const { version, ...fields } = req;
const { data } = await client.put<{ const { data } = await client.put<{
success: boolean; success: boolean;
data: PointsProduct; data: PointsProduct;
}>(`/health/admin/points/products/${id}`, req); }>(`/health/admin/points/products/${id}`, { data: fields, version });
return data.data; return data.data;
}, },

View File

@@ -18,10 +18,10 @@ export interface UpdateUserRequest {
version: number; version: number;
} }
export async function listUsers(page = 1, pageSize = 20, search = '') { export async function listUsers(page = 1, pageSize = 20, search = '', excludeOnlyRoles?: string) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<UserInfo> }>( const { data } = await client.get<{ success: boolean; data: PaginatedResponse<UserInfo> }>(
'/users', '/users',
{ params: { page, page_size: pageSize, search: search || undefined } } { params: { page, page_size: pageSize, search: search || undefined, exclude_only_roles: excludeOnlyRoles } }
); );
return data.data; return data.data;
} }

View File

@@ -19,6 +19,8 @@ interface DrawerFormProps {
sections?: FormSection[]; sections?: FormSection[];
children?: React.ReactNode; children?: React.ReactNode;
columns?: 1 | 2; columns?: 1 | 2;
form?: ReturnType<typeof Form.useForm>[0];
onValuesChange?: (changedValues: Record<string, unknown>, allValues: Record<string, unknown>) => void;
} }
export function DrawerForm({ export function DrawerForm({
@@ -32,8 +34,11 @@ export function DrawerForm({
sections, sections,
children, children,
columns = 2, columns = 2,
form: externalForm,
onValuesChange,
}: DrawerFormProps) { }: DrawerFormProps) {
const [form] = Form.useForm(); const [internalForm] = Form.useForm();
const form = externalForm ?? internalForm;
const isDark = useThemeMode(); const isDark = useThemeMode();
React.useEffect(() => { React.useEffect(() => {
@@ -81,7 +86,7 @@ export function DrawerForm({
</Space> </Space>
} }
> >
<Form form={form} layout="vertical" initialValues={initialValues}> <Form form={form} layout="vertical" initialValues={initialValues} onValuesChange={onValuesChange}>
{sections {sections
? sections.map((s, i) => ( ? sections.map((s, i) => (
<div key={i}> <div key={i}>

View File

@@ -56,56 +56,62 @@ export function usePaginatedData<T, F = string>(
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [filters, setFilters] = useState<F>(defaultFilters); const [filters, setFilters] = useState<F>(defaultFilters);
const fetchFnRef = useRef(fetchFn); const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = fetchFn;
const searchTextRef = useRef(searchText); const searchTextRef = useRef(searchText);
searchTextRef.current = searchText;
const filtersRef = useRef(filters); const filtersRef = useRef(filters);
filtersRef.current = filters;
const stateRef = useRef(state); const stateRef = useRef(state);
stateRef.current = state;
const refresh = useCallback(
async (p?: number) => {
const targetPage = p ?? stateRef.current.page;
setState((s) => ({ ...s, loading: true }));
try {
const result = await fetchFnRef.current(
targetPage,
pageSize,
filtersRef.current ?? searchTextRef.current,
);
setState({ data: result.data, total: result.total, page: targetPage, loading: false });
} catch (err) {
console.warn('[usePaginatedData] 加载数据失败:', err);
message.error('加载数据失败');
setState((s) => ({ ...s, loading: false }));
}
},
[pageSize],
);
// 合并初始 fetch 和 filters 变化时的 fetch消除双重请求
const isFirstRender = useRef(true);
useEffect(() => { useEffect(() => {
fetchFnRef.current = fetchFn;
searchTextRef.current = searchText;
filtersRef.current = filters;
stateRef.current = state;
});
// 所有 fetch 统一走 useEffect通过 fetchTrigger 触发
const [fetchTrigger, setFetchTrigger] = useState(0);
const pendingPageRef = useRef<number | undefined>(undefined);
const isFirstRender = useRef(true);
// refresh 只负责设置目标页并递增 trigger实际 fetch 在 useEffect 中执行
const refresh = useCallback((p?: number) => {
pendingPageRef.current = p;
setFetchTrigger((t) => t + 1);
}, []);
useEffect(() => {
const targetPage = pendingPageRef.current ?? stateRef.current.page;
pendingPageRef.current = undefined;
if (isFirstRender.current) { if (isFirstRender.current) {
isFirstRender.current = false; isFirstRender.current = false;
if (shouldAutoFetch) { if (!shouldAutoFetch) return;
refresh(1);
}
return;
} }
if (shouldAutoFetch) {
refresh(1); // eslint-disable-next-line react-hooks/set-state-in-effect -- 数据获取 hookloading → fetch → setState 是标准模式
} setState((s) => ({ ...s, loading: true }));
// refresh 每次渲染都稳定不放入依赖数组filters 变化触发重新 fetch
// eslint-disable-next-line react-hooks/exhaustive-deps let cancelled = false;
}, [shouldAutoFetch, filters]); fetchFnRef.current(targetPage, pageSize, filtersRef.current ?? searchTextRef.current)
.then((result) => {
if (!cancelled) {
setState({ data: result.data, total: result.total, page: targetPage, loading: false });
}
})
.catch((err) => {
if (!cancelled) {
console.warn('[usePaginatedData] 加载数据失败:', err);
message.error('加载数据失败');
setState((s) => ({ ...s, loading: false }));
}
});
return () => { cancelled = true; };
// fetchTrigger 变化 = 手动 refreshfilters 变化 = 筛选刷新
}, [shouldAutoFetch, filters, fetchTrigger, pageSize]);
return { ...state, searchText, setSearchText, filters, setFilters, refresh }; return { ...state, searchText, setSearchText, filters, setFilters, refresh };
} }
@@ -115,5 +121,5 @@ interface PaginatedResult<T, F> extends PaginatedState<T> {
setSearchText: (text: string) => void; setSearchText: (text: string) => void;
filters: F; filters: F;
setFilters: (filters: F | ((prev: F) => F)) => void; setFilters: (filters: F | ((prev: F) => F)) => void;
refresh: (page?: number) => Promise<void>; refresh: (page?: number) => void;
} }

View File

@@ -52,7 +52,7 @@ export default function Users() {
const { const {
data: users, total, page, loading, refresh, data: users, total, page, loading, refresh,
} = usePaginatedData<UserInfo>(async (p, pageSize, search) => { } = usePaginatedData<UserInfo>(async (p, pageSize, search) => {
const result = await listUsers(p, pageSize, search); const result = await listUsers(p, pageSize, search, 'patient');
return { data: result.data, total: result.total }; return { data: result.data, total: result.total };
}, 20); }, 20);

View File

@@ -0,0 +1,566 @@
import { useCallback, useEffect, useState } from 'react';
import {
Card,
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Switch,
message,
Popconfirm,
Tag,
Upload,
Progress,
Drawer,
List,
Empty,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
UploadOutlined,
SearchOutlined,
FileTextOutlined,
DatabaseOutlined,
} from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import {
knowledgeV2Api,
type KnowledgeBase,
type KnowledgeDocument,
type SearchHit,
type CreateKnowledgeBaseReq,
} from '../../api/ai/knowledgeV2';
const KB_TYPES = [
{ label: '临床指南', value: 'clinical_guide' },
{ label: '操作规程', value: 'sop' },
{ label: 'FAQ', value: 'faq' },
{ label: '产品知识', value: 'product' },
{ label: '通用', value: 'general' },
];
export default function KnowledgeV2Page() {
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editKb, setEditKb] = useState<KnowledgeBase | null>(null);
const [form] = Form.useForm();
// Document drawer state
const [docDrawerKb, setDocDrawerKb] = useState<KnowledgeBase | null>(null);
const [docs, setDocs] = useState<KnowledgeDocument[]>([]);
const [docsLoading, setDocsLoading] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadKbId, setUploadKbId] = useState<string>('');
const [fileList, setFileList] = useState<UploadFile[]>([]);
// Hit test state
const [hitTestKb, setHitTestKb] = useState<KnowledgeBase | null>(null);
const [hitTestQuery, setHitTestQuery] = useState('');
const [hitResults, setHitResults] = useState<SearchHit[]>([]);
const [hitTestLoading, setHitTestLoading] = useState(false);
const loadKbs = useCallback(async () => {
setLoading(true);
try {
const res = await knowledgeV2Api.listKnowledgeBases({
page,
page_size: 20,
});
setKbs(res.data);
setTotal(res.total);
} catch {
message.error('加载知识库列表失败');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
loadKbs();
}, [loadKbs]);
const handleCreate = async () => {
try {
const values = await form.validateFields();
const req: CreateKnowledgeBaseReq = {
name: values.name,
kb_type: values.kb_type,
description: values.description,
is_enabled: values.is_enabled ?? true,
};
await knowledgeV2Api.createKnowledgeBase(req);
message.success('知识库创建成功');
setCreateModalOpen(false);
form.resetFields();
loadKbs();
} catch {
// validation error
}
};
const handleUpdate = async () => {
if (!editKb) return;
try {
const values = await form.validateFields();
await knowledgeV2Api.updateKnowledgeBase(editKb.id, {
name: values.name,
kb_type: values.kb_type,
description: values.description,
is_enabled: values.is_enabled,
});
message.success('知识库更新成功');
setEditKb(null);
form.resetFields();
loadKbs();
} catch {
// validation error
}
};
const handleDelete = async (id: string) => {
try {
await knowledgeV2Api.deleteKnowledgeBase(id);
message.success('知识库已删除');
loadKbs();
} catch {
message.error('删除失败');
}
};
const loadDocuments = async (kb: KnowledgeBase) => {
setDocDrawerKb(kb);
setDocsLoading(true);
try {
const res = await knowledgeV2Api.listDocuments(kb.id, {
page: 1,
page_size: 50,
});
setDocs(res.data);
} catch {
message.error('加载文档列表失败');
} finally {
setDocsLoading(false);
}
};
const handleUpload = async () => {
if (!uploadKbId || fileList.length === 0) return;
try {
const file = fileList[0].originFileObj;
if (!file) return;
await knowledgeV2Api.uploadDocument(uploadKbId, file);
message.success('文档上传成功,正在处理...');
setUploadModalOpen(false);
setFileList([]);
if (docDrawerKb) {
loadDocuments(docDrawerKb);
}
} catch {
message.error('上传失败');
}
};
const handleDeleteDoc = async (kbId: string, docId: string) => {
try {
await knowledgeV2Api.deleteDocument(kbId, docId);
message.success('文档已删除');
if (docDrawerKb) {
loadDocuments(docDrawerKb);
}
} catch {
message.error('删除失败');
}
};
const handleHitTest = async () => {
if (!hitTestKb || !hitTestQuery.trim()) return;
setHitTestLoading(true);
try {
const res = await knowledgeV2Api.hitTest(hitTestKb.id, hitTestQuery, 5);
setHitResults(res.hits);
} catch {
message.error('搜索失败');
setHitResults([]);
} finally {
setHitTestLoading(false);
}
};
const statusTag = (status: string) => {
const map: Record<string, { color: string; label: string }> = {
pending: { color: 'default', label: '待处理' },
processing: { color: 'processing', label: '处理中' },
completed: { color: 'success', label: '已完成' },
failed: { color: 'error', label: '失败' },
};
const info = map[status] || { color: 'default', label: status };
return <Tag color={info.color}>{info.label}</Tag>;
};
const kbColumns = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (name: string, record: KnowledgeBase) => (
<Button type="link" onClick={() => loadDocuments(record)}>
{name}
</Button>
),
},
{
title: '类型',
dataIndex: 'kb_type',
key: 'kb_type',
render: (type: string) => {
const found = KB_TYPES.find((t) => t.value === type);
return found?.label || type;
},
},
{
title: '文档数',
dataIndex: 'document_count',
key: 'document_count',
width: 90,
},
{
title: '切片数',
dataIndex: 'chunk_count',
key: 'chunk_count',
width: 90,
},
{
title: '状态',
dataIndex: 'is_enabled',
key: 'is_enabled',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
width: 240,
render: (_: unknown, record: KnowledgeBase) => (
<Space size="small">
<Button
size="small"
icon={<UploadOutlined />}
onClick={() => {
setUploadKbId(record.id);
setUploadModalOpen(true);
}}
>
</Button>
<Button
size="small"
icon={<SearchOutlined />}
onClick={() => {
setHitTestKb(record);
setHitResults([]);
setHitTestQuery('');
}}
>
</Button>
<Button
size="small"
onClick={() => {
setEditKb(record);
form.setFieldsValue({
name: record.name,
kb_type: record.kb_type,
description: record.description,
is_enabled: record.is_enabled,
});
}}
>
</Button>
<Popconfirm
title="确定删除此知识库?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const kbFormContent = (
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="知识库名称"
rules={[{ required: true, message: '请输入知识库名称' }]}
>
<Input placeholder="例:高血压临床指南" />
</Form.Item>
<Form.Item
name="kb_type"
label="知识库类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select options={KB_TYPES} placeholder="选择类型" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="知识库描述(可选)" />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch defaultChecked />
</Form.Item>
</Form>
);
const docColumns = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: '类型',
dataIndex: 'doc_type',
key: 'doc_type',
width: 80,
},
{
title: '来源',
dataIndex: 'source_type',
key: 'source_type',
width: 80,
render: (v: string) => <Tag>{v === 'upload' ? '上传' : '手动'}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: statusTag,
},
{
title: '切片/嵌入',
key: 'progress',
width: 130,
render: (_: unknown, record: KnowledgeDocument) => {
if (record.chunk_count === 0) return '-';
const pct = Math.round(
(record.embedded_count / record.chunk_count) * 100,
);
return <Progress percent={pct} size="small" />;
},
},
{
title: '操作',
key: 'actions',
width: 70,
render: (_: unknown, record: KnowledgeDocument) => (
<Popconfirm
title="确定删除此文档?"
onConfirm={() =>
handleDeleteDoc(record.knowledge_base_id, record.id)
}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
return (
<div style={{ padding: 24 }}>
<Card
title={
<Space>
<DatabaseOutlined />
V2
</Space>
}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
setCreateModalOpen(true);
}}
>
</Button>
}
>
<Table
rowKey="id"
columns={kbColumns}
dataSource={kbs}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t}`,
}}
/>
</Card>
{/* 创建知识库 Modal */}
<Modal
title="新建知识库"
open={createModalOpen}
onOk={handleCreate}
onCancel={() => {
setCreateModalOpen(false);
form.resetFields();
}}
okText="创建"
>
{kbFormContent}
</Modal>
{/* 编辑知识库 Modal */}
<Modal
title="编辑知识库"
open={!!editKb}
onOk={handleUpdate}
onCancel={() => {
setEditKb(null);
form.resetFields();
}}
okText="保存"
>
{kbFormContent}
</Modal>
{/* 文档列表 Drawer */}
<Drawer
title={
docDrawerKb
? `${docDrawerKb.name} — 文档列表`
: '文档列表'
}
open={!!docDrawerKb}
onClose={() => setDocDrawerKb(null)}
width={720}
>
<Table
rowKey="id"
columns={docColumns}
dataSource={docs}
loading={docsLoading}
pagination={false}
size="small"
locale={{ emptyText: <Empty description="暂无文档" /> }}
/>
</Drawer>
{/* 上传文档 Modal */}
<Modal
title="上传文档"
open={uploadModalOpen}
onOk={handleUpload}
onCancel={() => {
setUploadModalOpen(false);
setFileList([]);
}}
okText="上传"
>
<Upload
beforeUpload={() => false}
maxCount={1}
fileList={fileList}
onChange={({ fileList: fl }) => setFileList(fl)}
accept=".pdf,.txt,.md,.docx,.xlsx"
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
PDFTXTMarkdownDOCXXLSX 20MB
</div>
</Modal>
{/* Hit Test Drawer */}
<Drawer
title={
hitTestKb ? `${hitTestKb.name} — 向量搜索测试` : '搜索测试'
}
open={!!hitTestKb}
onClose={() => {
setHitTestKb(null);
setHitResults([]);
}}
width={600}
>
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
<Input
placeholder="输入搜索文本..."
value={hitTestQuery}
onChange={(e) => setHitTestQuery(e.target.value)}
onPressEnter={handleHitTest}
/>
<Button
type="primary"
icon={<SearchOutlined />}
loading={hitTestLoading}
onClick={handleHitTest}
>
</Button>
</Space.Compact>
<List
dataSource={hitResults}
locale={{ emptyText: <Empty description="输入查询后点击搜索" /> }}
renderItem={(item) => (
<List.Item key={item.chunk_id}>
<List.Item.Meta
avatar={
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
}
title={
<Space>
<span>{item.doc_title}</span>
<Tag color="blue"> #{item.chunk_index}</Tag>
<Tag color="green">
{(item.similarity * 100).toFixed(1)}%
</Tag>
</Space>
}
description={
<div
style={{
maxHeight: 120,
overflow: 'hidden',
textOverflow: 'ellipsis',
color: '#666',
}}
>
{item.content}
</div>
}
/>
</List.Item>
)}
/>
</Drawer>
</div>
);
}

View File

@@ -1,508 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import {
Card,
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Switch,
message,
Popconfirm,
Tabs,
Tag,
Tooltip,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ReloadOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import {
knowledgeApi,
type KnowledgeReference,
type KnowledgeGuide,
type CreateReferenceReq,
type UpdateReferenceReq,
type CreateGuideReq,
type UpdateGuideReq,
} from '../../api/ai/knowledge';
import { AuthButton } from '../../components/AuthButton';
const ANALYSIS_TYPES = [
{ value: 'lab_report', label: '化验报告' },
{ value: 'trend', label: '趋势分析' },
{ value: 'report_summary', label: '报告摘要' },
{ value: 'dialysis_risk', label: '透析风险' },
{ value: 'checkup_plan', label: '体检计划' },
{ value: 'follow_up', label: '随访总结' },
];
export default function AiKnowledgePage() {
return (
<Card title="AI 知识库管理">
<Tabs
items={[
{ key: 'references', label: '参考资料', children: <ReferencesTab /> },
{ key: 'guides', label: '临床指南', children: <GuidesTab /> },
]}
/>
</Card>
);
}
// === References Tab ===
function ReferencesTab() {
const [data, setData] = useState<KnowledgeReference[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<KnowledgeReference | null>(null);
const [filterType, setFilterType] = useState<string | undefined>();
const [form] = Form.useForm();
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await knowledgeApi.listReferences(
filterType ? { analysis_type: filterType } : undefined,
);
setData(result.data);
} catch {
message.error('加载参考资料失败');
} finally {
setLoading(false);
}
}, [filterType]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ is_enabled: true });
setModalOpen(true);
};
const openEdit = (record: KnowledgeReference) => {
setEditing(record);
form.setFieldsValue({
title: record.title,
analysis_type: record.analysis_type,
source_name: record.source_name,
content_summary: record.content_summary,
is_enabled: record.is_enabled,
});
setModalOpen(true);
};
const handleSubmit = async () => {
const values = await form.validateFields();
try {
if (editing) {
const req: UpdateReferenceReq = {
title: values.title,
analysis_type: values.analysis_type,
source_name: values.source_name,
content_summary: values.content_summary,
is_enabled: values.is_enabled,
};
await knowledgeApi.updateReference(editing.id, req);
message.success('更新成功');
} else {
const req: CreateReferenceReq = {
title: values.title,
analysis_type: values.analysis_type,
source_name: values.source_name,
content_summary: values.content_summary,
is_enabled: values.is_enabled,
};
await knowledgeApi.createReference(req);
message.success('创建成功');
}
setModalOpen(false);
fetchData();
} catch {
message.error(editing ? '更新失败' : '创建失败');
}
};
const handleDelete = async (id: string) => {
try {
await knowledgeApi.deleteReference(id);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
};
const handleReEmbed = async (id: string) => {
try {
await knowledgeApi.reEmbedReference(id);
message.success('向量重新生成已触发');
} catch {
message.error('向量重新生成失败');
}
};
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{
title: '分析类型',
dataIndex: 'analysis_type',
key: 'analysis_type',
width: 120,
render: (v: string) => {
const found = ANALYSIS_TYPES.find((t) => t.value === v);
return <Tag>{found?.label ?? v}</Tag>;
},
},
{ title: '来源', dataIndex: 'source_name', key: 'source_name', width: 150, ellipsis: true },
{
title: '状态',
dataIndex: 'is_enabled',
key: 'is_enabled',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString() : '-'),
},
{
title: '操作',
key: 'actions',
width: 200,
render: (_: unknown, record: KnowledgeReference) => (
<Space size="small">
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
</Tooltip>
<Tooltip title="重新生成向量">
<Button
type="text"
icon={<ThunderboltOutlined />}
onClick={() => handleReEmbed(record.id)}
/>
</Tooltip>
<Popconfirm
title="确定删除此参考资料?"
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="按分析类型过滤"
style={{ width: 180 }}
options={ANALYSIS_TYPES}
value={filterType}
onChange={setFilterType}
/>
<AuthButton code="ai.knowledge.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
<Button icon={<ReloadOutlined />} onClick={fetchData}>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
size="small"
/>
<Modal
title={editing ? '编辑参考资料' : '新增参考资料'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={600}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
<Input />
</Form.Item>
<Form.Item
name="analysis_type"
label="分析类型"
rules={[{ required: true, message: '请选择分析类型' }]}
>
<Select options={ANALYSIS_TYPES} />
</Form.Item>
<Form.Item name="source_name" label="来源名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="content_summary"
label="内容摘要"
rules={[{ required: true, message: '请输入内容摘要' }]}
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
);
}
// === Guides Tab ===
function GuidesTab() {
const [data, setData] = useState<KnowledgeGuide[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<KnowledgeGuide | null>(null);
const [filterType, setFilterType] = useState<string | undefined>();
const [form] = Form.useForm();
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await knowledgeApi.listGuides(
filterType ? { analysis_type: filterType } : undefined,
);
setData(result.data);
} catch {
message.error('加载临床指南失败');
} finally {
setLoading(false);
}
}, [filterType]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ is_enabled: true });
setModalOpen(true);
};
const openEdit = (record: KnowledgeGuide) => {
setEditing(record);
form.setFieldsValue({
title: record.title,
analysis_type: record.analysis_type,
content: record.content,
category: record.category,
is_enabled: record.is_enabled,
});
setModalOpen(true);
};
const handleSubmit = async () => {
const values = await form.validateFields();
try {
if (editing) {
const req: UpdateGuideReq = {
title: values.title,
analysis_type: values.analysis_type,
content: values.content,
category: values.category,
is_enabled: values.is_enabled,
};
await knowledgeApi.updateGuide(editing.id, req);
message.success('更新成功');
} else {
const req: CreateGuideReq = {
title: values.title,
analysis_type: values.analysis_type,
content: values.content,
category: values.category,
is_enabled: values.is_enabled,
};
await knowledgeApi.createGuide(req);
message.success('创建成功');
}
setModalOpen(false);
fetchData();
} catch {
message.error(editing ? '更新失败' : '创建失败');
}
};
const handleDelete = async (id: string) => {
try {
await knowledgeApi.deleteGuide(id);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
};
const handleReEmbed = async (id: string) => {
try {
await knowledgeApi.reEmbedGuide(id);
message.success('向量重新生成已触发');
} catch {
message.error('向量重新生成失败');
}
};
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{
title: '分析类型',
dataIndex: 'analysis_type',
key: 'analysis_type',
width: 120,
render: (v: string) => {
const found = ANALYSIS_TYPES.find((t) => t.value === v);
return <Tag>{found?.label ?? v}</Tag>;
},
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 100,
render: (v: string | null) => v || '-',
},
{
title: '状态',
dataIndex: 'is_enabled',
key: 'is_enabled',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString() : '-'),
},
{
title: '操作',
key: 'actions',
width: 200,
render: (_: unknown, record: KnowledgeGuide) => (
<Space size="small">
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
</Tooltip>
<Tooltip title="重新生成向量">
<Button
type="text"
icon={<ThunderboltOutlined />}
onClick={() => handleReEmbed(record.id)}
/>
</Tooltip>
<Popconfirm
title="确定删除此临床指南?"
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="按分析类型过滤"
style={{ width: 180 }}
options={ANALYSIS_TYPES}
value={filterType}
onChange={setFilterType}
/>
<AuthButton code="ai.knowledge.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
<Button icon={<ReloadOutlined />} onClick={fetchData}>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
size="small"
/>
<Modal
title={editing ? '编辑临床指南' : '新增临床指南'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={700}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
<Input />
</Form.Item>
<Form.Item
name="analysis_type"
label="分析类型"
rules={[{ required: true, message: '请选择分析类型' }]}
>
<Select options={ANALYSIS_TYPES} />
</Form.Item>
<Form.Item name="category" label="分类">
<Input placeholder="如:心血管、内分泌" />
</Form.Item>
<Form.Item
name="content"
label="指南内容"
rules={[{ required: true, message: '请输入指南内容' }]}
>
<Input.TextArea rows={8} />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
);
}

View File

@@ -3,36 +3,68 @@ import {
Table, Table,
Button, Button,
Space, Space,
Modal,
Form, Form,
Input, Input,
Select, Select,
Tag, Tag,
Badge,
message, message,
Drawer,
Descriptions,
Typography,
Slider,
InputNumber,
Modal,
} from 'antd'; } from 'antd';
import { PlusOutlined, UndoOutlined, CheckOutlined } from '@ant-design/icons'; import {
import { promptApi, type PromptItem, type CreatePromptReq } from '../../api/ai/prompts'; PlusOutlined,
UndoOutlined,
CheckOutlined,
EyeOutlined,
StopOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { promptApi, type PromptItem } from '../../api/ai/prompts';
import { AuthButton } from '../../components/AuthButton'; import { AuthButton } from '../../components/AuthButton';
import { DrawerForm } from '../../components/DrawerForm';
import type { FormSection } from '../../components/DrawerForm';
import { PageContainer } from '../../components/PageContainer';
import { useThemeMode } from '../../hooks/useThemeMode'; import { useThemeMode } from '../../hooks/useThemeMode';
import { formatDateTime } from '../../utils/format';
const CATEGORIES = [ // --- 分析类型定义(与后端 AnalysisType::prompt_name() 一一对应) ---
{ value: 'lab_report_interpretation', label: '化验单解读' },
{ value: 'health_trend_analysis', label: '趋势分析' }, const ANALYSIS_TYPES = [
{ value: 'personalized_checkup_plan', label: '体检方案' }, { value: 'lab_report_interpretation', label: '化验单解读', api: '化验单解读 API' },
{ value: 'report_summary_generation', label: '报告摘要' }, { value: 'health_trend_analysis', label: '趋势分析', api: '趋势分析 API' },
{ value: 'personalized_checkup_plan', label: '体检方案', api: '体检方案 API' },
{ value: 'report_summary_generation', label: '报告摘要', api: '报告摘要 API' },
{ value: 'follow_up_summary_generation', label: '随访摘要', api: '随访摘要 API' },
] as const;
const ANALYSIS_TYPE_MAP = Object.fromEntries(
ANALYSIS_TYPES.map((t) => [t.value, t]),
);
const MODEL_OPTIONS = [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'deepseek-reasoner', label: 'DeepSeek Reasoner' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'qwen-plus', label: 'Qwen Plus' },
]; ];
const CATEGORY_MAP: Record<string, string> = Object.fromEntries( const DEFAULT_MODEL_CONFIG = { model: 'deepseek-chat', temperature: 0.7, max_tokens: 4096 };
CATEGORIES.map((c) => [c.value, c.label]),
);
export default function AiPromptList() { export default function AiPromptList() {
const [data, setData] = useState<PromptItem[]>([]); const [data, setData] = useState<PromptItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [categoryFilter, setCategoryFilter] = useState<string | undefined>(); const [analysisTypeFilter, setAnalysisTypeFilter] = useState<string | undefined>();
const [modalOpen, setModalOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [viewing, setViewing] = useState<PromptItem | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const isDark = useThemeMode(); const isDark = useThemeMode();
@@ -43,7 +75,7 @@ export default function AiPromptList() {
const result = await promptApi.list({ const result = await promptApi.list({
page: p, page: p,
page_size: 20, page_size: 20,
category: categoryFilter, analysis_type: analysisTypeFilter,
}); });
setData(result.data); setData(result.data);
setTotal(result.total); setTotal(result.total);
@@ -53,18 +85,29 @@ export default function AiPromptList() {
setLoading(false); setLoading(false);
} }
}, },
[page, categoryFilter], [page, analysisTypeFilter],
); );
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
const handleCreate = async (values: CreatePromptReq) => { const handleCreate = async (values: Record<string, unknown>) => {
const model = String(values.model ?? 'deepseek-chat');
const temperature = Number(values.temperature ?? 0.7);
const max_tokens = Number(values.max_tokens ?? 4096);
try { try {
await promptApi.create(values); await promptApi.create({
name: String(values.name ?? ''),
analysis_type: String(values.analysis_type ?? ''),
category: String(values.analysis_type ?? ''),
description: values.description ? String(values.description) : undefined,
system_prompt: String(values.system_prompt ?? ''),
user_prompt_template: String(values.user_prompt_template ?? ''),
model_config: { model, temperature, max_tokens },
});
message.success('Prompt 创建成功'); message.success('Prompt 创建成功');
setModalOpen(false); setDrawerOpen(false);
form.resetFields(); form.resetFields();
fetchData(); fetchData();
} catch { } catch {
@@ -72,7 +115,7 @@ export default function AiPromptList() {
} }
}; };
const handleActivate = async (id: string) => { const handleActivate = useCallback(async (id: string) => {
try { try {
await promptApi.activate(id); await promptApi.activate(id);
message.success('已激活'); message.success('已激活');
@@ -80,9 +123,19 @@ export default function AiPromptList() {
} catch { } catch {
message.error('激活失败'); message.error('激活失败');
} }
}; }, [fetchData]);
const handleRollback = async (id: string) => { const handleDeactivate = useCallback(async (id: string) => {
try {
await promptApi.deactivate(id);
message.success('已停用');
fetchData();
} catch {
message.error('停用失败');
}
}, [fetchData]);
const handleRollback = useCallback(async (id: string) => {
try { try {
await promptApi.rollback(id); await promptApi.rollback(id);
message.success('已回滚'); message.success('已回滚');
@@ -90,31 +143,78 @@ export default function AiPromptList() {
} catch { } catch {
message.error('回滚失败'); message.error('回滚失败');
} }
}, [fetchData]);
const handleDelete = useCallback((record: PromptItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除 Prompt「${record.name}」(v${record.version}) 吗?`,
okType: 'danger',
onOk: async () => {
try {
await promptApi.delete(record.id);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
},
});
}, [fetchData]);
const openDetail = (record: PromptItem) => {
setViewing(record);
setDetailOpen(true);
}; };
// 按 analysis_type 汇总当前激活版本
const activeVersionMap = useMemo(() => {
const map = new Map<string, number>();
for (const item of data) {
if (item.is_active) {
map.set(item.analysis_type, item.version);
}
}
return map;
}, [data]);
const columns = useMemo(() => [ const columns = useMemo(() => [
{ {
title: '名称', title: '名称',
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
width: 180, width: 160,
render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>, render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>,
}, },
{ {
title: '类别', title: '分析类型',
dataIndex: 'category', dataIndex: 'analysis_type',
key: 'category', key: 'analysis_type',
width: 120, width: 130,
render: (v: string) => ( render: (v: string) => {
<Tag color="blue">{CATEGORY_MAP[v] || v}</Tag> const cfg = ANALYSIS_TYPE_MAP[v];
), return cfg ? <Tag color="blue">{cfg.label}</Tag> : <Tag>{v}</Tag>;
},
},
{
title: '调用链路',
dataIndex: 'analysis_type',
key: 'api_route',
width: 160,
render: (v: string) => {
const cfg = ANALYSIS_TYPE_MAP[v];
return <Typography.Text type="secondary" style={{ fontSize: 12 }}>{cfg?.api ?? v}</Typography.Text>;
},
}, },
{ {
title: '版本', title: '版本',
dataIndex: 'version', dataIndex: 'version',
key: 'version', key: 'version',
width: 70, width: 70,
render: (v: number) => `v${v}`, render: (v: number, record: PromptItem) => {
const isActive = activeVersionMap.get(record.analysis_type) === v && record.is_active;
return isActive ? <Tag color="green">v{v}</Tag> : <span>v{v}</span>;
},
}, },
{ {
title: '状态', title: '状态',
@@ -122,7 +222,7 @@ export default function AiPromptList() {
key: 'is_active', key: 'is_active',
width: 80, width: 80,
render: (v: boolean) => ( render: (v: boolean) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag> <Badge status={v ? 'success' : 'default'} text={v ? '启用' : '停用'} />
), ),
}, },
{ {
@@ -130,145 +230,247 @@ export default function AiPromptList() {
dataIndex: 'updated_at', dataIndex: 'updated_at',
key: 'updated_at', key: 'updated_at',
width: 170, width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'), render: (v: string) => formatDateTime(v),
}, },
{ {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
width: 160, width: 220,
render: (_: unknown, record: PromptItem) => ( render: (_: unknown, record: PromptItem) => (
<AuthButton code="ai.prompt.manage"> <AuthButton code="ai.prompt.manage">
<Space size={4}> <Space size={4}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => openDetail(record)}>
</Button>
{!record.is_active && ( {!record.is_active && (
<Button <Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleActivate(record.id)}>
type="link"
size="small"
icon={<CheckOutlined />}
onClick={() => handleActivate(record.id)}
>
</Button> </Button>
)} )}
<Button {record.is_active && (
type="link" <Button type="link" size="small" icon={<StopOutlined />} onClick={() => handleDeactivate(record.id)}>
size="small"
icon={<UndoOutlined />} </Button>
onClick={() => handleRollback(record.id)} )}
> <Button type="link" size="small" icon={<UndoOutlined />} onClick={() => handleRollback(record.id)}>
</Button> </Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
</Button>
</Space> </Space>
</AuthButton> </AuthButton>
), ),
}, },
], [handleActivate, handleRollback]); ], [handleActivate, handleDeactivate, handleRollback, handleDelete, activeVersionMap]);
return ( const formSections: FormSection[] = [
<div> {
<div className="erp-page-header"> title: '基本信息',
<div> fields: (
<h4>AI Prompt </h4> <>
<div className="erp-page-subtitle"> AI </div> <Form.Item
</div> name="analysis_type"
<Space size={8}> label="分析类型"
<Select rules={[{ required: true, message: '请选择分析类型' }]}
placeholder="筛选类别" extra="选择后自动填充名称,决定该 Prompt 被哪条分析链路调用"
value={categoryFilter} >
onChange={(v) => { <Select
setCategoryFilter(v); options={ANALYSIS_TYPES.map((t) => ({ value: t.value, label: t.label }))}
setPage(1); placeholder="选择分析类型"
}} />
options={CATEGORIES} </Form.Item>
allowClear
style={{ width: 150 }}
/>
<AuthButton code="ai.prompt.manage">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
setModalOpen(true);
}}
>
Prompt
</Button>
</AuthButton>
</Space>
</div>
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}
>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchData(p);
},
showTotal: (t) => `${t} 条记录`,
style: { padding: '12px 16px', margin: 0 },
}}
/>
</div>
<Modal
title="新建 Prompt"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
width={600}
destroyOnHidden
>
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item <Form.Item
name="name" name="name"
label="名称" label="标识符"
rules={[{ required: true, message: '请输入 Prompt 名称' }]} rules={[
{ required: true, message: '请输入标识符' },
{ pattern: /^[a-z0-9_]{3,64}$/, message: '仅允许小写字母、数字、下划线3-64位' },
]}
extra="后端按此标识符查找 Prompt通常与分析类型一致非必要勿改"
> >
<Input placeholder="如:化验单解读 V2" /> <Input placeholder="如 lab_report_interpretation" />
</Form.Item>
<Form.Item name="category" label="类别" rules={[{ required: true, message: '请选择类别' }]}>
<Select options={CATEGORIES} placeholder="选择类别" />
</Form.Item> </Form.Item>
<Form.Item name="description" label="描述"> <Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="Prompt 用途说明" /> <Input.TextArea rows={2} placeholder="Prompt 用途说明(仅展示,不影响选择)" />
</Form.Item> </Form.Item>
</>
),
},
{
title: '模型配置',
fields: (
<>
<Form.Item name="model" label="模型" rules={[{ required: true, message: '请选择模型' }]}>
<Select options={MODEL_OPTIONS} placeholder="选择 AI 模型" />
</Form.Item>
<Form.Item name="temperature" label="Temperature" extra="越低越确定,越高越多样">
<Slider min={0} max={2} step={0.1} />
</Form.Item>
<Form.Item name="max_tokens" label="Max Tokens">
<InputNumber min={256} max={8192} step={256} style={{ width: '100%' }} placeholder="4096" />
</Form.Item>
</>
),
},
{
title: '提示词模板',
fields: (
<>
<Form.Item <Form.Item
name="system_prompt" name="system_prompt"
label="System Prompt" label="System Prompt"
rules={[{ required: true, message: '请输入 System Prompt' }]} rules={[{ required: true, message: '请输入 System Prompt' }]}
> >
<Input.TextArea rows={4} placeholder="系统提示词" /> <Input.TextArea rows={6} placeholder="系统提示词,定义 AI 的角色和行为规则" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="user_prompt_template" name="user_prompt_template"
label="User Prompt 模板" label="User Prompt 模板"
rules={[{ required: true, message: '请输入 User Prompt 模板' }]} rules={[{ required: true, message: '请输入 User Prompt 模板' }]}
extra="支持 Handlebars {{变量}} 语法,如 {{patient_name}}、{{report_date}}"
> >
<Input.TextArea rows={4} placeholder="用户提示词模板,可用 {{变量}} 占位" /> <Input.TextArea rows={6} placeholder="用户提示词模板,可用 {{变量}} 占位" />
</Form.Item> </Form.Item>
<Form.Item </>
name="model_config" ),
label="模型配置 (JSON)" },
initialValue={{ model: 'deepseek-chat', temperature: 0.7 }} ];
return (
<PageContainer
title="AI Prompt 管理"
subtitle="管理 AI 分析提示词模板和版本"
filters={
<Select
placeholder="筛选分析类型"
value={analysisTypeFilter}
onChange={(v) => {
setAnalysisTypeFilter(v);
setPage(1);
}}
options={ANALYSIS_TYPES.map((t) => ({ value: t.value, label: t.label }))}
allowClear
style={{ width: 160 }}
/>
}
actions={
<AuthButton code="ai.prompt.manage">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
form.setFieldsValue(DEFAULT_MODEL_CONFIG);
setDrawerOpen(true);
}}
> >
<Input.TextArea rows={3} placeholder='{"model": "deepseek-chat", "temperature": 0.7}' /> Prompt
</Form.Item> </Button>
</Form> </AuthButton>
</Modal> }
</div> >
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchData(p);
},
showTotal: (t) => `${t} 条记录`,
}}
/>
{/* 新建 Prompt Drawer */}
<DrawerForm
title="新建 Prompt"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onSubmit={handleCreate}
form={form}
onValuesChange={(changed) => {
if ('analysis_type' in changed && changed.analysis_type) {
form.setFieldValue('name', changed.analysis_type);
}
}}
initialValues={{
...DEFAULT_MODEL_CONFIG,
category: '',
analysis_type: undefined,
name: '',
description: '',
}}
width={720}
columns={1}
sections={formSections}
/>
{/* 查看 Prompt 详情 */}
<Drawer
title={viewing ? `${viewing.name} (v${viewing.version})` : 'Prompt 详情'}
open={detailOpen}
onClose={() => { setDetailOpen(false); setViewing(null); }}
width={640}
styles={{ body: { background: isDark ? '#141414' : undefined } }}
>
{viewing && (
<>
<Descriptions column={2} size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="分析类型">
<Tag color="blue">{ANALYSIS_TYPE_MAP[viewing.analysis_type]?.label ?? viewing.analysis_type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="标识符">
<Typography.Text code style={{ fontSize: 12 }}>{viewing.analysis_type}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge status={viewing.is_active ? 'success' : 'default'} text={viewing.is_active ? '启用' : '停用'} />
</Descriptions.Item>
<Descriptions.Item label="版本">v{viewing.version}</Descriptions.Item>
<Descriptions.Item label="调用链路" span={2}>
<Typography.Text type="secondary">{ANALYSIS_TYPE_MAP[viewing.analysis_type]?.api ?? viewing.analysis_type}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="更新时间">{formatDateTime(viewing.updated_at)}</Descriptions.Item>
</Descriptions>
{viewing.description && (
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<div style={{ marginTop: 4 }}>{viewing.description}</div>
</div>
)}
{[
{ label: 'System Prompt', content: viewing.system_prompt },
{ label: 'User Prompt 模板', content: viewing.user_prompt_template },
{ label: '模型配置', content: JSON.stringify(viewing.model_config, null, 2) },
].map(({ label, content }) => (
<div key={label} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{label}</Typography.Text>
<pre style={{
marginTop: 4,
padding: 12,
background: isDark ? '#1e293b' : '#f8fafc',
borderRadius: 8,
fontSize: 13,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 300,
overflow: 'auto',
}}>
{content}
</pre>
</div>
))}
</>
)}
</Drawer>
</PageContainer>
); );
} }

View File

@@ -57,7 +57,7 @@ export default function ArticleCategoryManage() {
setModalOpen(true); setModalOpen(true);
}; };
const openEditModal = (record: ArticleCategory) => { const openEditModal = useCallback((record: ArticleCategory) => {
setEditingCategory(record); setEditingCategory(record);
form.setFieldsValue({ form.setFieldsValue({
name: record.name, name: record.name,
@@ -67,7 +67,7 @@ export default function ArticleCategoryManage() {
description: record.description, description: record.description,
}); });
setModalOpen(true); setModalOpen(true);
}; }, [form]);
const closeModal = () => { const closeModal = () => {
setModalOpen(false); setModalOpen(false);
@@ -111,15 +111,15 @@ export default function ArticleCategoryManage() {
} }
}; };
const handleDelete = async (id: string) => { const handleDelete = useCallback(async (record: ArticleCategory) => {
try { try {
await articleCategoryApi.delete(id); await articleCategoryApi.delete(record.id, record.version ?? 0);
message.success('分类已删除'); message.success('分类已删除');
fetchCategories(); fetchCategories();
} catch { } catch {
message.error('删除失败,可能该分类下还有文章'); message.error('删除失败,可能该分类下还有文章');
} }
}; }, [fetchCategories]);
// 构建父分类选项(排除自身) // 构建父分类选项(排除自身)
const parentOptions = categories const parentOptions = categories
@@ -184,7 +184,7 @@ export default function ArticleCategoryManage() {
<Popconfirm <Popconfirm
title="确定删除此分类?" title="确定删除此分类?"
description="删除后不可恢复,关联文章将变为未分类" description="删除后不可恢复,关联文章将变为未分类"
onConfirm={() => handleDelete(record.id)} onConfirm={() => handleDelete(record)}
> >
<Button size="small" type="text" icon={<DeleteOutlined />} danger /> <Button size="small" type="text" icon={<DeleteOutlined />} danger />
</Popconfirm> </Popconfirm>

View File

@@ -119,7 +119,7 @@ export default function ArticleManageList() {
.catch((err) => console.warn('[ArticleManageList] 获取文章分类失败:', err)); .catch((err) => console.warn('[ArticleManageList] 获取文章分类失败:', err));
}, []); }, []);
const handleDelete = async (id: string, version: number) => { const handleDelete = useCallback(async (id: string, version: number) => {
try { try {
await articleApi.delete(id, version); await articleApi.delete(id, version);
message.success('文章已删除'); message.success('文章已删除');
@@ -127,9 +127,9 @@ export default function ArticleManageList() {
} catch { } catch {
message.error('删除失败'); message.error('删除失败');
} }
}; }, [refresh]);
const handleSubmit = async (record: ArticleListItem) => { const handleSubmit = useCallback(async (record: ArticleListItem) => {
try { try {
await articleApi.submit(record.id, record.version); await articleApi.submit(record.id, record.version);
message.success('已提交审核'); message.success('已提交审核');
@@ -137,9 +137,9 @@ export default function ArticleManageList() {
} catch { } catch {
message.error('提交审核失败'); message.error('提交审核失败');
} }
}; }, [refresh]);
const handleApprove = async (record: ArticleListItem) => { const handleApprove = useCallback(async (record: ArticleListItem) => {
try { try {
await articleApi.approve(record.id, record.version); await articleApi.approve(record.id, record.version);
message.success('审核通过,文章已发布'); message.success('审核通过,文章已发布');
@@ -147,13 +147,13 @@ export default function ArticleManageList() {
} catch { } catch {
message.error('审核操作失败'); message.error('审核操作失败');
} }
}; }, [refresh]);
const openRejectModal = (record: ArticleListItem) => { const openRejectModal = useCallback((record: ArticleListItem) => {
setRejectingArticle(record); setRejectingArticle(record);
rejectForm.resetFields(); rejectForm.resetFields();
setRejectModalOpen(true); setRejectModalOpen(true);
}; }, [rejectForm]);
const handleReject = async (values: { review_note: string }) => { const handleReject = async (values: { review_note: string }) => {
if (!rejectingArticle) return; if (!rejectingArticle) return;
@@ -167,7 +167,7 @@ export default function ArticleManageList() {
} }
}; };
const handleUnpublish = async (record: ArticleListItem) => { const handleUnpublish = useCallback(async (record: ArticleListItem) => {
try { try {
await articleApi.unpublish(record.id, record.version); await articleApi.unpublish(record.id, record.version);
message.success('文章已撤回为草稿'); message.success('文章已撤回为草稿');
@@ -175,9 +175,9 @@ export default function ArticleManageList() {
} catch { } catch {
message.error('撤回操作失败'); message.error('撤回操作失败');
} }
}; }, [refresh]);
const renderActions = (record: ArticleListItem) => ( const renderActions = useCallback((record: ArticleListItem) => (
<Space size={4} wrap> <Space size={4} wrap>
{record.status === 'draft' && ( {record.status === 'draft' && (
<> <>
@@ -252,7 +252,7 @@ export default function ArticleManageList() {
</AuthButton> </AuthButton>
)} )}
</Space> </Space>
); ), [navigate, handleSubmit, handleApprove, openRejectModal, handleUnpublish, handleDelete]);
const columns = useMemo(() => [ const columns = useMemo(() => [
{ {
@@ -410,7 +410,7 @@ export default function ArticleManageList() {
<Button size="small" type="text" icon={<EditOutlined />} <Button size="small" type="text" icon={<EditOutlined />}
onClick={() => { setCatEditing(record); catForm.setFieldsValue(record); setCatModalOpen(true); }} /> onClick={() => { setCatEditing(record); catForm.setFieldsValue(record); setCatModalOpen(true); }} />
<Popconfirm title="确定删除?" onConfirm={async () => { <Popconfirm title="确定删除?" onConfirm={async () => {
try { await articleCategoryApi.delete(record.id); message.success('已删除'); fetchCategories(); } catch { message.error('删除失败'); } try { await articleCategoryApi.delete(record.id, record.version ?? 0); message.success('已删除'); fetchCategories(); } catch { message.error('删除失败'); }
}}> }}>
<Button size="small" type="text" icon={<DeleteOutlined />} danger /> <Button size="small" type="text" icon={<DeleteOutlined />} danger />
</Popconfirm> </Popconfirm>
@@ -522,7 +522,6 @@ export default function ArticleManageList() {
</Button> </Button>
</AuthButton> </AuthButton>
)} )}
loading={loading}
> >
<Tabs <Tabs
activeKey={activePageTab} activeKey={activePageTab}

View File

@@ -11,12 +11,15 @@ import {
Tag, Tag,
Badge, Badge,
Switch, Switch,
Upload,
message, message,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
PictureOutlined,
UploadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
pointsApi, pointsApi,
@@ -26,8 +29,11 @@ import {
import { AuthButton } from '../../components/AuthButton'; import { AuthButton } from '../../components/AuthButton';
import { DrawerForm } from '../../components/DrawerForm'; import { DrawerForm } from '../../components/DrawerForm';
import type { FormSection } from '../../components/DrawerForm'; import type { FormSection } from '../../components/DrawerForm';
import MediaPicker from '../../components/MediaPicker';
import { PageContainer } from '../../components/PageContainer'; import { PageContainer } from '../../components/PageContainer';
import { uploadFile } from '../../api/upload';
import { usePaginatedData } from '../../hooks/usePaginatedData'; import { usePaginatedData } from '../../hooks/usePaginatedData';
import { useThemeMode } from '../../hooks/useThemeMode';
import { formatDateTime } from '../../utils/format'; import { formatDateTime } from '../../utils/format';
/** 商品类型映射 */ /** 商品类型映射 */
@@ -59,6 +65,9 @@ interface ProductFilters {
export default function PointsProductList() { export default function PointsProductList() {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<PointsProduct | null>(null); const [editing, setEditing] = useState<PointsProduct | null>(null);
const [mediaPickerOpen, setMediaPickerOpen] = useState(false);
const [imageUrl, setImageUrl] = useState<string>('');
const isDark = useThemeMode();
const fetchProducts = useCallback( const fetchProducts = useCallback(
async (page: number, pageSize: number, filters: ProductFilters) => { async (page: number, pageSize: number, filters: ProductFilters) => {
@@ -94,11 +103,13 @@ export default function PointsProductList() {
// ---- 新建 / 编辑 ---- // ---- 新建 / 编辑 ----
const openCreate = () => { const openCreate = () => {
setEditing(null); setEditing(null);
setImageUrl('');
setModalOpen(true); setModalOpen(true);
}; };
const openEdit = (record: PointsProduct) => { const openEdit = (record: PointsProduct) => {
setEditing(record); setEditing(record);
setImageUrl(record.image_url || '');
setModalOpen(true); setModalOpen(true);
}; };
@@ -115,12 +126,14 @@ export default function PointsProductList() {
points_cost: number; points_cost: number;
stock: number; stock: number;
description?: string; description?: string;
image_url?: string;
sort_order?: number; sort_order?: number;
}; };
// 保存时去掉 URL 中的 ?token= 参数token 是临时的,不应持久化)
const cleanImageUrl = imageUrl ? imageUrl.replace(/\?token=.*$/, '') : undefined;
if (editing) { if (editing) {
await pointsApi.updateProduct(editing.id, { await pointsApi.updateProduct(editing.id, {
...typed, ...typed,
image_url: cleanImageUrl,
version: editing.version, version: editing.version,
}); });
} else { } else {
@@ -130,7 +143,7 @@ export default function PointsProductList() {
points_cost: typed.points_cost, points_cost: typed.points_cost,
stock: typed.stock, stock: typed.stock,
description: typed.description, description: typed.description,
image_url: typed.image_url, image_url: cleanImageUrl,
sort_order: typed.sort_order, sort_order: typed.sort_order,
}; };
await pointsApi.createProduct(req); await pointsApi.createProduct(req);
@@ -138,8 +151,9 @@ export default function PointsProductList() {
message.success(editing ? '更新成功' : '创建成功'); message.success(editing ? '更新成功' : '创建成功');
handleCloseDrawer(); handleCloseDrawer();
refresh(page); refresh(page);
} catch { } catch (err: unknown) {
message.error(editing ? '更新失败' : '创建失败'); const apiMsg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
message.error(apiMsg || (editing ? '更新失败' : '创建失败'));
} }
}; };
@@ -309,8 +323,51 @@ export default function PointsProductList() {
title: '展示设置', title: '展示设置',
fields: ( fields: (
<> <>
<Form.Item name="image_url" label="图片链接"> <Form.Item label="商品图片">
<Input placeholder="商品图片 URL" /> <Space.Compact style={{ width: '100%' }}>
<Input
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="输入 URL 或从媒体库选择"
style={{ flex: 1 }}
/>
<Button icon={<PictureOutlined />} onClick={() => setMediaPickerOpen(true)}>
</Button>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={async (file) => {
try {
const result = await uploadFile(file);
setImageUrl(result.url);
message.success('图片上传成功');
} catch {
message.error('图片上传失败');
}
return false;
}}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
</Space.Compact>
{imageUrl && (
<div
style={{
marginTop: 8,
borderRadius: 8,
overflow: 'hidden',
border: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
}}
>
<img
src={imageUrl}
alt="商品图片预览"
style={{ width: '100%', height: 120, objectFit: 'cover' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
)}
</Form.Item> </Form.Item>
<Form.Item name="sort_order" label="排序"> <Form.Item name="sort_order" label="排序">
<InputNumber min={0} max={9999} style={{ width: '100%' }} placeholder="0" /> <InputNumber min={0} max={9999} style={{ width: '100%' }} placeholder="0" />
@@ -394,7 +451,6 @@ export default function PointsProductList() {
points_cost: editing.points_cost, points_cost: editing.points_cost,
stock: editing.stock, stock: editing.stock,
description: editing.description, description: editing.description,
image_url: editing.image_url,
sort_order: editing.sort_order, sort_order: editing.sort_order,
} }
: { stock: -1, sort_order: 0 }} : { stock: -1, sort_order: 0 }}
@@ -403,6 +459,15 @@ export default function PointsProductList() {
columns={2} columns={2}
sections={formSections} sections={formSections}
/> />
<MediaPicker
open={mediaPickerOpen}
onClose={() => setMediaPickerOpen(false)}
onSelect={(url) => {
setImageUrl(url);
message.success('已选择图片');
}}
/>
</PageContainer> </PageContainer>
); );
} }

View File

@@ -142,8 +142,9 @@ export default function PointsRuleList() {
setModalOpen(false); setModalOpen(false);
form.resetFields(); form.resetFields();
fetchData(); fetchData();
} catch { } catch (err: unknown) {
message.error(editing ? '更新失败' : '创建失败'); const apiMsg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
message.error(apiMsg || (editing ? '更新失败' : '创建失败'));
} }
}; };

View File

@@ -6,11 +6,13 @@ import {
MedicineBoxOutlined, MedicineBoxOutlined,
UserOutlined, UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useStatsData } from './useStatsData'; import { useStatsData } from './useStatsData';
import { useCountUp } from '../../../hooks/useCountUp'; import { useCountUp } from '../../../hooks/useCountUp';
import HealthDataCenter from './HealthDataCenter'; import HealthDataCenter from './HealthDataCenter';
export function AdminDashboard() { export function AdminDashboard() {
const navigate = useNavigate();
const { patientStats, followUpStats, healthDataStats, dialysisStats, doctorCount, loading } = useStatsData(); const { patientStats, followUpStats, healthDataStats, dialysisStats, doctorCount, loading } = useStatsData();
const patientCount = useCountUp(patientStats?.total_patients ?? 0); const patientCount = useCountUp(patientStats?.total_patients ?? 0);
const appointmentCount = useCountUp(healthDataStats?.appointments?.this_month ?? 0); const appointmentCount = useCountUp(healthDataStats?.appointments?.this_month ?? 0);
@@ -18,39 +20,51 @@ export function AdminDashboard() {
if (loading && !patientStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />; if (loading && !patientStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
const newThisMonth = patientStats?.new_this_month ?? 0;
const newThisWeek = patientStats?.new_this_week ?? 0;
return ( return (
<div> <div>
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}> <Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary">
{newThisMonth > 0 ? `本月+${newThisMonth}` : ''} · +{newThisWeek} · {patientStats?.active_this_month ?? 0}
</Typography.Text>
</div> </div>
</Flex> </Flex>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={12} sm={8} lg={4}> <Col xs={12} sm={8} lg={4}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/patients')}>
<Statistic title="患者总数" value={patientCount} prefix={<TeamOutlined />} /> <Statistic title="患者总数" value={patientCount} prefix={<TeamOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} lg={4}> <Col xs={12} sm={8} lg={4}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/appointments')}>
<Statistic title="本月预约" value={appointmentCount} prefix={<CalendarOutlined />} /> <Statistic title="本月预约" value={appointmentCount} prefix={<CalendarOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} lg={4}> <Col xs={12} sm={8} lg={4}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/follow-up-tasks')}>
<Statistic <Statistic
title="随访完成" title="随访完成"
value={followUpStats?.completion_rate ?? 0} value={followUpStats?.completion_rate ?? 0}
precision={0} precision={0}
suffix="%" suffix="%"
prefix={<SafetyCertificateOutlined />} prefix={<SafetyCertificateOutlined />}
suffix={
<span style={{ fontSize: 12 }}>
% {followUpStats && followUpStats.overdue > 0 && (
<Typography.Text type="danger" style={{ fontSize: 11 }}>({followUpStats.overdue})</Typography.Text>
)}
</span>
}
/> />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} lg={4}> <Col xs={12} sm={8} lg={4}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/daily-monitoring')}>
<Statistic <Statistic
title="体征上报" title="体征上报"
value={healthDataStats?.vital_signs_report_rate?.report_rate ?? 0} value={healthDataStats?.vital_signs_report_rate?.report_rate ?? 0}
@@ -61,10 +75,20 @@ export function AdminDashboard() {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} lg={4}> <Col xs={12} sm={8} lg={4}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/doctors')}>
<Statistic title="医护人数" value={doctorCountDisplay} prefix={<UserOutlined />} /> <Statistic title="医护人数" value={doctorCountDisplay} prefix={<UserOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/consultations')}>
<Statistic
title="咨询待回复"
value={healthDataStats ? 0 : 0}
prefix={<TeamOutlined />}
valueStyle={{ color: '#d97706' }}
/>
</Card>
</Col>
</Row> </Row>
{/* 健康数据中心 Tab */} {/* 健康数据中心 Tab */}

View File

@@ -2,9 +2,10 @@ import { Row, Col, Card, Statistic, List, Tag, Spin, Typography, Flex, Space, Bu
import { import {
TeamOutlined, TeamOutlined,
MessageOutlined, MessageOutlined,
SafetyCertificateOutlined,
MedicineBoxOutlined, MedicineBoxOutlined,
CalendarOutlined,
ArrowUpOutlined, ArrowUpOutlined,
ArrowDownOutlined,
AlertOutlined, AlertOutlined,
RightOutlined, RightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -137,7 +138,7 @@ export function DoctorDashboard() {
{/* 统计卡片 */} {/* 统计卡片 */}
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/patients')}>
<Statistic <Statistic
title="我的患者" title="我的患者"
value={myPatientsCount} value={myPatientsCount}
@@ -151,19 +152,22 @@ export function DoctorDashboard() {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/follow-up-tasks')}>
<Statistic <Statistic
title="随访完成率" title="今日预约"
value={p?.follow_up_rate ?? 0} value={p?.today_appointments ?? 0}
precision={0} prefix={<CalendarOutlined />}
suffix="%" suffix={p?.yesterday_today_appointments != null ? (() => {
prefix={<SafetyCertificateOutlined />} const diff = (p.today_appointments ?? 0) - (p.yesterday_today_appointments ?? 0);
styles={{ content: { color: (p?.follow_up_rate ?? 0) >= 80 ? '#3f8600' : '#cf1322' } }} if (diff > 0) return <Typography.Text type="success" style={{ fontSize: 12 }}><ArrowUpOutlined /> {diff}</Typography.Text>;
if (diff < 0) return <Typography.Text type="danger" style={{ fontSize: 12 }}><ArrowDownOutlined /> {Math.abs(diff)}</Typography.Text>;
return null;
})() : undefined}
/> />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/consultations')}>
<Statistic <Statistic
title="本月咨询" title="本月咨询"
value={consultationsCount} value={consultationsCount}
@@ -177,7 +181,7 @@ export function DoctorDashboard() {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/daily-monitoring')}>
<Statistic <Statistic
title="体征上报率" title="体征上报率"
value={p?.vital_signs_report_rate ?? 0} value={p?.vital_signs_report_rate ?? 0}
@@ -192,13 +196,18 @@ export function DoctorDashboard() {
{/* 化验审核 */} {/* 化验审核 */}
{p && p.pending_lab_reviews > 0 && ( {p && p.pending_lab_reviews > 0 && (
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Card title={`化验审核 (${p.pending_lab_reviews}待审)`} size="small"> <Card
<List title={`化验审核 (${p.pending_lab_reviews}待审)`}
size="small" size="small"
dataSource={[]} extra={
locale={{ emptyText: '暂无待审核化验' }} <Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/patients')}>
renderItem={() => <List.Item />}
/> </Button>
}
>
<Typography.Text type="secondary">
{p.pending_lab_reviews}
</Typography.Text>
</Card> </Card>
</Col> </Col>
)} )}
@@ -211,7 +220,7 @@ export function DoctorDashboard() {
dataSource={activeConsultations} dataSource={activeConsultations}
locale={{ emptyText: '暂无未读消息' }} locale={{ emptyText: '暂无未读消息' }}
renderItem={(session) => ( renderItem={(session) => (
<List.Item style={{ padding: '6px 0' }}> <List.Item style={{ padding: '6px 0', cursor: 'pointer' }} onClick={() => navigate(`/health/consultations/${session.id}`)}>
<Space> <Space>
<Typography.Text strong style={{ fontSize: 13 }}>{session.patient_name ?? '患者'}</Typography.Text> <Typography.Text strong style={{ fontSize: 13 }}>{session.patient_name ?? '患者'}</Typography.Text>
<Tag color={session.status === 'active' ? 'green' : 'default'}>{session.status === 'active' ? '进行中' : session.status}</Tag> <Tag color={session.status === 'active' ? 'green' : 'default'}>{session.status === 'active' ? '进行中' : session.status}</Tag>

View File

@@ -1,4 +1,5 @@
import { Row, Col, Card, Statistic, Progress, List, Spin, Typography, Flex, Space } from 'antd'; import { Row, Col, Card, Statistic, Progress, List, Spin, Typography, Flex, Space } from 'antd';
import { useNavigate } from 'react-router-dom';
import { import {
TeamOutlined, TeamOutlined,
SafetyCertificateOutlined, SafetyCertificateOutlined,
@@ -11,6 +12,7 @@ import { followUpApi, type FollowUpTask } from '../../../api/health/followUp';
import { useCountUp } from '../../../hooks/useCountUp'; import { useCountUp } from '../../../hooks/useCountUp';
export function NurseDashboard() { export function NurseDashboard() {
const navigate = useNavigate();
const [personal, setPersonal] = useState<PersonalStats | null>(null); const [personal, setPersonal] = useState<PersonalStats | null>(null);
const [followUpTasks, setFollowUpTasks] = useState<FollowUpTask[]>([]); const [followUpTasks, setFollowUpTasks] = useState<FollowUpTask[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -66,7 +68,7 @@ export function NurseDashboard() {
{p.abnormal_vital_signs} {p.abnormal_vital_signs}
</Typography.Text> </Typography.Text>
</Space> </Space>
<Typography.Link> </Typography.Link> <Typography.Link onClick={() => navigate('/health/alert-dashboard')}> </Typography.Link>
</Flex> </Flex>
</Card> </Card>
</Col> </Col>

View File

@@ -1,21 +1,25 @@
import { Row, Col, Card, Statistic, List, Spin, Typography, Flex } from 'antd'; import { Row, Col, Card, Statistic, List, Spin, Typography, Flex, Button } from 'antd';
import { import {
TrophyOutlined, TrophyOutlined,
FileTextOutlined, FileTextOutlined,
CalendarOutlined, CalendarOutlined,
ShoppingOutlined, ShoppingOutlined,
RightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStatsData } from './useStatsData'; import { useStatsData } from './useStatsData';
import { articleApi, type ArticleListItem } from '../../../api/health/articles'; import { articleApi, type ArticleListItem } from '../../../api/health/articles';
import { useCountUp } from '../../../hooks/useCountUp'; import { useCountUp } from '../../../hooks/useCountUp';
export function OperatorDashboard() { export function OperatorDashboard() {
const { pointsStats, loading } = useStatsData(); const navigate = useNavigate();
const { pointsStats, offlineEventCount, loading } = useStatsData();
const [topArticles, setTopArticles] = useState<ArticleListItem[]>([]); const [topArticles, setTopArticles] = useState<ArticleListItem[]>([]);
const issuedCount = useCountUp(pointsStats?.total_issued ?? 0); const issuedCount = useCountUp(pointsStats?.total_issued ?? 0);
const spentCount = useCountUp(pointsStats?.total_spent ?? 0); const spentCount = useCountUp(pointsStats?.total_spent ?? 0);
const activeCount = useCountUp(pointsStats?.active_accounts ?? 0); const activeCount = useCountUp(pointsStats?.active_accounts ?? 0);
const offlineCount = useCountUp(offlineEventCount);
const fetchTopArticles = useCallback(async () => { const fetchTopArticles = useCallback(async () => {
try { try {
@@ -26,6 +30,8 @@ export function OperatorDashboard() {
} }
}, []); }, []);
// fetchTopArticles 内部 async setState外部数据获取非同步派生 state属合理 effect 用法
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { fetchTopArticles(); }, [fetchTopArticles]); useEffect(() => { fetchTopArticles(); }, [fetchTopArticles]);
if (loading && !pointsStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />; if (loading && !pointsStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
@@ -41,12 +47,12 @@ export function OperatorDashboard() {
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
<Statistic title="积分发放" value={issuedCount} prefix={<TrophyOutlined />} /> <Statistic title="积分发放" value={issuedCount} prefix={<TrophyOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
<Statistic title="积分消费" value={spentCount} prefix={<ShoppingOutlined />} <Statistic title="积分消费" value={spentCount} prefix={<ShoppingOutlined />}
suffix={pointsStats ? ( suffix={pointsStats ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
@@ -57,26 +63,34 @@ export function OperatorDashboard() {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
<Statistic title="活跃账户" value={activeCount} prefix={<FileTextOutlined />} /> <Statistic title="活跃账户" value={activeCount} prefix={<FileTextOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/offline-events')}>
<Statistic title="线下活动" value={0} prefix={<CalendarOutlined />} /> <Statistic title="线下活动" value={offlineCount} prefix={<CalendarOutlined />} />
</Card> </Card>
</Col> </Col>
{/* 积分排行 */} {/* 积分排行 */}
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Card title="积分消费排行" size="small"> <Card
title="积分消费排行"
size="small"
extra={
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/points-rules')}>
</Button>
}
>
<List <List
size="small" size="small"
dataSource={pointsStats?.top_earners?.slice(0, 5) ?? []} dataSource={pointsStats?.top_earners?.slice(0, 5) ?? []}
locale={{ emptyText: '暂无数据' }} locale={{ emptyText: '暂无数据' }}
renderItem={(item, idx) => ( renderItem={(item, idx) => (
<List.Item> <List.Item style={{ cursor: 'pointer' }} onClick={() => navigate(`/health/patients/${item.patient_id}`)}>
<Typography.Text>{idx + 1}. {item.patient_id?.slice(0, 8) ?? '未知'}</Typography.Text> <Typography.Text>{idx + 1}. {item.patient_name}</Typography.Text>
<Typography.Text type="secondary">{item.total_earned} </Typography.Text> <Typography.Text type="secondary">{item.total_earned} </Typography.Text>
</List.Item> </List.Item>
)} )}
@@ -86,13 +100,21 @@ export function OperatorDashboard() {
{/* 热门文章 */} {/* 热门文章 */}
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Card title="热门文章" size="small"> <Card
title="热门文章"
size="small"
extra={
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/articles')}>
</Button>
}
>
<List <List
size="small" size="small"
dataSource={topArticles} dataSource={topArticles}
locale={{ emptyText: '暂无数据' }} locale={{ emptyText: '暂无数据' }}
renderItem={(article) => ( renderItem={(article) => (
<List.Item style={{ padding: '6px 0' }}> <List.Item style={{ padding: '6px 0', cursor: 'pointer' }} onClick={() => navigate(`/health/articles/${article.id}/edit`)}>
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>{article.title}</Typography.Text> <Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>{article.title}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}> <Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{article.view_count} {article.view_count}

View File

@@ -24,6 +24,7 @@ export interface StatsData {
healthDataStats: HealthDataStats | null; healthDataStats: HealthDataStats | null;
dialysisStats: DialysisStatistics | null; dialysisStats: DialysisStatistics | null;
doctorCount: number; doctorCount: number;
offlineEventCount: number;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refresh: () => void; refresh: () => void;
@@ -40,6 +41,7 @@ export function useStatsData(): StatsData {
const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null); const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null);
const [dialysisStats, setDialysisStats] = useState<DialysisStatistics | null>(null); const [dialysisStats, setDialysisStats] = useState<DialysisStatistics | null>(null);
const [doctorCount, setDoctorCount] = useState(0); const [doctorCount, setDoctorCount] = useState(0);
const [offlineEventCount, setOfflineEventCount] = useState(0);
const fetchAllStats = useCallback(async () => { const fetchAllStats = useCallback(async () => {
// 缓存未过期,直接使用 // 缓存未过期,直接使用
@@ -52,6 +54,7 @@ export function useStatsData(): StatsData {
setHealthDataStats(c.healthDataStats as HealthDataStats | null); setHealthDataStats(c.healthDataStats as HealthDataStats | null);
setDialysisStats(c.dialysisStats as DialysisStatistics | null); setDialysisStats(c.dialysisStats as DialysisStatistics | null);
setDoctorCount(c.doctorCount as number); setDoctorCount(c.doctorCount as number);
setOfflineEventCount(c.offlineEventCount as number);
setLoading(false); setLoading(false);
return; return;
} }
@@ -66,6 +69,7 @@ export function useStatsData(): StatsData {
setHealthDataStats(c.healthDataStats as HealthDataStats | null); setHealthDataStats(c.healthDataStats as HealthDataStats | null);
setDialysisStats(c.dialysisStats as DialysisStatistics | null); setDialysisStats(c.dialysisStats as DialysisStatistics | null);
setDoctorCount(c.doctorCount as number); setDoctorCount(c.doctorCount as number);
setOfflineEventCount(c.offlineEventCount as number);
setLoading(false); setLoading(false);
return; return;
} }
@@ -86,6 +90,7 @@ export function useStatsData(): StatsData {
healthDataStats: null, healthDataStats: null,
dialysisStats: null, dialysisStats: null,
doctorCount: 0, doctorCount: 0,
offlineEventCount: 0,
}; };
const tryFetch = async <T,>(fn: () => Promise<T>, key: string, label: string) => { const tryFetch = async <T,>(fn: () => Promise<T>, key: string, label: string) => {
@@ -110,14 +115,19 @@ export function useStatsData(): StatsData {
'doctorCount', 'doctorCount',
'医护', '医护',
), ),
tryFetch(
async () => { const r = await pointsApi.listOfflineEvents({ page: 1, page_size: 1 }); return r.total; },
'offlineEventCount',
'线下活动',
),
]); ]);
if (!hasAnyError || errors.length < 7) { if (!hasAnyError || errors.length < 8) {
cachedStats = results; cachedStats = results;
cachedAt = Date.now(); cachedAt = Date.now();
} }
if (hasAnyError && errors.length === 7) { if (hasAnyError && errors.length === 8) {
setError('加载统计数据失败'); setError('加载统计数据失败');
} }
@@ -133,6 +143,7 @@ export function useStatsData(): StatsData {
setHealthDataStats(c.healthDataStats as HealthDataStats | null); setHealthDataStats(c.healthDataStats as HealthDataStats | null);
setDialysisStats(c.dialysisStats as DialysisStatistics | null); setDialysisStats(c.dialysisStats as DialysisStatistics | null);
setDoctorCount(c.doctorCount as number); setDoctorCount(c.doctorCount as number);
setOfflineEventCount(c.offlineEventCount as number);
} finally { } finally {
fetchPromise = null; fetchPromise = null;
setLoading(false); setLoading(false);
@@ -144,7 +155,7 @@ export function useStatsData(): StatsData {
}, [fetchAllStats]); }, [fetchAllStats]);
return { return {
patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, dialysisStats, doctorCount, patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, dialysisStats, doctorCount, offlineEventCount,
loading, error, refresh: fetchAllStats, loading, error, refresh: fetchAllStats,
}; };
} }

View File

@@ -100,7 +100,7 @@ export default function ArticleEditor() {
setCoverImage(article.cover_image || ''); setCoverImage(article.cover_image || '');
setSlug(article.slug || ''); setSlug(article.slug || '');
setCategoryId(article.category_id); setCategoryId(article.category_id);
setSelectedTagIds(article.tags?.map((t) => t.id) || []); setSelectedTagIds(article.tags?.map((t) => t.id).filter(Boolean) || []);
setSortOrder(article.sort_order); setSortOrder(article.sort_order);
setIsPublic(article.is_public ?? true); setIsPublic(article.is_public ?? true);
setVersion(article.version); setVersion(article.version);
@@ -230,7 +230,7 @@ export default function ArticleEditor() {
cover_image: coverImage || undefined, cover_image: coverImage || undefined,
slug: slug || undefined, slug: slug || undefined,
category_id: categoryId, category_id: categoryId,
tag_ids: selectedTagIds, tag_ids: selectedTagIds.filter(Boolean),
sort_order: sortOrder, sort_order: sortOrder,
is_public: isPublic, is_public: isPublic,
version, version,
@@ -246,7 +246,7 @@ export default function ArticleEditor() {
cover_image: coverImage || undefined, cover_image: coverImage || undefined,
slug: slug || undefined, slug: slug || undefined,
category_id: categoryId, category_id: categoryId,
tag_ids: selectedTagIds, tag_ids: selectedTagIds.filter(Boolean),
sort_order: sortOrder, sort_order: sortOrder,
is_public: isPublic, is_public: isPublic,
}); });
@@ -279,7 +279,7 @@ export default function ArticleEditor() {
cover_image: coverImage || undefined, cover_image: coverImage || undefined,
slug: slug || undefined, slug: slug || undefined,
category_id: categoryId, category_id: categoryId,
tag_ids: selectedTagIds, tag_ids: selectedTagIds.filter(Boolean),
sort_order: sortOrder, sort_order: sortOrder,
is_public: isPublic, is_public: isPublic,
version, version,
@@ -295,7 +295,7 @@ export default function ArticleEditor() {
cover_image: coverImage || undefined, cover_image: coverImage || undefined,
slug: slug || undefined, slug: slug || undefined,
category_id: categoryId, category_id: categoryId,
tag_ids: selectedTagIds, tag_ids: selectedTagIds.filter(Boolean),
sort_order: sortOrder, sort_order: sortOrder,
is_public: isPublic, is_public: isPublic,
}); });

View File

@@ -26,3 +26,4 @@ sha2.workspace = true
redis.workspace = true redis.workspace = true
hex.workspace = true hex.workspace = true
regex-lite.workspace = true regex-lite.workspace = true
pdf-extract.workspace = true

View File

@@ -0,0 +1,31 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_knowledge_bases")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
pub kb_type: String,
pub description: Option<String>,
pub icon: Option<String>,
pub chunk_strategy: serde_json::Value,
pub intent_keywords: serde_json::Value,
pub embedding_model: Option<String>,
pub is_enabled: bool,
pub document_count: i32,
pub chunk_count: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Option<Uuid>,
pub updated_by: Option<Uuid>,
pub deleted_at: Option<DateTimeUtc>,
pub version_lock: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,34 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_knowledge_chunks")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub knowledge_base_id: Uuid,
pub document_id: Uuid,
pub chunk_index: i32,
pub content: String,
pub token_count: Option<i32>,
// pgvector 字段 — SeaORM 不原生支持 vector 类型,查询时用 raw SQL
#[sea_orm(ignore)]
pub embedding: Option<Vec<f32>>,
pub start_offset: Option<i32>,
pub end_offset: Option<i32>,
pub page_number: Option<i32>,
pub metadata: serde_json::Value,
pub hit_count: i32,
pub last_hit_at: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Option<Uuid>,
pub updated_by: Option<Uuid>,
pub deleted_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,36 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_knowledge_documents")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub knowledge_base_id: Uuid,
pub title: String,
pub doc_type: String,
pub source_type: String,
pub source_url: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<i64>,
pub file_mime_type: Option<String>,
pub content: Option<String>,
pub status: String,
pub chunk_count: i32,
pub embedded_count: i32,
pub error_message: Option<String>,
pub processing_started_at: Option<DateTimeUtc>,
pub processing_completed_at: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Option<Uuid>,
pub updated_by: Option<Uuid>,
pub deleted_at: Option<DateTimeUtc>,
pub version_lock: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -16,6 +16,8 @@ pub struct Model {
pub version: i32, pub version: i32,
pub is_active: bool, pub is_active: bool,
pub category: String, pub category: String,
/// 后端选择键:与 AnalysisType::prompt_name() 对应handler 按此字段查找激活 Prompt
pub analysis_type: String,
pub tags: Option<serde_json::Value>, pub tags: Option<serde_json::Value>,
pub created_at: DateTimeUtc, pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc, pub updated_at: DateTimeUtc,

View File

@@ -3,6 +3,9 @@ pub mod ai_analysis_queue;
pub mod ai_chat_message; pub mod ai_chat_message;
pub mod ai_chat_session; pub mod ai_chat_session;
pub mod ai_feature_flags; pub mod ai_feature_flags;
pub mod ai_knowledge_bases;
pub mod ai_knowledge_chunks;
pub mod ai_knowledge_documents;
pub mod ai_knowledge_guides; pub mod ai_knowledge_guides;
pub mod ai_knowledge_references; pub mod ai_knowledge_references;
pub mod ai_knowledge_rules; pub mod ai_knowledge_rules;

View File

@@ -240,6 +240,14 @@ where
let provider_name = provider_arc.name().to_string(); let provider_name = provider_arc.name().to_string();
let supports_fc = provider_name != "ollama"; // Ollama generate_with_tools 未实现 let supports_fc = provider_name != "ollama"; // Ollama generate_with_tools 未实现
// 收集 token 和 display_hints
#[allow(unused_assignments)]
let mut input_tokens: u32 = 0;
#[allow(unused_assignments)]
let mut output_tokens: u32 = 0;
let mut duration_ms: u64 = 0;
let mut collected_hints: Option<Vec<crate::agent::tool::DisplayHint>> = None;
let result = if supports_fc { let result = if supports_fc {
// FC provider执行完整 Agent ReAct 循环 // FC provider执行完整 Agent ReAct 循环
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry)); let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
@@ -256,6 +264,11 @@ where
tracing::error!(error = %e, "AI Agent run failed"); tracing::error!(error = %e, "AI Agent run failed");
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into()) erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
})?; })?;
input_tokens = agent_result.total_input_tokens;
output_tokens = agent_result.total_output_tokens;
if !agent_result.display_hints.is_empty() {
collected_hints = Some(agent_result.display_hints);
}
agent_result.reply agent_result.reply
} else { } else {
// 非 FC provider降级为普通对话 // 非 FC provider降级为普通对话
@@ -279,6 +292,9 @@ where
tracing::error!(error = %e, "AI generate failed"); tracing::error!(error = %e, "AI generate failed");
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into()) erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
})?; })?;
input_tokens = resp.input_tokens;
output_tokens = resp.output_tokens;
duration_ms = resp.duration_ms;
resp.content resp.content
}; };
@@ -297,7 +313,7 @@ where
"AI chat response sent" "AI chat response sent"
); );
// 记录用量的 token 消耗(简化模式下无法精确计量,记 0 // 记录用量的 token 消耗
if let Err(e) = ai_state if let Err(e) = ai_state
.usage .usage
.log_usage( .log_usage(
@@ -305,9 +321,9 @@ where
&provider_name, &provider_name,
&run_params.model, &run_params.model,
"chat", "chat",
0, input_tokens,
0, output_tokens,
0, duration_ms,
0, 0,
false, false,
) )
@@ -362,7 +378,7 @@ where
reply, reply,
message_id, message_id,
iterations: if supports_fc { 1 } else { 0 }, iterations: if supports_fc { 1 } else { 0 },
display_hints: None, display_hints: collected_hints,
}))) })))
} }

View File

@@ -0,0 +1,307 @@
use axum::Json;
use axum::extract::{Extension, FromRef, Multipart, Path, State};
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use serde::Deserialize;
use crate::service::document::{CreateDocumentReq, ListDocumentsQuery, UploadDocumentParams};
use crate::state::AiState;
#[derive(Debug, Deserialize)]
pub struct ListDocumentsParams {
pub kb_id: uuid::Uuid,
pub status: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[utoipa::path(
get,
path = "/ai/knowledge-bases/{kb_id}/documents",
responses((status = 200, description = "文档列表")),
tag = "知识库文档",
security(("bearer_auth" = [])),
)]
pub async fn list_documents<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(kb_id): Path<uuid::Uuid>,
axum::extract::Query(params): axum::extract::Query<ListDocumentsParamsNoKb>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.list")?;
let query = ListDocumentsQuery {
status: params.status,
page: params.page,
page_size: params.page_size,
};
let (items, total) = state
.document
.list_documents(ctx.tenant_id, kb_id, &query)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items,
"total": total,
"page": query.page.unwrap_or(1),
"page_size": query.page_size.unwrap_or(20),
}))))
}
#[derive(Debug, Deserialize)]
pub struct ListDocumentsParamsNoKb {
pub status: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[utoipa::path(
get,
path = "/ai/documents/{id}",
responses((status = 200, description = "文档详情")),
tag = "知识库文档",
security(("bearer_auth" = [])),
)]
pub async fn get_document<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.list")?;
let doc = state.document.get_document(ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(
serde_json::to_value(&doc).unwrap_or_default(),
)))
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateDocumentBody {
pub kb_id: uuid::Uuid,
pub title: String,
pub doc_type: Option<String>,
pub source_type: Option<String>,
pub source_url: Option<String>,
pub content: Option<String>,
}
#[utoipa::path(
post,
path = "/ai/documents/manual",
request_body = CreateDocumentBody,
responses((status = 200, description = "创建手动文档")),
tag = "知识库文档",
security(("bearer_auth" = [])),
)]
pub async fn create_manual_document<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<CreateDocumentBody>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
if body.title.trim().is_empty() {
return Err(erp_core::error::AppError::Validation(
"文档标题不能为空".into(),
));
}
let req = CreateDocumentReq {
title: body.title,
doc_type: body.doc_type,
source_type: body.source_type,
source_url: body.source_url,
content: body.content,
};
let id = state
.document
.create_manual_document(ctx.tenant_id, ctx.user_id, body.kb_id, req)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
const MAX_FILE_SIZE: usize = 20 * 1024 * 1024; // 20MB
#[utoipa::path(
post,
path = "/ai/documents/upload",
responses((status = 200, description = "上传文档文件")),
tag = "知识库文档",
security(("bearer_auth" = [])),
)]
pub async fn upload_document<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
let mut kb_id: Option<uuid::Uuid> = None;
let mut title: Option<String> = None;
let mut file_data: Option<(String, String, Vec<u8>)> = None; // (filename, mime, bytes)
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| erp_core::error::AppError::Validation(format!("读取上传字段失败: {}", e)))?
{
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
"kb_id" => {
let text = field.text().await.map_err(|e| {
erp_core::error::AppError::Validation(format!("读取 kb_id 失败: {}", e))
})?;
kb_id =
Some(uuid::Uuid::parse_str(&text).map_err(|_| {
erp_core::error::AppError::Validation("kb_id 格式错误".into())
})?);
}
"title" => {
let text = field.text().await.map_err(|e| {
erp_core::error::AppError::Validation(format!("读取 title 失败: {}", e))
})?;
title = Some(text);
}
"file" => {
let file_name = field.file_name().unwrap_or("unknown").to_string();
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let bytes = field.bytes().await.map_err(|e| {
erp_core::error::AppError::Validation(format!("读取文件失败: {}", e))
})?;
if bytes.len() > MAX_FILE_SIZE {
return Err(erp_core::error::AppError::Validation(format!(
"文件大小超过限制 (最大 {}MB)",
MAX_FILE_SIZE / 1024 / 1024
)));
}
file_data = Some((file_name, content_type, bytes.to_vec()));
}
_ => {}
}
}
let kb_id =
kb_id.ok_or_else(|| erp_core::error::AppError::Validation("缺少 kb_id 字段".into()))?;
let (file_name, mime_type, bytes) =
file_data.ok_or_else(|| erp_core::error::AppError::Validation("缺少 file 字段".into()))?;
let doc_title = title.unwrap_or_else(|| file_name.clone());
// 解析文档内容
let content = crate::service::document::parser::parse_document(&file_name, &mime_type, &bytes)
.map_err(|e| erp_core::error::AppError::Validation(format!("文档解析失败: {}", e)))?;
let params = UploadDocumentParams {
file_name,
file_size: bytes.len() as i64,
mime_type,
content,
};
let id = state
.document
.create_upload_document(ctx.tenant_id, ctx.user_id, kb_id, doc_title, params)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
#[utoipa::path(
delete,
path = "/ai/knowledge-bases/{kb_id}/documents/{id}",
responses((status = 200, description = "删除文档")),
tag = "知识库文档",
security(("bearer_auth" = [])),
)]
pub async fn delete_document<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path((kb_id, id)): Path<(uuid::Uuid, uuid::Uuid)>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
state
.document
.delete_document(ctx.tenant_id, kb_id, id)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct HitTestBody {
pub kb_id: uuid::Uuid,
pub query: String,
pub top_k: Option<i64>,
}
#[utoipa::path(
post,
path = "/ai/documents/hit-test",
request_body = HitTestBody,
responses((status = 200, description = "向量搜索 hit test")),
tag = "知识库文档",
security(("bearer_auth" = [])),
)]
pub async fn hit_test<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<HitTestBody>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.list")?;
if body.query.trim().is_empty() {
return Err(erp_core::error::AppError::Validation(
"搜索查询不能为空".into(),
));
}
// 生成 query embedding
let embedding =
state.embedding.embed(&body.query).await.map_err(|e| {
erp_core::error::AppError::Internal(format!("Embedding 生成失败: {}", e))
})?;
let top_k = body.top_k.unwrap_or(5).min(20);
let hits = state
.knowledge_v2
.vector_search(ctx.tenant_id, body.kb_id, &embedding, top_k)
.await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"query": body.query,
"total": hits.len(),
"hits": hits,
}))))
}

View File

@@ -0,0 +1,172 @@
use axum::Json;
use axum::extract::{Extension, FromRef, Path, State};
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use serde::Deserialize;
use crate::service::knowledge_v2::{
CreateKnowledgeBaseReq, ListKnowledgeBasesQuery, UpdateKnowledgeBaseReq,
};
use crate::state::AiState;
#[derive(Debug, Deserialize)]
pub struct ListKnowledgeBasesParams {
pub kb_type: Option<String>,
pub is_enabled: Option<bool>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[utoipa::path(
get,
path = "/ai/knowledge-bases",
responses((status = 200, description = "知识库列表")),
tag = "知识库V2",
security(("bearer_auth" = [])),
)]
pub async fn list_knowledge_bases<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
axum::extract::Query(params): axum::extract::Query<ListKnowledgeBasesParams>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.list")?;
let query = ListKnowledgeBasesQuery {
kb_type: params.kb_type,
is_enabled: params.is_enabled,
page: params.page,
page_size: params.page_size,
};
let (items, total) = state.knowledge_v2.list(ctx.tenant_id, &query).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items,
"total": total,
"page": query.page.unwrap_or(1),
"page_size": query.page_size.unwrap_or(20),
}))))
}
#[utoipa::path(
get,
path = "/ai/knowledge-bases/{id}",
responses((status = 200, description = "知识库详情")),
tag = "知识库V2",
security(("bearer_auth" = [])),
)]
pub async fn get_knowledge_base<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.list")?;
let kb = state.knowledge_v2.get_by_id(ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(
serde_json::to_value(&kb).unwrap_or_default(),
)))
}
#[utoipa::path(
post,
path = "/ai/knowledge-bases",
request_body = CreateKnowledgeBaseReq,
responses((status = 200, description = "创建知识库")),
tag = "知识库V2",
security(("bearer_auth" = [])),
)]
pub async fn create_knowledge_base<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<CreateKnowledgeBaseReq>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
if body.name.trim().is_empty() {
return Err(erp_core::error::AppError::Validation(
"知识库名称不能为空".into(),
));
}
if body.kb_type.trim().is_empty() {
return Err(erp_core::error::AppError::Validation(
"知识库类型不能为空".into(),
));
}
let id = state
.knowledge_v2
.create(ctx.tenant_id, ctx.user_id, body)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
#[utoipa::path(
put,
path = "/ai/knowledge-bases/{id}",
request_body = UpdateKnowledgeBaseReq,
responses((status = 200, description = "更新知识库")),
tag = "知识库V2",
security(("bearer_auth" = [])),
)]
pub async fn update_knowledge_base<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Json(body): Json<UpdateKnowledgeBaseReq>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
if let Some(ref name) = body.name
&& name.trim().is_empty()
{
return Err(erp_core::error::AppError::Validation(
"知识库名称不能为空".into(),
));
}
state
.knowledge_v2
.update(ctx.tenant_id, ctx.user_id, id, body)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
#[utoipa::path(
delete,
path = "/ai/knowledge-bases/{id}",
responses((status = 200, description = "删除知识库")),
tag = "知识库V2",
security(("bearer_auth" = [])),
)]
pub async fn delete_knowledge_base<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
state.knowledge_v2.delete(ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}

View File

@@ -14,8 +14,10 @@ use crate::state::AiState;
pub mod chat_handler; pub mod chat_handler;
pub mod config_handler; pub mod config_handler;
pub mod document_handler;
pub mod insight_handler; pub mod insight_handler;
pub mod knowledge_handler; pub mod knowledge_handler;
pub mod knowledge_v2_handler;
pub mod risk_handler; pub mod risk_handler;
pub mod rule_handler; pub mod rule_handler;
pub mod suggestion_handler; pub mod suggestion_handler;
@@ -95,7 +97,7 @@ where
let prompt = state let prompt = state
.prompt .prompt
.get_active_prompt(ctx.tenant_id, "lab_report_interpretation") .get_active_prompt(ctx.tenant_id, AnalysisType::LabReport.prompt_name())
.await?; .await?;
let model_config = &prompt.model_config; let model_config = &prompt.model_config;
@@ -190,7 +192,7 @@ where
let prompt = state let prompt = state
.prompt .prompt
.get_active_prompt(ctx.tenant_id, "health_trend_analysis") .get_active_prompt(ctx.tenant_id, AnalysisType::Trends.prompt_name())
.await?; .await?;
let model_config = &prompt.model_config; let model_config = &prompt.model_config;
@@ -262,7 +264,7 @@ where
let prompt = state let prompt = state
.prompt .prompt
.get_active_prompt(ctx.tenant_id, "personalized_checkup_plan") .get_active_prompt(ctx.tenant_id, AnalysisType::CheckupPlan.prompt_name())
.await?; .await?;
let model_config = &prompt.model_config; let model_config = &prompt.model_config;
@@ -341,7 +343,7 @@ where
let prompt = state let prompt = state
.prompt .prompt
.get_active_prompt(ctx.tenant_id, "report_summary_generation") .get_active_prompt(ctx.tenant_id, AnalysisType::ReportSummary.prompt_name())
.await?; .await?;
let model_config = &prompt.model_config; let model_config = &prompt.model_config;
@@ -417,7 +419,7 @@ where
let prompt = state let prompt = state
.prompt .prompt
.get_active_prompt(ctx.tenant_id, "follow_up_summary_generation") .get_active_prompt(ctx.tenant_id, AnalysisType::FollowUpSummary.prompt_name())
.await?; .await?;
let model_config = &prompt.model_config; let model_config = &prompt.model_config;
@@ -577,6 +579,7 @@ where
#[derive(Debug, Deserialize, utoipa::IntoParams)] #[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct ListPromptsQuery { pub struct ListPromptsQuery {
pub category: Option<String>, pub category: Option<String>,
pub analysis_type: Option<String>,
pub page: Option<u64>, pub page: Option<u64>,
pub page_size: Option<u64>, pub page_size: Option<u64>,
} }
@@ -605,7 +608,11 @@ where
}; };
let (items, total) = state let (items, total) = state
.prompt .prompt
.list_prompts(ctx.tenant_id, params.category, &pagination) .list_prompts(
ctx.tenant_id,
params.analysis_type.or(params.category),
&pagination,
)
.await?; .await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items, "data": items,
@@ -623,6 +630,7 @@ pub struct CreatePromptBody {
pub user_prompt_template: String, pub user_prompt_template: String,
pub model_config: serde_json::Value, pub model_config: serde_json::Value,
pub category: String, pub category: String,
pub analysis_type: String,
} }
#[utoipa::path( #[utoipa::path(
@@ -655,6 +663,7 @@ where
body.user_prompt_template, body.user_prompt_template,
body.model_config, body.model_config,
body.category, body.category,
body.analysis_type,
) )
.await?; .await?;
Ok(Json(ApiResponse::ok(prompt))) Ok(Json(ApiResponse::ok(prompt)))
@@ -702,6 +711,48 @@ where
Ok(Json(ApiResponse::ok(prompt))) Ok(Json(ApiResponse::ok(prompt)))
} }
#[utoipa::path(
post,
path = "/ai/prompts/{id}/deactivate",
responses((status = 200, description = "停用 Prompt 模板")),
tag = "AI Prompt",
security(("bearer_auth" = [])),
)]
pub async fn deactivate_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<crate::entity::ai_prompt::Model>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
let prompt = state.prompt.deactivate_prompt(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(prompt)))
}
#[utoipa::path(
delete,
path = "/ai/prompts/{id}",
responses((status = 200, description = "删除 Prompt 模板")),
tag = "AI Prompt",
security(("bearer_auth" = [])),
)]
pub async fn delete_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
state.prompt.delete_prompt(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(())))
}
// === 用量统计 === // === 用量统计 ===
#[utoipa::path( #[utoipa::path(

View File

@@ -1,4 +1,5 @@
pub mod structured_source; pub mod structured_source;
pub mod v2_source;
pub mod vector_search; pub mod vector_search;
pub mod vector_source; pub mod vector_source;

View File

@@ -0,0 +1,166 @@
use async_trait::async_trait;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use uuid::Uuid;
use super::{KnowledgeContext, KnowledgeQuery, KnowledgeSource, Reference};
use crate::error::AiResult;
use crate::service::embedding::EmbeddingService;
use crate::service::knowledge_v2::KnowledgeV2Service;
/// 知识库 V2 向量检索源 — 基于 ai_knowledge_chunks + pgvector
pub struct KnowledgeV2Source {
db: DatabaseConnection,
knowledge_v2: Arc<KnowledgeV2Service>,
embedding: Arc<EmbeddingService>,
}
impl KnowledgeV2Source {
pub fn new(
db: DatabaseConnection,
knowledge_v2: Arc<KnowledgeV2Service>,
embedding: Arc<EmbeddingService>,
) -> Self {
Self {
db,
knowledge_v2,
embedding,
}
}
}
#[async_trait]
impl KnowledgeSource for KnowledgeV2Source {
async fn get_context(&self, query: &KnowledgeQuery) -> AiResult<KnowledgeContext> {
let query_text = match &query.query_text {
Some(t) if !t.trim().is_empty() => t.clone(),
_ => {
return Ok(KnowledgeContext {
source: "knowledge_v2".into(),
context_text: String::new(),
references: vec![],
confidence: 0.0,
});
}
};
if !self.embedding.is_configured() {
return Ok(KnowledgeContext {
source: "knowledge_v2".into(),
context_text: String::new(),
references: vec![],
confidence: 0.0,
});
}
// 查找租户下所有启用的知识库
let kb_ids = get_enabled_kb_ids(&self.db, query.tenant_id).await?;
if kb_ids.is_empty() {
return Ok(KnowledgeContext {
source: "knowledge_v2".into(),
context_text: String::new(),
references: vec![],
confidence: 0.0,
});
}
let embedding = match self.embedding.embed(&query_text).await {
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, "KnowledgeV2 Source embedding 失败");
return Ok(KnowledgeContext {
source: "knowledge_v2".into(),
context_text: String::new(),
references: vec![],
confidence: 0.0,
});
}
};
// 在所有知识库中搜索,取最佳结果
let mut all_hits = Vec::new();
for kb_id in &kb_ids {
if let Ok(hits) = self
.knowledge_v2
.vector_search(query.tenant_id, *kb_id, &embedding, 5)
.await
{
all_hits.extend(hits);
}
}
// 按相似度排序,取 top 10
all_hits.sort_by(|a, b| {
b.similarity
.partial_cmp(&a.similarity)
.unwrap_or(std::cmp::Ordering::Equal)
});
all_hits.truncate(10);
if all_hits.is_empty() {
return Ok(KnowledgeContext {
source: "knowledge_v2".into(),
context_text: String::new(),
references: vec![],
confidence: 0.0,
});
}
let max_confidence = all_hits[0].similarity as f32;
let context_parts: Vec<String> = all_hits
.iter()
.map(|h| {
format!(
"[文档: {} | 相似度: {:.2}]\n{}",
h.doc_title, h.similarity, h.content
)
})
.collect();
let references: Vec<Reference> = all_hits
.iter()
.map(|h| Reference {
title: h.doc_title.clone(),
source: format!("chunk_{}", h.chunk_index),
relevance_score: h.similarity as f32,
})
.collect();
Ok(KnowledgeContext {
source: "knowledge_v2".into(),
context_text: context_parts.join("\n\n"),
references,
confidence: max_confidence,
})
}
fn source_type(&self) -> &str {
"knowledge_v2"
}
async fn health_check(&self) -> AiResult<bool> {
Ok(true)
}
}
async fn get_enabled_kb_ids(db: &DatabaseConnection, tenant_id: Uuid) -> AiResult<Vec<Uuid>> {
#[derive(sea_orm::FromQueryResult)]
struct KbIdRow {
id: Uuid,
}
let results: Vec<KbIdRow> = sea_orm::FromQueryResult::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
"SELECT id FROM ai_knowledge_bases WHERE tenant_id = $1 AND is_enabled = true AND deleted_at IS NULL",
[sea_orm::Value::from(tenant_id)],
),
)
.all(db)
.await
.map_err(|e: sea_orm::DbErr| crate::error::AiError::DbError(e.to_string()))?;
Ok(results.into_iter().map(|r| r.id).collect())
}

View File

@@ -511,6 +511,14 @@ impl AiModule {
"/ai/prompts/{id}/rollback", "/ai/prompts/{id}/rollback",
axum::routing::post(crate::handler::rollback_prompt), axum::routing::post(crate::handler::rollback_prompt),
) )
.route(
"/ai/prompts/{id}/deactivate",
axum::routing::post(crate::handler::deactivate_prompt),
)
.route(
"/ai/prompts/{id}",
axum::routing::delete(crate::handler::delete_prompt),
)
.route( .route(
"/ai/usage/overview", "/ai/usage/overview",
axum::routing::get(crate::handler::usage_overview), axum::routing::get(crate::handler::usage_overview),
@@ -580,6 +588,52 @@ impl AiModule {
"/ai/knowledge/guides/{id}/re-embed", "/ai/knowledge/guides/{id}/re-embed",
axum::routing::post(crate::handler::knowledge_handler::re_embed_guide), axum::routing::post(crate::handler::knowledge_handler::re_embed_guide),
) )
// 知识库 V2 路由
.route(
"/ai/knowledge-bases",
axum::routing::get(crate::handler::knowledge_v2_handler::list_knowledge_bases),
)
.route(
"/ai/knowledge-bases",
axum::routing::post(crate::handler::knowledge_v2_handler::create_knowledge_base),
)
.route(
"/ai/knowledge-bases/{id}",
axum::routing::get(crate::handler::knowledge_v2_handler::get_knowledge_base),
)
.route(
"/ai/knowledge-bases/{id}",
axum::routing::put(crate::handler::knowledge_v2_handler::update_knowledge_base),
)
.route(
"/ai/knowledge-bases/{id}",
axum::routing::delete(crate::handler::knowledge_v2_handler::delete_knowledge_base),
)
// 文档管理路由
.route(
"/ai/knowledge-bases/{kb_id}/documents",
axum::routing::get(crate::handler::document_handler::list_documents),
)
.route(
"/ai/documents/manual",
axum::routing::post(crate::handler::document_handler::create_manual_document),
)
.route(
"/ai/documents/upload",
axum::routing::post(crate::handler::document_handler::upload_document),
)
.route(
"/ai/documents/{id}",
axum::routing::get(crate::handler::document_handler::get_document),
)
.route(
"/ai/documents/hit-test",
axum::routing::post(crate::handler::document_handler::hit_test),
)
.route(
"/ai/knowledge-bases/{kb_id}/documents/{id}",
axum::routing::delete(crate::handler::document_handler::delete_document),
)
.route( .route(
"/ai/dialysis/risk-assessment", "/ai/dialysis/risk-assessment",
axum::routing::post(crate::handler::assess_dialysis_risk), axum::routing::post(crate::handler::assess_dialysis_risk),

View File

@@ -21,7 +21,7 @@ pub struct AnalysisService {
pub sanitizer: SanitizationService, pub sanitizer: SanitizationService,
pub renderer: PromptRenderer, pub renderer: PromptRenderer,
pub db: sea_orm::DatabaseConnection, pub db: sea_orm::DatabaseConnection,
pub knowledge_source: Option<std::sync::Arc<dyn KnowledgeSource>>, pub knowledge_sources: Vec<std::sync::Arc<dyn KnowledgeSource>>,
} }
impl AnalysisService { impl AnalysisService {
@@ -34,12 +34,12 @@ impl AnalysisService {
sanitizer: SanitizationService::new(), sanitizer: SanitizationService::new(),
renderer: PromptRenderer::new(), renderer: PromptRenderer::new(),
db, db,
knowledge_source: None, knowledge_sources: vec![],
} }
} }
pub fn with_knowledge_source(mut self, source: std::sync::Arc<dyn KnowledgeSource>) -> Self { pub fn with_knowledge_source(mut self, source: std::sync::Arc<dyn KnowledgeSource>) -> Self {
self.knowledge_source = Some(source); self.knowledge_sources.push(source);
self self
} }
@@ -100,42 +100,47 @@ impl AnalysisService {
例如:\"根据临床指南 [ref:uuid-of-guideline],建议...\"\n\ 例如:\"根据临床指南 [ref:uuid-of-guideline],建议...\"\n\
每个引用的知识库条目必须在回答中标注。如果没有引用任何知识库条目,则无需标注。"; 每个引用的知识库条目必须在回答中标注。如果没有引用任何知识库条目,则无需标注。";
let system_prompt = if let Some(ref ks) = self.knowledge_source { let system_prompt = if !self.knowledge_sources.is_empty() {
let query = crate::knowledge::KnowledgeQuery { let query = crate::knowledge::KnowledgeQuery {
tenant_id, tenant_id,
analysis_type: analysis_type.as_str().to_string(), analysis_type: analysis_type.as_str().to_string(),
patient_context: None, patient_context: None,
query_text: None, query_text: None,
}; };
match ks.get_context(&query).await { let mut best_ctx: Option<crate::knowledge::KnowledgeContext> = None;
Ok(ctx) if ctx.confidence > 0.0 => { for ks in &self.knowledge_sources {
tracing::info!( if let Ok(ctx) = ks.get_context(&query).await
source = %ctx.source, && ctx.confidence > 0.0
confidence = ctx.confidence, {
"知识库上下文注入" match &best_ctx {
); Some(bc) if bc.confidence >= ctx.confidence => {}
// 将引用的来源 ID 附加到上下文中 _ => best_ctx = Some(ctx),
let refs_info = if ctx.references.is_empty() { }
String::new()
} else {
let refs_list: Vec<String> = ctx
.references
.iter()
.map(|r| format!("- {} (ID: {})", r.title, r.source))
.collect();
format!("\n\n可用引用源:\n{}", refs_list.join("\n"))
};
format!(
"{}\n\n=== 知识库参考 ===\n{}{}{}",
system_prompt, ctx.context_text, refs_info, citation_instruction
)
}
Ok(_) => system_prompt,
Err(e) => {
tracing::warn!(error = %e, "知识库查询失败,跳过注入");
system_prompt
} }
} }
if let Some(ctx) = best_ctx {
tracing::info!(
source = %ctx.source,
confidence = ctx.confidence,
"知识库上下文注入"
);
let refs_info = if ctx.references.is_empty() {
String::new()
} else {
let refs_list: Vec<String> = ctx
.references
.iter()
.map(|r| format!("- {} (ID: {})", r.title, r.source))
.collect();
format!("\n\n可用引用源:\n{}", refs_list.join("\n"))
};
format!(
"{}\n\n=== 知识库参考 ===\n{}{}{}",
system_prompt, ctx.context_text, refs_info, citation_instruction
)
} else {
system_prompt
}
} else { } else {
// 无知识库时也添加引用指令(供通用场景使用) // 无知识库时也添加引用指令(供通用场景使用)
format!("{}{}", system_prompt, citation_instruction) format!("{}{}", system_prompt, citation_instruction)

View File

@@ -1,4 +1,4 @@
use sea_orm::{ActiveModelTrait, EntityTrait, FromQueryResult, Set, Statement}; use sea_orm::{ActiveModelTrait, EntityTrait, FromQueryResult, Set, Statement, TransactionTrait};
use uuid::Uuid; use uuid::Uuid;
use crate::entity::ai_analysis_queue; use crate::entity::ai_analysis_queue;
@@ -93,43 +93,74 @@ impl AnalysisQueue {
&self, &self,
tenant_id: Option<Uuid>, tenant_id: Option<Uuid>,
) -> AiResult<Option<ai_analysis_queue::Model>> { ) -> AiResult<Option<ai_analysis_queue::Model>> {
let sql = match tenant_id { // 事务内 SELECT ... FOR UPDATE SKIP LOCKED + UPDATE
Some(tid) => format!( // - 参数化($1消除原 format! 拼 tenant_id 的 SQL 注入风险
"SELECT * FROM ai_analysis_queue WHERE tenant_id = '{}' AND status = 'pending' AND deleted_at IS NULL AND scheduled_at <= NOW() ORDER BY priority DESC, scheduled_at ASC LIMIT 1", // - FOR UPDATE SKIP LOCKED 在事务内持行锁到 UPDATE 完成,防多消费者并发重复 claim
tid let claimed = self
), .db
None => r#" .transaction::<_, Option<ai_analysis_queue::Model>, AiError>(|txn| {
SELECT * FROM ai_analysis_queue Box::pin(async move {
WHERE status = 'pending' let row: Option<QueueRow> = match tenant_id {
AND deleted_at IS NULL Some(tid) => {
AND scheduled_at <= NOW() QueueRow::find_by_statement(Statement::from_sql_and_values(
ORDER BY priority DESC, scheduled_at ASC sea_orm::DatabaseBackend::Postgres,
LIMIT 1 r#"SELECT * FROM ai_analysis_queue
"# WHERE tenant_id = $1
.to_string(), AND status = 'pending'
}; AND deleted_at IS NULL
AND scheduled_at <= NOW()
ORDER BY priority DESC, scheduled_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED"#,
[tid.into()],
))
.one(txn)
.await?
}
None => {
QueueRow::find_by_statement(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
r#"SELECT * FROM ai_analysis_queue
WHERE status = 'pending'
AND deleted_at IS NULL
AND scheduled_at <= NOW()
ORDER BY priority DESC, scheduled_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED"#
.to_string(),
))
.one(txn)
.await?
}
};
let row: Option<QueueRow> = QueueRow::find_by_statement(Statement::from_string( match row {
sea_orm::DatabaseBackend::Postgres, Some(r) => {
sql.to_string(), let now = chrono::Utc::now();
)) let model = ai_analysis_queue::Entity::find_by_id(r.id)
.one(&self.db) .one(txn)
.await?; .await?
.ok_or_else(|| {
match row { AiError::QueueError(format!("队列任务 {} 未找到", r.id))
Some(r) => { })?;
let now = chrono::Utc::now(); let mut active: ai_analysis_queue::ActiveModel = model.into();
let mut active: ai_analysis_queue::ActiveModel = active.status = Set("running".to_string());
self.find_by_id(r.id).await?.into(); active.started_at = Set(Some(now));
active.status = Set("running".to_string()); active.updated_at = Set(now);
active.started_at = Set(Some(now)); active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
active.updated_at = Set(now); let updated = active.update(txn).await?;
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1); Ok(Some(updated))
let model = active.update(&self.db).await?; }
Ok(Some(model)) None => Ok(None),
} }
None => Ok(None), })
} })
.await
.map_err(|e| match e {
sea_orm::TransactionError::Connection(d) => d.into(),
sea_orm::TransactionError::Transaction(a) => a,
})?;
Ok(claimed)
} }
pub async fn mark_completed(&self, id: Uuid, result_analysis_id: Uuid) -> AiResult<()> { pub async fn mark_completed(&self, id: Uuid, result_analysis_id: Uuid) -> AiResult<()> {

View File

@@ -0,0 +1,321 @@
//! AI 分析队列消费者 — 把 pending 队列任务驱动到 completed/failed。
//!
//! `module.rs` 的事件入队 + `auto_analysis.rs` 的定时入队把任务写入 `ai_analysis_queue`
//! 但 `claim_next` 此前无人调用,所有任务永远 pending违反「每个事件必须有消费者」铁律
//!
//! 本 worker 在后台循环 claim → 路由处理 → mark_completed / mark_failed
//! 把 erp-health 触发的分析链路真正打通MVP 聚焦趋势分析 trend其他类型暂 skip
use std::time::Duration;
use erp_core::health_provider::TimeRange;
use futures::StreamExt;
use uuid::Uuid;
use crate::dto::AnalysisType;
use crate::entity::ai_analysis_queue;
use crate::error::{AiError, AiResult};
use crate::service::analysis_queue::AnalysisQueue;
use crate::state::AiState;
/// 轮询间隔:无任务时休眠 10 秒避免空转
const IDLE_SLEEP: Duration = Duration::from_secs(10);
/// 启动 AI 分析队列消费者(后台 tokio 任务)。
///
/// 不阻塞调用方:`tokio::spawn` 后立即返回。
/// 在 `erp-server/src/main.rs` 中与 `start_auto_analysis` 一起启动。
pub fn start_analysis_worker(state: AiState) {
tokio::spawn(async move {
tracing::info!("AI 分析队列消费者已启动(轮询间隔 {:?}", IDLE_SLEEP);
loop {
match process_once(&state).await {
Ok(Processed) => {
// 立即尝试下一个任务,不等待
}
Ok(Idle) => {
tokio::time::sleep(IDLE_SLEEP).await;
}
Err(e) => {
tracing::warn!(error = %e, "分析队列消费循环异常,休眠后重试");
tokio::time::sleep(IDLE_SLEEP).await;
}
}
}
});
}
enum ProcessOutcome {
/// 成功处理了一个任务(或已路由 skip立刻尝试下一个
Processed,
/// 队列空,进入休眠
Idle,
}
use ProcessOutcome::{Idle, Processed};
async fn process_once(state: &AiState) -> AiResult<ProcessOutcome> {
let queue = AnalysisQueue::new(state.db.clone());
let job = match queue.claim_next(None).await? {
Some(j) => j,
None => return Ok(Idle),
};
let job_id = job.id;
tracing::info!(
job_id = %job_id,
tenant_id = %job.tenant_id,
patient_id = %job.patient_id,
analysis_type = %job.analysis_type,
source_event = ?job.source_event,
"已领取分析队列任务,开始处理"
);
match job.analysis_type.as_str() {
"trend" => handle_trend(state, &queue, job).await,
other => {
// MVP 阶段:非 trend 类型暂不支持自动消费。
// 不写假数据,不标 completed保留 pending 等未来扩展消费者),
// 只记日志后回滚 running → pending 让任务可被未来的处理器接手。
tracing::info!(
job_id = %job_id,
analysis_type = %other,
"MVP 暂不支持的分析类型,跳过(保持 pending 供未来消费者处理)"
);
// 回滚事务claim_next 是事务化的,这里只更新状态不置 completed
rollback_running_to_pending(state, job_id).await?;
Ok(Processed)
}
}
}
/// 把 running 状态的任务回滚为 pending用于 MVP 不支持的类型)。
///
/// 注意retry_count 不递增这是路由跳过而非处理失败max_retries 不应被消耗。
async fn rollback_running_to_pending(state: &AiState, job_id: Uuid) -> AiResult<()> {
use sea_orm::ActiveModelTrait;
use sea_orm::EntityTrait;
use sea_orm::Set;
let now = chrono::Utc::now();
let entity = ai_analysis_queue::Entity::find_by_id(job_id)
.one(&state.db)
.await?
.ok_or_else(|| AiError::QueueError(format!("队列任务 {job_id} 未找到")))?;
let mut active: ai_analysis_queue::ActiveModel = entity.into();
active.status = Set("pending".to_string());
active.started_at = Set(None);
active.updated_at = Set(now);
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
active.update(&state.db).await?;
Ok(())
}
/// 趋势分析:复刻 handler 的 stream_trends 链路drain 流式结果后 complete。
async fn handle_trend(
state: &AiState,
queue: &AnalysisQueue,
job: ai_analysis_queue::Model,
) -> AiResult<ProcessOutcome> {
let job_id = job.id;
let tenant_id = job.tenant_id;
let patient_id = job.patient_id;
// 失败统一走 mark_failed自带 retry_count/max_retries 重试逻辑)
match run_trend_analysis(state, tenant_id, patient_id).await {
Ok(analysis_id) => match queue.mark_completed(job_id, analysis_id).await {
Ok(()) => {
tracing::info!(
job_id = %job_id,
analysis_id = %analysis_id,
"趋势分析任务完成"
);
Ok(Processed)
}
Err(e) => {
tracing::warn!(job_id = %job_id, error = %e, "mark_completed 失败");
Ok(Processed)
}
},
Err(e) => {
let err_msg = e.to_string();
tracing::warn!(
job_id = %job_id,
patient_id = %patient_id,
error = %err_msg,
"趋势分析处理失败"
);
match queue.mark_failed(job_id, err_msg).await {
Ok(()) => {}
Err(mfe) => {
tracing::warn!(job_id = %job_id, error = %mfe, "mark_failed 本身失败");
}
}
Ok(Processed)
}
}
}
/// 执行一次趋势分析,返回新建的 analysis_id。
///
/// 流程对齐 `handler::stream_trends` + `build_sse_stream`
/// 取趋势数据 → sanitize → 加载 prompt → stream_analyze → drain 流 → complete_analysis。
async fn run_trend_analysis(state: &AiState, tenant_id: Uuid, patient_id: Uuid) -> AiResult<Uuid> {
let metrics = vec![
"systolic_bp_morning".to_string(),
"diastolic_bp_morning".to_string(),
"heart_rate".to_string(),
"weight".to_string(),
"blood_sugar".to_string(),
];
let range = TimeRange {
start: chrono::Utc::now() - chrono::Duration::days(90),
end: chrono::Utc::now(),
};
let trend_data = state
.health_provider
.get_trend_analysis_data(tenant_id, patient_id, &metrics, &range)
.await
.map_err(|e| AiError::ProviderError(format!("获取趋势数据失败: {e}")))?;
if trend_data.metrics.is_empty() {
// 数据为空不是程序错误,但分析无法进行 → 返回失败让队列走重试/最终失败
return Err(AiError::ProviderError(
"患者在选定时间段内无体征监测数据".to_string(),
));
}
let sanitized_data = state
.analysis
.sanitizer
.sanitize_trend_analysis(&trend_data)?;
let prompt = state
.prompt
.get_active_prompt(tenant_id, AnalysisType::Trends.prompt_name())
.await?;
let (model, temperature, max_tokens) =
resolve_model_config(&prompt.model_config, tenant_id, &state.db).await;
// 队列任务无 HTTP 上下文user_id 用 nil 占位(仅用于审计记录)
let system_user = Uuid::nil();
let (stream, analysis_id, _provider) = state
.analysis
.stream_analyze(
tenant_id,
system_user,
patient_id,
AnalysisType::Trends,
patient_id.to_string(),
prompt.system_prompt,
prompt.user_prompt_template,
sanitized_data,
model,
temperature,
max_tokens,
)
.await?;
// drain 流:累积全部输出,遇错 fail_analysis
let mut stream = std::pin::pin!(stream);
let mut full_content = String::new();
while let Some(result) = stream.next().await {
match result {
Ok(chunk) => full_content.push_str(&chunk),
Err(e) => {
let _ = state
.analysis
.fail_analysis(analysis_id, e.to_string())
.await;
return Err(e);
}
}
}
let metadata = serde_json::json!({ "analysis_type": "trend", "source": "queue_worker" });
state
.analysis
.complete_analysis(analysis_id, full_content.clone(), metadata.clone())
.await?;
// 用量记录4 字符 ≈ 1 token 估算,对齐 SSE handler 逻辑)
let est_output_tokens = (full_content.len() as u32) / 4;
if let Err(e) = state
.usage
.log_usage(
tenant_id,
"queue_worker",
"",
"trend",
0,
est_output_tokens,
0,
0,
false,
)
.await
{
tracing::warn!(error = %e, "队列消费者记录用量失败");
}
// 后处理(解析建议、发布事件等)— 与 SSE handler 一致
crate::service::post_process::post_process_analysis(
state,
analysis_id,
&full_content,
tenant_id,
patient_id,
system_user,
"trend",
metadata,
)
.await;
Ok(analysis_id)
}
/// 解析 prompt.model_config + 租户默认配置,返回 (model, temperature, max_tokens)。
///
/// 与 `handler::resolve_model_config` 实现等价(独立复制避免跨模块可见性问题)。
async fn resolve_model_config(
model_config: &serde_json::Value,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> (String, f32, u32) {
let defaults = crate::config_resolver::load_ai_config(tenant_id, db).await;
let analysis = &defaults.analysis_defaults;
let model = model_config
.get("model")
.and_then(|v| v.as_str())
.unwrap_or(&analysis.model)
.to_string();
let temperature = model_config
.get("temperature")
.and_then(|v| v.as_f64())
.unwrap_or(analysis.temperature as f64) as f32;
let max_tokens = model_config
.get("max_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(analysis.max_tokens as u64) as u32;
(model, temperature, max_tokens)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn idle_sleep_为10秒() {
assert_eq!(IDLE_SLEEP.as_secs(), 10);
}
#[test]
fn process_outcome_枚举可用() {
let _a = Processed;
let _b = Idle;
}
}

View File

@@ -0,0 +1,87 @@
/// 文本切片:按固定大小 + 重叠切分
pub fn chunk_text(text: &str, chunk_size: usize, overlap: usize) -> Vec<String> {
if text.is_empty() {
return vec![];
}
let chars: Vec<char> = text.chars().collect();
let total = chars.len();
if total <= chunk_size {
return vec![text.to_string()];
}
let mut chunks = Vec::new();
let mut start = 0;
while start < total {
let end = (start + chunk_size).min(total);
let chunk: String = chars[start..end].iter().collect();
let trimmed = chunk.trim().to_string();
if !trimmed.is_empty() {
chunks.push(trimmed);
}
if end >= total {
break;
}
start += chunk_size.saturating_sub(overlap);
// 防止无限循环
if start <= end - chunk_size && start > 0 {
start = end;
}
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chunk_empty() {
assert_eq!(chunk_text("", 100, 20), Vec::<String>::new());
}
#[test]
fn test_chunk_small_text() {
let text = "hello world";
let chunks = chunk_text(text, 100, 20);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0], "hello world");
}
#[test]
fn test_chunk_long_text() {
let text = "abcdefghij".repeat(100); // 1000 chars
let chunks = chunk_text(&text, 200, 50);
assert!(chunks.len() > 1);
// First chunk should be 200 chars
assert_eq!(chars_count(&chunks[0]), 200);
}
#[test]
fn test_chunk_with_overlap() {
let text = "abcdefghijklmnopqrstuvwxyz".repeat(20); // 520 chars
let chunks = chunk_text(&text, 100, 20);
assert!(chunks.len() > 1);
}
#[test]
fn test_chunk_chinese() {
let text = "你好世界这是一段中文测试文本。".repeat(30);
let chunks = chunk_text(&text, 100, 20);
assert!(chunks.len() > 1);
// 确保中文不被截断
for chunk in &chunks {
assert!(!chunk.is_empty());
}
}
fn chars_count(s: &str) -> usize {
s.chars().count()
}
}

View File

@@ -0,0 +1,459 @@
pub mod chunker;
pub mod parser;
use sea_orm::{
ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::entity::ai_knowledge_documents;
use crate::error::{AiError, AiResult};
use crate::service::embedding::{EmbeddingService, format_vector};
use crate::service::knowledge_v2::KnowledgeV2Service;
use std::sync::Arc;
// ─── DTO ───
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct CreateDocumentReq {
pub title: String,
pub doc_type: Option<String>,
pub source_type: Option<String>,
pub source_url: Option<String>,
pub content: Option<String>,
}
#[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct ListDocumentsQuery {
pub status: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[derive(Debug)]
pub struct UploadDocumentParams {
pub file_name: String,
pub file_size: i64,
pub mime_type: String,
pub content: String,
}
// ─── Service ───
pub struct DocumentService {
db: sea_orm::DatabaseConnection,
knowledge_v2: Arc<KnowledgeV2Service>,
embedding: Arc<EmbeddingService>,
}
impl DocumentService {
pub fn new(
db: sea_orm::DatabaseConnection,
knowledge_v2: Arc<KnowledgeV2Service>,
embedding: Arc<EmbeddingService>,
) -> Self {
Self {
db,
knowledge_v2,
embedding,
}
}
pub async fn list_documents(
&self,
tenant_id: Uuid,
kb_id: Uuid,
query: &ListDocumentsQuery,
) -> AiResult<(Vec<ai_knowledge_documents::Model>, u64)> {
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let mut find = ai_knowledge_documents::Entity::find()
.filter(ai_knowledge_documents::Column::TenantId.eq(tenant_id))
.filter(ai_knowledge_documents::Column::KnowledgeBaseId.eq(kb_id))
.filter(ai_knowledge_documents::Column::DeletedAt.is_null());
if let Some(ref status) = query.status {
find = find.filter(ai_knowledge_documents::Column::Status.eq(status.as_str()));
}
let paginator = find
.order_by_desc(ai_knowledge_documents::Column::CreatedAt)
.paginate(&self.db, page_size);
let total = paginator.num_items().await?;
let items = paginator.fetch_page(page - 1).await?;
Ok((items, total))
}
pub async fn get_document(
&self,
tenant_id: Uuid,
id: Uuid,
) -> AiResult<ai_knowledge_documents::Model> {
ai_knowledge_documents::Entity::find_by_id(id)
.one(&self.db)
.await
.map_err(|e| AiError::DbError(e.to_string()))?
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or_else(|| AiError::KnowledgeError("文档不存在".into()))
}
/// 创建手动输入文档并立即处理
pub async fn create_manual_document(
&self,
tenant_id: Uuid,
user_id: Uuid,
kb_id: Uuid,
req: CreateDocumentReq,
) -> AiResult<Uuid> {
// 验证知识库存在
self.knowledge_v2.get_by_id(tenant_id, kb_id).await?;
let id = Uuid::now_v7();
let now = chrono::Utc::now();
let active = ai_knowledge_documents::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
knowledge_base_id: Set(kb_id),
title: Set(req.title),
doc_type: Set(req.doc_type.unwrap_or_else(|| "manual".into())),
source_type: Set(req.source_type.unwrap_or_else(|| "manual".into())),
source_url: Set(req.source_url),
file_name: Set(None),
file_size: Set(None),
file_mime_type: Set(None),
content: Set(req.content),
status: Set("pending".into()),
chunk_count: Set(0),
embedded_count: Set(0),
error_message: Set(None),
processing_started_at: Set(None),
processing_completed_at: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(Some(user_id)),
updated_by: Set(Some(user_id)),
deleted_at: Set(None),
version_lock: Set(1),
};
ai_knowledge_documents::Entity::insert(active)
.exec(&self.db)
.await
.map_err(|e| AiError::DbError(e.to_string()))?;
// 异步处理文档(切片 + 嵌入)
self.knowledge_v2.increment_document_count(kb_id, 1).await?;
self.process_document(id).await?;
Ok(id)
}
/// 创建文件上传文档记录
pub async fn create_upload_document(
&self,
tenant_id: Uuid,
user_id: Uuid,
kb_id: Uuid,
title: String,
params: UploadDocumentParams,
) -> AiResult<Uuid> {
self.knowledge_v2.get_by_id(tenant_id, kb_id).await?;
let id = Uuid::now_v7();
let now = chrono::Utc::now();
let doc_type = mime_to_doc_type(&params.mime_type);
let active = ai_knowledge_documents::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
knowledge_base_id: Set(kb_id),
title: Set(title),
doc_type: Set(doc_type),
source_type: Set("upload".into()),
source_url: Set(None),
file_name: Set(Some(params.file_name)),
file_size: Set(Some(params.file_size)),
file_mime_type: Set(Some(params.mime_type)),
content: Set(Some(params.content)),
status: Set("pending".into()),
chunk_count: Set(0),
embedded_count: Set(0),
error_message: Set(None),
processing_started_at: Set(None),
processing_completed_at: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(Some(user_id)),
updated_by: Set(Some(user_id)),
deleted_at: Set(None),
version_lock: Set(1),
};
ai_knowledge_documents::Entity::insert(active)
.exec(&self.db)
.await
.map_err(|e| AiError::DbError(e.to_string()))?;
self.knowledge_v2.increment_document_count(kb_id, 1).await?;
self.process_document(id).await?;
Ok(id)
}
pub async fn delete_document(&self, tenant_id: Uuid, kb_id: Uuid, id: Uuid) -> AiResult<()> {
let existing = self.get_document(tenant_id, id).await?;
if existing.knowledge_base_id != kb_id {
return Err(AiError::KnowledgeError("文档不属于该知识库".into()));
}
let now = chrono::Utc::now();
let active = ai_knowledge_documents::ActiveModel {
id: Set(existing.id),
tenant_id: Set(existing.tenant_id),
knowledge_base_id: Set(existing.knowledge_base_id),
title: Set(existing.title),
doc_type: Set(existing.doc_type),
source_type: Set(existing.source_type),
source_url: Set(existing.source_url),
file_name: Set(existing.file_name),
file_size: Set(existing.file_size),
file_mime_type: Set(existing.file_mime_type),
content: Set(existing.content),
status: Set(existing.status),
chunk_count: Set(existing.chunk_count),
embedded_count: Set(existing.embedded_count),
error_message: Set(existing.error_message),
processing_started_at: Set(existing.processing_started_at),
processing_completed_at: Set(existing.processing_completed_at),
created_at: Set(existing.created_at),
updated_at: Set(now),
created_by: Set(existing.created_by),
updated_by: Set(existing.updated_by),
deleted_at: Set(Some(now)),
version_lock: Set(existing.version_lock + 1),
};
ai_knowledge_documents::Entity::update(active)
.exec(&self.db)
.await
.map_err(|e| AiError::DbError(e.to_string()))?;
self.knowledge_v2
.increment_document_count(kb_id, -1)
.await?;
self.knowledge_v2
.increment_chunk_count(kb_id, -existing.chunk_count)
.await?;
Ok(())
}
/// 处理文档:切片 → 嵌入 → 更新状态
async fn process_document(&self, doc_id: Uuid) -> AiResult<()> {
let now = chrono::Utc::now();
// 标记处理中
self.update_doc_status(doc_id, "processing", None, Some(now), None)
.await?;
let doc = match ai_knowledge_documents::Entity::find_by_id(doc_id)
.one(&self.db)
.await
{
Ok(Some(d)) if d.deleted_at.is_none() => d,
_ => {
self.update_doc_status(
doc_id,
"failed",
Some("文档未找到".into()),
None,
Some(now),
)
.await?;
return Ok(());
}
};
let content = match &doc.content {
Some(c) if !c.trim().is_empty() => c.clone(),
_ => {
self.update_doc_status(
doc_id,
"failed",
Some("文档内容为空".into()),
None,
Some(now),
)
.await?;
return Ok(());
}
};
// 切片
let chunks = chunker::chunk_text(&content, 500, 50);
if chunks.is_empty() {
self.update_doc_status(
doc_id,
"failed",
Some("切片结果为空".into()),
None,
Some(now),
)
.await?;
return Ok(());
}
// 嵌入 + 存储
let mut embedded_count = 0u32;
for (idx, chunk_content) in chunks.iter().enumerate() {
let chunk_id = Uuid::now_v7();
let embedding = self.try_embed(chunk_content).await;
let embedding_val = embedding
.as_ref()
.map(|e| sea_orm::Value::String(Some(Box::new(format_vector(e)))))
.unwrap_or(sea_orm::Value::String(None));
let sql = r#"
INSERT INTO ai_knowledge_chunks
(id, tenant_id, knowledge_base_id, document_id, chunk_index, content,
embedding, metadata, hit_count, created_at, updated_at, created_by, updated_by, deleted_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::vector, '{}', 0, $8, $8, $9, $9, NULL)
"#;
let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[
sea_orm::Value::from(chunk_id),
sea_orm::Value::from(doc.tenant_id),
sea_orm::Value::from(doc.knowledge_base_id),
sea_orm::Value::from(doc_id),
sea_orm::Value::from(idx as i32),
sea_orm::Value::String(Some(Box::new(chunk_content.clone()))),
embedding_val,
sea_orm::Value::from(now),
sea_orm::Value::from(doc.created_by),
],
);
match self.db.execute(stmt).await {
Ok(_) => {
if embedding.is_some() {
embedded_count += 1;
}
}
Err(e) => {
tracing::warn!(chunk_index = idx, error = %e, "切片插入失败,跳过");
}
}
}
// 更新文档状态
let completed_now = chrono::Utc::now();
let sql = r#"
UPDATE ai_knowledge_documents
SET status = 'completed', chunk_count = $2, embedded_count = $3,
processing_completed_at = $4, updated_at = $4, version_lock = version_lock + 1
WHERE id = $1 AND deleted_at IS NULL
"#;
let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[
sea_orm::Value::from(doc_id),
sea_orm::Value::from(chunks.len() as i32),
sea_orm::Value::from(embedded_count as i32),
sea_orm::Value::from(completed_now),
],
);
self.db
.execute(stmt)
.await
.map_err(|e| AiError::DbError(e.to_string()))?;
// 原子递增知识库切片计数
self.knowledge_v2
.increment_chunk_count(doc.knowledge_base_id, chunks.len() as i32)
.await?;
Ok(())
}
async fn update_doc_status(
&self,
doc_id: Uuid,
status: &str,
error: Option<String>,
started_at: Option<chrono::DateTime<chrono::Utc>>,
completed_at: Option<chrono::DateTime<chrono::Utc>>,
) -> AiResult<()> {
let now = chrono::Utc::now();
let mut values: Vec<sea_orm::Value> = vec![
sea_orm::Value::from(doc_id),
sea_orm::Value::String(Some(Box::new(status.to_string()))),
error
.map(|e| sea_orm::Value::String(Some(Box::new(e))))
.unwrap_or(sea_orm::Value::String(None)),
sea_orm::Value::from(now),
];
let mut extra_sql = String::new();
if let Some(sa) = started_at {
values.push(sea_orm::Value::from(sa));
extra_sql.push_str(", processing_started_at = $5");
}
if let Some(ca) = completed_at {
values.push(sea_orm::Value::from(ca));
let idx = values.len();
extra_sql.push_str(&format!(", processing_completed_at = ${}", idx));
}
let sql = format!(
"UPDATE ai_knowledge_documents SET status = $2, error_message = $3, updated_at = $4, version_lock = version_lock + 1{} WHERE id = $1 AND deleted_at IS NULL",
extra_sql
);
let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
&sql,
values,
);
self.db
.execute(stmt)
.await
.map_err(|e| AiError::DbError(e.to_string()))?;
Ok(())
}
async fn try_embed(&self, text: &str) -> Option<Vec<f32>> {
if !self.embedding.is_configured() {
return None;
}
match self.embedding.embed(text).await {
Ok(e) => Some(e),
Err(e) => {
tracing::warn!(error = %e, "Embedding 生成失败");
None
}
}
}
}
fn mime_to_doc_type(mime: &str) -> String {
match mime {
"application/pdf" => "pdf".into(),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx".into(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx".into(),
"text/plain" => "txt".into(),
"text/markdown" => "md".into(),
_ => "other".into(),
}
}

View File

@@ -0,0 +1,60 @@
use crate::error::{AiError, AiResult};
/// 从文件内容解析出纯文本
pub fn parse_document(file_name: &str, mime_type: &str, data: &[u8]) -> AiResult<String> {
match mime_type {
"application/pdf" => parse_pdf(data),
"text/plain" | "text/markdown" => parse_text(data),
_ => {
if file_name.ends_with(".pdf") {
return parse_pdf(data);
}
// DOCX/XLSX 等二进制格式用 UTF-8 lossy 提取可读文本
// 后续 Phase 可替换为专业解析器
if file_name.ends_with(".txt") || file_name.ends_with(".md") {
return parse_text(data);
}
// 二进制格式兜底:提取 UTF-8 可读片段
parse_binary_text(data)
}
}
}
fn parse_pdf(data: &[u8]) -> AiResult<String> {
pdf_extract::extract_text_from_mem(data)
.map(|t| t.trim().to_string())
.map_err(|e| AiError::KnowledgeError(format!("PDF 解析失败: {}", e)))
}
fn parse_text(data: &[u8]) -> AiResult<String> {
Ok(String::from_utf8_lossy(data).trim().to_string())
}
/// 从二进制文件中提取可读文本片段DOCX/XLSX 兜底方案)
fn parse_binary_text(data: &[u8]) -> AiResult<String> {
let text = String::from_utf8_lossy(data);
let mut readable = String::new();
let mut chunk = String::new();
for ch in text.chars() {
let punctuation = ",。、;:\u{201c}\u{201d}\u{2018}\u{2019}!?()《》【】…—·\t\n\r";
if ch.is_alphanumeric() || ch.is_whitespace() || punctuation.contains(ch) {
chunk.push(ch);
} else if !chunk.trim().is_empty() {
readable.push_str(chunk.trim());
readable.push(' ');
chunk.clear();
}
}
if !chunk.trim().is_empty() {
readable.push_str(chunk.trim());
}
let result = readable.split_whitespace().collect::<Vec<_>>().join(" ");
if result.len() < 20 {
return Err(AiError::KnowledgeError(
"无法从文件中提取有效文本内容".into(),
));
}
Ok(result)
}

View File

@@ -0,0 +1,336 @@
use sea_orm::{
ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::entity::ai_knowledge_bases;
use crate::error::{AiError, AiResult};
// ─── DTO ───
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct CreateKnowledgeBaseReq {
pub name: String,
pub kb_type: String,
pub description: Option<String>,
pub icon: Option<String>,
pub chunk_strategy: Option<serde_json::Value>,
pub intent_keywords: Option<serde_json::Value>,
pub embedding_model: Option<String>,
pub is_enabled: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct UpdateKnowledgeBaseReq {
pub name: Option<String>,
pub kb_type: Option<String>,
pub description: Option<String>,
pub icon: Option<String>,
pub chunk_strategy: Option<serde_json::Value>,
pub intent_keywords: Option<serde_json::Value>,
pub embedding_model: Option<String>,
pub is_enabled: Option<bool>,
}
#[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct ListKnowledgeBasesQuery {
pub kb_type: Option<String>,
pub is_enabled: Option<bool>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
// ─── Service ───
pub struct KnowledgeV2Service {
db: sea_orm::DatabaseConnection,
}
impl KnowledgeV2Service {
pub fn new(db: sea_orm::DatabaseConnection) -> Self {
Self { db }
}
pub async fn list(
&self,
tenant_id: Uuid,
query: &ListKnowledgeBasesQuery,
) -> AiResult<(Vec<ai_knowledge_bases::Model>, u64)> {
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let mut find = ai_knowledge_bases::Entity::find()
.filter(ai_knowledge_bases::Column::TenantId.eq(tenant_id))
.filter(ai_knowledge_bases::Column::DeletedAt.is_null());
if let Some(ref kb_type) = query.kb_type {
find = find.filter(ai_knowledge_bases::Column::KbType.eq(kb_type.as_str()));
}
if let Some(is_enabled) = query.is_enabled {
find = find.filter(ai_knowledge_bases::Column::IsEnabled.eq(is_enabled));
}
let paginator = find
.order_by_desc(ai_knowledge_bases::Column::CreatedAt)
.paginate(&self.db, page_size);
let total = paginator.num_items().await?;
let items = paginator.fetch_page(page - 1).await?;
Ok((items, total))
}
pub async fn get_by_id(
&self,
tenant_id: Uuid,
id: Uuid,
) -> AiResult<ai_knowledge_bases::Model> {
ai_knowledge_bases::Entity::find_by_id(id)
.one(&self.db)
.await
.map_err(|e| AiError::DbError(e.to_string()))?
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or_else(|| AiError::KnowledgeError("知识库不存在".into()))
}
pub async fn create(
&self,
tenant_id: Uuid,
user_id: Uuid,
req: CreateKnowledgeBaseReq,
) -> AiResult<Uuid> {
let id = Uuid::now_v7();
let now = chrono::Utc::now();
let active = ai_knowledge_bases::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(req.name),
kb_type: Set(req.kb_type),
description: Set(req.description),
icon: Set(req.icon),
chunk_strategy: Set(req.chunk_strategy.unwrap_or(
serde_json::json!({"strategy": "auto", "chunk_size": 500, "overlap": 50}),
)),
intent_keywords: Set(req.intent_keywords.unwrap_or(serde_json::json!([]))),
embedding_model: Set(req.embedding_model),
is_enabled: Set(req.is_enabled.unwrap_or(true)),
document_count: Set(0),
chunk_count: Set(0),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(Some(user_id)),
updated_by: Set(Some(user_id)),
deleted_at: Set(None),
version_lock: Set(1),
};
ai_knowledge_bases::Entity::insert(active)
.exec(&self.db)
.await
.map_err(|e| AiError::DbError(e.to_string()))?;
Ok(id)
}
pub async fn update(
&self,
tenant_id: Uuid,
user_id: Uuid,
id: Uuid,
req: UpdateKnowledgeBaseReq,
) -> AiResult<()> {
let existing = self.get_by_id(tenant_id, id).await?;
let now = chrono::Utc::now();
let active = ai_knowledge_bases::ActiveModel {
id: Set(existing.id),
tenant_id: Set(existing.tenant_id),
name: Set(req.name.unwrap_or(existing.name)),
kb_type: Set(req.kb_type.unwrap_or(existing.kb_type)),
description: Set(req.description.or(existing.description)),
icon: Set(req.icon.or(existing.icon)),
chunk_strategy: Set(req.chunk_strategy.unwrap_or(existing.chunk_strategy)),
intent_keywords: Set(req.intent_keywords.unwrap_or(existing.intent_keywords)),
embedding_model: Set(req.embedding_model.or(existing.embedding_model)),
is_enabled: Set(req.is_enabled.unwrap_or(existing.is_enabled)),
document_count: Set(existing.document_count),
chunk_count: Set(existing.chunk_count),
created_at: Set(existing.created_at),
updated_at: Set(now),
created_by: Set(existing.created_by),
updated_by: Set(Some(user_id)),
deleted_at: Set(existing.deleted_at),
version_lock: Set(existing.version_lock + 1),
};
ai_knowledge_bases::Entity::update(active)
.exec(&self.db)
.await
.map_err(|e| AiError::DbError(e.to_string()))?;
Ok(())
}
pub async fn delete(&self, tenant_id: Uuid, id: Uuid) -> AiResult<()> {
let existing = self.get_by_id(tenant_id, id).await?;
let now = chrono::Utc::now();
let active = ai_knowledge_bases::ActiveModel {
id: Set(existing.id),
tenant_id: Set(existing.tenant_id),
name: Set(existing.name),
kb_type: Set(existing.kb_type),
description: Set(existing.description),
icon: Set(existing.icon),
chunk_strategy: Set(existing.chunk_strategy),
intent_keywords: Set(existing.intent_keywords),
embedding_model: Set(existing.embedding_model),
is_enabled: Set(existing.is_enabled),
document_count: Set(existing.document_count),
chunk_count: Set(existing.chunk_count),
created_at: Set(existing.created_at),
updated_at: Set(now),
created_by: Set(existing.created_by),
updated_by: Set(existing.updated_by),
deleted_at: Set(Some(now)),
version_lock: Set(existing.version_lock + 1),
};
ai_knowledge_bases::Entity::update(active)
.exec(&self.db)
.await
.map_err(|e| AiError::DbError(e.to_string()))?;
Ok(())
}
/// 原子递增文档计数(用于文档上传成功后)
pub async fn increment_document_count(&self, kb_id: Uuid, delta: i32) -> AiResult<()> {
let sql = r#"
UPDATE ai_knowledge_bases
SET document_count = document_count + $2,
updated_at = $3,
version_lock = version_lock + 1
WHERE id = $1 AND deleted_at IS NULL
"#;
let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[
sea_orm::Value::from(kb_id),
sea_orm::Value::from(delta),
sea_orm::Value::from(chrono::Utc::now()),
],
);
self.db
.execute(stmt)
.await
.map_err(|e| AiError::DbError(e.to_string()))?;
Ok(())
}
/// 原子递增切片计数(用于切片生成后)
pub async fn increment_chunk_count(&self, kb_id: Uuid, delta: i32) -> AiResult<()> {
let sql = r#"
UPDATE ai_knowledge_bases
SET chunk_count = chunk_count + $2,
updated_at = $3,
version_lock = version_lock + 1
WHERE id = $1 AND deleted_at IS NULL
"#;
let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[
sea_orm::Value::from(kb_id),
sea_orm::Value::from(delta),
sea_orm::Value::from(chrono::Utc::now()),
],
);
self.db
.execute(stmt)
.await
.map_err(|e| AiError::DbError(e.to_string()))?;
Ok(())
}
/// 向量相似度搜索:在指定知识库中搜索与 query_embedding 最相似的 top_k 个切片
pub async fn vector_search(
&self,
tenant_id: Uuid,
kb_id: Uuid,
query_embedding: &[f32],
top_k: i64,
) -> AiResult<Vec<SearchHit>> {
let vector_str = crate::service::embedding::format_vector(query_embedding);
let sql = r#"
SELECT c.id, c.document_id, c.chunk_index, c.content, c.metadata,
d.title AS doc_title,
1 - (c.embedding <=> $3::vector) AS similarity
FROM ai_knowledge_chunks c
JOIN ai_knowledge_documents d ON d.id = c.document_id
WHERE c.tenant_id = $1
AND c.knowledge_base_id = $2
AND c.deleted_at IS NULL
AND d.deleted_at IS NULL
AND c.embedding IS NOT NULL
ORDER BY c.embedding <=> $3::vector
LIMIT $4
"#;
let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[
sea_orm::Value::from(tenant_id),
sea_orm::Value::from(kb_id),
sea_orm::Value::String(Some(Box::new(vector_str))),
sea_orm::Value::from(top_k),
],
);
let rows: Vec<SearchHitRow> = sea_orm::FromQueryResult::find_by_statement(stmt)
.all(&self.db)
.await
.map_err(|e| AiError::DbError(e.to_string()))?;
Ok(rows.into_iter().map(SearchHit::from).collect())
}
}
#[derive(Debug, sea_orm::FromQueryResult)]
struct SearchHitRow {
id: Uuid,
document_id: Uuid,
chunk_index: i32,
content: String,
metadata: serde_json::Value,
doc_title: String,
similarity: f64,
}
#[derive(Debug, serde::Serialize)]
pub struct SearchHit {
pub chunk_id: Uuid,
pub document_id: Uuid,
pub chunk_index: i32,
pub content: String,
pub doc_title: String,
pub similarity: f64,
pub metadata: serde_json::Value,
}
impl From<SearchHitRow> for SearchHit {
fn from(row: SearchHitRow) -> Self {
Self {
chunk_id: row.id,
document_id: row.document_id,
chunk_index: row.chunk_index,
content: row.content,
doc_title: row.doc_title,
similarity: row.similarity,
metadata: row.metadata,
}
}
}

View File

@@ -1,5 +1,6 @@
pub mod analysis; pub mod analysis;
pub mod analysis_queue; pub mod analysis_queue;
pub mod analysis_worker;
pub mod auto_analysis; pub mod auto_analysis;
pub mod cache; pub mod cache;
pub mod chat_message; pub mod chat_message;
@@ -7,10 +8,12 @@ pub mod chat_session;
pub mod comparison; pub mod comparison;
pub mod cost; pub mod cost;
pub mod dialysis_risk_scorer; pub mod dialysis_risk_scorer;
pub mod document;
pub mod embedding; pub mod embedding;
pub mod feature_flag_service; pub mod feature_flag_service;
pub mod insight_service; pub mod insight_service;
pub mod knowledge; pub mod knowledge;
pub mod knowledge_v2;
pub mod local_rules; pub mod local_rules;
pub mod output_parser; pub mod output_parser;
pub mod post_process; pub mod post_process;

View File

@@ -17,20 +17,20 @@ impl PromptService {
Self { db } Self { db }
} }
/// 获取当前激活的 Prompt 模板 /// 获取当前激活的 Prompt 模板(按 analysis_type 查找)
pub async fn get_active_prompt( pub async fn get_active_prompt(
&self, &self,
tenant_id: Uuid, tenant_id: Uuid,
name: &str, analysis_type: &str,
) -> AiResult<ai_prompt::Model> { ) -> AiResult<ai_prompt::Model> {
ai_prompt::Entity::find() ai_prompt::Entity::find()
.filter(ai_prompt::Column::TenantId.eq(tenant_id)) .filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::Name.eq(name)) .filter(ai_prompt::Column::AnalysisType.eq(analysis_type))
.filter(ai_prompt::Column::IsActive.eq(true)) .filter(ai_prompt::Column::IsActive.eq(true))
.filter(ai_prompt::Column::DeletedAt.is_null()) .filter(ai_prompt::Column::DeletedAt.is_null())
.one(&self.db) .one(&self.db)
.await? .await?
.ok_or_else(|| AiError::PromptNotFound(name.into())) .ok_or_else(|| AiError::PromptNotFound(analysis_type.into()))
} }
/// 新建 Prompt /// 新建 Prompt
@@ -44,6 +44,7 @@ impl PromptService {
user_prompt_template: String, user_prompt_template: String,
model_config: serde_json::Value, model_config: serde_json::Value,
category: String, category: String,
analysis_type: String,
) -> AiResult<ai_prompt::Model> { ) -> AiResult<ai_prompt::Model> {
let id = Uuid::now_v7(); let id = Uuid::now_v7();
let now = chrono::Utc::now(); let now = chrono::Utc::now();
@@ -59,6 +60,7 @@ impl PromptService {
version: Set(1), version: Set(1),
is_active: Set(true), is_active: Set(true),
category: Set(category), category: Set(category),
analysis_type: Set(analysis_type),
tags: Set(None), tags: Set(None),
created_at: Set(now), created_at: Set(now),
updated_at: Set(now), updated_at: Set(now),
@@ -74,15 +76,15 @@ impl PromptService {
pub async fn list_prompts( pub async fn list_prompts(
&self, &self,
tenant_id: Uuid, tenant_id: Uuid,
category: Option<String>, analysis_type: Option<String>,
pagination: &Pagination, pagination: &Pagination,
) -> AiResult<(Vec<ai_prompt::Model>, u64)> { ) -> AiResult<(Vec<ai_prompt::Model>, u64)> {
let mut query = ai_prompt::Entity::find() let mut query = ai_prompt::Entity::find()
.filter(ai_prompt::Column::TenantId.eq(tenant_id)) .filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::DeletedAt.is_null()); .filter(ai_prompt::Column::DeletedAt.is_null());
if let Some(cat) = &category { if let Some(at) = &analysis_type {
query = query.filter(ai_prompt::Column::Category.eq(cat.as_str())); query = query.filter(ai_prompt::Column::AnalysisType.eq(at.as_str()));
} }
let total = query.clone().count(&self.db).await?; let total = query.clone().count(&self.db).await?;
@@ -132,6 +134,7 @@ impl PromptService {
version: Set(entity.version + 1), version: Set(entity.version + 1),
is_active: Set(entity.is_active), is_active: Set(entity.is_active),
category: Set(entity.category.clone()), category: Set(entity.category.clone()),
analysis_type: Set(entity.analysis_type.clone()),
tags: Set(entity.tags.clone()), tags: Set(entity.tags.clone()),
created_at: Set(now), created_at: Set(now),
updated_at: Set(now), updated_at: Set(now),
@@ -143,7 +146,7 @@ impl PromptService {
Ok(active.insert(&self.db).await?) Ok(active.insert(&self.db).await?)
} }
/// 激活指定 Prompt停用同 name+category 的其他版本) /// 激活指定 Prompt停用同 analysis_type 的其他版本,原子操作
pub async fn activate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> { pub async fn activate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
let entity = ai_prompt::Entity::find_by_id(id) let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db) .one(&self.db)
@@ -154,25 +157,23 @@ impl PromptService {
return Err(AiError::Validation("跨租户操作".into())); return Err(AiError::Validation("跨租户操作".into()));
} }
// 停用同 name + category 的其他激活版本 // 原子操作:停用同 analysis_type 的其他版本
let siblings = ai_prompt::Entity::find() ai_prompt::Entity::update_many()
.col_expr(
ai_prompt::Column::IsActive,
sea_orm::sea_query::Expr::value(false),
)
.col_expr(
ai_prompt::Column::UpdatedAt,
sea_orm::sea_query::Expr::value(chrono::Utc::now()),
)
.filter(ai_prompt::Column::TenantId.eq(tenant_id)) .filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::Name.eq(&entity.name)) .filter(ai_prompt::Column::AnalysisType.eq(&entity.analysis_type))
.filter(ai_prompt::Column::Category.eq(&entity.category))
.filter(ai_prompt::Column::IsActive.eq(true))
.filter(ai_prompt::Column::DeletedAt.is_null())
.filter(ai_prompt::Column::Id.ne(id)) .filter(ai_prompt::Column::Id.ne(id))
.all(&self.db) .filter(ai_prompt::Column::DeletedAt.is_null())
.exec(&self.db)
.await?; .await?;
for sibling in siblings {
let mut active: ai_prompt::ActiveModel = sibling.into();
active.is_active = Set(false);
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
active.update(&self.db).await?;
}
// 激活目标 // 激活目标
let mut active: ai_prompt::ActiveModel = entity.into(); let mut active: ai_prompt::ActiveModel = entity.into();
active.is_active = Set(true); active.is_active = Set(true);
@@ -185,4 +186,41 @@ impl PromptService {
pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> { pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
self.activate_prompt(id, tenant_id).await self.activate_prompt(id, tenant_id).await
} }
/// 停用 Prompt
pub async fn deactivate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
if entity.tenant_id != tenant_id {
return Err(AiError::Validation("跨租户操作".into()));
}
let mut active: ai_prompt::ActiveModel = entity.into();
active.is_active = Set(false);
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
Ok(active.update(&self.db).await?)
}
/// 删除 Prompt软删除
pub async fn delete_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<()> {
let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
if entity.tenant_id != tenant_id {
return Err(AiError::Validation("跨租户操作".into()));
}
let mut active: ai_prompt::ActiveModel = entity.into();
active.deleted_at = Set(Some(chrono::Utc::now()));
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
active.update(&self.db).await?;
Ok(())
}
} }

View File

@@ -9,9 +9,12 @@ use crate::service::analysis::AnalysisService;
use crate::service::cache::CacheService; use crate::service::cache::CacheService;
use crate::service::chat_message::ChatMessageService; use crate::service::chat_message::ChatMessageService;
use crate::service::chat_session::ChatSessionService; use crate::service::chat_session::ChatSessionService;
use crate::service::document::DocumentService;
use crate::service::embedding::EmbeddingService;
use crate::service::feature_flag_service::FeatureFlagService; use crate::service::feature_flag_service::FeatureFlagService;
use crate::service::insight_service::InsightService; use crate::service::insight_service::InsightService;
use crate::service::knowledge::KnowledgeService; use crate::service::knowledge::KnowledgeService;
use crate::service::knowledge_v2::KnowledgeV2Service;
use crate::service::prompt::PromptService; use crate::service::prompt::PromptService;
use crate::service::quota::QuotaService; use crate::service::quota::QuotaService;
use crate::service::risk_service::RiskService; use crate::service::risk_service::RiskService;
@@ -34,6 +37,9 @@ pub struct AiState {
pub insight_service: Arc<InsightService>, pub insight_service: Arc<InsightService>,
pub feature_flags: Arc<FeatureFlagService>, pub feature_flags: Arc<FeatureFlagService>,
pub knowledge: Arc<KnowledgeService>, pub knowledge: Arc<KnowledgeService>,
pub knowledge_v2: Arc<KnowledgeV2Service>,
pub document: Arc<DocumentService>,
pub embedding: Arc<EmbeddingService>,
pub chat_session: Arc<ChatSessionService>, pub chat_session: Arc<ChatSessionService>,
pub chat_message: Arc<ChatMessageService>, pub chat_message: Arc<ChatMessageService>,
} }

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