Compare commits

197 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

11
.lintstagedrc.js Normal file
View File

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

28
Cargo.lock generated
View File

@@ -1411,10 +1411,12 @@ dependencies = [
"async-trait",
"axum",
"chrono",
"dashmap",
"erp-core",
"futures",
"handlebars",
"hex",
"redis",
"reqwest",
"sea-orm",
"serde",
@@ -1527,6 +1529,7 @@ name = "erp-health"
version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"async-trait",
"axum",
"base64 0.22.1",
@@ -1534,7 +1537,9 @@ dependencies = [
"erp-core",
"hex",
"hmac",
"jsonwebtoken",
"num-traits",
"rand_core 0.6.4",
"sea-orm",
"serde",
"serde_json",
@@ -1662,30 +1667,12 @@ dependencies = [
"wit-bindgen 0.55.0",
]
[[package]]
name = "erp-points"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"chrono",
"erp-core",
"sea-orm",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"utoipa",
"uuid",
"validator",
]
[[package]]
name = "erp-server"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"chrono",
"config",
@@ -1699,6 +1686,8 @@ dependencies = [
"erp-plugin",
"erp-server-migration",
"erp-workflow",
"futures",
"hex",
"metrics",
"metrics-exporter-prometheus",
"moka",
@@ -1706,6 +1695,7 @@ dependencies = [
"sea-orm",
"serde",
"serde_json",
"sha2",
"sqlx",
"tokio",
"tower",

View File

@@ -111,6 +111,7 @@ erp-dialysis = { path = "crates/erp-dialysis" }
futures = "0.3"
tokio-stream = "0.1"
async-stream = "0.3"
dashmap = "6"
# Template engine
handlebars = "6"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ export default defineConfig(async (merge) => {
const baseConfig = {
projectName: 'hms-miniprogram',
date: '2026-4-23',
designWidth: 750,
designWidth: 375,
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
sourceRoot: 'src',
outputRoot: 'dist',

View File

@@ -42,6 +42,7 @@
"miniprogram-automator": "^0.12.1",
"sass": "^1.87.0",
"typescript": "^5.8.0",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"webpack": "~5.95.0"
}

View File

@@ -19,13 +19,13 @@ importers:
version: 7.28.5(@babel/core@7.29.0)
'@tarojs/components':
specifier: 4.2.0
version: 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)))(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)))(webpack@5.95.0(@swc/core@1.3.96))
version: 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':
specifier: 4.2.0
version: 4.2.0
'@tarojs/plugin-framework-react':
specifier: 4.2.0
version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(react@18.3.1)(webpack@5.95.0(@swc/core@1.3.96))
version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(react@18.3.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
'@tarojs/plugin-platform-weapp':
specifier: 4.2.0
version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0)
@@ -40,7 +40,7 @@ importers:
version: 4.2.0
'@tarojs/taro':
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)))(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)))(webpack@5.95.0(@swc/core@1.3.96)))(@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)))(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)))(webpack@5.95.0(@swc/core@1.3.96))
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))
babel-preset-taro:
specifier: ^4.2.0
version: 4.2.0(@babel/core@7.29.0)(@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0))(@babel/preset-react@7.28.5(@babel/core@7.29.0))
@@ -71,7 +71,7 @@ importers:
version: 4.2.0(@types/node@25.6.0)
'@tarojs/webpack5-runner':
specifier: 4.2.0
version: 4.2.0(@babel/core@7.29.0)(@swc/core@1.3.96)(@tarojs/runtime@4.2.0)(less@3.13.1)(postcss@8.5.12)(sass@1.99.0)(stylus@0.64.0)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96))
version: 4.2.0(@babel/core@7.29.0)(@swc/core@1.3.96)(@tarojs/runtime@4.2.0)(less@3.13.1)(postcss@8.5.12)(sass@1.99.0)(stylus@0.64.0)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
@@ -87,12 +87,15 @@ importers:
typescript:
specifier: ^5.8.0
version: 5.9.3
vite:
specifier: ^8.0.10
version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2)
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@25.6.0)(jsdom@24.1.3)
version: 4.1.5(@types/node@25.6.0)(jsdom@24.1.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2))
webpack:
specifier: ~5.95.0
version: 5.95.0(@swc/core@1.3.96)
version: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
packages:
@@ -705,6 +708,15 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -1227,6 +1239,12 @@ packages:
'@napi-rs/triples@1.2.0':
resolution: {integrity: sha512-HAPjR3bnCsdXBsATpDIP5WCrw0JcACwhhrwIAQhiR46n+jm+a2F8kBsfseAuWtSyQ+H3Yebt2k43B5dy+04yMA==}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -1239,6 +1257,9 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@oxc-project/types@0.127.0':
resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
@@ -1350,6 +1371,104 @@ packages:
'@rnx-kit/console@1.1.0':
resolution: {integrity: sha512-N+zFhTSXroiK4eL26vs61Pmtl7wzTPAKLd4JKw9/fk5cNAHUscCXF/uclzuYN61Ye5AwygIvcwbm9wv4Jfa92A==}
'@rolldown/binding-android-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.0.0-rc.17':
resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.0.0-rc.17':
resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.0-rc.17':
resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
'@sideway/address@4.1.5':
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
@@ -1726,6 +1845,9 @@ packages:
stylus:
optional: true
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/archy@0.0.31':
resolution: {integrity: sha512-v+dxizsFVyXgD3EpFuqT9YjdEjbJmPxNf1QIX9ohZOhxh1ZF2yhqv3vYaeum9lg3VghhxS5S0a6yldN9J9lPEQ==}
@@ -4804,6 +4926,11 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rolldown@1.0.0-rc.17:
resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rollup@3.30.0:
resolution: {integrity: sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
@@ -5386,6 +5513,49 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
vite@8.0.10:
resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.1.0
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vitest@4.1.5:
resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -6452,6 +6622,22 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.21.5':
optional: true
@@ -6920,6 +7106,13 @@ snapshots:
'@napi-rs/triples@1.2.0': {}
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@tybys/wasm-util': 0.10.1
optional: true
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -6932,6 +7125,8 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@oxc-project/types@0.127.0': {}
'@parcel/watcher-android-arm64@2.5.6':
optional: true
@@ -7009,6 +7204,57 @@ snapshots:
'@rnx-kit/console@1.1.0': {}
'@rolldown/binding-android-arm64@1.0.0-rc.17':
optional: true
'@rolldown/binding-darwin-arm64@1.0.0-rc.17':
optional: true
'@rolldown/binding-darwin-x64@1.0.0-rc.17':
optional: true
'@rolldown/binding-freebsd-x64@1.0.0-rc.17':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
optional: true
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
optional: true
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
optional: true
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
optional: true
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
optional: true
'@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
optional: true
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
optional: true
'@rolldown/pluginutils@1.0.0-rc.17': {}
'@sideway/address@4.1.5':
dependencies:
'@hapi/hoek': 9.3.0
@@ -7146,12 +7392,12 @@ snapshots:
- debug
- supports-color
'@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)))(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)))(webpack@5.95.0(@swc/core@1.3.96))':
'@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))':
dependencies:
'@stencil/core': 2.22.3
'@tarojs/runtime': 4.2.0
'@tarojs/shared': 4.2.0
'@tarojs/taro': 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)))(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)))(webpack@5.95.0(@swc/core@1.3.96)))(@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)))(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)))(webpack@5.95.0(@swc/core@1.3.96))
'@tarojs/taro': 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))
classnames: 2.5.1
hammerjs: 2.0.8
hls.js: 1.6.16
@@ -7244,7 +7490,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tarojs/plugin-framework-react@4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(react@18.3.1)(webpack@5.95.0(@swc/core@1.3.96))':
'@tarojs/plugin-framework-react@4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(react@18.3.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))':
dependencies:
'@tarojs/helper': 4.2.0
'@tarojs/runtime': 4.2.0
@@ -7255,7 +7501,8 @@ snapshots:
tslib: 2.8.1
optionalDependencies:
react: 18.3.1
webpack: 5.95.0(@swc/core@1.3.96)
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
'@tarojs/plugin-platform-weapp@4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0)':
dependencies:
@@ -7300,19 +7547,19 @@ snapshots:
'@tarojs/shared@4.2.0': {}
'@tarojs/taro-loader@4.2.0(webpack@5.95.0(@swc/core@1.3.96))':
'@tarojs/taro-loader@4.2.0(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))':
dependencies:
'@tarojs/helper': 4.2.0
'@tarojs/shared': 4.2.0
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
transitivePeerDependencies:
- '@swc/helpers'
- supports-color
'@tarojs/taro@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)))(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)))(webpack@5.95.0(@swc/core@1.3.96)))(@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)))(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)))(webpack@5.95.0(@swc/core@1.3.96))':
'@tarojs/taro@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))':
dependencies:
'@tarojs/api': 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@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)))(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)))(webpack@5.95.0(@swc/core@1.3.96))
'@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/runtime': 4.2.0
'@tarojs/shared': 4.2.0
@@ -7320,77 +7567,77 @@ snapshots:
postcss: 8.5.12
optionalDependencies:
'@types/react': 18.3.28
html-webpack-plugin: 5.6.7(webpack@5.95.0(@swc/core@1.3.96))
html-webpack-plugin: 5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
rollup: 3.30.0
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
webpack-chain: 6.5.1
webpack-dev-server: 4.15.2(webpack@5.95.0(@swc/core@1.3.96))
webpack-dev-server: 4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
'@tarojs/webpack5-prebundle@4.2.0(webpack@5.95.0(@swc/core@1.3.96))':
'@tarojs/webpack5-prebundle@4.2.0(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))':
dependencies:
'@tarojs/helper': 4.2.0
'@tarojs/shared': 4.2.0
enhanced-resolve: 5.21.0
es-module-lexer: 0.10.5
lodash: 4.18.1
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
webpack-virtual-modules: 0.6.2
transitivePeerDependencies:
- '@swc/helpers'
- supports-color
'@tarojs/webpack5-runner@4.2.0(@babel/core@7.29.0)(@swc/core@1.3.96)(@tarojs/runtime@4.2.0)(less@3.13.1)(postcss@8.5.12)(sass@1.99.0)(stylus@0.64.0)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96))':
'@tarojs/webpack5-runner@4.2.0(@babel/core@7.29.0)(@swc/core@1.3.96)(@tarojs/runtime@4.2.0)(less@3.13.1)(postcss@8.5.12)(sass@1.99.0)(stylus@0.64.0)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))':
dependencies:
'@babel/core': 7.29.0
'@tarojs/helper': 4.2.0
'@tarojs/runner-utils': 4.2.0
'@tarojs/runtime': 4.2.0
'@tarojs/shared': 4.2.0
'@tarojs/taro-loader': 4.2.0(webpack@5.95.0(@swc/core@1.3.96))
'@tarojs/webpack5-prebundle': 4.2.0(webpack@5.95.0(@swc/core@1.3.96))
'@tarojs/taro-loader': 4.2.0(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
'@tarojs/webpack5-prebundle': 4.2.0(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
acorn: 8.16.0
acorn-walk: 8.3.5
autoprefixer: 10.5.0(postcss@8.5.12)
babel-loader: 8.2.1(@babel/core@7.29.0)(webpack@5.95.0(@swc/core@1.3.96))
copy-webpack-plugin: 12.0.2(webpack@5.95.0(@swc/core@1.3.96))
css-loader: 7.1.4(webpack@5.95.0(@swc/core@1.3.96))
css-minimizer-webpack-plugin: 6.0.0(esbuild@0.21.5)(lightningcss@1.32.0)(webpack@5.95.0(@swc/core@1.3.96))
babel-loader: 8.2.1(@babel/core@7.29.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
copy-webpack-plugin: 12.0.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
css-loader: 7.1.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
css-minimizer-webpack-plugin: 6.0.0(esbuild@0.21.5)(lightningcss@1.32.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
detect-port: 1.6.1
esbuild: 0.21.5
esbuild-loader: 4.4.3(webpack@5.95.0(@swc/core@1.3.96))
esbuild-loader: 4.4.3(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
html-minifier: 4.0.0
html-webpack-plugin: 5.6.7(webpack@5.95.0(@swc/core@1.3.96))
html-webpack-plugin: 5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
jsdom: 24.1.3
less-loader: 12.3.2(less@3.13.1)(webpack@5.95.0(@swc/core@1.3.96))
less-loader: 12.3.2(less@3.13.1)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
lightningcss: 1.32.0
loader-utils: 3.3.1
lodash: 4.18.1
md5: 2.3.0
mini-css-extract-plugin: 2.10.2(webpack@5.95.0(@swc/core@1.3.96))
mini-css-extract-plugin: 2.10.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
miniprogram-simulate: 1.6.1
ora: 5.4.1
picomatch: 4.0.4
postcss: 8.5.12
postcss-html-transform: 4.2.0(postcss@8.5.12)
postcss-import: 16.1.1(postcss@8.5.12)
postcss-loader: 8.2.1(postcss@8.5.12)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96))
postcss-loader: 8.2.1(postcss@8.5.12)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
postcss-plugin-constparse: 4.2.0(postcss@8.5.12)
postcss-pxtransform: 4.2.0(postcss@8.5.12)
postcss-url: 10.1.3(postcss@8.5.12)
regenerator-runtime: 0.11.1
resolve-url-loader: 5.0.0
sass-loader: 14.2.1(sass@1.99.0)(webpack@5.95.0(@swc/core@1.3.96))
sass-loader: 14.2.1(sass@1.99.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
sax: 1.2.4
style-loader: 3.3.4(webpack@5.95.0(@swc/core@1.3.96))
stylus-loader: 8.1.3(stylus@0.64.0)(webpack@5.95.0(@swc/core@1.3.96))
terser-webpack-plugin: 5.5.0(@swc/core@1.3.96)(esbuild@0.21.5)(webpack@5.95.0(@swc/core@1.3.96))
style-loader: 3.3.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
stylus-loader: 8.1.3(stylus@0.64.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
terser-webpack-plugin: 5.5.0(@swc/core@1.3.96)(esbuild@0.21.5)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
vm2: 3.10.5
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
webpack-chain: 6.5.1
webpack-dev-server: 4.15.2(webpack@5.95.0(@swc/core@1.3.96))
webpack-dev-server: 4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
webpack-format-messages: 3.0.1
webpack-virtual-modules: 0.6.2
webpackbar: 5.0.2(webpack@5.95.0(@swc/core@1.3.96))
webpackbar: 5.0.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
optionalDependencies:
less: 3.13.1
sass: 1.99.0
@@ -7414,6 +7661,11 @@ snapshots:
- utf-8-validate
- webpack-cli
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
optional: true
'@types/archy@0.0.31': {}
'@types/body-parser@1.19.6':
@@ -7597,11 +7849,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.5':
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2))':
dependencies:
'@vitest/spy': 4.1.5
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2)
'@vitest/pretty-format@4.1.5':
dependencies:
@@ -7826,7 +8080,7 @@ snapshots:
transitivePeerDependencies:
- debug
babel-loader@8.2.1(@babel/core@7.29.0)(webpack@5.95.0(@swc/core@1.3.96)):
babel-loader@8.2.1(@babel/core@7.29.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
'@babel/core': 7.29.0
find-cache-dir: 2.1.0
@@ -7834,7 +8088,7 @@ snapshots:
make-dir: 2.1.0
pify: 4.0.1
schema-utils: 2.7.1
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
babel-plugin-const-enum@1.2.0(@babel/core@7.29.0):
dependencies:
@@ -8243,7 +8497,7 @@ snapshots:
dependencies:
is-what: 3.14.1
copy-webpack-plugin@12.0.2(webpack@5.95.0(@swc/core@1.3.96)):
copy-webpack-plugin@12.0.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
fast-glob: 3.3.3
glob-parent: 6.0.2
@@ -8251,7 +8505,7 @@ snapshots:
normalize-path: 3.0.0
schema-utils: 4.3.3
serialize-javascript: 6.0.2
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
core-js-compat@3.49.0:
dependencies:
@@ -8288,7 +8542,7 @@ snapshots:
dependencies:
postcss: 8.5.12
css-loader@7.1.4(webpack@5.95.0(@swc/core@1.3.96)):
css-loader@7.1.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
icss-utils: 5.1.0(postcss@8.5.12)
postcss: 8.5.12
@@ -8299,9 +8553,9 @@ snapshots:
postcss-value-parser: 4.2.0
semver: 7.7.4
optionalDependencies:
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
css-minimizer-webpack-plugin@6.0.0(esbuild@0.21.5)(lightningcss@1.32.0)(webpack@5.95.0(@swc/core@1.3.96)):
css-minimizer-webpack-plugin@6.0.0(esbuild@0.21.5)(lightningcss@1.32.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
cssnano: 6.1.2(postcss@8.5.12)
@@ -8309,7 +8563,7 @@ snapshots:
postcss: 8.5.12
schema-utils: 4.3.3
serialize-javascript: 6.0.2
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
optionalDependencies:
esbuild: 0.21.5
lightningcss: 1.32.0
@@ -8674,12 +8928,12 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.3
esbuild-loader@4.4.3(webpack@5.95.0(@swc/core@1.3.96)):
esbuild-loader@4.4.3(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
esbuild: 0.27.7
get-tsconfig: 4.14.0
loader-utils: 2.0.4
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
webpack-sources: 3.4.0
esbuild@0.21.5:
@@ -9272,7 +9526,7 @@ snapshots:
relateurl: 0.2.7
uglify-js: 3.19.3
html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)):
html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
'@types/html-minifier-terser': 6.1.0
html-minifier-terser: 6.1.0
@@ -9280,7 +9534,7 @@ snapshots:
pretty-error: 4.0.0
tapable: 2.3.3
optionalDependencies:
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
htmlparser2@6.1.0:
dependencies:
@@ -9653,11 +9907,11 @@ snapshots:
picocolors: 1.1.1
shell-quote: 1.8.3
less-loader@12.3.2(less@3.13.1)(webpack@5.95.0(@swc/core@1.3.96)):
less-loader@12.3.2(less@3.13.1)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
less: 3.13.1
optionalDependencies:
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
less@3.13.1:
dependencies:
@@ -9883,11 +10137,11 @@ snapshots:
dependencies:
dom-walk: 0.1.2
mini-css-extract-plugin@2.10.2(webpack@5.95.0(@swc/core@1.3.96)):
mini-css-extract-plugin@2.10.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
schema-utils: 4.3.3
tapable: 2.3.3
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
minimalistic-assert@1.0.1: {}
@@ -10295,14 +10549,14 @@ snapshots:
read-cache: 1.0.0
resolve: 1.22.12
postcss-loader@8.2.1(postcss@8.5.12)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96)):
postcss-loader@8.2.1(postcss@8.5.12)(typescript@5.9.3)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
cosmiconfig: 9.0.1(typescript@5.9.3)
jiti: 2.6.1
postcss: 8.5.12
semver: 7.7.4
optionalDependencies:
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
transitivePeerDependencies:
- typescript
@@ -10699,6 +10953,27 @@ snapshots:
dependencies:
glob: 7.2.3
rolldown@1.0.0-rc.17:
dependencies:
'@oxc-project/types': 0.127.0
'@rolldown/pluginutils': 1.0.0-rc.17
optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.0-rc.17
'@rolldown/binding-darwin-arm64': 1.0.0-rc.17
'@rolldown/binding-darwin-x64': 1.0.0-rc.17
'@rolldown/binding-freebsd-x64': 1.0.0-rc.17
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.17
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.17
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.17
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
rollup@3.30.0:
optionalDependencies:
fsevents: 2.3.3
@@ -10723,12 +10998,12 @@ snapshots:
safer-buffer@2.1.2: {}
sass-loader@14.2.1(sass@1.99.0)(webpack@5.95.0(@swc/core@1.3.96)):
sass-loader@14.2.1(sass@1.99.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
neo-async: 2.6.2
optionalDependencies:
sass: 1.99.0
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
sass@1.99.0:
dependencies:
@@ -11019,9 +11294,9 @@ snapshots:
dependencies:
escape-string-regexp: 1.0.5
style-loader@3.3.4(webpack@5.95.0(@swc/core@1.3.96)):
style-loader@3.3.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
stylehacks@6.1.1(postcss@8.5.12):
dependencies:
@@ -11029,13 +11304,13 @@ snapshots:
postcss: 8.5.12
postcss-selector-parser: 6.1.2
stylus-loader@8.1.3(stylus@0.64.0)(webpack@5.95.0(@swc/core@1.3.96)):
stylus-loader@8.1.3(stylus@0.64.0)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
fast-glob: 3.3.3
normalize-path: 3.0.0
stylus: 0.64.0
optionalDependencies:
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
stylus@0.64.0:
dependencies:
@@ -11088,17 +11363,28 @@ snapshots:
to-buffer: 1.2.2
xtend: 4.0.2
terser-webpack-plugin@5.5.0(@swc/core@1.3.96)(esbuild@0.21.5)(webpack@5.95.0(@swc/core@1.3.96)):
terser-webpack-plugin@5.5.0(@swc/core@1.3.96)(esbuild@0.21.5)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.46.2
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
optionalDependencies:
'@swc/core': 1.3.96
esbuild: 0.21.5
terser-webpack-plugin@5.5.0(@swc/core@1.3.96)(esbuild@0.27.7)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.46.2
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
optionalDependencies:
'@swc/core': 1.3.96
esbuild: 0.27.7
terser@5.46.2:
dependencies:
'@jridgewell/source-map': 0.3.11
@@ -11272,10 +11558,27 @@ snapshots:
vary@1.1.2: {}
vitest@4.1.5(@types/node@25.6.0)(jsdom@24.1.3):
vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.12
rolldown: 1.0.0-rc.17
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.6.0
esbuild: 0.27.7
fsevents: 2.3.3
jiti: 2.6.1
less: 3.13.1
sass: 1.99.0
stylus: 0.64.0
terser: 5.46.2
vitest@4.1.5(@types/node@25.6.0)(jsdom@24.1.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
@@ -11292,6 +11595,7 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@3.13.1)(sass@1.99.0)(stylus@0.64.0)(terser@5.46.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.0
@@ -11328,16 +11632,16 @@ snapshots:
deepmerge: 1.5.2
javascript-stringify: 2.1.0
webpack-dev-middleware@5.3.4(webpack@5.95.0(@swc/core@1.3.96)):
webpack-dev-middleware@5.3.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
colorette: 2.0.20
memfs: 3.5.3
mime-types: 2.1.35
range-parser: 1.2.1
schema-utils: 4.3.3
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)):
webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
'@types/bonjour': 3.5.13
'@types/connect-history-api-fallback': 1.5.4
@@ -11367,10 +11671,10 @@ snapshots:
serve-index: 1.9.2
sockjs: 0.3.24
spdy: 4.0.2
webpack-dev-middleware: 5.3.4(webpack@5.95.0(@swc/core@1.3.96))
webpack-dev-middleware: 5.3.4(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
ws: 8.20.0
optionalDependencies:
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
transitivePeerDependencies:
- bufferutil
- debug
@@ -11391,7 +11695,7 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
webpack@5.95.0(@swc/core@1.3.96):
webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7):
dependencies:
'@types/estree': 1.0.8
'@webassemblyjs/ast': 1.14.1
@@ -11413,7 +11717,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.3.3
terser-webpack-plugin: 5.5.0(@swc/core@1.3.96)(esbuild@0.21.5)(webpack@5.95.0(@swc/core@1.3.96))
terser-webpack-plugin: 5.5.0(@swc/core@1.3.96)(esbuild@0.27.7)(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
watchpack: 2.5.1
webpack-sources: 3.4.0
transitivePeerDependencies:
@@ -11421,13 +11725,13 @@ snapshots:
- esbuild
- uglify-js
webpackbar@5.0.2(webpack@5.95.0(@swc/core@1.3.96)):
webpackbar@5.0.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)):
dependencies:
chalk: 4.1.2
consola: 2.15.3
pretty-time: 1.1.0
std-env: 3.10.0
webpack: 5.95.0(@swc/core@1.3.96)
webpack: 5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)
websocket-driver@0.7.4:
dependencies:

View File

@@ -9,8 +9,12 @@
"enhance": false,
"compileHotReLoad": true,
"postcss": false,
"minified": false,
"minified": true,
"bundle": false,
"minifyWXML": true
}
"minifyWXML": true,
"packNpmManually": false,
"packNpmRelationList": [],
"ignoreUploadUnusedFiles": true
},
"condition": {}
}

View File

@@ -7,7 +7,7 @@
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"autoAudits": true,
"useApiHook": true,
"showShadowRootInWxmlPanel": false,
"useStaticServer": false,

View File

@@ -44,6 +44,7 @@ export default defineAppConfig({
'dialysis-records/index', 'dialysis-records/detail/index',
'dialysis-prescriptions/index', 'dialysis-prescriptions/detail/index',
'consents/index', 'health-records/index', 'diagnoses/index',
'elder-mode/index',
],
},
{
@@ -88,5 +89,6 @@ export default defineAppConfig({
navigationBarBackgroundColor: '#FFFFFF',
navigationBarTitleText: '健康管理',
navigationBarTextStyle: 'black',
enablePullDownRefresh: true,
},
});

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
box-shadow: $shadow-sm;
.device-icon {
font-size: 48rpx;
font-size: var(--tk-font-h2);
margin-right: 20rpx;
}
@@ -18,14 +18,14 @@
flex: 1;
.device-name {
font-size: 28rpx;
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx;
display: block;
}
.device-status {
font-size: 24rpx;
font-size: var(--tk-font-micro);
margin-top: 4rpx;
display: block;
@@ -34,7 +34,7 @@
}
.last-sync {
font-size: 22rpx;
font-size: var(--tk-font-micro);
color: $tx3;
margin-top: 4rpx;
display: block;
@@ -46,6 +46,6 @@
background: $pri;
color: #fff;
border-radius: $r-pill;
font-size: 24rpx;
font-size: var(--tk-font-micro);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,13 +17,13 @@
.progress-ring-percent {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
font-size: var(--tk-font-body);
font-weight: bold;
line-height: 1;
}
.progress-ring-unit {
font-size: 12px;
font-size: var(--tk-font-micro);
font-weight: 600;
line-height: 1;
}

View File

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

View File

@@ -13,8 +13,8 @@
}
.trend-chart-empty-text {
font-size: 28px;
color: $tx3;
font-size: var(--tk-font-body-lg);
color: var(--tk-text-secondary);
}
.trend-chart-skeleton {

View File

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

View File

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

View File

@@ -1,23 +1,5 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@import '../../../styles/mixins.scss';
.detail-page {
min-height: 100vh;
@@ -45,7 +27,7 @@
}
.meta-item {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx3;
}
@@ -63,7 +45,7 @@
}
p {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
line-height: 1.8;
margin-bottom: 16px;
@@ -75,7 +57,7 @@
}
li {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
line-height: 1.8;
margin-bottom: 8px;
@@ -87,7 +69,7 @@
}
.report-content {
font-size: 28px;
font-size: var(--tk-font-body-lg);
line-height: 1.8;
color: $tx;
}
@@ -96,8 +78,8 @@
display: block;
text-align: center;
padding: 120px 0;
color: $tx3;
font-size: 28px;
color: var(--tk-text-secondary);
font-size: var(--tk-font-body-lg);
}
.auto-badge {
@@ -109,7 +91,7 @@
display: inline-block;
padding: 4px 16px;
border-radius: 8px;
font-size: 20px;
font-size: var(--tk-font-body);
font-weight: 500;
background: #f0e6ff;
color: #7c3aed;
@@ -124,7 +106,7 @@
}
.trend-tip-text {
font-size: 22px;
font-size: var(--tk-font-body);
color: #92400e;
line-height: 1.6;
}

View File

@@ -3,6 +3,7 @@ import { View, Text, RichText } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
const TYPE_LABELS: Record<string, string> = {
@@ -44,6 +45,7 @@ function markdownToHtml(md: string): string {
}
export default function AiReportDetail() {
const modeClass = useElderClass();
const router = useRouter();
const id = router.params.id || '';
@@ -63,7 +65,7 @@ export default function AiReportDetail() {
if (!analysis) {
return (
<View className='detail-page'>
<View className={`detail-page ${modeClass}`}>
<Text className='empty-text'></Text>
</View>
);
@@ -77,7 +79,7 @@ export default function AiReportDetail() {
const isAutoAnalysis = (analysis.result_metadata as Record<string, unknown>)?.auto_analysis === true;
return (
<View className='detail-page'>
<View className={`detail-page ${modeClass}`}>
<View className='detail-card'>
<Text className='detail-type'>{TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type}</Text>
<View className='detail-meta'>

View File

@@ -1,28 +1,5 @@
@import '../../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@import '../../../styles/mixins.scss';
.ai-report-page {
min-height: 100vh;
@@ -55,7 +32,7 @@
}
.card-type {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 500;
color: $tx;
}
@@ -87,19 +64,19 @@
}
.card-time {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx3;
}
.card-model {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx3;
}
.no-more {
text-align: center;
font-size: 24px;
color: $tx3;
font-size: var(--tk-font-h2);
color: var(--tk-text-secondary);
padding: 24px 0;
display: block;
}

View File

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

View File

@@ -55,7 +55,7 @@
.dept-initial-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-size: var(--tk-font-num);
font-weight: bold;
color: $pri;
@@ -65,7 +65,7 @@
}
.dept-label {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx;
font-weight: 500;
}
@@ -77,7 +77,7 @@
.slot-section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
margin-bottom: 16px;
@@ -114,14 +114,14 @@
.slot-time {
@include serif-number;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
display: block;
}
.slot-count {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx3;
display: block;
margin-top: 6px;
@@ -156,7 +156,7 @@
.confirm-icon-serif {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 24px;
font-size: var(--tk-font-h2);
font-weight: bold;
color: $pri;
}
@@ -169,12 +169,12 @@
}
.confirm-label {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx3;
}
.confirm-value {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
}
@@ -185,7 +185,7 @@
}
.confirm-dept-text {
font-size: 20px;
font-size: var(--tk-font-body);
font-weight: 500;
color: $pri;
}
@@ -225,7 +225,7 @@
.doctor-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
font-size: var(--tk-font-num);
color: $pri;
font-weight: bold;
}
@@ -238,18 +238,18 @@
}
.doctor-name {
font-size: 30px;
font-size: var(--tk-font-num);
font-weight: bold;
color: $tx;
}
.doctor-title {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
}
.doctor-specialty {
font-size: 22px;
font-size: var(--tk-font-body);
color: $pri;
}
@@ -263,7 +263,7 @@
}
.doctor-check-text {
font-size: 24px;
font-size: var(--tk-font-h2);
color: white;
font-weight: bold;
}
@@ -274,7 +274,7 @@
}
.form-label {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
margin-bottom: 12px;
display: block;
@@ -284,7 +284,7 @@
background: $card;
border-radius: $r-sm;
padding: 24px 28px;
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
width: 100%;
box-sizing: border-box;
@@ -298,8 +298,8 @@
}
.empty-text {
font-size: 28px;
color: $tx3;
font-size: var(--tk-font-body-lg);
color: var(--tk-text-secondary);
}
/* 底部操作栏 */
@@ -339,7 +339,7 @@
}
.btn-text {
font-size: 30px;
font-size: var(--tk-font-num);
font-weight: bold;
color: $tx2;
}

View File

@@ -7,6 +7,7 @@ import { TEMPLATE_IDS } from '@/services/wechat-templates';
import { trackEvent } from '@/services/analytics';
import StepIndicator from '../../../components/StepIndicator';
import WeekCalendar from '../../../components/WeekCalendar';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
const DEPARTMENTS = [
@@ -44,6 +45,7 @@ export default function AppointmentCreate() {
const [loading, setLoading] = useState(false);
const [schedules, setSchedules] = useState<any[]>([]);
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
const modeClass = useElderClass();
const currentPatient = useAuthStore((s) => s.currentPatient);
@@ -148,7 +150,7 @@ export default function AppointmentCreate() {
};
return (
<View className='create-page'>
<View className={`create-page ${modeClass}`}>
<StepIndicator
steps={[{ label: '选科室' }, { label: '选医生' }, { label: '选时段' }]}
current={currentStep}

View File

@@ -23,14 +23,14 @@
}
.back-text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $pri;
font-weight: 500;
}
.header-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 34px;
font-size: var(--tk-font-num-lg);
font-weight: bold;
color: $tx;
}
@@ -80,19 +80,19 @@
}
.status-tag-text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
}
.status-doctor {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
font-size: var(--tk-font-num-lg);
font-weight: bold;
color: $tx;
}
.status-dept {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}
@@ -130,7 +130,7 @@
.info-icon-serif {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 20px;
font-size: var(--tk-font-body);
color: $pri;
background: $pri-l;
width: 36px;
@@ -143,12 +143,12 @@
}
.info-label {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}
.info-value {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
font-weight: 500;
}
@@ -163,8 +163,8 @@
.info-id {
@include serif-number;
font-size: 22px;
color: $tx3;
font-size: var(--tk-font-body);
color: var(--tk-text-secondary);
word-break: break-all;
max-width: 400px;
text-align: right;
@@ -180,7 +180,7 @@
.tips-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $wrn;
margin-bottom: 12px;
@@ -188,7 +188,7 @@
}
.tips-text {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
line-height: 1.6;
}
@@ -219,7 +219,7 @@
}
.cancel-text {
font-size: 30px;
font-size: var(--tk-font-num);
font-weight: bold;
color: $dan;
}

View File

@@ -5,6 +5,7 @@ import { getAppointment, cancelAppointment } from '../../../services/appointment
import type { Appointment } from '../../../services/appointment';
import Loading from '../../../components/Loading';
import ErrorState from '../../../components/ErrorState';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; className: string }> = {
@@ -22,6 +23,7 @@ export default function AppointmentDetail() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [cancelling, setCancelling] = useState(false);
const modeClass = useElderClass();
useEffect(() => {
if (!id) return;
@@ -65,7 +67,7 @@ export default function AppointmentDetail() {
if (loading) {
return (
<View className='detail-page'>
<View className={`detail-page ${modeClass}`}>
<View className='detail-header'>
<View className='back-btn' onClick={goBack}><Text className='back-text'></Text></View>
<Text className='header-title'></Text>
@@ -78,7 +80,7 @@ export default function AppointmentDetail() {
if (error || !appointment) {
return (
<View className='detail-page'>
<View className={`detail-page ${modeClass}`}>
<View className='detail-header'>
<View className='back-btn' onClick={goBack}><Text className='back-text'></Text></View>
<Text className='header-title'></Text>
@@ -90,7 +92,7 @@ export default function AppointmentDetail() {
}
return (
<View className='detail-page'>
<View className={`detail-page ${modeClass}`}>
<View className='detail-header'>
<View className='back-btn' onClick={goBack}><Text className='back-text'></Text></View>
<Text className='header-title'></Text>

View File

@@ -17,13 +17,13 @@
.page-title {
@include section-title;
margin-bottom: 4px;
font-size: 36px;
font-size: var(--tk-font-num-lg);
}
.page-subtitle {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
color: $tx3;
font-size: var(--tk-font-body);
color: var(--tk-text-secondary);
letter-spacing: 1px;
}
@@ -67,7 +67,7 @@
.dept-initial-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
font-size: var(--tk-font-num);
font-weight: bold;
color: $pri;
}
@@ -80,7 +80,7 @@
}
.doctor-name {
font-size: 30px;
font-size: var(--tk-font-num);
font-weight: bold;
color: $tx;
overflow: hidden;
@@ -93,7 +93,7 @@
}
.dept-tag-text {
font-size: 20px;
font-size: var(--tk-font-body);
font-weight: 500;
color: $pri;
}
@@ -124,7 +124,7 @@
}
.status-tag-text {
font-size: 20px;
font-size: var(--tk-font-body);
font-weight: 500;
}
@@ -157,12 +157,12 @@
.info-icon-serif {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx2;
}
.info-text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}
@@ -186,7 +186,7 @@
}
.fab-text {
font-size: 30px;
font-size: var(--tk-font-num);
color: white;
font-weight: bold;
letter-spacing: 2px;

View File

@@ -5,6 +5,7 @@ import { listAppointments } from '../../services/appointment';
import type { Appointment } from '../../services/appointment';
import EmptyState from '../../components/EmptyState';
import Loading from '../../components/Loading';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; className: string }> = {
@@ -30,6 +31,7 @@ export default function AppointmentList() {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
const modeClass = useElderClass();
const fetchData = useCallback(async (pageNum: number, isRefresh = false) => {
if (loadingRef.current) return;
@@ -86,11 +88,10 @@ export default function AppointmentList() {
};
return (
<View className='appointment-page'>
<View className={`appointment-page ${modeClass}`}>
{/* 页面标题 */}
<View className='page-header'>
<Text className='page-title'></Text>
<Text className='page-subtitle'>Appointment</Text>
</View>
{/* 预约列表 */}

View File

@@ -13,7 +13,7 @@
}
.article-title {
font-size: 38px;
font-size: var(--tk-font-hero);
font-weight: bold;
color: $tx;
display: block;
@@ -29,7 +29,7 @@
}
.article-category {
font-size: 22px;
font-size: var(--tk-font-body);
color: $pri;
background: $pri-l;
padding: 4px 12px;
@@ -37,13 +37,13 @@
}
.article-author {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
}
.article-date {
font-size: 24px;
color: $tx3;
font-size: var(--tk-font-h2);
color: var(--tk-text-secondary);
}
.article-summary {
@@ -53,7 +53,7 @@
}
.summary-text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
line-height: 1.6;
}
@@ -70,7 +70,7 @@
}
p {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
line-height: 1.8;
margin-bottom: 16px;
@@ -93,6 +93,6 @@
.loading-text,
.empty-text {
font-size: 28px;
color: $tx3;
font-size: var(--tk-font-body-lg);
color: var(--tk-text-secondary);
}

View File

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

View File

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

View File

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

View File

@@ -8,61 +8,108 @@
background: $bg;
}
/* ─── 导航栏 ─── */
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
padding: 12px 16px;
background: $card;
border-bottom: 1px solid $bd;
border-bottom: 1px solid $bd-l;
flex-shrink: 0;
position: relative;
}
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: 600;
color: $tx;
.chat-header__back {
position: absolute;
left: 16px;
z-index: 1;
&:active {
opacity: 0.7;
}
}
&__status {
font-size: 24px;
.chat-header__back-text {
font-size: var(--tk-font-body-sm);
color: $pri;
}
.chat-header__center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.chat-header__title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
}
.chat-header__status {
font-size: var(--tk-font-micro);
color: $acc;
margin-top: 2px;
&--closed {
color: $tx3;
}
}
/* ─── 消息区 ─── */
.chat-messages {
flex: 1;
padding: 24px;
padding: 16px 16px 0;
overflow-y: auto;
}
.msg-row {
display: flex;
margin-bottom: 20px;
margin-bottom: 16px;
gap: 8px;
&--self {
justify-content: flex-end;
}
}
/* ─── 医生头像 ─── */
.msg-avatar {
width: 32px;
height: 32px;
border-radius: 16px;
background: $pri-l;
@include flex-center;
flex-shrink: 0;
}
.msg-avatar-char {
@include serif-number;
font-size: var(--tk-font-cap);
font-weight: 700;
color: $pri;
}
/* ─── 消息气泡 ─── */
.msg-bubble {
max-width: 70%;
padding: 20px 24px;
border-radius: $r-lg;
position: relative;
padding: 12px 16px;
box-shadow: $shadow-sm;
&--other {
background: $card;
border-top-left-radius: $r-sm;
border-radius: $r $r $r $r-xs;
}
&--self {
background: $pri;
border-top-right-radius: $r-sm;
border-radius: $r $r $r-xs $r;
}
}
.msg-text {
font-size: 28px;
font-size: var(--tk-font-cap);
color: $tx;
display: block;
line-height: 1.6;
@@ -76,88 +123,95 @@
.msg-date-divider {
display: flex;
justify-content: center;
padding: 16px 0 12px;
padding: 12px 0;
&__text {
font-size: 22px;
color: #94A3B8;
background: #F1F5F9;
padding: 4px 16px;
border-radius: 8px;
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
background: $surface-alt;
padding: 2px 12px;
border-radius: $r-pill;
}
}
.msg-image {
width: 320px;
border-radius: 12px;
width: 200px;
border-radius: $r-sm;
margin-top: 4px;
}
.msg-time {
font-size: 20px;
color: $tx3;
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
display: block;
margin-top: 8px;
text-align: right;
margin-top: 4px;
.msg-bubble--self & {
color: rgba(255, 255, 255, 0.7);
text-align: right;
}
}
.chat-empty {
text-align: center;
padding: 120px 32px;
padding: 80px 24px;
&__text {
font-size: 26px;
color: $tx3;
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
}
}
/* ─── 输入栏 ─── */
.chat-input-bar {
display: flex;
align-items: center;
padding: 16px 24px;
gap: 10px;
padding: 10px 16px 38px;
background: $card;
border-top: 1px solid $bd;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
border-top: 1px solid $bd-l;
flex-shrink: 0;
}
.chat-input {
flex: 1;
height: 40px;
background: $bg;
border-radius: $r;
padding: 16px 20px;
font-size: 28px;
margin-right: 16px;
border: 1.5px solid $bd;
border-radius: 20px;
padding: 0 14px;
font-size: var(--tk-font-cap);
color: $tx;
}
.chat-send-btn {
width: 40px;
height: 40px;
border-radius: 20px;
background: $pri;
border-radius: $r;
padding: 16px 28px;
@include flex-center;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(196, 98, 58, 0.3);
&--disabled {
opacity: 0.5;
}
}
&__text {
font-size: 28px;
color: #fff;
font-weight: 500;
}
.chat-send-btn__icon {
font-size: var(--tk-font-cap);
color: #fff;
font-weight: 600;
}
.chat-closed-bar {
padding: 24px;
padding: 16px;
text-align: center;
background: $card;
border-top: 1px solid $bd;
border-top: 1px solid $bd-l;
&__text {
font-size: 26px;
color: $tx3;
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
}
}

View File

@@ -6,14 +6,14 @@ import {
listMessages,
sendMessage,
markSessionRead,
pollMessages,
type ConsultationSession,
type ConsultationMessage,
} from '@/services/consultation';
import Loading from '@/components/Loading';
import { useElderClass } from '@/hooks/useElderClass';
import './index.scss';
const POLL_INTERVAL = 8000;
export default function ConsultationDetail() {
const router = useRouter();
const sessionId = router.params.id || '';
@@ -23,43 +23,35 @@ export default function ConsultationDetail() {
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(true);
const scrollViewRef = useRef('');
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollingRef = useRef(false);
const modeClass = useElderClass();
useEffect(() => {
if (sessionId) {
loadData();
markRead();
startPolling();
startLongPolling();
}
return () => stopPolling();
return () => { pollingRef.current = false; };
}, [sessionId]);
const startPolling = () => {
stopPolling();
pollTimerRef.current = setInterval(pollNewMessages, POLL_INTERVAL);
useEffect(() => {
if (session?.status === 'closed') {
pollingRef.current = false;
}
}, [session?.status]);
const startLongPolling = () => {
pollingRef.current = true;
longPoll();
};
const stopPolling = () => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
};
const pollNewMessages = async () => {
if (!session || session.status === 'closed') {
stopPolling();
return;
}
const longPoll = async () => {
if (!pollingRef.current) return;
try {
const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined;
const m = await listMessages(sessionId, {
page: 1,
page_size: 50,
after_id: lastId,
});
const newMsgs = m.data || [];
if (newMsgs.length > 0) {
const newMsgs = await pollMessages(sessionId, lastId);
if (newMsgs && newMsgs.length > 0) {
setMessages((prev) => {
const existing = new Set(prev.map((msg) => msg.id));
const fresh = newMsgs.filter((msg) => !existing.has(msg.id));
@@ -67,7 +59,12 @@ export default function ConsultationDetail() {
});
scrollViewRef.current = `msg-${messages.length + newMsgs.length}`;
}
} catch { /* 轮询失败静默忽略 */ }
} catch {
// 超时或网络错误,静默重试
}
if (pollingRef.current) {
longPoll();
}
};
const loadData = async () => {
@@ -80,7 +77,7 @@ export default function ConsultationDetail() {
setSession(s);
setMessages(m.data || []);
scrollViewRef.current = `msg-${(m.data || []).length}`;
if (s.status === 'closed') stopPolling();
if (s.status === 'closed') pollingRef.current = false;
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
@@ -137,14 +134,24 @@ export default function ConsultationDetail() {
if (loading) return <Loading />;
const isOpen = session?.status !== 'closed';
const doctorInitial = (session?.subject || '医').charAt(0);
const statusLabel = session?.status === 'active' ? '进行中'
: session?.status === 'pending' ? '等待接诊'
: '已结束';
return (
<View className='chat-page'>
<View className={`chat-page ${modeClass}`}>
{/* 导航栏 — 对齐设计稿:返回 + 标题 + 副标题 */}
<View className='chat-header'>
<Text className='chat-header__title'>{session?.subject || '在线咨询'}</Text>
{!isOpen && (
<Text className='chat-header__status'></Text>
)}
<View className='chat-header__back' onClick={() => Taro.navigateBack()}>
<Text className='chat-header__back-text'> </Text>
</View>
<View className='chat-header__center'>
<Text className='chat-header__title'>{session?.subject || '在线咨询'}</Text>
<Text className={`chat-header__status ${isOpen ? '' : 'chat-header__status--closed'}`}>
{statusLabel}
</Text>
</View>
</View>
<ScrollView
@@ -164,6 +171,11 @@ export default function ConsultationDetail() {
</View>
)}
<View id={`msg-${idx + 1}`} className={`msg-row ${isSelf ? 'msg-row--self' : ''}`}>
{!isSelf && (
<View className='msg-avatar'>
<Text className='msg-avatar-char'>{doctorInitial}</Text>
</View>
)}
<View className={`msg-bubble ${isSelf ? 'msg-bubble--self' : 'msg-bubble--other'}`}>
{isImageUrl(msg.content) ? (
<Image
@@ -203,7 +215,7 @@ export default function ConsultationDetail() {
className={`chat-send-btn ${(!inputText.trim() || sending) ? 'chat-send-btn--disabled' : ''}`}
onClick={handleSend}
>
<Text className='chat-send-btn__text'>{sending ? '...' : '发送'}</Text>
<Text className='chat-send-btn__icon'></Text>
</View>
</View>
) : (

View File

@@ -6,26 +6,36 @@
background: $bg;
}
/* ─── 页头 ─── */
.consultation-header {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
padding: 48px 32px 36px;
color: #fff;
}
.consultation-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-weight: bold;
color: #fff;
display: block;
margin-bottom: 8px;
.consultation-body {
padding: 12px 24px 24px;
}
/* ─── 副标题 ─── */
.consultation-subtitle {
font-size: 24px;
color: rgba(255, 255, 255, 0.75);
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
display: block;
margin-bottom: 20px;
}
/* ─── 发起咨询按钮 — 实心主色 ─── */
.consultation-create-btn {
height: 48px;
border-radius: $r;
background: $pri;
@include flex-center;
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.25);
margin-bottom: 20px;
&:active {
opacity: 0.85;
}
}
.consultation-create-btn-text {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: #fff;
}
/* ─── 居中容器 ─── */
@@ -37,7 +47,7 @@
}
.consultation-error {
font-size: 26px;
font-size: var(--tk-font-cap);
color: $dan;
}
@@ -47,51 +57,52 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 160px 40px;
padding: 120px 40px;
}
.empty-icon {
width: 120px;
height: 120px;
width: 80px;
height: 80px;
border-radius: 50%;
background: $pri-l;
@include flex-center;
margin-bottom: 32px;
margin-bottom: 20px;
}
.empty-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 52px;
font-weight: bold;
@include serif-number;
font-size: var(--tk-font-num);
font-weight: 700;
color: $pri;
line-height: 1;
}
.empty-title {
font-size: 32px;
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
margin-bottom: 12px;
margin-bottom: 8px;
}
.empty-hint {
font-size: 26px;
color: $tx3;
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
text-align: center;
}
/* ─── 会话列表 ─── */
.session-list {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.session-card {
display: flex;
align-items: center;
gap: 12px;
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 12px;
padding: 16px;
box-shadow: $shadow-sm;
&:active {
@@ -99,7 +110,27 @@
}
}
.session-main {
.session-card-closed {
opacity: 0.6;
}
.session-avatar {
width: 36px;
height: 36px;
border-radius: 18px;
background: $pri-l;
@include flex-center;
flex-shrink: 0;
}
.session-avatar-char {
@include serif-number;
font-size: var(--tk-font-body-sm);
font-weight: 700;
color: $pri;
}
.session-body {
flex: 1;
min-width: 0;
}
@@ -108,56 +139,73 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
margin-bottom: 6px;
}
.session-subject {
font-size: 28px;
color: $tx;
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 12px;
}
.session-tag {
&.tag-ok { @include tag($acc-l, $acc); }
&.tag-warn { @include tag($wrn-l, $wrn); }
&.tag-default { @include tag($bd-l, $tx2); }
}
.session-message {
font-size: 26px;
color: $tx2;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 8px;
margin-right: 8px;
}
.session-time {
font-size: 22px;
color: $tx3;
display: block;
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
flex-shrink: 0;
}
.session-tag {
font-size: var(--tk-font-micro);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
display: inline-block;
&.tag-ok { background: $acc-l; color: $acc; }
&.tag-warn { background: $wrn-l; color: $wrn; }
&.tag-default { background: $surface-alt; color: $tx3; }
}
.session-meta {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.session-message-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.session-message {
font-size: var(--tk-font-cap);
color: $tx2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 8px;
}
/* ─── 未读角标 ─── */
.session-badge {
background: $dan;
border-radius: $r-pill;
min-width: 36px;
height: 36px;
min-width: 18px;
height: 18px;
@include flex-center;
padding: 0 10px;
margin-left: 12px;
padding: 0 5px;
flex-shrink: 0;
}
.session-badge-text {
font-size: 22px;
font-size: var(--tk-font-micro);
color: #fff;
font-weight: 600;
}

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { listConsultations, ConsultationSession } from '@/services/consultation';
import Loading from '../../components/Loading';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss';
function getStatusTag(status: string) {
@@ -33,99 +34,137 @@ export default function Consultation() {
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const modeClass = useElderClass();
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const loadingRef = useRef(false);
const loadSessions = async () => {
setLoading(true);
const loadSessions = async (pageNum: number, isRefresh = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
if (isRefresh) setLoading(true);
setError('');
try {
const resp = await listConsultations({ page: 1, page_size: 20 });
setSessions(resp.data || []);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '加载失败';
setError(msg);
const resp = await listConsultations({ page: pageNum, page_size: 20 });
const list = resp.data || [];
if (isRefresh) {
setSessions(list);
} else {
setSessions((prev) => [...prev, ...list]);
}
setTotal(resp.total || 0);
setPage(pageNum);
} catch {
if (isRefresh) {
setSessions([]);
setTotal(0);
}
} finally {
setLoading(false);
loadingRef.current = false;
}
};
useDidShow(() => {
Taro.setNavigationBarTitle({ title: '在线咨询' });
loadSessions();
loadSessions(1, true);
});
usePullDownRefresh(() => {
loadSessions().finally(() => {
loadSessions(1, true).finally(() => {
Taro.stopPullDownRefresh();
});
});
useReachBottom(() => {
if (!loading && sessions.length < total) {
loadSessions(page + 1);
}
});
const handleTapSession = (session: ConsultationSession) => {
Taro.navigateTo({ url: `/pages/consultation/detail/index?id=${session.id}` });
};
return (
<View className='consultation-page'>
{/* 页头 */}
<View className='consultation-header'>
<Text className='consultation-title'>线</Text>
<View className={`consultation-page ${modeClass}`}>
<View className='consultation-body'>
{/* 副标题 */}
<Text className='consultation-subtitle'></Text>
</View>
{/* 内容区 */}
{loading ? (
<View className='consultation-center'>
<Loading text='加载中...' />
{/* 发起咨询按钮 — 实心主色 */}
<View
className='consultation-create-btn'
onClick={() => Taro.navigateTo({ url: '/pages/consultation/create/index' })}
>
<Text className='consultation-create-btn-text'></Text>
</View>
) : error ? (
<View className='consultation-center'>
<Text className='consultation-error'>{error}</Text>
</View>
) : sessions.length === 0 ? (
<View className='consultation-empty'>
<View className='empty-icon'>
<Text className='empty-char'></Text>
{/* 内容区 */}
{loading ? (
<View className='consultation-center'>
<Loading text='加载中...' />
</View>
<Text className='empty-title'></Text>
<Text className='empty-hint'></Text>
</View>
) : (
<View className='session-list'>
{sessions.map((session) => {
const tag = getStatusTag(session.status);
return (
<View
key={session.id}
className='session-card'
onClick={() => handleTapSession(session)}
>
<View className='session-main'>
<View className='session-top'>
<Text className='session-subject'>
{session.subject || '在线咨询'}
</Text>
<Text className={`session-tag ${tag.cls}`}>{tag.label}</Text>
) : error ? (
<View className='consultation-center'>
<Text className='consultation-error'>{error}</Text>
</View>
) : sessions.length === 0 ? (
<View className='consultation-empty'>
<View className='empty-icon'>
<Text className='empty-char'></Text>
</View>
<Text className='empty-title'></Text>
<Text className='empty-hint'></Text>
</View>
) : (
<View className='session-list'>
{sessions.map((session) => {
const tag = getStatusTag(session.status);
const initial = (session.subject || '咨').charAt(0);
const isClosed = session.status === 'closed' || session.status === 'cancelled';
return (
<View
key={session.id}
className={`session-card ${isClosed ? 'session-card-closed' : ''}`}
onClick={() => handleTapSession(session)}
>
<View className='session-avatar'>
<Text className='session-avatar-char'>{initial}</Text>
</View>
<View className='session-body'>
<View className='session-top'>
<Text className='session-subject'>
{session.subject || '在线咨询'}
</Text>
<Text className='session-time'>
{session.last_message_at
? formatTime(session.last_message_at)
: formatTime(session.created_at)}
</Text>
</View>
<View className='session-meta'>
<Text className={`session-tag ${tag.cls}`}>{tag.label}</Text>
</View>
<View className='session-message-row'>
<Text className='session-message'>
{session.last_message || '暂无消息'}
</Text>
{session.unread_count_patient > 0 && (
<View className='session-badge'>
<Text className='session-badge-text'>
{session.unread_count_patient > 99 ? '99+' : session.unread_count_patient}
</Text>
</View>
)}
</View>
</View>
<Text className='session-message'>
{session.last_message || '暂无消息'}
</Text>
<Text className='session-time'>
{session.last_message_at
? formatTime(session.last_message_at)
: formatTime(session.created_at)}
</Text>
</View>
{session.unread_count_patient > 0 && (
<View className='session-badge'>
<Text className='session-badge-text'>
{session.unread_count_patient > 99 ? '99+' : session.unread_count_patient}
</Text>
</View>
)}
</View>
);
})}
</View>
)}
);
})}
</View>
)}
</View>
</View>
);
}

View File

@@ -1,34 +1,5 @@
@import '../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@import '../../styles/mixins.scss';
.device-sync-page {
min-height: 100vh;
@@ -43,9 +14,8 @@
}
.sync-header-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-weight: bold;
@include section-title;
color: $card;
}
.sync-section {
@@ -72,20 +42,17 @@
margin-bottom: 20px;
color: $pri;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
font-size: var(--tk-font-num-lg);
font-weight: bold;
}
.sync-hero-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 34px;
font-weight: bold;
color: $tx;
@include section-title;
margin-bottom: 8px;
}
.sync-hero-desc {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}
@@ -110,7 +77,7 @@
.sync-action-text {
color: $card;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 500;
}
@@ -120,7 +87,7 @@
.sync-section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
margin-bottom: 12px;
@@ -144,19 +111,19 @@
}
.sync-device-name {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 500;
color: $tx;
}
.sync-device-adapter {
font-size: 22px;
color: $tx3;
font-size: var(--tk-font-body);
color: var(--tk-text-secondary);
margin-top: 4px;
}
.sync-device-rssi {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx2;
}
@@ -183,7 +150,7 @@
}
.sync-status-text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
}
@@ -208,12 +175,12 @@
}
.sync-reading-type {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}
.sync-reading-value {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $pri;
@include serif-number;
@@ -222,8 +189,8 @@
.sync-readings-count {
display: block;
margin-top: 12px;
font-size: 22px;
color: $tx3;
font-size: var(--tk-font-body);
color: var(--tk-text-secondary);
text-align: center;
}
@@ -240,7 +207,7 @@
}
.sync-error-text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $dan;
}
@@ -250,7 +217,7 @@
}
.sync-loading-text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx2;
}
@@ -273,20 +240,17 @@
@include flex-center;
color: $acc;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
font-size: var(--tk-font-num-lg);
font-weight: bold;
margin-bottom: 16px;
}
.sync-result-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 34px;
font-weight: bold;
color: $tx;
@include section-title;
margin-bottom: 8px;
}
.sync-result-count {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}

View File

@@ -1,23 +1,28 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import { BLEManager } from '@/services/ble/BLEManager';
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter';
import { GlucoseMeterAdapter } from '@/services/ble/adapters/GlucoseMeterAdapter';
import { CustomBandAdapter } from '@/services/ble/adapters/GenericBleAdapter';
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
import { uploadReadings } from '@/services/device-sync';
import { useAuthStore } from '@/stores/auth';
import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss';
const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
bleManager.registerAdapter(XiaomiBandAdapter);
bleManager.registerAdapter(BloodPressureAdapter);
bleManager.registerAdapter(GlucoseMeterAdapter);
bleManager.registerAdapter(CustomBandAdapter);
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
export default function DeviceSync() {
const modeClass = useElderClass();
const { currentPatient } = useAuthStore();
const router = useRouter();
const returnTo = router.params.returnTo || '';
@@ -27,6 +32,12 @@ export default function DeviceSync() {
const [liveReadings, setLiveReadings] = useState<NormalizedReading[]>([]);
const [syncCount, setSyncCount] = useState(0);
const [errorMsg, setErrorMsg] = useState('');
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
const [pendingCount, setPendingCount] = useState(0);
const scheduler = useMemo(() => new DataSyncScheduler({
intervalMs: 60 * 60 * 1000,
}), []);
useDidShow(() => {
bleManager.setOnConnectionChange(() => {});
@@ -34,7 +45,29 @@ export default function DeviceSync() {
setLiveReadings((prev) => [...prev, ...readings]);
});
// 显示上次同步时间
setLastSyncAt(scheduler.getLastSyncAt());
// 检查是否有未上传的缓冲数据
const buffer = (bleManager as any).dataBuffer;
if (buffer) {
setPendingCount(buffer.size());
}
// 自动同步:超过间隔时尝试上传缓冲数据
if (currentPatient && scheduler.needsSync()) {
scheduler.tryAutoSync(async () => {
const count = await bleManager.flushPendingReadings(async (readings) => {
return uploadReadings(currentPatient.id, 'buffered', undefined, readings);
});
setLastSyncAt(Date.now());
setPendingCount(0);
return { success: count > 0, uploadedCount: count };
});
}
return () => {
scheduler.destroy();
bleManager.destroy();
};
});
@@ -87,6 +120,7 @@ export default function DeviceSync() {
if (result.success) {
setSyncCount(result.uploadedCount);
setLastSyncAt(Date.now());
setPageState('done');
// 如果从体征录入页跳转而来,将最新读数写入 storage 供回填
@@ -136,6 +170,21 @@ export default function DeviceSync() {
<Text className="sync-hero-desc"></Text>
</View>
{(lastSyncAt || pendingCount > 0) && (
<View className="sync-status-info">
{lastSyncAt && (
<Text className="sync-status-time">
: {new Date(lastSyncAt).toLocaleTimeString()}
</Text>
)}
{pendingCount > 0 && (
<Text className="sync-status-pending">
{pendingCount}
</Text>
)}
</View>
)}
<View className="sync-action" onClick={handleScan}>
<Text className="sync-action-text"></Text>
</View>
@@ -224,7 +273,7 @@ export default function DeviceSync() {
);
return (
<View className="device-sync-page">
<View className={`device-sync-page ${modeClass}`}>
<View className="sync-header">
<Text className="sync-header-title"></Text>
</View>

View File

@@ -1,13 +1,16 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.action-inbox-page {
min-height: 100vh;
background: #f5f5f5;
background: $bg;
}
.inbox-tabs {
display: flex;
background: white;
background: $card;
padding: 0 16px;
border-bottom: 1px solid #eee;
border-bottom: 1px solid $bd;
.inbox-tab {
flex: 1;
@@ -16,7 +19,7 @@
&.active {
.inbox-tab-text {
color: #C4623A;
color: $pri;
font-weight: 600;
position: relative;
@@ -27,7 +30,7 @@
left: 30%;
right: 30%;
height: 3px;
background: #C4623A;
background: $pri;
border-radius: 2px;
}
}
@@ -35,8 +38,8 @@
}
.inbox-tab-text {
font-size: 14px;
color: #666;
font-size: var(--tk-font-cap);
color: $tx2;
}
}
@@ -46,10 +49,11 @@
}
.inbox-card {
background: white;
border-radius: 12px;
background: $card;
border-radius: $r-sm;
padding: 14px 16px;
margin-bottom: 10px;
box-shadow: $shadow-sm;
.inbox-card-header {
display: flex;
@@ -59,21 +63,22 @@
}
.inbox-type-tag {
color: white;
font-size: 10px;
color: $card;
font-size: var(--tk-font-micro);
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
}
.inbox-card-title {
font-size: 14px;
font-size: var(--tk-font-cap);
font-weight: 500;
color: $tx;
}
.inbox-card-desc {
font-size: 12px;
color: #999;
font-size: var(--tk-font-micro);
color: $tx3;
}
}
@@ -82,38 +87,39 @@
padding: 80px 0;
.inbox-empty-text {
font-size: 14px;
color: #999;
font-size: var(--tk-font-cap);
color: $tx3;
}
}
// 半屏弹窗
.half-screen-dialog {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 70vh;
background: white;
border-radius: 16px 16px 0 0;
background: $card;
border-radius: $r-lg $r-lg 0 0;
z-index: 1000;
overflow-y: auto;
box-shadow: $shadow-lg;
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid $bd-l;
.dialog-title {
font-size: 16px;
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
}
.dialog-close {
font-size: 13px;
color: #999;
font-size: var(--tk-font-cap);
color: $tx3;
}
}
@@ -122,8 +128,8 @@
}
.dialog-patient {
font-size: 13px;
color: #666;
font-size: var(--tk-font-cap);
color: $tx2;
display: block;
margin-bottom: 12px;
}
@@ -142,21 +148,22 @@
margin-top: 4px;
flex-shrink: 0;
&.completed { background: #52c41a; }
&.in_progress { background: #faad14; }
&.pending { background: #d9d9d9; }
&.dismissed { background: #ff4d4f; }
&.completed { background: $acc; }
&.in_progress { background: $wrn; }
&.pending { background: $tx3; }
&.dismissed { background: $dan; }
}
.thread-content {
.thread-label {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $tx;
display: block;
}
.thread-time {
font-size: 11px;
color: #999;
font-size: var(--tk-font-micro);
color: $tx3;
}
}
@@ -164,19 +171,19 @@
display: flex;
gap: 8px;
padding: 12px 20px 20px;
border-top: 1px solid #f0f0f0;
border-top: 1px solid $bd-l;
.action-btn {
flex: 1;
text-align: center;
padding: 10px;
border-radius: 8px;
font-size: 14px;
border-radius: $r-sm;
font-size: var(--tk-font-cap);
font-weight: 500;
&.primary { background: #C4623A; color: white; }
&.danger { background: #ff4d4f; color: white; }
&.default { background: #f5f5f5; color: #666; }
&.primary { background: $pri; color: $card; }
&.danger { background: $dan; color: $card; }
&.default { background: $surface-alt; color: $tx2; }
}
}
}

View File

@@ -8,6 +8,8 @@ import {
type ThreadResponse,
} from '@/services/action-inbox';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../hooks/useElderClass';
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag';
import './index.scss';
const TYPE_LABEL: Record<string, string> = {
@@ -32,6 +34,7 @@ const STATUS_TABS = [
];
export default function ActionInboxPage() {
const modeClass = useElderClass();
const [items, setItems] = useState<ActionItem[]>([]);
const [total, setTotal] = useState(0);
const [_page, setPage] = useState(1);
@@ -117,7 +120,7 @@ export default function ActionInboxPage() {
};
return (
<View className="action-inbox-page">
<View className={`action-inbox-page ${modeClass}`}>
<View className="inbox-tabs">
{STATUS_TABS.map((tab) => (
<View

View File

@@ -18,13 +18,13 @@
}
&__time {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
}
}
.detail-severity {
font-size: 24px;
font-size: var(--tk-font-h2);
font-weight: 600;
padding: 6px 16px;
border-radius: $r-sm;
@@ -51,7 +51,7 @@
}
.detail-status {
font-size: 24px;
font-size: var(--tk-font-h2);
padding: 6px 16px;
border-radius: $r-sm;
@@ -84,24 +84,24 @@
box-shadow: $shadow-sm;
&__label {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
margin-bottom: 8px;
}
&__value {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
word-break: break-all;
&--id {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
font-family: monospace;
}
&--detail {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx2;
font-family: monospace;
line-height: 1.6;
@@ -125,7 +125,7 @@
flex: 1;
height: 88px;
line-height: 88px;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 600;
border-radius: $r-lg;
text-align: center;

View File

@@ -3,6 +3,7 @@ import { View, Text, ScrollView, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import './index.scss';
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
@@ -20,6 +21,7 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
};
export default function AlertDetail() {
const modeClass = useElderClass();
const [alert, setAlert] = useState<doctorApi.Alert | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
@@ -93,7 +95,7 @@ export default function AlertDetail() {
if (loading) return <Loading />;
if (!alert) {
return (
<View className='alert-detail-page'>
<View className={`alert-detail-page ${modeClass}`}>
<Text></Text>
</View>
);
@@ -105,7 +107,7 @@ export default function AlertDetail() {
const isAcknowledged = alert.status === 'acknowledged';
return (
<ScrollView scrollY className='alert-detail-page'>
<ScrollView scrollY className={`alert-detail-page ${modeClass}`}>
{/* 顶部状态 */}
<View className='alert-detail-header'>
<View className='alert-detail-header__tags'>

View File

@@ -16,13 +16,13 @@
}
.alert-list-title {
font-size: 36px;
font-size: var(--tk-font-num-lg);
font-weight: 600;
color: $tx;
}
.alert-list-count {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
}
@@ -36,7 +36,7 @@
padding: 10px 24px;
border-radius: $r-pill;
background: $bd-l;
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
transition: all 0.2s;
@@ -79,7 +79,7 @@
}
&__title {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 500;
color: $tx;
margin-bottom: 8px;
@@ -92,13 +92,13 @@
}
&__time {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx3;
}
}
.alert-severity {
font-size: 22px;
font-size: var(--tk-font-body);
font-weight: 600;
padding: 4px 12px;
border-radius: $r-sm;
@@ -125,7 +125,7 @@
}
.alert-status {
font-size: 22px;
font-size: var(--tk-font-body);
padding: 4px 12px;
border-radius: $r-sm;
@@ -158,7 +158,7 @@
margin-top: 32px;
&__btn {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $pri;
padding: 12px 24px;
@@ -168,7 +168,7 @@
}
&__info {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
}
}

View File

@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
@@ -28,12 +29,15 @@ const STATUS_TABS = [
];
export default function AlertList() {
const modeClass = useElderClass();
const [alerts, setAlerts] = useState<doctorApi.Alert[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('');
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
useEffect(() => {
loadAlerts();
}, [page, activeTab]);
@@ -79,7 +83,7 @@ export default function AlertList() {
if (loading && alerts.length === 0) return <Loading />;
return (
<ScrollView scrollY className='alert-list-page'>
<ScrollView scrollY className={`alert-list-page ${modeClass}`}>
<View className='alert-list-header'>
<Text className='alert-list-title'></Text>
<Text className='alert-list-count'> {total} </Text>
@@ -137,11 +141,11 @@ export default function AlertList() {
</Text>
<Text className='alert-pagination__info'>
{page} / {Math.ceil(total / 20)}
{page} / {totalPages}
</Text>
<Text
className={`alert-pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
className={`alert-pagination__btn ${page >= totalPages ? 'disabled' : ''}`}
onClick={() => page < totalPages && setPage(page + 1)}
>
</Text>

View File

@@ -18,13 +18,13 @@
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-size: var(--tk-font-num);
font-weight: 600;
color: $tx;
}
&__close {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $dan;
padding: 8px 16px;
}
@@ -63,7 +63,7 @@
}
.msg-text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
display: block;
line-height: 1.6;
@@ -76,7 +76,7 @@
.msg-time {
@include serif-number;
font-size: 20px;
font-size: var(--tk-font-body);
color: $tx3;
display: block;
margin-top: 8px;
@@ -92,7 +92,7 @@
padding: 120px 32px;
&__text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx3;
}
}
@@ -111,7 +111,7 @@
background: $bd-l;
border-radius: $r;
padding: 16px 20px;
font-size: 28px;
font-size: var(--tk-font-body-lg);
margin-right: 16px;
}
@@ -126,7 +126,7 @@
}
&__text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $card;
font-weight: 500;
}
@@ -139,7 +139,7 @@
border-top: 1px solid $bd;
&__text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx3;
}
}

View File

@@ -3,56 +3,47 @@ import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import './index.scss';
const POLL_INTERVAL = 8000;
export default function ConsultationDetail() {
const router = useRouter();
const sessionId = router.params.id || '';
const modeClass = useElderClass();
const [session, setSession] = useState<doctorApi.ConsultationSession | null>(null);
const [messages, setMessages] = useState<doctorApi.ConsultationMessage[]>([]);
const [inputText, setInputText] = useState('');
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(true);
const scrollViewRef = useRef('');
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollingRef = useRef(false);
useEffect(() => {
if (sessionId) {
loadData();
markRead();
startPolling();
startLongPolling();
}
return () => stopPolling();
return () => { pollingRef.current = false; };
}, [sessionId]);
const startPolling = () => {
stopPolling();
pollTimerRef.current = setInterval(pollNewMessages, POLL_INTERVAL);
useEffect(() => {
if (session?.status === 'closed') {
pollingRef.current = false;
}
}, [session?.status]);
const startLongPolling = () => {
pollingRef.current = true;
longPoll();
};
const stopPolling = () => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
};
const pollNewMessages = async () => {
if (!session || session.status === 'closed') {
stopPolling();
return;
}
const longPoll = async () => {
if (!pollingRef.current) return;
try {
const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined;
const m = await doctorApi.listMessages(sessionId, {
page: 1,
page_size: 50,
after_id: lastId,
});
const newMsgs = m.data || [];
if (newMsgs.length > 0) {
const newMsgs = await doctorApi.pollMessages(sessionId, lastId);
if (newMsgs && newMsgs.length > 0) {
setMessages((prev) => {
const existing = new Set(prev.map((msg) => msg.id));
const fresh = newMsgs.filter((msg) => !existing.has(msg.id));
@@ -60,7 +51,12 @@ export default function ConsultationDetail() {
});
scrollViewRef.current = `msg-${messages.length + newMsgs.length}`;
}
} catch { /* 轮询失败静默忽略 */ }
} catch {
// 超时或网络错误,静默重试
}
if (pollingRef.current) {
longPoll();
}
};
const loadData = async () => {
@@ -73,7 +69,7 @@ export default function ConsultationDetail() {
setSession(s);
setMessages(m.data || []);
scrollViewRef.current = `msg-${(m.data || []).length}`;
if (s.status === 'closed') stopPolling();
if (s.status === 'closed') pollingRef.current = false;
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
@@ -132,7 +128,7 @@ export default function ConsultationDetail() {
const isOpen = session?.status !== 'closed';
return (
<View className='chat-page'>
<View className={`chat-page ${modeClass}`}>
{/* Header */}
<View className='chat-header'>
<Text className='chat-header__title'>{session?.subject || '在线咨询'}</Text>

View File

@@ -17,7 +17,7 @@
flex: 1;
text-align: center;
padding: 24px 0;
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx2;
position: relative;
@@ -64,7 +64,7 @@
}
&__subject {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
flex: 1;
@@ -80,7 +80,7 @@
}
&__status-text {
font-size: 22px;
font-size: var(--tk-font-body);
font-weight: 500;
}
@@ -96,12 +96,12 @@
}
&__time {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
}
&__preview {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
overflow: hidden;
text-overflow: ellipsis;
@@ -123,7 +123,7 @@
&__badge-text {
@include serif-number;
font-size: 22px;
font-size: var(--tk-font-body);
color: $card;
font-weight: 600;
}
@@ -137,7 +137,7 @@
padding: 24px;
&__btn {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $pri;
padding: 12px 24px;
@@ -147,7 +147,7 @@
}
&__info {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
}
}

View File

@@ -1,17 +1,14 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import { useElderClass } from '../../../hooks/useElderClass';
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag';
import { formatDateTime } from '@/utils/date';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; color: string }> = {
waiting: { label: '等待中', color: '#f59e0b' },
active: { label: '进行中', color: '#10b981' },
closed: { label: '已关闭', color: '#94a3b8' },
};
const TABS = [
{ key: '', label: '全部' },
{ key: 'active', label: '进行中' },
@@ -20,12 +17,15 @@ const TABS = [
];
export default function ConsultationList() {
const modeClass = useElderClass();
const [sessions, setSessions] = useState<doctorApi.ConsultationSession[]>([]);
const [activeTab, setActiveTab] = useState('');
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
useEffect(() => {
loadSessions();
}, [page, activeTab]);
@@ -54,18 +54,13 @@ export default function ConsultationList() {
const formatTime = (dateStr?: string | null) => {
if (!dateStr) return '';
const d = new Date(dateStr);
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
return formatDateTime(dateStr);
};
if (loading && sessions.length === 0) return <Loading />;
return (
<ScrollView scrollY className='consultation-page'>
<ScrollView scrollY className={`consultation-page ${modeClass}`}>
<View className='tabs'>
{TABS.map((t) => (
<View
@@ -83,7 +78,6 @@ export default function ConsultationList() {
) : (
<View className='session-list'>
{sessions.map((s) => {
const st = STATUS_MAP[s.status] || { label: s.status, color: '#94a3b8' };
return (
<View
key={s.id}
@@ -92,8 +86,8 @@ export default function ConsultationList() {
>
<View className='session-card__top'>
<Text className='session-card__subject'>{s.subject || '在线咨询'}</Text>
<View className='session-card__status' style={`background: ${st.color}20; color: ${st.color}`}>
<Text className='session-card__status-text'>{st.label}</Text>
<View className='session-card__status' style={getStatusInlineStyle(s.status)}>
<Text className='session-card__status-text'>{getStatusLabel(s.status)}</Text>
</View>
</View>
<View className='session-card__info'>
@@ -122,10 +116,10 @@ export default function ConsultationList() {
className={`pagination__btn ${page <= 1 ? 'disabled' : ''}`}
onClick={() => page > 1 && setPage(page - 1)}
></Text>
<Text className='pagination__info'>{page} / {Math.ceil(total / 20)}</Text>
<Text className='pagination__info'>{page} / {totalPages}</Text>
<Text
className={`pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
className={`pagination__btn ${page >= totalPages ? 'disabled' : ''}`}
onClick={() => page < totalPages && setPage(page + 1)}
></Text>
</View>
)}

View File

@@ -1,4 +1,5 @@
@import '../../../../styles/variables.scss';
@import '../../../../styles/mixins.scss';
.create-page {
min-height: 100vh;
@@ -16,7 +17,7 @@
}
.section-title {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
display: block;
@@ -41,7 +42,7 @@
}
.form-label {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
flex-shrink: 0;
min-width: 140px;
@@ -50,12 +51,12 @@
.form-input {
flex: 1;
text-align: right;
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
}
.form-value {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
&.placeholder {
@@ -66,7 +67,7 @@
.form-textarea {
width: 100%;
margin-top: 12px;
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
min-height: 120px;
background: $bg;
@@ -91,7 +92,7 @@
}
.submit-btn__text {
font-size: 30px;
font-size: var(--tk-font-num);
font-weight: bold;
color: #fff;
}

View File

@@ -3,6 +3,7 @@ import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/compone
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import './index.scss';
const DIALYSIS_TYPES = ['HD', 'HDF', 'HF'];
@@ -55,6 +56,7 @@ export default function DialysisCreate() {
const version = router.params.version ? Number(router.params.version) : 0;
const patientIdFromRoute = router.params.patientId || '';
const isEdit = !!id;
const modeClass = useElderClass();
const [form, setForm] = useState<FormState>({ ...initialForm, patient_id: patientIdFromRoute });
const [loading, setLoading] = useState(isEdit);
@@ -167,7 +169,7 @@ export default function DialysisCreate() {
);
return (
<ScrollView scrollY className='create-page'>
<ScrollView scrollY className={`create-page ${modeClass}`}>
<View className='section'>
<Text className='section-title'></Text>
<View className='form-row'>

View File

@@ -1,4 +1,5 @@
@import '../../../../styles/variables.scss';
@import '../../../../styles/mixins.scss';
.dialysis-detail {
min-height: 100vh;
@@ -16,7 +17,7 @@
}
.section-title {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
display: block;
@@ -31,7 +32,7 @@
}
.record-header__title {
font-size: 34px;
font-size: var(--tk-font-num-lg);
font-weight: bold;
color: $tx;
font-variant-numeric: tabular-nums;
@@ -41,7 +42,7 @@
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 22px;
font-size: var(--tk-font-body);
background: $bd-l;
color: $tx3;
@@ -57,13 +58,13 @@
}
.record-sub {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
display: block;
}
.review-info {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
display: block;
margin-top: 8px;
@@ -82,12 +83,12 @@
}
.detail-label {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}
.detail-value {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
text-align: right;
flex: 1;
@@ -98,7 +99,7 @@
.error-text {
text-align: center;
padding: 120px 0;
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx3;
}
@@ -150,6 +151,6 @@
}
.action-btn__text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 500;
}

View File

@@ -3,11 +3,13 @@ import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import './index.scss';
export default function DialysisDetail() {
const router = useRouter();
const id = router.params.id || '';
const modeClass = useElderClass();
const [record, setRecord] = useState<doctorApi.DialysisRecord | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -85,13 +87,13 @@ export default function DialysisDetail() {
};
if (loading) return <Loading />;
if (!record) return <View className='error-text'><Text></Text></View>;
if (!record) return <View className={`error-text ${modeClass}`}><Text></Text></View>;
const canComplete = record.status === 'draft';
const canReview = record.status === 'completed';
return (
<ScrollView scrollY className='dialysis-detail'>
<ScrollView scrollY className={`dialysis-detail ${modeClass}`}>
{/* 状态头部 */}
<View className='section'>
<View className='record-header'>

View File

@@ -1,4 +1,5 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.dialysis-page {
min-height: 100vh;
@@ -15,7 +16,7 @@
background: $bg;
border-radius: $r-sm;
padding: 16px 20px;
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
}
@@ -52,7 +53,7 @@
}
.tab-text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}
@@ -61,7 +62,7 @@
}
.record-count {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
padding: 8px 0 16px;
}
@@ -89,7 +90,7 @@
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 22px;
font-size: var(--tk-font-body);
font-weight: 600;
background: $pri-l;
color: $pri-d;
@@ -109,7 +110,7 @@
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-size: var(--tk-font-body);
background: $bd-l;
color: $tx3;
@@ -131,13 +132,13 @@
}
.record-card__date {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
font-variant-numeric: tabular-nums;
}
.record-card__meta {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
font-variant-numeric: tabular-nums;
}
@@ -154,7 +155,7 @@
padding: 12px 24px;
background: $card;
border-radius: $r-sm;
font-size: 26px;
font-size: var(--tk-font-h1);
color: $pri;
&--disabled {
@@ -163,7 +164,7 @@
}
.page-info {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
}
@@ -187,7 +188,7 @@
}
.fab-text {
font-size: 40px;
font-size: var(--tk-font-hero);
color: #fff;
font-weight: bold;
}

View File

@@ -4,6 +4,7 @@ import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
const TABS = [
@@ -18,6 +19,7 @@ const TYPE_MAP: Record<string, string> = { HD: 'HD', HDF: 'HDF', HF: 'HF' };
export default function DialysisList() {
const router = useRouter();
const patientId = router.params.patientId || '';
const modeClass = useElderClass();
const [searchPatient, setSearchPatient] = useState('');
const [currentPatientId, setCurrentPatientId] = useState(patientId);
const [activeTab, setActiveTab] = useState('');
@@ -72,7 +74,7 @@ export default function DialysisList() {
if (loading && records.length === 0) return <Loading />;
return (
<ScrollView scrollY className='dialysis-page'>
<ScrollView scrollY className={`dialysis-page ${modeClass}`}>
{!patientId && (
<View className='search-bar'>
<Input

View File

@@ -28,13 +28,13 @@
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-size: var(--tk-font-num);
font-weight: 700;
color: $tx;
}
&__status {
font-size: 24px;
font-size: var(--tk-font-h2);
padding: 6px 16px;
border-radius: $r;
font-weight: 500;
@@ -60,12 +60,12 @@
}
.info-label {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx3;
}
.info-value {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
font-weight: 500;
}
@@ -77,14 +77,14 @@
border-radius: $r;
&__label {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx2;
display: block;
margin-bottom: 8px;
}
&__text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
line-height: 1.6;
}
@@ -99,14 +99,14 @@
}
&__date {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx3;
display: block;
margin-bottom: 8px;
}
&__text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
display: block;
margin-bottom: 4px;
@@ -121,7 +121,7 @@
border-radius: $r;
margin-bottom: 24px;
color: $card;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 500;
}
@@ -130,7 +130,7 @@
}
.form-label {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
font-weight: 500;
display: block;
@@ -143,7 +143,7 @@
background: $bd-l;
border-radius: $r;
padding: 16px 20px;
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
box-sizing: border-box;
line-height: 1.6;
@@ -154,7 +154,7 @@
padding: 16px 20px;
background: $bd-l;
border-radius: $r;
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
box-sizing: border-box;
}
@@ -171,7 +171,7 @@
}
&__text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $card;
font-weight: 600;
}
@@ -181,5 +181,5 @@
text-align: center;
padding: 80px 32px;
color: $tx3;
font-size: 28px;
font-size: var(--tk-font-body-lg);
}

View File

@@ -3,6 +3,7 @@ import { View, Text, Textarea, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import './index.scss';
const STATUS_LABELS: Record<string, string> = {
@@ -16,6 +17,7 @@ const STATUS_LABELS: Record<string, string> = {
export default function FollowUpDetail() {
const router = useRouter();
const taskId = router.params.id || '';
const modeClass = useElderClass();
const [task, setTask] = useState<doctorApi.FollowUpTask | null>(null);
const [records, setRecords] = useState<doctorApi.FollowUpRecord[]>([]);
const [loading, setLoading] = useState(true);
@@ -87,12 +89,12 @@ export default function FollowUpDetail() {
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
if (loading) return <Loading />;
if (!task) return <View className='error-text'><Text></Text></View>;
if (!task) return <View className={`error-text ${modeClass}`}><Text></Text></View>;
const canSubmit = task.status === 'in_progress' || task.status === 'pending' || task.status === 'overdue';
return (
<ScrollView scrollY className='followup-detail'>
<ScrollView scrollY className={`followup-detail ${modeClass}`}>
<View className='section'>
<View className='task-header'>
<Text className='task-header__title'>访</Text>

View File

@@ -18,7 +18,7 @@
.tab {
display: inline-block;
padding: 24px 16px;
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
position: relative;
flex-shrink: 0;
@@ -44,7 +44,7 @@
padding: 20px 28px;
text {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
}
}
@@ -75,19 +75,19 @@
&__type {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
}
&__status {
@include tag(transparent, $tx2);
font-size: 22px;
font-size: var(--tk-font-body);
font-weight: 500;
}
&__patient {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
display: block;
margin-bottom: 8px;
@@ -99,7 +99,7 @@
}
&__date {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
}
}

View File

@@ -4,16 +4,10 @@ import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import { useElderClass } from '../../../hooks/useElderClass';
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; color: string }> = {
pending: { label: '待处理', color: '#f59e0b' },
in_progress: { label: '进行中', color: '#0891b2' },
completed: { label: '已完成', color: '#10b981' },
overdue: { label: '已逾期', color: '#ef4444' },
cancelled: { label: '已取消', color: '#94a3b8' },
};
const TABS = [
{ key: '', label: '全部' },
{ key: 'pending', label: '待处理' },
@@ -25,6 +19,7 @@ const TABS = [
export default function FollowUpList() {
const router = useRouter();
const patientId = router.params.patientId || '';
const modeClass = useElderClass();
const [tasks, setTasks] = useState<doctorApi.FollowUpTask[]>([]);
const [activeTab, setActiveTab] = useState('');
const [loading, setLoading] = useState(true);
@@ -69,7 +64,7 @@ export default function FollowUpList() {
if (loading && tasks.length === 0) return <Loading />;
return (
<ScrollView scrollY className='followup-page'>
<ScrollView scrollY className={`followup-page ${modeClass}`}>
<View className='tabs'>
{TABS.map((t) => (
<View
@@ -91,7 +86,6 @@ export default function FollowUpList() {
) : (
<View className='task-list'>
{tasks.map((task) => {
const st = STATUS_MAP[task.status] || { label: task.status, color: '#94a3b8' };
return (
<View
key={task.id}
@@ -100,8 +94,8 @@ export default function FollowUpList() {
>
<View className='task-card__header'>
<Text className='task-card__type'>{getTypeLabel(task.follow_up_type)}</Text>
<View className='task-card__status' style={`background: ${st.color}20; color: ${st.color}`}>
<Text>{st.label}</Text>
<View className='task-card__status' style={getStatusInlineStyle(task.status)}>
<Text>{getStatusLabel(task.status)}</Text>
</View>
</View>
<Text className='task-card__patient'>{task.patient_name || '未知患者'}</Text>

View File

@@ -13,19 +13,18 @@
&__title {
@include section-title;
font-size: 40px;
margin-bottom: 12px;
}
&__greeting {
font-size: 28px;
font-size: var(--tk-font-h2);
color: $tx2;
display: block;
margin-bottom: 8px;
}
&__date {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
}
@@ -34,34 +33,34 @@
align-items: center;
margin: 16px 24px;
padding: 16px 20px;
background: #FEF2F2;
background: $dan-l;
border-radius: $r;
border-left: 4px solid #EF4444;
border-left: 4px solid $dan;
}
&__alert-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: #EF4444;
background: $dan;
color: #fff;
text-align: center;
line-height: 36px;
font-weight: bold;
font-size: 22px;
font-size: var(--tk-font-body);
margin-right: 12px;
flex-shrink: 0;
}
&__alert-text {
flex: 1;
font-size: 26px;
color: #991B1B;
font-size: var(--tk-font-h1);
color: $dan;
}
&__alert-link {
font-size: 24px;
color: #EF4444;
font-size: var(--tk-font-h2);
color: $dan;
flex-shrink: 0;
}
@@ -70,11 +69,11 @@
}
&__search-input {
background: #F1F5F9;
background: $surface-alt;
border-radius: $r;
padding: 16px 20px;
font-size: 26px;
color: #94A3B8;
font-size: var(--tk-font-h1);
color: $tx3;
}
&__section {
@@ -113,14 +112,14 @@
background: $pri-l;
color: $pri;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 700;
margin-bottom: 8px;
}
&__card-num {
@include serif-number;
font-size: 48px;
font-size: var(--tk-font-hero);
font-weight: 700;
color: $tx;
display: block;
@@ -128,7 +127,7 @@
}
&__card-label {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
}
@@ -145,7 +144,7 @@
&__logout {
color: $dan;
font-size: 28px;
font-size: var(--tk-font-h2);
padding: 16px 48px;
display: inline-block;
}
@@ -172,7 +171,7 @@
background: $acc-l;
color: $acc;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 700;
}
@@ -192,14 +191,14 @@
text-align: center;
background: $dan;
color: #fff;
font-size: 18px;
font-size: var(--tk-font-body-sm);
font-weight: 700;
border-radius: $r-pill;
padding: 0 6px;
}
&__label {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
display: block;
}

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useAuthStore } from '@/stores/auth';
import { useElderClass } from '../../hooks/useElderClass';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import './index.scss';
@@ -11,35 +12,67 @@ interface CardConfig {
label: string;
initial: string;
route: string;
roles?: string[];
}
const CARDS: CardConfig[] = [
const ALL_CARDS: CardConfig[] = [
{ key: 'total_patients', label: '我的患者', initial: '患', route: '/pages/doctor/patients/index' },
{ key: 'unread_messages', label: '未读消息', initial: '消', route: '/pages/doctor/consultation/index' },
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages/doctor/followup/index' },
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages/doctor/consultation/index' },
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages/doctor/consultation/index', roles: ['doctor', 'health_manager'] },
];
const HEALTH_CARDS: CardConfig[] = [
{ key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages/doctor/report/index' },
const ALL_HEALTH_CARDS: CardConfig[] = [
{ key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages/doctor/report/index', roles: ['doctor'] },
{ key: 'today_appointments', label: '今日预约', initial: '约', route: '/pages/doctor/patients/index' },
];
const QUICK_ACTIONS = [
{ label: '化验审核', initial: '审', route: '/pages/doctor/report/index' },
{ label: '患者查询', initial: '查', route: '/pages/doctor/patients/index' },
{ label: '随访记录', initial: '随', route: '/pages/doctor/followup/index' },
{ label: '告警中心', initial: '警', route: '/pages/doctor/alerts/index' },
{ label: '透析记录', initial: '透', route: '/pages/doctor/dialysis/index' },
{ label: '透析处方', initial: '方', route: '/pages/doctor/prescription/index' },
interface QuickAction {
label: string;
initial: string;
route: string;
roles: string[];
}
const ALL_QUICK_ACTIONS: QuickAction[] = [
{ label: '化验审核', initial: '审', route: '/pages/doctor/report/index', roles: ['doctor'] },
{ label: '患者查询', initial: '查', route: '/pages/doctor/patients/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '随访记录', initial: '随', route: '/pages/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '告警中心', initial: '警', route: '/pages/doctor/alerts/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '透析管理', initial: '透', route: '/pages/doctor/dialysis/index', roles: ['doctor'] },
{ label: '处方管理', initial: '方', route: '/pages/doctor/prescription/index', roles: ['doctor'] },
{ label: '行动收件箱', initial: '行', route: '/pages/doctor/action-inbox/index', roles: ['doctor', 'nurse', 'health_manager'] },
];
const ROLE_LABELS: Record<string, string> = {
doctor: '医生',
nurse: '护士',
health_manager: '健康管理师',
admin: '管理员',
operator: '运营',
};
export default function DoctorHome() {
const { user, logout } = useAuthStore();
const { user, logout, roles } = useAuthStore();
const modeClass = useElderClass();
const [dashboard, setDashboard] = useState<doctorApi.DoctorDashboard | null>(null);
const [alertCount, setAlertCount] = useState(0);
const [loading, setLoading] = useState(true);
const hasRole = (allowed: string[] | undefined) => {
if (!allowed) return true;
return roles.some((r) => r === 'admin' || allowed.includes(r));
};
const cards = useMemo(() => ALL_CARDS.filter((c) => hasRole(c.roles)), [roles]);
const healthCards = useMemo(() => ALL_HEALTH_CARDS.filter((c) => hasRole(c.roles)), [roles]);
const quickActions = useMemo(() => ALL_QUICK_ACTIONS.filter((a) => hasRole(a.roles)), [roles]);
const roleLabel = useMemo(() => {
const primary = roles.find((r) => r !== 'admin');
return primary ? (ROLE_LABELS[primary] || primary) : '医护';
}, [roles]);
useEffect(() => {
loadDashboard();
}, []);
@@ -74,11 +107,11 @@ export default function DoctorHome() {
if (loading) return <Loading />;
return (
<ScrollView scrollY className='doctor-home'>
<ScrollView scrollY className={`doctor-home ${modeClass}`}>
<View className='doctor-home__header'>
<Text className='doctor-home__title'></Text>
<Text className='doctor-home__greeting'>
{user?.display_name || user?.username || '医生'}
{user?.display_name || user?.username || roleLabel}
</Text>
<Text className='doctor-home__date'>
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })}
@@ -104,7 +137,7 @@ export default function DoctorHome() {
<View className='doctor-home__section'>
<Text className='doctor-home__section-title'></Text>
<View className='doctor-home__grid'>
{CARDS.map((card) => (
{cards.map((card) => (
<View
key={card.key}
className='doctor-home__card'
@@ -118,10 +151,10 @@ export default function DoctorHome() {
</View>
</View>
<View className='doctor-home__section'>
{healthCards.length > 0 && (<View className='doctor-home__section'>
<Text className='doctor-home__section-title'></Text>
<View className='doctor-home__grid'>
{HEALTH_CARDS.map((card) => (
{healthCards.map((card) => (
<View
key={card.key}
className='doctor-home__card'
@@ -133,12 +166,12 @@ export default function DoctorHome() {
</View>
))}
</View>
</View>
</View>)}
<View className='doctor-home__section'>
<Text className='doctor-home__section-title'></Text>
<View className='doctor-home__quick-actions'>
{QUICK_ACTIONS.map((action) => (
{quickActions.map((action) => (
<View
key={action.route}
className='quick-action'

View File

@@ -33,12 +33,12 @@
}
.info-label {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx3;
}
.info-value {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
font-weight: 500;
}
@@ -51,7 +51,7 @@
}
.warning-label {
font-size: 22px;
font-size: var(--tk-font-body);
color: $wrn;
font-weight: 600;
display: block;
@@ -59,7 +59,7 @@
}
.warning-text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $pri-d;
}
@@ -68,14 +68,14 @@
}
.info-block-label {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx3;
display: block;
margin-bottom: 8px;
}
.info-block-text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
line-height: 1.6;
}
@@ -96,7 +96,7 @@
.vital-value {
@include serif-number;
font-size: 36px;
font-size: var(--tk-font-num-lg);
font-weight: 700;
color: $pri;
display: block;
@@ -104,7 +104,7 @@
}
.vital-label {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx2;
}
@@ -116,13 +116,13 @@
}
.stat-label {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}
.stat-value {
@include serif-number;
font-size: 26px;
font-size: var(--tk-font-h1);
font-weight: 600;
color: $tx;
@@ -151,18 +151,18 @@
}
&__type {
font-size: 26px;
font-size: var(--tk-font-h1);
font-weight: 500;
color: $tx;
}
&__date {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
}
&__abnormal {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $dan;
font-weight: 500;
}
@@ -180,7 +180,7 @@
border-radius: $r;
background: $pri;
color: $card;
font-size: 26px;
font-size: var(--tk-font-h1);
font-weight: 500;
&:active {
@@ -196,5 +196,5 @@
text-align: center;
padding: 80px 32px;
color: $tx3;
font-size: 28px;
font-size: var(--tk-font-body-lg);
}

View File

@@ -3,11 +3,13 @@ import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import './index.scss';
export default function PatientDetail() {
const router = useRouter();
const patientId = router.params.id || '';
const modeClass = useElderClass();
const [patient, setPatient] = useState<doctorApi.PatientDetail | null>(null);
const [summary, setSummary] = useState<doctorApi.HealthSummary | null>(null);
const [loading, setLoading] = useState(true);
@@ -40,10 +42,10 @@ export default function PatientDetail() {
};
if (loading) return <Loading />;
if (!patient) return <View className='error-text'><Text></Text></View>;
if (!patient) return <View className={`error-text ${modeClass}`}><Text></Text></View>;
return (
<ScrollView scrollY className='patient-detail'>
<ScrollView scrollY className={`patient-detail ${modeClass}`}>
{/* 基本信息 */}
<View className='section'>
<View className='section-header'>

View File

@@ -15,7 +15,7 @@
background: $card;
border-radius: $r;
padding: 20px 24px;
font-size: 28px;
font-size: var(--tk-font-body-lg);
width: 100%;
box-sizing: border-box;
box-shadow: $shadow-sm;
@@ -33,7 +33,7 @@
padding: 10px 24px;
border-radius: $r-pill;
background: $bd-l;
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
margin-right: 16px;
@@ -47,7 +47,7 @@
margin-bottom: 16px;
text {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
}
}
@@ -75,14 +75,14 @@
}
&__name {
font-size: 30px;
font-size: var(--tk-font-num);
font-weight: 600;
color: $tx;
margin-right: 16px;
}
&__meta {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
}
@@ -112,7 +112,7 @@
background: $pri-l;
&__text {
font-size: 22px;
font-size: var(--tk-font-body);
}
}
@@ -124,7 +124,7 @@
margin-top: 32px;
&__btn {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $pri;
padding: 12px 24px;
@@ -134,7 +134,7 @@
}
&__info {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
}
}

View File

@@ -1,12 +1,14 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
export default function PatientList() {
const modeClass = useElderClass();
const [patients, setPatients] = useState<doctorApi.PatientItem[]>([]);
const [tags, setTags] = useState<doctorApi.PatientTag[]>([]);
const [activeTag, setActiveTag] = useState<string>('');
@@ -14,14 +16,15 @@ export default function PatientList() {
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const loadingRef = useRef(false);
useEffect(() => {
loadTags();
}, []);
useEffect(() => {
loadPatients();
}, [page, activeTag]);
loadPatients(1, true);
}, [activeTag]);
const loadTags = async () => {
try {
@@ -30,32 +33,51 @@ export default function PatientList() {
} catch { /* ignore */ }
};
const loadPatients = async () => {
setLoading(true);
const loadPatients = async (pageNum: number, isRefresh = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
if (isRefresh) setLoading(true);
try {
const res = await doctorApi.listPatients({
page,
page: pageNum,
page_size: 20,
search: search || undefined,
tag_id: activeTag || undefined,
});
setPatients(res.data || []);
const list = res.data || [];
if (isRefresh) {
setPatients(list);
} else {
setPatients((prev) => [...prev, ...list]);
}
setTotal(res.total || 0);
setPage(pageNum);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
loadingRef.current = false;
}
};
usePullDownRefresh(() => {
loadPatients(1, true).finally(() => {
Taro.stopPullDownRefresh();
});
});
useReachBottom(() => {
if (!loading && patients.length < total) {
loadPatients(page + 1);
}
});
const handleSearch = () => {
setPage(1);
loadPatients();
loadPatients(1, true);
};
const handleTagFilter = (tagId: string) => {
setActiveTag(tagId === activeTag ? '' : tagId);
setPage(1);
};
const getGenderLabel = (gender?: string) => {
@@ -78,7 +100,7 @@ export default function PatientList() {
if (loading && patients.length === 0) return <Loading />;
return (
<ScrollView scrollY className='patient-list-page'>
<ScrollView scrollY className={`patient-list-page ${modeClass}`}>
<View className='search-bar'>
<Input
className='search-input'
@@ -154,23 +176,12 @@ export default function PatientList() {
</View>
)}
{total > 20 && (
<View className='pagination'>
<Text
className={`pagination__btn ${page <= 1 ? 'disabled' : ''}`}
onClick={() => page > 1 && setPage(page - 1)}
>
</Text>
<Text className='pagination__info'>{page} / {Math.ceil(total / 20)}</Text>
<Text
className={`pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
>
</Text>
{!loading && patients.length >= total && total > 0 && (
<View style={{ textAlign: 'center', padding: '20px' }}>
<Text style={{ fontSize: '24px', color: '#78716C' }}></Text>
</View>
)}
{loading && patients.length > 0 && <Loading />}
</ScrollView>
);
}

View File

@@ -1,4 +1,5 @@
@import '../../../../styles/variables.scss';
@import '../../../../styles/mixins.scss';
.create-page {
min-height: 100vh;
@@ -16,7 +17,7 @@
}
.section-title {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
display: block;
@@ -36,7 +37,7 @@
}
.form-label {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
flex-shrink: 0;
min-width: 140px;
@@ -45,12 +46,12 @@
.form-input {
flex: 1;
text-align: right;
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
}
.form-value {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
&.placeholder {
@@ -61,7 +62,7 @@
.form-textarea {
width: 100%;
margin-top: 12px;
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
min-height: 120px;
background: $bg;
@@ -86,7 +87,7 @@
}
.submit-btn__text {
font-size: 30px;
font-size: var(--tk-font-num);
font-weight: bold;
color: #fff;
}

View File

@@ -3,6 +3,7 @@ import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/compone
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import './index.scss';
interface FormState {
@@ -50,6 +51,7 @@ const initialForm: FormState = {
export default function PrescriptionCreate() {
const router = useRouter();
const patientId = router.params.patientId || '';
const modeClass = useElderClass();
const [form, setForm] = useState<FormState>(initialForm);
const [submitting, setSubmitting] = useState(false);
@@ -114,7 +116,7 @@ export default function PrescriptionCreate() {
);
return (
<ScrollView scrollY className='create-page'>
<ScrollView scrollY className={`create-page ${modeClass}`}>
{/* 透析器 */}
<View className='section'>
<Text className='section-title'></Text>

View File

@@ -1,4 +1,5 @@
@import '../../../../styles/variables.scss';
@import '../../../../styles/mixins.scss';
.prescription-detail {
min-height: 100vh;
@@ -16,7 +17,7 @@
}
.section-title {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
display: block;
@@ -31,7 +32,7 @@
}
.rx-header__title {
font-size: 34px;
font-size: var(--tk-font-num-lg);
font-weight: bold;
color: $tx;
}
@@ -40,7 +41,7 @@
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 22px;
font-size: var(--tk-font-body);
background: $bd-l;
color: $tx3;
@@ -51,7 +52,7 @@
}
.rx-sub {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
display: block;
font-variant-numeric: tabular-nums;
@@ -69,12 +70,12 @@
}
.detail-label {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}
.detail-value {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
text-align: right;
flex: 1;
@@ -83,7 +84,7 @@
}
.notes-text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
line-height: 1.6;
}
@@ -91,7 +92,7 @@
.error-text {
text-align: center;
padding: 120px 0;
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx3;
}
@@ -131,6 +132,6 @@
}
.action-btn__text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 500;
}

View File

@@ -3,11 +3,13 @@ import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import './index.scss';
export default function PrescriptionDetail() {
const router = useRouter();
const id = router.params.id || '';
const modeClass = useElderClass();
const [rx, setRx] = useState<doctorApi.DialysisPrescription | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -76,10 +78,10 @@ export default function PrescriptionDetail() {
};
if (loading) return <Loading />;
if (!rx) return <View className='error-text'><Text></Text></View>;
if (!rx) return <View className={`error-text ${modeClass}`}><Text></Text></View>;
return (
<ScrollView scrollY className='prescription-detail'>
<ScrollView scrollY className={`prescription-detail ${modeClass}`}>
{/* 状态头部 */}
<View className='section'>
<View className='rx-header'>

View File

@@ -1,4 +1,5 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.prescription-page {
min-height: 100vh;
@@ -15,7 +16,7 @@
background: $bg;
border-radius: $r-sm;
padding: 16px 20px;
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
}
@@ -52,7 +53,7 @@
}
.tab-text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}
@@ -61,7 +62,7 @@
}
.prescription-count {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
padding: 8px 0 16px;
}
@@ -86,7 +87,7 @@
}
.prescription-card__model {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
}
@@ -95,7 +96,7 @@
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-size: var(--tk-font-body);
background: $bd-l;
color: $tx3;
@@ -112,13 +113,13 @@
}
.prescription-card__meta {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
font-variant-numeric: tabular-nums;
}
.prescription-card__date {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
display: block;
font-variant-numeric: tabular-nums;
@@ -136,7 +137,7 @@
padding: 12px 24px;
background: $card;
border-radius: $r-sm;
font-size: 26px;
font-size: var(--tk-font-h1);
color: $pri;
&--disabled {
@@ -145,7 +146,7 @@
}
.page-info {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
}
@@ -169,7 +170,7 @@
}
.fab-text {
font-size: 40px;
font-size: var(--tk-font-hero);
color: #fff;
font-weight: bold;
}

View File

@@ -4,6 +4,7 @@ import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
const TABS = [
@@ -15,6 +16,7 @@ const TABS = [
export default function PrescriptionList() {
const router = useRouter();
const patientId = router.params.patientId || '';
const modeClass = useElderClass();
const [searchPatient, setSearchPatient] = useState('');
const [currentPatientId, setCurrentPatientId] = useState(patientId);
const [activeTab, setActiveTab] = useState('');
@@ -66,7 +68,7 @@ export default function PrescriptionList() {
if (loading && prescriptions.length === 0) return <Loading />;
return (
<ScrollView scrollY className='prescription-page'>
<ScrollView scrollY className={`prescription-page ${modeClass}`}>
{!patientId && (
<View className='search-bar'>
<Input

View File

@@ -28,13 +28,13 @@
&__type {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
font-size: var(--tk-font-num);
font-weight: 700;
color: $tx;
}
&__status {
font-size: 24px;
font-size: var(--tk-font-h2);
padding: 6px 16px;
border-radius: $r;
font-weight: 500;
@@ -45,13 +45,13 @@
}
.report-date {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
display: block;
}
.review-info {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $acc;
display: block;
margin-top: 8px;
@@ -81,7 +81,7 @@
}
.indicator-cell {
font-size: 24px;
font-size: var(--tk-font-h2);
&--name {
flex: 2;
@@ -116,7 +116,7 @@
}
.indicator-row--header & {
font-size: 22px;
font-size: var(--tk-font-body);
color: $tx3;
font-weight: 400;
}
@@ -129,7 +129,7 @@
}
.notes-text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
line-height: 1.6;
}
@@ -140,7 +140,7 @@
background: $bd-l;
border-radius: $r;
padding: 20px;
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
box-sizing: border-box;
line-height: 1.6;
@@ -158,7 +158,7 @@
}
&__text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $card;
font-weight: 600;
}
@@ -168,5 +168,5 @@
text-align: center;
padding: 80px 32px;
color: $tx3;
font-size: 28px;
font-size: var(--tk-font-body-lg);
}

View File

@@ -3,12 +3,14 @@ import { View, Text, Textarea, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import './index.scss';
export default function ReportDetail() {
const router = useRouter();
const patientId = router.params.patientId || '';
const reportId = router.params.id || '';
const modeClass = useElderClass();
const [report, setReport] = useState<doctorApi.LabReportDetail | null>(null);
const [loading, setLoading] = useState(true);
const [doctorNotes, setDoctorNotes] = useState('');
@@ -51,10 +53,10 @@ export default function ReportDetail() {
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
if (loading) return <Loading />;
if (!report) return <View className='error-text'><Text></Text></View>;
if (!report) return <View className={`error-text ${modeClass}`}><Text></Text></View>;
return (
<ScrollView scrollY className='report-detail'>
<ScrollView scrollY className={`report-detail ${modeClass}`}>
{/* 基本信息 */}
<View className='section'>
<View className='report-header'>

View File

@@ -15,7 +15,7 @@
background: $card;
border-radius: $r;
padding: 20px 24px;
font-size: 28px;
font-size: var(--tk-font-body-lg);
width: 100%;
box-sizing: border-box;
box-shadow: $shadow-sm;
@@ -26,7 +26,7 @@
margin-bottom: 16px;
text {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
}
}
@@ -56,13 +56,13 @@
&__type {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
}
&__date {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx3;
}
@@ -73,13 +73,13 @@
}
&__abnormal {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $dan;
font-weight: 600;
}
&__normal {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $acc;
}

View File

@@ -4,11 +4,13 @@ import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
export default function ReportList() {
const router = useRouter();
const patientId = router.params.patientId || '';
const modeClass = useElderClass();
const [searchPatient, setSearchPatient] = useState('');
const [currentPatientId, setCurrentPatientId] = useState(patientId);
const [reports, setReports] = useState<doctorApi.LabReportItem[]>([]);
@@ -55,7 +57,7 @@ export default function ReportList() {
if (loading && reports.length === 0) return <Loading />;
return (
<ScrollView scrollY className='report-page'>
<ScrollView scrollY className={`report-page ${modeClass}`}>
{!patientId && (
<View className='search-bar'>
<Input

View File

@@ -1,34 +1,5 @@
@import '../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@import '../../styles/mixins.scss';
.events-page {
min-height: 100vh;
@@ -43,14 +14,14 @@
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-size: var(--tk-font-h1);
font-weight: bold;
display: block;
margin-bottom: 8px;
}
&__subtitle {
font-size: 26px;
font-size: var(--tk-font-h1);
opacity: 0.85;
}
}
@@ -77,7 +48,7 @@
&__status {
@include tag($bd-l, $tx2);
font-size: 22px;
font-size: var(--tk-font-body);
}
&__status--published {
@@ -97,7 +68,7 @@
}
&__points {
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $wrn;
@include serif-number;
@@ -105,7 +76,7 @@
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-size: var(--tk-font-h1);
font-weight: bold;
color: $tx;
display: block;
@@ -113,7 +84,7 @@
}
&__desc {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
display: block;
margin-bottom: 16px;
@@ -128,13 +99,13 @@
}
&__date {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $tx2;
}
&__location {
font-size: 24px;
color: $tx3;
font-size: var(--tk-font-h2);
color: var(--tk-text-secondary);
}
&__footer {
@@ -146,8 +117,8 @@
}
&__participants {
font-size: 24px;
color: $tx3;
font-size: var(--tk-font-h2);
color: var(--tk-text-secondary);
@include serif-number;
}
@@ -161,7 +132,7 @@
}
&-text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $card;
font-weight: 500;
}

View File

@@ -4,6 +4,7 @@ import Taro from '@tarojs/taro';
import * as pointsApi from '@/services/points';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; className: string }> = {
@@ -14,6 +15,7 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
};
export default function EventsPage() {
const modeClass = useElderClass();
const [events, setEvents] = useState<pointsApi.OfflineEvent[]>([]);
const [loading, setLoading] = useState(true);
const [registering, setRegistering] = useState<string | null>(null);
@@ -59,7 +61,7 @@ export default function EventsPage() {
if (loading) return <Loading />;
return (
<ScrollView scrollY className='events-page'>
<ScrollView scrollY className={`events-page ${modeClass}`}>
<View className='events-header'>
<Text className='events-header__title'>线</Text>
<Text className='events-header__subtitle'></Text>

View File

@@ -18,7 +18,7 @@
.detail-title {
@include section-title;
font-size: 34px;
font-size: var(--tk-font-num-lg);
margin-bottom: 20px;
}
@@ -34,12 +34,12 @@
}
.detail-label {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
}
.detail-value {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
&.status-completed { color: $acc; }
@@ -59,7 +59,7 @@
}
.countdown-text {
font-size: 24px;
font-size: var(--tk-font-h2);
color: $wrn;
font-weight: bold;
@@ -74,7 +74,7 @@
}
.detail-desc-text {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx;
line-height: 1.6;
}
@@ -94,7 +94,7 @@
.submit-textarea {
width: 100%;
min-height: 200px;
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
background: $bg;
border-radius: $r-sm;
@@ -121,7 +121,7 @@
}
.submit-btn-text {
font-size: 30px;
font-size: var(--tk-font-num);
color: #fff;
font-weight: 600;
}
@@ -134,6 +134,6 @@
.loading-text,
.empty-text {
font-size: 28px;
color: $tx3;
font-size: var(--tk-font-body-lg);
color: var(--tk-text-secondary);
}

View File

@@ -7,9 +7,11 @@ import { TEMPLATE_IDS } from '@/services/wechat-templates';
import { trackEvent } from '@/services/analytics';
import Loading from '../../../components/Loading';
import ErrorState from '../../../components/ErrorState';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
export default function FollowUpDetail() {
const modeClass = useElderClass();
const router = useRouter();
const id = router.params.id || '';
@@ -82,7 +84,7 @@ export default function FollowUpDetail() {
if (loading) {
return (
<View className='detail-page'>
<View className={`detail-page ${modeClass}`}>
<Loading />
</View>
);
@@ -90,7 +92,7 @@ export default function FollowUpDetail() {
if (error || !task) {
return (
<View className='detail-page'>
<View className={`detail-page ${modeClass}`}>
<ErrorState text='任务不存在' />
</View>
);
@@ -99,7 +101,7 @@ export default function FollowUpDetail() {
const isCompleted = task.status === 'completed';
return (
<View className='detail-page'>
<View className={`detail-page ${modeClass}`}>
<View className='detail-card'>
<Text className='detail-title'>{task.follow_up_type}</Text>
<View className='detail-row'>

View File

@@ -4,17 +4,18 @@
.health-page {
min-height: 100vh;
background: $bg;
padding: 20px 16px 100px;
padding: 20px 24px 100px;
padding-bottom: calc(100px + env(safe-area-inset-bottom));
}
/* ─── 页头 ─── */
.health-header {
margin-bottom: 16px;
margin-bottom: 20px;
}
.health-title {
font-size: 22px;
@include serif-number;
font-size: var(--tk-font-h1);
font-weight: 700;
color: $tx;
}
@@ -40,6 +41,7 @@
&.vital-tab-active {
background: $pri;
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.25);
.vital-tab-text {
color: #fff;
@@ -48,7 +50,7 @@
}
.vital-tab-text {
font-size: 15px;
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx2;
}
@@ -65,11 +67,11 @@
/* ─── 录入区 ─── */
.input-section {
margin-bottom: 12px;
margin-bottom: 20px;
background: $card;
border-radius: $r;
padding: 16px;
box-shadow: $shadow-sm;
padding: 20px;
box-shadow: $shadow-md;
}
.input-group {
@@ -77,8 +79,8 @@
}
.input-label {
font-size: 13px;
color: $tx3;
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
display: block;
margin-bottom: 4px;
}
@@ -90,7 +92,7 @@
border-radius: 12px;
padding: 0 16px;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
width: 100%;
@@ -98,13 +100,17 @@
}
.input-ref {
font-size: 13px;
color: $tx3;
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
display: block;
margin-top: 8px;
margin-bottom: 4px;
}
.input-label--secondary {
margin-top: 20px;
}
/* ─── 血糖时段选择 ─── */
.period-group {
display: flex;
@@ -133,7 +139,7 @@
}
.period-btn-text {
font-size: 15px;
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx2;
}
@@ -145,7 +151,8 @@
border-radius: 14px;
background: $pri;
@include flex-center;
margin-top: 12px;
margin-top: 20px;
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.25);
&:active {
opacity: 0.85;
@@ -153,7 +160,7 @@
}
.save-btn-text {
font-size: 17px;
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: #fff;
}
@@ -176,7 +183,7 @@
}
.trend-empty-text {
font-size: 14px;
font-size: var(--tk-font-cap);
color: $tx2;
}
@@ -195,6 +202,25 @@
border-radius: 12px;
padding: 12px 8px;
gap: 0;
position: relative;
}
.trend-threshold-line {
position: absolute;
left: 8px;
right: 8px;
border-top: 1.5px dashed $wrn;
opacity: 0.6;
pointer-events: none;
}
.trend-threshold-label {
position: absolute;
right: 0;
top: -16px;
font-size: var(--tk-font-micro);
color: $wrn;
opacity: 0.8;
}
.trend-bar-col {
@@ -222,8 +248,8 @@
}
.trend-bar-label {
font-size: 11px;
color: $tx3;
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
margin-top: 6px;
}
@@ -256,7 +282,7 @@
}
.device-icon-text {
font-size: 22px;
font-size: var(--tk-font-body);
}
.device-info {
@@ -265,21 +291,21 @@
}
.device-name {
font-size: 15px;
font-size: var(--tk-font-cap);
font-weight: 500;
color: $tx;
display: block;
}
.device-desc {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $acc;
display: block;
}
.device-arrow {
font-size: 14px;
color: $tx3;
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
flex-shrink: 0;
}
@@ -296,37 +322,38 @@
}
.article-entry-text {
font-size: 15px;
font-size: var(--tk-font-cap);
color: $tx;
font-weight: 500;
}
/* ─── AI 建议卡片 ─── */
.ai-suggestion-card {
background: $card;
background: $acc-l;
border-radius: $r;
padding: 16px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
border-left: 4px solid $pri;
margin-bottom: 20px;
box-shadow: none;
border-left: 4px solid $acc;
}
.ai-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
margin-bottom: 8px;
}
.ai-card-title {
font-size: 15px;
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx;
color: $acc;
}
.ai-card-count {
font-size: 12px;
font-size: var(--tk-font-micro);
color: $acc;
opacity: 0.7;
}
.ai-suggestion-item {
@@ -344,6 +371,7 @@
}
.ai-suggestion-text {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.6;
}

View File

@@ -1,11 +1,13 @@
import { useState, useEffect } from 'react';
import { View, Text, Input } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import { useHealthStore } from '../../stores/health';
import { useAuthStore } from '../../stores/auth';
import { inputVitalSign, getTrend } from '../../services/health';
import { useElderClass } from '../../hooks/useElderClass';
import { inputVitalSign, getTrend, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } from '../../services/health';
import { listPendingSuggestions, type AiSuggestionItem } from '../../services/ai-analysis';
import Loading from '../../components/Loading';
import GuestGuard from '../../components/GuestGuard';
import './index.scss';
type VitalType = 'blood_pressure' | 'heart_rate' | 'blood_sugar' | 'weight';
@@ -17,12 +19,21 @@ const VITAL_TABS: { key: VitalType; label: string }[] = [
{ key: 'weight', label: '体重' },
];
const REF_RANGES: Record<VitalType, { range: string; warn: string }> = {
blood_pressure: { range: '收缩压 90-140 / 舒张压 60-90 mmHg', warn: '血压偏高,确认提交?' },
heart_rate: { range: '60-100 bpm', warn: '心率异常,确认提交?' },
blood_sugar: { range: '空腹 3.9-6.1 / 餐后 <7.8 mmol/L', warn: '血糖偏高,确认提交?' },
weight: { range: '根据 BMI 18.5-24 计算', warn: '' },
};
/** 根据阈值列表构建参考范围文案 */
function buildRefRange(t: HealthThreshold[]): Record<VitalType, string> {
const bpSys = findThreshold(t, 'systolic_bp', 'high')?.threshold_value ?? 140;
const bpDia = findThreshold(t, 'diastolic_bp', 'high')?.threshold_value ?? 90;
const hrHigh = findThreshold(t, 'heart_rate', 'high')?.threshold_value ?? 100;
const hrLow = findThreshold(t, 'heart_rate', 'low')?.threshold_value ?? 60;
const bsFasting = findThreshold(t, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
const bsPp = findThreshold(t, 'blood_sugar_postprandial', 'high')?.threshold_value ?? 7.8;
return {
blood_pressure: `收缩压 90-${bpSys} / 舒张压 60-${bpDia} mmHg`,
heart_rate: `${hrLow}-${hrHigh} bpm`,
blood_sugar: `空腹 3.9-${bsFasting} / 餐后 <${bsPp} mmol/L`,
weight: '根据 BMI 18.5-24 计算',
};
}
interface TrendPoint {
date: string;
@@ -31,7 +42,8 @@ interface TrendPoint {
export default function Health() {
const { todaySummary, loading, refreshToday, getTrend: fetchTrend } = useHealthStore();
const { currentPatient } = useAuthStore();
const { user, currentPatient } = useAuthStore();
const modeClass = useElderClass();
const [activeTab, setActiveTab] = useState<VitalType>('blood_pressure');
const [systolic, setSystolic] = useState('');
const [diastolic, setDiastolic] = useState('');
@@ -43,13 +55,27 @@ export default function Health() {
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestionItem[]>([]);
const [thresholds, setThresholds] = useState<HealthThreshold[]>(DEFAULT_THRESHOLDS);
useDidShow(() => {
if (!user) return;
refreshToday();
loadTrend(activeTab);
loadAiSuggestions();
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); });
});
usePullDownRefresh(() => {
if (!user) return;
Promise.all([refreshToday(true), loadTrend(activeTab), loadAiSuggestions()]).finally(() => {
Taro.stopPullDownRefresh();
});
});
if (!user) {
return <GuestGuard title='请先登录' desc='登录后即可记录和查看健康数据' />;
}
const loadAiSuggestions = async () => {
try {
const items = await listPendingSuggestions();
@@ -63,9 +89,9 @@ export default function Health() {
setTrendLoading(true);
try {
const indicatorMap: Record<VitalType, string> = {
blood_pressure: 'blood_pressure_systolic',
blood_pressure: 'systolic_bp_morning',
heart_rate: 'heart_rate',
blood_sugar: 'blood_sugar_fasting',
blood_sugar: 'blood_sugar',
weight: 'weight',
};
const points = await fetchTrend(indicatorMap[type], '7d');
@@ -86,18 +112,29 @@ export default function Health() {
if (type === 'blood_pressure') {
const sys = parseFloat(systolic);
const dia = parseFloat(diastolic);
if (sys > 140 || dia > 90) return REF_RANGES.blood_pressure.warn;
const sysMax = findThreshold(thresholds, 'systolic_bp', 'high')?.threshold_value ?? 140;
const diaMax = findThreshold(thresholds, 'diastolic_bp', 'high')?.threshold_value ?? 90;
if (sys > sysMax || dia > diaMax) return '血压偏高,确认提交?';
} else if (type === 'heart_rate') {
const val = parseFloat(heartRateVal);
if (val > 100 || val < 60) return REF_RANGES.heart_rate.warn;
const hrHigh = findThreshold(thresholds, 'heart_rate', 'high')?.threshold_value ?? 100;
const hrLow = findThreshold(thresholds, 'heart_rate', 'low')?.threshold_value ?? 60;
if (val > hrHigh || val < hrLow) return '心率异常,确认提交?';
} else if (type === 'blood_sugar') {
const val = parseFloat(sugarVal);
if (sugarPeriod === 'fasting' && val > 6.1) return REF_RANGES.blood_sugar.warn;
if (sugarPeriod === 'postprandial' && val > 7.8) return REF_RANGES.blood_sugar.warn;
if (sugarPeriod === 'fasting') {
const bsMax = findThreshold(thresholds, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
if (val > bsMax) return '血糖偏高,确认提交?';
} else {
const bsMax = findThreshold(thresholds, 'blood_sugar_postprandial', 'high')?.threshold_value ?? 7.8;
if (val > bsMax) return '血糖偏高,确认提交?';
}
}
return null;
};
const refRanges = buildRefRange(thresholds);
const handleSave = async () => {
const patientId = currentPatient?.id;
if (!patientId) {
@@ -142,7 +179,8 @@ export default function Health() {
case 'blood_sugar': {
const val = parseFloat(sugarVal);
if (!val) { Taro.showToast({ title: '请填写血糖值', icon: 'none' }); return; }
await inputVitalSign(patientId, { indicator_type: 'blood_sugar', value: val });
const bsType = sugarPeriod === 'fasting' ? 'blood_sugar_fasting' : 'blood_sugar_postprandial';
await inputVitalSign(patientId, { indicator_type: bsType, value: val });
setSugarVal('');
break;
}
@@ -165,10 +203,17 @@ export default function Health() {
};
const maxTrendValue = Math.max(...trendData.map((d) => d.value), 1);
const getThresholdValue = (type: VitalType, th: HealthThreshold[]): number | null => {
if (type === 'blood_pressure') return findThreshold(th, 'systolic_bp', 'high')?.threshold_value ?? 140;
if (type === 'heart_rate') return findThreshold(th, 'heart_rate', 'high')?.threshold_value ?? 100;
if (type === 'blood_sugar') return findThreshold(th, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
return null;
};
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
return (
<View className='health-page'>
<View className={`health-page ${modeClass}`}>
{/* 页头 */}
<View className='health-header'>
<Text className='health-title'></Text>
@@ -179,7 +224,7 @@ export default function Health() {
<View className='ai-suggestion-card' onClick={() => {
const first = aiSuggestions[0];
if (first?.suggestion_type === 'appointment') {
Taro.navigateTo({ url: `/pages/pkg-appointment/create/index?patientId=${first.patient_id}` });
Taro.navigateTo({ url: `/pages/appointment/create/index` });
} else if (first?.suggestion_type === 'followup') {
Taro.navigateTo({ url: '/pages/pkg-profile/followups/index' });
} else {
@@ -237,7 +282,7 @@ export default function Health() {
value={systolic}
onInput={(e) => setSystolic(e.detail.value)}
/>
<Text className='input-label' style='margin-top:20px;'></Text>
<Text className='input-label input-label--secondary'></Text>
<Input
className='input-field'
type='number'
@@ -245,7 +290,7 @@ export default function Health() {
value={diastolic}
onInput={(e) => setDiastolic(e.detail.value)}
/>
<Text className='input-ref'>{REF_RANGES.blood_pressure.range}</Text>
<Text className='input-ref'>{refRanges.blood_pressure}</Text>
</View>
)}
@@ -259,7 +304,7 @@ export default function Health() {
value={heartRateVal}
onInput={(e) => setHeartRateVal(e.detail.value)}
/>
<Text className='input-ref'>{REF_RANGES.heart_rate.range}</Text>
<Text className='input-ref'>{refRanges.heart_rate}</Text>
</View>
)}
@@ -287,7 +332,7 @@ export default function Health() {
<Text className='period-btn-text'> 2h</Text>
</View>
</View>
<Text className='input-ref'>{REF_RANGES.blood_sugar.range}</Text>
<Text className='input-ref'>{refRanges.blood_sugar}</Text>
</View>
)}
@@ -301,7 +346,7 @@ export default function Health() {
value={weightVal}
onInput={(e) => setWeightVal(e.detail.value)}
/>
<Text className='input-ref'>{REF_RANGES.weight.range}</Text>
<Text className='input-ref'>{refRanges.weight}</Text>
</View>
)}
@@ -322,12 +367,20 @@ export default function Health() {
) : (
<View className='trend-chart'>
<View className='trend-bars'>
{/* 阈值标线 */}
{getThresholdValue(activeTab, thresholds) && (() => {
const tv = getThresholdValue(activeTab, thresholds)!;
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>
);
})()}
{trendData.map((point, i) => {
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
const isAbnormal = activeTab === 'blood_pressure' ? point.value > 140
: activeTab === 'heart_rate' ? (point.value > 100 || point.value < 60)
: activeTab === 'blood_sugar' ? point.value > 6.1
: false;
const tv = getThresholdValue(activeTab, thresholds);
const isAbnormal = tv ? point.value >= tv : false;
const dayOfWeek = new Date(point.date).getDay();
return (
<View className='trend-bar-col' key={i}>
@@ -344,22 +397,7 @@ export default function Health() {
)}
</View>
{/* BLE 设备卡片 */}
<View className='device-section'>
<View
className='device-card'
onClick={() => Taro.navigateTo({ url: '/pages/device-sync/index' })}
>
<View className='device-icon'>
<Text className='device-icon-text'></Text>
</View>
<View className='device-info'>
<Text className='device-name'></Text>
<Text className='device-desc'></Text>
</View>
<Text className='device-arrow'></Text>
</View>
</View>
{/* BLE 设备同步功能暂缓开放 */}
{/* 健康资讯入口 */}
<View

View File

@@ -1,19 +1,23 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
/* ═══════════════════════════════════════
登录后首页
═══════════════════════════════════════ */
.home-page {
min-height: 100vh;
background: $bg;
padding: 20px 16px 100px;
padding: 20px 24px 100px;
padding-bottom: calc(100px + env(safe-area-inset-bottom));
}
/* ─── 区域 1问候 + 日期 + 铃铛 ─── */
/* ─── 问候区 ─── */
.greeting-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
margin-bottom: 24px;
}
.greeting-left {
@@ -21,7 +25,8 @@
}
.greeting-text {
font-size: 24px;
@include serif-number;
font-size: var(--tk-font-h1);
font-weight: 700;
color: $tx;
display: block;
@@ -29,14 +34,16 @@
}
.greeting-date {
font-size: 14px;
color: $tx3;
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
}
.greeting-bell {
position: relative;
width: 40px;
height: 40px;
width: 44px;
height: 44px;
border-radius: 22px;
background: $pri-l;
@include flex-center;
flex-shrink: 0;
@@ -46,16 +53,27 @@
}
.greeting-bell-icon {
font-size: 22px;
font-size: var(--tk-font-body-sm);
color: $pri-d;
}
/* ─── 区域 2今日体征完成度 ─── */
.greeting-bell-dot {
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
border-radius: 4px;
background: $dan;
}
/* ─── 今日体征进度 ─── */
.checkin-card {
background: $card;
border-radius: $r;
box-shadow: $shadow-sm;
padding: 16px;
margin-bottom: 12px;
box-shadow: $shadow-md;
padding: 20px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 16px;
@@ -76,7 +94,7 @@
}
.checkin-title {
font-size: 16px;
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
display: block;
@@ -90,7 +108,7 @@
}
.capsule {
font-size: 11px;
font-size: var(--tk-font-micro);
padding: 3px 8px;
border-radius: $r-pill;
font-weight: 500;
@@ -106,9 +124,9 @@
}
}
/* ─── 区域 3今日体征 2x2 ─── */
/* ─── 今日体征 2x2 ─── */
.vitals-section {
margin-bottom: 12px;
margin-bottom: 16px;
}
.section-title {
@@ -133,40 +151,39 @@
}
.vital-label {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $tx2;
display: block;
margin-bottom: 4px;
margin-bottom: 6px;
}
.vital-value-row {
display: flex;
align-items: baseline;
margin-bottom: 4px;
margin-bottom: 6px;
}
.vital-value {
@include serif-number;
font-size: 32px;
font-size: var(--tk-font-num);
font-weight: 700;
color: $tx;
line-height: 1.1;
line-height: 1;
}
.vital-unit {
font-size: 12px;
color: $tx3;
margin-left: 2px;
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
margin-left: 3px;
}
.vital-bottom {
display: flex;
align-items: center;
gap: 0;
}
.vital-tag {
font-size: 12px;
font-size: var(--tk-font-micro);
font-weight: 500;
padding: 2px 8px;
border-radius: $r-pill;
@@ -189,103 +206,85 @@
}
}
/* ─── 区域 4今日待办 ─── */
.todo-section {
margin-bottom: 12px;
}
.todo-empty {
background: $card;
/* ─── 智能提醒卡片 ─── */
.reminder-card {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
border-radius: $r;
padding: 24px;
text-align: center;
box-shadow: $shadow-sm;
padding: 18px;
margin-bottom: 16px;
color: #fff;
}
.todo-empty-text {
font-size: 14px;
color: $tx2;
.reminder-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.todo-list {
background: $card;
border-radius: $r;
overflow: hidden;
box-shadow: $shadow-sm;
.reminder-title {
font-size: var(--tk-font-cap);
font-weight: 600;
color: #fff;
}
.todo-item {
.reminder-count {
font-size: var(--tk-font-micro);
opacity: 0.7;
color: #fff;
}
.reminder-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid $bd;
&:last-child {
border-bottom: none;
}
gap: 8px;
padding: 8px 0;
&:active {
background: $bd-l;
opacity: 0.8;
}
}
.todo-icon-wrap {
width: 36px;
height: 36px;
border-radius: 10px;
background: $pri-l;
@include flex-center;
.reminder-item-border {
border-top: 1px solid rgba(255, 255, 255, 0.15);
}
.reminder-tag {
font-size: var(--tk-font-micro);
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.2);
font-weight: 500;
color: #fff;
flex-shrink: 0;
}
.todo-icon-char {
font-size: 18px;
font-weight: bold;
color: $pri;
}
.todo-info {
.reminder-text {
font-size: var(--tk-font-cap);
flex: 1;
min-width: 0;
}
.todo-title {
font-size: 15px;
color: $tx;
font-weight: 500;
display: block;
margin-bottom: 2px;
}
.todo-sub {
font-size: 13px;
color: $tx3;
display: block;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-arrow {
font-size: 14px;
color: $tx3;
.reminder-arrow {
opacity: 0.5;
color: #fff;
flex-shrink: 0;
}
/* ─── 区域 5快捷操作 ─── */
/* ─── 快捷操作 ─── */
.action-section {
display: flex;
gap: 10px;
margin-top: 16px;
margin-top: 8px;
}
.action-btn {
flex: 1;
height: 52px;
border-radius: 14px;
font-size: 17px;
font-weight: 600;
@include flex-center;
&:active {
@@ -296,6 +295,7 @@
.action-primary {
background: $pri;
color: #fff;
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.25);
}
.action-outline {
@@ -305,6 +305,169 @@
}
.action-btn-text {
font-size: 17px;
font-size: var(--tk-font-body-sm);
font-weight: 600;
}
/* ═══════════════════════════════════════
访客首页
═══════════════════════════════════════ */
.guest-page {
min-height: 100vh;
background: $bg;
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
/* ─── 轮播图 ─── */
.guest-swiper {
width: 100%;
height: 360px;
}
.guest-slide {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.guest-slide-bg {
position: absolute;
inset: 0;
&--1 {
background: linear-gradient(135deg, $pri-d 0%, $pri 60%, $pri-l 100%);
}
}
.guest-slide:nth-child(2) .guest-slide-bg {
background: linear-gradient(135deg, $acc 0%, #3D5A40 60%, $acc-l 100%);
}
.guest-slide:nth-child(3) .guest-slide-bg {
background: linear-gradient(135deg, #8B6F4E 0%, $wrn 60%, $wrn-l 100%);
}
.guest-slide-content {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding: 40px 32px;
}
.guest-slide-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h1);
font-weight: 700;
color: #FFFFFF;
display: block;
margin-bottom: 8px;
}
.guest-slide-desc {
font-size: var(--tk-font-body-sm);
color: rgba(255, 255, 255, 0.85);
display: block;
}
/* ─── 健康资讯 ─── */
.guest-section {
padding: 24px 24px 0;
}
.guest-section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body);
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
}
.guest-articles {
display: flex;
flex-direction: column;
gap: 12px;
}
.guest-article-card {
background: $card;
border-radius: $r;
padding: 16px 18px;
box-shadow: $shadow-sm;
&:active {
opacity: 0.85;
}
}
.guest-article-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
display: block;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.guest-article-summary {
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.guest-empty {
padding: 40px 0;
text-align: center;
}
.guest-empty-text {
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
}
/* ─── 底部登录引导 ─── */
.guest-login-prompt {
margin: 24px 24px 0;
background: $card;
border-radius: $r;
padding: 20px;
box-shadow: $shadow-md;
display: flex;
align-items: center;
gap: 16px;
}
.guest-login-text {
flex: 1;
font-size: var(--tk-font-cap);
color: $tx2;
}
.guest-login-btn {
height: 56px;
padding: 0 28px;
background: $pri;
border-radius: $r-pill;
@include flex-center;
flex-shrink: 0;
&:active {
opacity: 0.85;
}
}
.guest-login-btn-text {
font-size: var(--tk-font-h2);
font-weight: 600;
color: #fff;
}

View File

@@ -1,54 +1,151 @@
import { View, Text } from '@tarojs/components';
import { useState } from 'react';
import Taro, { useDidShow } from '@tarojs/taro';
import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
import { useState, useCallback } from 'react';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import { useAuthStore } from '../../stores/auth';
import { useUIStore } from '../../stores/ui';
import { navigateToLogin } from '../../utils/navigate';
import { useHealthStore } from '../../stores/health';
import ProgressRing from '../../components/ProgressRing';
import Loading from '../../components/Loading';
import { trackPageView } from '@/services/analytics';
import * as appointmentApi from '@/services/appointment';
import * as followupApi from '@/services/followup';
import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis';
import { notificationService } from '@/services/notification';
import './index.scss';
interface UpcomingItem {
interface ReminderItem {
id: string;
title: string;
subtitle: string;
type: 'appointment' | 'followup';
icon: string;
text: string;
type: 'ai' | 'appointment' | 'followup';
tag: string;
}
export default function Index() {
// ─── 访客首页 ───
const CAROUSEL_SLIDES = [
{ id: 'slide-1', title: '专业血透中心', desc: '三甲级医护团队全程守护' },
{ id: 'slide-2', title: '智慧健康管理', desc: 'AI 驱动个性化健康方案' },
{ id: 'slide-3', title: '温馨就医环境', desc: '舒适安全的治疗体验' },
];
function GuestHome({ modeClass }: { modeClass: string }) {
return (
<View className={`guest-page ${modeClass}`}>
{/* 轮播图 */}
<Swiper
className='guest-swiper'
indicatorDots
indicatorColor='rgba(255,255,255,0.4)'
indicatorActiveColor='#FFFFFF'
autoplay
circular
interval={4000}
duration={500}
>
{CAROUSEL_SLIDES.map((slide) => (
<SwiperItem key={slide.id}>
<View className='guest-slide'>
<View className='guest-slide-bg guest-slide-bg--1' />
<View className='guest-slide-content'>
<Text className='guest-slide-title'>{slide.title}</Text>
<Text className='guest-slide-desc'>{slide.desc}</Text>
</View>
</View>
</SwiperItem>
))}
</Swiper>
{/* 功能亮点 */}
<View className='guest-section'>
<Text className='guest-section-title'></Text>
<View className='guest-articles'>
<View className='guest-article-card'>
<Text className='guest-article-title'></Text>
<Text className='guest-article-summary'></Text>
</View>
<View className='guest-article-card'>
<Text className='guest-article-title'></Text>
<Text className='guest-article-summary'>线</Text>
</View>
<View className='guest-article-card'>
<Text className='guest-article-title'>AI </Text>
<Text className='guest-article-summary'></Text>
</View>
</View>
</View>
{/* 底部登录引导 */}
<View className='guest-login-prompt'>
<Text className='guest-login-text'>使</Text>
<View
className='guest-login-btn'
onClick={navigateToLogin}
>
<Text className='guest-login-btn-text'></Text>
</View>
</View>
</View>
);
}
// ─── 登录后首页 ───
function HomeDashboard({ modeClass }: { modeClass: string }) {
const { user, currentPatient } = useAuthStore();
const { todaySummary, loading, refreshToday } = useHealthStore();
const [upcomingItems, setUpcomingItems] = useState<UpcomingItem[]>([]);
const [upcomingLoading, setUpcomingLoading] = useState(false);
const [reminders, setReminders] = useState<ReminderItem[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [remindersLoading, setRemindersLoading] = useState(false);
useDidShow(() => {
refreshToday();
loadUpcoming();
loadReminders();
loadUnread();
trackPageView('home');
});
const loadUpcoming = async () => {
usePullDownRefresh(() => {
Promise.all([refreshToday(true), loadReminders(), loadUnread()]).finally(() => {
Taro.stopPullDownRefresh();
});
});
const loadUnread = async () => {
try {
const res = await notificationService.getUnreadCount() as { data?: { count?: number } | number };
const d = res.data;
setUnreadCount(typeof d === 'object' && d ? (d.count ?? 0) : 0);
} catch {
// ignore
}
};
const loadReminders = async () => {
const patientId = useAuthStore.getState().currentPatient?.id;
if (!patientId) return;
setUpcomingLoading(true);
setRemindersLoading(true);
try {
const items: UpcomingItem[] = [];
const [apptRes, taskRes] = await Promise.allSettled([
const items: ReminderItem[] = [];
const [apptRes, taskRes, suggestRes] = await Promise.allSettled([
appointmentApi.listAppointments(patientId, 1),
followupApi.listTasks(patientId, 'pending'),
listPendingSuggestions(),
]);
if (suggestRes.status === 'fulfilled') {
for (const s of suggestRes.value.data.slice(0, 1)) {
items.push({ id: s.id, text: buildSuggestionText(s), type: 'ai', tag: 'AI 建议' });
}
}
if (apptRes.status === 'fulfilled') {
for (const a of apptRes.value.data.slice(0, 2)) {
for (const a of apptRes.value.data.slice(0, 1)) {
if (a.status === 'pending' || a.status === 'confirmed') {
items.push({
id: a.id,
title: `${a.appointment_date} ${a.start_time}`,
subtitle: `${a.doctor_name || '医护'} · ${a.department || '门诊'}`,
text: `${a.appointment_date} ${a.start_time}${a.doctor_name || '医护'} ${a.department || '门诊'}`,
type: 'appointment',
icon: '约',
tag: '约',
});
}
}
@@ -57,18 +154,17 @@ export default function Index() {
for (const t of taskRes.value.data.slice(0, 1)) {
items.push({
id: t.id,
title: t.follow_up_type,
subtitle: `${t.content_template?.slice(0, 20) || '随访任务'} · 截止 ${t.planned_date}`,
text: `${t.follow_up_type} · 截止 ${t.planned_date}`,
type: 'followup',
icon: '随',
tag: '随访',
});
}
}
setUpcomingItems(items.slice(0, 3));
setReminders(items.slice(0, 3));
} catch {
setUpcomingItems([]);
setReminders([]);
} finally {
setUpcomingLoading(false);
setRemindersLoading(false);
}
};
@@ -76,14 +172,8 @@ export default function Index() {
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
const displayName = user?.display_name || currentPatient?.name || '访客';
// 计算今日体征完成度4 个指标:血压/心率/血糖/体重)
const summary = todaySummary || {};
const indicators = [
!!summary.blood_pressure,
!!summary.heart_rate,
!!summary.blood_sugar,
!!summary.weight,
];
const indicators = [!!summary.blood_pressure, !!summary.heart_rate, !!summary.blood_sugar, !!summary.weight];
const completedCount = indicators.filter(Boolean).length;
const progressPercent = Math.round((completedCount / 4) * 100);
@@ -95,9 +185,9 @@ export default function Index() {
];
const healthItems = [
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status, indicator: 'blood_pressure_systolic' },
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status, indicator: 'systolic_bp_morning' },
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status, indicator: 'heart_rate' },
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status, indicator: 'blood_sugar_fasting' },
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status, indicator: 'blood_sugar' },
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status, indicator: 'weight' },
];
@@ -108,8 +198,8 @@ export default function Index() {
};
return (
<View className='home-page'>
{/* 区域 1问候 + 日期 + 消息入口 */}
<View className={`home-page ${modeClass}`}>
{/* 问候区 */}
<View className='greeting-section'>
<View className='greeting-left'>
<Text className='greeting-text'>{greeting}{displayName}</Text>
@@ -117,19 +207,14 @@ export default function Index() {
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' })}
</Text>
</View>
<View
className='greeting-bell'
onClick={() => Taro.switchTab({ url: '/pages/messages/index' })}
>
<Text className='greeting-bell-icon'>🔔</Text>
<View className='greeting-bell' onClick={() => Taro.switchTab({ url: '/pages/messages/index' })}>
<Text className='greeting-bell-icon'></Text>
{unreadCount > 0 && <View className='greeting-bell-dot' />}
</View>
</View>
{/* 区域 2今日体征完成度 */}
<View
className='checkin-card'
onClick={() => Taro.switchTab({ url: '/pages/health/index' })}
>
{/* 今日体征度 */}
<View className='checkin-card' onClick={() => Taro.switchTab({ url: '/pages/health/index' })}>
<View className='checkin-left'>
<ProgressRing percent={progressPercent} />
</View>
@@ -139,19 +224,17 @@ export default function Index() {
</Text>
<View className='checkin-capsules'>
{indicatorCapsules.map((cap) => (
<Text
key={cap.label}
className={`capsule ${cap.done ? 'capsule-done' : 'capsule-pending'}`}
>
{cap.done ? '✓' : ''}{cap.label}
<Text key={cap.label} className={`capsule ${cap.done ? 'capsule-done' : 'capsule-pending'}`}>
{cap.done ? '✓ ' : ''}{cap.label}
</Text>
))}
</View>
</View>
</View>
{/* 区域 3今日体征 2x2 网格 */}
{/* 体征 2x2 */}
<View className='vitals-section'>
<Text className='section-title'></Text>
{loading && !todaySummary ? (
<Loading />
) : (
@@ -180,58 +263,65 @@ export default function Index() {
)}
</View>
{/* 区域 4今日待办≤3 条) */}
<View className='todo-section'>
<Text className='section-title'></Text>
{upcomingLoading ? (
<Loading />
) : upcomingItems.length === 0 ? (
<View className='todo-empty'>
<Text className='todo-empty-text'></Text>
{/* 智能提醒卡片 */}
{!remindersLoading && reminders.length > 0 && (
<View className='reminder-card'>
<View className='reminder-header'>
<Text className='reminder-title'></Text>
<Text className='reminder-count'>{reminders.length} </Text>
</View>
) : (
<View className='todo-list'>
{upcomingItems.map((item) => (
<View
key={item.id}
className='todo-item'
onClick={() => {
if (item.type === 'appointment') {
Taro.navigateTo({ url: '/pages/appointment/index' });
} else {
Taro.navigateTo({ url: `/pages/followup/detail/index?id=${item.id}` });
}
}}
>
<View className='todo-icon-wrap'>
<Text className='todo-icon-char'>{item.icon}</Text>
</View>
<View className='todo-info'>
<Text className='todo-title'>{item.title}</Text>
<Text className='todo-sub'>{item.subtitle}</Text>
</View>
<Text className='todo-arrow'></Text>
</View>
))}
</View>
)}
</View>
{reminders.map((r, i) => (
<View
key={r.id}
className={`reminder-item ${i > 0 ? 'reminder-item-border' : ''}`}
onClick={() => {
if (r.type === 'appointment') Taro.navigateTo({ url: '/pages/appointment/index' });
else if (r.type === 'followup') Taro.navigateTo({ url: `/pages/followup/detail/index?id=${r.id}` });
}}
>
<Text className='reminder-tag'>{r.tag}</Text>
<Text className='reminder-text'>{r.text}</Text>
<Text className='reminder-arrow'></Text>
</View>
))}
</View>
)}
{/* 区域 5快捷操作 */}
{/* 快捷操作 */}
<View className='action-section'>
<View
className='action-btn action-primary'
onClick={() => Taro.switchTab({ url: '/pages/health/index' })}
>
<View className='action-btn action-primary' onClick={() => Taro.switchTab({ url: '/pages/health/index' })}>
<Text className='action-btn-text'></Text>
</View>
<View
className='action-btn action-outline'
onClick={() => Taro.navigateTo({ url: '/pages/appointment/create/index' })}
>
<View className='action-btn action-outline' onClick={() => Taro.navigateTo({ url: '/pages/appointment/create/index' })}>
<Text className='action-btn-text'></Text>
</View>
</View>
</View>
);
}
// ─── 首页入口:根据登录状态切换 ───
export default function Index() {
const user = useAuthStore((s) => s.user);
const mode = useUIStore((s) => s.mode);
const modeClass = mode === 'elder' ? 'elder-mode' : '';
if (!user) {
return <GuestHome modeClass={modeClass} />;
}
return <HomeDashboard modeClass={modeClass} />;
}
function buildSuggestionText(s: AiSuggestionItem): string {
const riskMap: Record<string, string> = { high: '高风险', medium: '中风险', low: '低风险' };
const typeMap: Record<string, string> = {
vital_sign_anomaly: '体征异常',
lab_result_anomaly: '化验异常',
medication_adherence: '用药提醒',
lifestyle: '生活建议',
};
const risk = riskMap[s.risk_level] || '';
const type = typeMap[s.suggestion_type] || '健康建议';
return `${type}:发现${risk}指标,建议关注`;
}

View File

@@ -8,19 +8,19 @@
}
.legal-content {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx;
line-height: 1.8;
h3 {
font-size: 34px;
font-size: var(--tk-font-num-lg);
font-weight: bold;
color: $tx;
margin-bottom: 12px;
}
h4 {
font-size: 30px;
font-size: var(--tk-font-num);
font-weight: bold;
color: $tx;
margin-top: 24px;
@@ -28,7 +28,7 @@
}
p {
font-size: 26px;
font-size: var(--tk-font-h1);
color: $tx2;
margin-bottom: 8px;
line-height: 1.8;
@@ -41,6 +41,6 @@
}
.legal-footer-text {
font-size: 24px;
color: $tx3;
font-size: var(--tk-font-h2);
color: var(--tk-text-secondary);
}

View File

@@ -11,7 +11,7 @@
display: flex;
flex-direction: column;
align-items: center;
padding: 160px 56px 80px;
padding: 100px 40px 60px;
}
/* ─── 品牌区 ─── */
@@ -19,22 +19,22 @@
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 80px;
margin-bottom: 48px;
}
.login-logo {
width: 128px;
height: 128px;
width: 96px;
height: 96px;
border-radius: $r-lg;
background: $pri;
@include flex-center;
margin-bottom: 36px;
margin-bottom: 24px;
box-shadow: 0 8px 24px rgba($pri, 0.3);
}
.login-logo-mark {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 64px;
font-size: var(--tk-font-hero);
color: #fff;
font-weight: bold;
line-height: 1;
@@ -42,14 +42,14 @@
.login-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 48px;
font-size: var(--tk-font-num);
color: $tx;
font-weight: bold;
margin-bottom: 12px;
margin-bottom: 8px;
}
.login-subtitle {
font-size: 26px;
font-size: var(--tk-font-body-sm);
color: $tx2;
letter-spacing: 0.05em;
}
@@ -57,7 +57,7 @@
/* ─── 装饰线 ─── */
.login-divider {
width: 48px;
margin-bottom: 64px;
margin-bottom: 40px;
}
.login-divider-line {
@@ -74,16 +74,18 @@
.login-btn {
width: 100%;
height: 96px;
height: $btn-primary-h;
background: $pri;
color: #fff;
font-size: 32px;
font-size: var(--tk-font-body-lg);
font-weight: 600;
border-radius: $r;
border: none;
@include flex-center;
letter-spacing: 0.04em;
box-shadow: 0 4px 16px rgba($pri, 0.25);
padding: 0;
line-height: 1;
&::after {
border: none;
@@ -98,14 +100,14 @@
.agreement-row {
display: flex;
align-items: flex-start;
margin-top: 40px;
gap: 12px;
margin-top: 28px;
gap: 10px;
width: 100%;
}
.agreement-check {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
border: 2px solid $bd;
border-radius: $r-sm;
@include flex-center;
@@ -120,14 +122,14 @@
}
.agreement-check-mark {
font-size: 20px;
font-size: var(--tk-font-body-sm);
color: #fff;
font-weight: bold;
line-height: 1;
}
.agreement-text {
font-size: 22px;
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.7;
}
@@ -136,3 +138,16 @@
color: $pri;
font-weight: 500;
}
/* ─── 暂不登录 ─── */
.skip-row {
width: 100%;
text-align: center;
margin-top: 24px;
}
.skip-btn {
font-size: var(--tk-font-body-sm);
color: var(--tk-text-secondary);
padding: 8px 16px;
}

View File

@@ -2,13 +2,18 @@ import { useState } from 'react';
import { View, Text, Button, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useAuthStore } from '../../stores/auth';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss';
export default function Login() {
const modeClass = useElderClass();
const [needBind, setNeedBind] = useState(false);
const [agreed, setAgreed] = useState(false);
const { login, bindPhone, loading, isMedicalStaff } = useAuthStore();
// 登录页不应用关怀模式(正常模式尺寸已足够大)
const loginClass = '';
const navigateAfterLogin = () => {
if (isMedicalStaff()) {
Taro.redirectTo({ url: '/pages/doctor/index' });
@@ -47,16 +52,29 @@ export default function Login() {
return;
}
const { encryptedData, iv } = e.detail;
const success = await bindPhone(encryptedData, iv);
if (success) {
navigateAfterLogin();
} else {
Taro.showToast({ title: '绑定失败,请重试', icon: 'none' });
try {
const success = await bindPhone(encryptedData, iv);
if (success) {
navigateAfterLogin();
}
} catch (err: any) {
const msg = err?.message || '绑定失败';
Taro.showModal({
title: '绑定手机号失败',
content: msg,
confirmText: '重新登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
setNeedBind(false);
}
},
});
}
};
return (
<ScrollView scrollY className='login-scroll'>
<ScrollView scrollY className={`login-scroll ${loginClass}`}>
<View className='login-page'>
{/* 品牌区 */}
<View className='login-brand'>
@@ -102,6 +120,13 @@ export default function Login() {
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/legal/privacy-policy' })}></Text>
</Text>
</View>
{/* 暂不登录 */}
<View className='skip-row'>
<Text className='skip-btn' onClick={() => Taro.reLaunch({ url: '/pages/index/index' })}>
</Text>
</View>
</View>
</ScrollView>
);

View File

@@ -28,7 +28,7 @@
}
.points-label {
font-size: 26px;
font-size: var(--tk-font-h1);
color: rgba(255, 255, 255, 0.85);
}
@@ -50,7 +50,7 @@
}
.checkin-btn-text {
font-size: 24px;
font-size: var(--tk-font-h2);
color: #fff;
font-weight: 600;
}
@@ -61,7 +61,7 @@
.points-balance {
@include serif-number;
font-size: 72px;
font-size: 72px; /* kept as-is: special display value */
font-weight: bold;
color: #fff;
display: block;
@@ -71,7 +71,7 @@
}
.points-streak {
font-size: 22px;
font-size: var(--tk-font-body);
color: rgba(255, 255, 255, 0.7);
display: block;
}
@@ -102,7 +102,7 @@
}
.type-tab-text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: $tx2;
&.active {
@@ -142,7 +142,7 @@
.product-image-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 56px;
font-size: var(--tk-font-hero);
font-weight: bold;
color: $pri;
line-height: 1;
@@ -156,7 +156,7 @@
}
.product-name {
font-size: 26px;
font-size: var(--tk-font-h1);
font-weight: 600;
color: $tx;
display: block;
@@ -180,20 +180,20 @@
.product-points-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
font-size: var(--tk-font-body);
font-weight: bold;
color: $wrn;
}
.product-points-value {
@include serif-number;
font-size: 28px;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $wrn;
}
.product-stock {
font-size: 20px;
font-size: var(--tk-font-body);
padding: 2px 10px;
border-radius: $r-sm;
@@ -212,7 +212,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 160px 40px;
padding: 100px 40px;
}
.empty-icon {
@@ -226,22 +226,22 @@
.empty-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 52px;
font-size: var(--tk-font-hero);
font-weight: bold;
color: $pri;
line-height: 1;
}
.empty-title {
font-size: 32px;
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
margin-bottom: 12px;
}
.empty-hint {
font-size: 26px;
color: $tx3;
font-size: var(--tk-font-h1);
color: var(--tk-text-secondary);
text-align: center;
margin-bottom: 24px;
}
@@ -257,7 +257,7 @@
}
.empty-action-text {
font-size: 28px;
font-size: var(--tk-font-body-lg);
color: #fff;
font-weight: 600;
}

View File

@@ -6,6 +6,7 @@ import type { PointsProduct } from '../../services/points';
import { useAuthStore } from '../../stores/auth';
import { usePointsStore } from '../../stores/points';
import Loading from '../../components/Loading';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss';
const PRODUCT_TYPE_TABS = [
@@ -32,6 +33,7 @@ export default function Mall() {
const [checkinLoading, setCheckinLoading] = useState(false);
const [noProfile, setNoProfile] = useState(false);
const loadingRef = useRef(false);
const modeClass = useElderClass();
const fetchProducts = useCallback(
async (pageNum: number, type: string, isRefresh = false) => {
@@ -132,7 +134,7 @@ export default function Mall() {
if (noProfile) {
return (
<View className='mall-page'>
<View className={`mall-page ${modeClass}`}>
<View className='mall-empty-state'>
<View className='empty-icon'>
<Text className='empty-char'></Text>
@@ -148,7 +150,7 @@ export default function Mall() {
}
return (
<View className='mall-page'>
<View className={`mall-page ${modeClass}`}>
{/* 积分余额卡片 */}
<View className='mall-header'>
<View className='points-card'>

View File

@@ -4,70 +4,86 @@
.messages-page {
min-height: 100vh;
background: $bg;
padding-bottom: calc(120px + env(safe-area-inset-bottom));
padding: 20px 24px 100px;
padding-bottom: calc(100px + env(safe-area-inset-bottom));
}
/* ─── 页头 ─── */
.messages-header {
padding: 24px 32px 8px;
margin-bottom: 20px;
}
.messages-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
font-weight: bold;
@include serif-number;
font-size: var(--tk-font-h1);
font-weight: 700;
color: $tx;
}
/* ─── Tab 切换 ─── */
.msg-tabs {
/* ─── 分段控件 Tab ─── */
.msg-segment {
display: flex;
padding: 16px 24px 0;
gap: 0;
background: $surface-alt;
border-radius: $r-sm;
padding: 3px;
margin-bottom: 12px;
}
.msg-tab {
.msg-segment-tab {
flex: 1;
height: $tab-h;
height: 40px;
border-radius: $r-xs;
@include flex-center;
position: relative;
&:active {
opacity: 0.85;
}
}
.msg-tab-text {
font-size: 28px;
font-weight: 600;
color: $tx2;
}
.msg-segment-active {
background: $card;
box-shadow: $shadow-sm;
.msg-tab-active .msg-tab-text {
color: $pri;
}
.msg-tab-indicator {
padding: 0 24px;
height: 3px;
background: $bd-l;
margin-bottom: 16px;
}
.msg-tab-bar {
width: 50%;
height: 3px;
background: $pri;
border-radius: 2px;
transition: transform 0.2s;
&.msg-tab-bar-right {
transform: translateX(100%);
.msg-segment-text {
color: $tx;
}
}
.msg-segment-text {
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx3;
}
.msg-segment-badge {
position: absolute;
top: 4px;
right: 12px;
min-width: 16px;
height: 16px;
border-radius: 8px;
background: $dan;
@include flex-center;
padding: 0 4px;
}
.msg-segment-badge-text {
font-size: var(--tk-font-micro);
color: #fff;
font-weight: 600;
}
/* ─── 内容区 ─── */
.msg-content {
padding: 0 24px;
// wrapper
}
.msg-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.msg-empty {
@@ -79,18 +95,18 @@
}
.msg-empty-text {
font-size: 26px;
font-size: var(--tk-font-cap);
color: $tx2;
}
/* ─── 咨询卡片 ─── */
.consult-card {
display: flex;
gap: 12px;
align-items: center;
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 12px;
padding: 16px;
box-shadow: $shadow-sm;
&:active {
@@ -98,53 +114,84 @@
}
}
.consult-info {
.consult-card-muted {
opacity: 0.65;
}
.consult-avatar {
width: 44px;
height: 44px;
border-radius: 22px;
background: $surface-alt;
@include flex-center;
flex-shrink: 0;
}
.consult-avatar-active {
background: $pri-l;
}
.consult-avatar-char {
@include serif-number;
font-size: var(--tk-font-body-sm);
font-weight: 700;
color: $tx3;
}
.consult-avatar-active .consult-avatar-char {
color: $pri;
}
.consult-body {
flex: 1;
min-width: 0;
}
.consult-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
}
.consult-doctor {
font-size: 28px;
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx;
display: block;
margin-bottom: 6px;
}
.consult-preview {
font-size: 24px;
color: $tx2;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.consult-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
margin-left: 16px;
}
.consult-time {
font-size: 22px;
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
flex-shrink: 0;
}
.consult-preview {
font-size: var(--tk-font-cap);
color: $tx2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 8px;
}
.consult-badge {
min-width: 24px;
height: 24px;
border-radius: 12px;
min-width: 18px;
height: 18px;
border-radius: 9px;
background: $dan;
@include flex-center;
padding: 0 6px;
padding: 0 4px;
flex-shrink: 0;
}
.consult-badge-text {
font-size: 18px;
font-size: var(--tk-font-micro);
color: #fff;
font-weight: 600;
}
@@ -152,43 +199,72 @@
/* ─── 通知卡片 ─── */
.notify-card {
display: flex;
align-items: center;
gap: 12px;
align-items: flex-start;
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 12px;
padding: 16px;
box-shadow: $shadow-sm;
&:active {
opacity: 0.85;
}
}
.notify-info {
.notify-card-muted {
opacity: 0.65;
}
.notify-icon {
width: 36px;
height: 36px;
border-radius: $r-sm;
@include flex-center;
flex-shrink: 0;
}
.notify-icon-char {
@include serif-number;
font-size: var(--tk-font-body-sm);
font-weight: 700;
}
.notify-body {
flex: 1;
min-width: 0;
}
.notify-title {
font-size: 28px;
font-weight: 600;
color: $tx;
display: block;
margin-bottom: 6px;
.notify-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.notify-desc {
font-size: 24px;
color: $tx2;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.notify-title {
font-size: var(--tk-font-cap);
font-weight: 400;
color: $tx;
}
.notify-title-bold {
font-weight: 600;
}
.notify-time {
font-size: 22px;
color: $tx2;
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
flex-shrink: 0;
margin-left: 16px;
margin-left: 8px;
}
.notify-desc {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.5;
}
.notify-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background: $pri;
flex-shrink: 0;
margin-top: 6px;
}

View File

@@ -1,9 +1,12 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro';
import { listConsultations, ConsultationSession } from '../../services/consultation';
import { notificationService } from '../../services/notification';
import Loading from '../../components/Loading';
import GuestGuard from '../../components/GuestGuard';
import { useAuthStore } from '../../stores/auth';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss';
type MsgTab = 'consultation' | 'notification';
@@ -14,41 +17,80 @@ interface NotificationItem {
desc: string;
time: string;
type: string;
read?: boolean;
}
const NOTIFY_ICONS: Record<string, { icon: string; bg: string; color: string }> = {
appointment: { icon: '约', bg: '#F0DDD4', color: '#C4623A' },
alert: { icon: '警', bg: '#FFF3E0', color: '#C4873A' },
followup: { icon: '随', bg: '#E8F0E8', color: '#5B7A5E' },
points: { icon: '分', bg: '#F0DDD4', color: '#C4623A' },
report: { icon: '报', bg: '#E8F0E8', color: '#5B7A5E' },
};
export default function Messages() {
const user = useAuthStore((s) => s.user);
const modeClass = useElderClass();
const [activeTab, setActiveTab] = useState<MsgTab>('consultation');
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const loadingRef = useRef(false);
useDidShow(() => {
loadData(activeTab);
});
const loadData = async (tab: MsgTab) => {
const loadData = async (tab: MsgTab, pageNum: number = 1, isRefresh = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
if (tab === 'consultation') {
const res = await listConsultations({ page: 1, page_size: 20 });
setSessions(res.data || []);
const res = await listConsultations({ page: pageNum, page_size: 20 });
const list = res.data || [];
if (isRefresh) {
setSessions(list);
} else {
setSessions((prev) => [...prev, ...list]);
}
setTotal(res.total || 0);
} else {
const res = await notificationService.list<{ data: unknown[] }>({ page: 1, page_size: 20 });
setNotifications((res as { data?: unknown[] })?.data || []);
const res = await notificationService.list<{ data: unknown[]; total?: number }>({ page: pageNum, page_size: 20 });
const list = (res as { data?: unknown[] })?.data || [];
if (isRefresh) {
setNotifications(list as NotificationItem[]);
} else {
setNotifications((prev) => [...prev, ...(list as NotificationItem[])]);
}
setTotal((res as { total?: number })?.total || 0);
}
setPage(pageNum);
} catch {
if (tab === 'consultation') setSessions([]);
else setNotifications([]);
if (isRefresh) {
if (tab === 'consultation') setSessions([]);
else setNotifications([]);
}
} finally {
setLoading(false);
loadingRef.current = false;
}
};
useDidShow(() => {
if (user) loadData(activeTab, 1, true);
});
const handleTabChange = (tab: MsgTab) => {
setActiveTab(tab);
loadData(tab);
loadData(tab, 1, true);
};
useReachBottom(() => {
const currentList = activeTab === 'consultation' ? sessions : notifications;
if (!loading && currentList.length < total) {
loadData(activeTab, page + 1);
}
});
const formatTime = (dateStr: string | null) => {
if (!dateStr) return '';
const d = new Date(dateStr);
@@ -61,94 +103,123 @@ export default function Messages() {
return dateStr.slice(0, 10);
};
if (!user) {
return <GuestGuard title='请先登录' desc='登录后即可查看消息和通知' />;
}
const unreadConsultCount = sessions.filter((s) => s.unread_count_patient > 0).length;
return (
<View className='messages-page'>
<View className={`messages-page ${modeClass}`}>
{/* 页头 */}
<View className='messages-header'>
<Text className='messages-title'></Text>
</View>
{/* Tab 切换 */}
<View className='msg-tabs'>
{/* 分段控件 Tab */}
<View className='msg-segment'>
<View
className={`msg-tab ${activeTab === 'consultation' ? 'msg-tab-active' : ''}`}
className={`msg-segment-tab ${activeTab === 'consultation' ? 'msg-segment-active' : ''}`}
onClick={() => handleTabChange('consultation')}
>
<Text className='msg-tab-text'></Text>
<Text className='msg-segment-text'></Text>
{unreadConsultCount > 0 && (
<View className='msg-segment-badge'>
<Text className='msg-segment-badge-text'>{unreadConsultCount}</Text>
</View>
)}
</View>
<View
className={`msg-tab ${activeTab === 'notification' ? 'msg-tab-active' : ''}`}
className={`msg-segment-tab ${activeTab === 'notification' ? 'msg-segment-active' : ''}`}
onClick={() => handleTabChange('notification')}
>
<Text className='msg-tab-text'></Text>
<Text className='msg-segment-text'></Text>
</View>
</View>
<View className='msg-tab-indicator'>
<View className={`msg-tab-bar ${activeTab === 'notification' ? 'msg-tab-bar-right' : ''}`} />
</View>
{/* 咨询列表 */}
{activeTab === 'consultation' && (
<View className='msg-content'>
{loading ? (
<View className='msg-content'>
{/* 咨询列表 */}
{activeTab === 'consultation' && (
loading ? (
<Loading />
) : sessions.length === 0 ? (
<View className='msg-empty'>
<Text className='msg-empty-text'></Text>
</View>
) : (
sessions.map((session) => (
<View
key={session.id}
className='consult-card'
onClick={() => Taro.navigateTo({ url: `/pages/consultation/detail/index?id=${session.id}` })}
>
<View className='consult-info'>
<Text className='consult-doctor'>
{session.consultation_type === 'online' ? '在线咨询' : '门诊咨询'}
</Text>
<Text className='consult-preview'>
{session.last_message || session.subject || '暂无消息'}
</Text>
</View>
<View className='consult-meta'>
<Text className='consult-time'>{formatTime(session.last_message_at)}</Text>
{session.unread_count_patient > 0 && (
<View className='consult-badge'>
<Text className='consult-badge-text'>
{session.unread_count_patient > 99 ? '99+' : session.unread_count_patient}
</Text>
<View className='msg-list'>
{sessions.map((session) => {
const doctorName = session.last_message?.slice(0, 1) || '医';
const hasUnread = session.unread_count_patient > 0;
return (
<View
key={session.id}
className={`consult-card ${hasUnread ? '' : 'consult-card-muted'}`}
onClick={() => Taro.navigateTo({ url: `/pages/consultation/detail/index?id=${session.id}` })}
>
<View className={`consult-avatar ${hasUnread ? 'consult-avatar-active' : ''}`}>
<Text className='consult-avatar-char'>{doctorName}</Text>
</View>
)}
</View>
</View>
))
)}
</View>
)}
<View className='consult-body'>
<View className='consult-row'>
<Text className='consult-doctor'>
{session.consultation_type === 'online' ? '在线咨询' : '门诊咨询'}
</Text>
<Text className='consult-time'>{formatTime(session.last_message_at)}</Text>
</View>
<View className='consult-row'>
<Text className='consult-preview'>
{session.last_message || session.subject || '暂无消息'}
</Text>
{hasUnread && (
<View className='consult-badge'>
<Text className='consult-badge-text'>
{session.unread_count_patient > 99 ? '99+' : session.unread_count_patient}
</Text>
</View>
)}
</View>
</View>
</View>
);
})}
</View>
)
)}
{/* 通知列表 */}
{activeTab === 'notification' && (
<View className='msg-content'>
{loading ? (
{/* 通知列表 */}
{activeTab === 'notification' && (
loading ? (
<Loading />
) : notifications.length === 0 ? (
<View className='msg-empty'>
<Text className='msg-empty-text'></Text>
</View>
) : (
notifications.map((n) => (
<View key={n.id} className='notify-card'>
<View className='notify-info'>
<Text className='notify-title'>{n.title}</Text>
<Text className='notify-desc'>{n.desc}</Text>
</View>
<Text className='notify-time'>{n.time}</Text>
</View>
))
)}
</View>
)}
<View className='msg-list'>
{notifications.map((n) => {
const cfg = NOTIFY_ICONS[n.type] || NOTIFY_ICONS.report;
const isUnread = !n.read;
return (
<View key={n.id} className={`notify-card ${isUnread ? '' : 'notify-card-muted'}`}>
<View className='notify-icon' style={`background:${cfg.bg};`}>
<Text className='notify-icon-char' style={`color:${cfg.color};`}>{cfg.icon}</Text>
</View>
<View className='notify-body'>
<View className='notify-row'>
<Text className={`notify-title ${isUnread ? 'notify-title-bold' : ''}`}>{n.title}</Text>
<Text className='notify-time'>{n.time}</Text>
</View>
<Text className='notify-desc'>{n.desc}</Text>
</View>
{isUnread && <View className='notify-dot' />}
</View>
);
})}
</View>
)
)}
</View>
</View>
);
}

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