Compare commits

352 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

修复:
- app.config.ts 添加 lazyCodeLoading: requiredComponents
  主包 2.0MB→766KB,taro.js 526→131KB,vendors.js 230→28KB
- request.ts 离线抑制改为指数退避(3s→6s→12s→30s cap)
  后端不可达时自动延长抑制,防止请求风暴
- SegmentTabs Tab 接口改为 readonly,修复 TS 编译错误
- AbortController polyfill 补齐小程序运行时缺失
- 健康首页/设备同步/健康档案/报告/设置页 UI 重构
- 文章页公开端点适配游客访问
- 健康首页 Swiper 间隔优化 4s→5s,动画 500→300ms
2026-05-24 11:32:40 +08:00
iven
675f8a4b10 fix(mp): 小程序真机 TextEncoder 不可用 + DevTools getPhoneNumber 绕过
- secure-storage-aes.ts 用纯 JS 实现 UTF-8 编解码替代 TextEncoder/TextDecoder
- 登录页绑定手机号步骤:DevTools/模拟器中跳过微信 SDK 直接调后端 mock
2026-05-23 13:00:05 +08:00
iven
e56ed9814a fix(mp): 绑定手机号按钮添加 disabled 防重复触发 getPhoneNumber 2026-05-23 12:44:52 +08:00
iven
f11dd59382 feat(auth,mp): 患者登录流程优化 — 智能合并 + 角色冻结 + 页面冻结
- 智能合并:微信注册时用手机号盲索引匹配已有患者档案,避免重复建
  档(AuthState 添加 PiiCrypto + ensure_patient_record 增加盲索引查询)
- 角色冻结:小程序仅允许患者角色登录,医护角色被拦截
  (auth_service.rs 添加反向拦截 + 登录页移除 credential login 表单)
- 页面冻结:10 个非核心页面替换为 FrozenPage 占位组件(用药/知情同意
  /透析/家属/诊断/事件),移除 profile 导航入口,移除医生端预加载
- 医生端代码保留,仅隐藏入口,后续可零成本恢复

讨论记录:docs/discussions/2026-05-23-account-registration-login-flow.md
2026-05-23 12:27:14 +08:00
iven
f7d98a59f0 fix(mp): 助手页输入框下方大片空白
- 移除 padding-bottom 中的 --tk-tabbar-space(100px),TabBar 页面高度已由框架自动扣除
- 仅保留 env(safe-area-inset-bottom) 适配全面屏
2026-05-22 20:17:10 +08:00
iven
b3f53cd437 fix(mp): 商品详情页底部操作栏 fixed 固定定位
- footer 改为 position: fixed 固定在视口底部,不随页面滚动
- 内容区添加 padding-bottom 留出 footer 高度 + 安全区域
- 移除不可靠的 flex + overflow 方案
2026-05-22 20:06:39 +08:00
iven
7f324466bf fix(mp): 商品详情页底部备注显示不全
- 移除 footer 的 position: absolute,改为 flex-shrink:0 自然固定
- 移除 scroll 区域的 padding-bottom: 90px 硬编码留白
- footer 添加 safe-area-inset-bottom 适配全面屏
2026-05-22 20:02:23 +08:00
iven
0748d20b4c fix(mp): 商品详情页加载超时 + 患者兑换权限
- 移除 getProduct 失败后的 listProducts 慢 fallback(拉 100 条),直接报错
- 处理 productId 为空时 loading 永不结束的卡死问题
- 添加 8s 加载超时保护,超时自动显示错误状态+重试按钮
- 新增迁移 000161:患者角色添加 health.points.manage 兑换权限
2026-05-22 19:44:48 +08:00
iven
09013ab94a feat(mp): 积分商城 V2 重设计 — design-handoff 全流程
- 新增 4 个 UI 组件: PointsCard/ProductCard/CheckinCalendar/CheckinModal
- 商城首页 V2: 积分卡 + 快捷操作 + 分类标签 + 商品网格
- 商品详情 V2: 大图 + 信息卡 + 库存/余额状态 + 底部操作栏
- TabBar 新增商城入口(5 Tab: 首页/健康/商城/助手/我的)
- 设计原型 docs/design/mp-05-mall-v2.html + SPEC.md 交付包
- CLAUDE.md 安全规范加固: 新增 §3.7 安全规范 6 条 + Feature DoD 安全清单扩展
2026-05-22 19:15:41 +08:00
iven
1d443ab894 chore(mp): package.json 添加 test/test:watch script 2026-05-22 12:15:24 +08:00
iven
c81c3b73d0 test(mp): request.ts 测试补全 — 新增 9 个测试
- ConcurrencyLimiter 排队/FIFO 释放顺序
- ResponseCache LRU 顺序验证 + TTL 过期
- Token 刷新成功后 401 重试
- Token 刷新失败跳转登录
- isLoggingOut 时立即抛出
- safeReLaunch 并发去重
2026-05-22 12:14:42 +08:00
iven
5816ebb5e6 perf(mp): 缓存优化 — restore 条件清理 + LRU + 差异化 TTL
- auth store: clearRequestCache 仅在 user/roles/patient 变更时调用
- ResponseCache: get() 命中时 delete+set 更新 Map 顺序(真 LRU)
- 告警 API 缓存 TTL 10s,通知未读数 TTL 10s,其余保持 60s 默认
2026-05-22 12:11:59 +08:00
iven
22e33114b1 feat(mp): 微信模板消息订阅统一封装
- 新增 requestSubscribe() 统一订阅函数,消除页面内类型断言重复
- 用药页面新增 requestSubscribeMessage 订阅(MEDICATION_REMINDER 模板)
- 告警页面改用 requestSubscribe('CRITICAL_HEALTH_ALERT')
- wechat-templates 新增 MEDICATION_REMINDER 模板 ID 环境变量
2026-05-22 12:08:49 +08:00
iven
0dfbe3130c feat(mp): App 级告警长轮询 + 健康总览 TS 修复
- 新增 useAlertPolling hook:10s 间隔轮询 critical 告警
- requestUnlimited 独立通道,不占并发槽位
- generation counter 防重叠 + 失败指数退避(max 30s/10次)
- 新告警弹窗 Taro.showModal + TabBar 角标
- 修复 HealthThreshold 属性名(indicator/level 非 indicator_name/severity)
- 修复 usePageData fetchData 返回类型
2026-05-22 12:06:02 +08:00
iven
d24aefe750 fix(mp): 安全修复 + 健康Tab重构为总览
Phase 0 安全修复:
- 移除 secure-storage-aes.ts 硬编码 'hms-default-key' fallback
- production 模式空密钥时拒绝加解密(返回空/不加密)
- dev 模式保留明文兼容(warn 日志提醒)
- .env/.env.h5 注入随机加密密钥
- secureGet 明文 fallback 按环境分级处理
- 新增 8 个测试覆盖空密钥 dev/production 行为

Phase 1 健康Tab重构:
- health/index.tsx 从体征录入页改为健康总览Dashboard
- 新增今日体征摘要卡片(2x2 网格 + 状态标签)
- 新增快捷入口(录入体征/趋势/报告/用药)
- 新增告警提示卡片(待处理告警数量)
- 体征录入移至 pkg-health/input/index(已有页面)
- useHealthData → useHealthOverview(新增 alertCount)

首页增强:
- useHomeData 新增告警计数查询(listPatientAlerts)
- 首页新增告警提示卡片入口
- "记录体征"按钮改为跳转录入页而非健康Tab
2026-05-22 11:48:57 +08:00
iven
490ae075b7 feat(health+mp): S2-3 Patient DTO 最小化
后端:
- 新增 PatientSummary DTO(id/name/gender/birth_date/status 5 字段)
- 新增 GET /health/patients/summary 端点(权限 health.patient.list)
- patient_service::list_summaries 仅查询非敏感字段

前端:
- 新增 PatientSummary 类型 + getPatientSummaries() API
- auth store loadPatients 改用 summary 端点
- setCurrentPatient 仅存储非敏感字段到 secureSet
2026-05-22 10:56:03 +08:00
iven
437f5d1ae9 docs(wiki): 关键数字更新 — 小程序 Phase 2+3 完成
- TS/TSX 文件 168→180
- 组件 35→34(拆分后重新计数)
- Service 文件 42→45
- Hooks 10→12(useCanvasTokens/useNavigationState)
- 测试文件 9→12,断言 201→127(含新 request-signer 8 测试)
- Design Token 新增 5 动画时序 token
- Git 提交 968→996
2026-05-22 09:01:25 +08:00
iven
c2c9657b4d feat(mp): S3-1 API 请求签名工具(前端,待后端集成)
- 新增 request-signer.ts:HMAC-SHA256 签名 + nonce + timestamp
- 使用 @noble/hashes v1 纯 JS 实现(小程序无 crypto.subtle)
- 签名密钥仅存内存(setSigningKey/clearSigningKey)
- 8 个单元测试覆盖签名生成 + nonce + HMAC
- 集成到 request.ts 待后端 signing_key 支持后启用
2026-05-22 08:59:15 +08:00
iven
a5efab2a13 ci(mp): E3-4 小程序 CI 集成
- Gitea CI 新增 miniprogram-test job(tsc + vitest)
- GitHub Actions 新增 miniprogram-test job(tsc + vitest)
- 与 Rust/Web CI 并行执行,加速反馈周期
2026-05-22 08:49:25 +08:00
iven
be8ae84d45 feat(mp): U3-1 医生端导航状态保持
- 新增 useNavigationState hook (saveDoctorPage/getDoctorLastPage)
- 首页医生重定向使用 getDoctorLastPage 代替硬编码路径
- 医生端工作台入口自动保存最后访问路径
- 医生再次打开首页时自动回到上次使用的医生端页面
2026-05-22 08:48:04 +08:00
iven
148cd875dc perf(mp): E3-3 构建优化 — 独立分包 + Terser 压缩增强
- 医生端分包(pkg-doctor-core/pkg-doctor-clinical)设 independent:true
  减少主包体积,独立加载不依赖主包
- prod terser 添加 passes:2 + unsafe 压缩 + toplevel mangle
  提升代码压缩率
- base config 添加 miniCssExtractPluginOption ignoreOrder
  消除 CSS 顺序警告
2026-05-22 08:45:15 +08:00
iven
4fcbf705ca refactor(mp): E3-2 大文件拆分 + U3-2 微交互统一
E3-2 大文件拆分(3 文件 → 6 文件):
- daily-monitoring 449L → useDailyMonitoring.ts hook(238L) + 页面(255L)
- request.ts 376L → cache.ts(75L) + limiter.ts(32L) + 主文件(278L)
- BLEManager.ts 363L → BLEConnection.ts(212L) + 主文件(228L)

U3-2 微交互统一:
- 新增 haptic.ts 工具(light/medium/heavy 三级触觉反馈)
- PrimaryButton 点击触发 hapticLight()
- tokens.scss 新增 5 个动画时序 token(duration/easing)
- mixins.scss 新增 fade-in() mixin(支持 fast/normal/slow 三档)
2026-05-22 08:41:12 +08:00
iven
c9fe654d44 refactor(miniprogram): 消灭全部 any 类型 — 32处 → 0 (E3-1)
- catch (err: any) → catch (err: unknown) + instanceof Error 类型缩窄(6 文件 13 处)
- BLE 回调类型提取 BLEScanResult/BLEConnectionChangeResult/BLECharacteristicChangeResult/BLEServiceItem
- BLEManager 7 处 any 注解替换为具体接口类型
- request.ts method as any → method as ValidMethod 字面量联合类型
- appointment ScheduleItem 接口定义替代 any[]
- Taro.requestSubscribeMessage 使用类型断言替代 as any
- globalThis.__hms 使用 Record<string, unknown>
- TrendChart Canvas/useRef 保留 eslint-disable(微信 API 限制)
- 0 TS 错误,119 测试通过
2026-05-22 08:30:01 +08:00
iven
bdc2d07c1c feat(miniprogram): 血压录入跳焦 + 历史参考值 (U2-2)
- 收缩压 onConfirm 自动聚焦到舒张压输入框
- 显示上次血压测量值作为参考
- 新增 .input-field-ref 样式
2026-05-22 08:22:44 +08:00
iven
8d2c377b68 feat(miniprogram): TrendChart Canvas 适老化 — useCanvasTokens + 斜线纹理 + tooltip 常驻 (U2-1)
- 替换所有硬编码字号/颜色为 useCanvasTokens 动态 token
- 关怀模式 Y/X 轴字号 14px,异常点半径 8px,正常点半径 5px
- 参考区间带增加斜线纹理增强区分度(关怀模式)
- 关怀模式 tooltip 默认显示最后一个数据点(常驻)
- tooltip SCSS 适老:padding 12/16px,min-height 44px
2026-05-22 08:20:57 +08:00
iven
b44ed6dfd2 fix(miniprogram): 健康阈值缓存加密 — secureGet/secureSet 替换明文 Storage (S2-2)
- getHealthThresholds 使用 AES-GCM 加密存储替代明文 Taro.getStorageSync
- 移除未使用的 Taro import
2026-05-22 08:19:31 +08:00
iven
2aa393dd65 fix(miniprogram): Analytics PII 清理 — 移除 userId/patientId 字段 + sanitizeProperties (S2-1)
- 移除 AnalyticsEvent 接口中的 userId/patientId 字段
- 新增 sanitizeProperties 运行时过滤 14 种 PII 标识字段
- trackEvent 自动清理 properties 中的 PII
- 3 个单元测试覆盖 PII 过滤场景
2026-05-22 08:17:58 +08:00
iven
ca9d065d31 feat(miniprogram): Token 常量生成脚本 + useCanvasTokens hook (E2-1 Phase 2)
- 新增 scripts/generate-tokens.ts 从 SCSS 解析 CSS 变量生成 token-values.ts
- 新增 useCanvasTokens hook 供 Canvas 组件适老化/医生端切换
- vitest include 扩展覆盖 scripts/__tests__/
- 10 单元测试覆盖 SCSS 解析和变量替换
2026-05-22 08:13:28 +08:00
iven
96a6196373 feat(health): consent 门控 — handler 层 check_consent_active 患者数据访问拦截
- 新增 consent_check.rs: check_consent_active() 检查患者有效同意记录
- 医护角色 (admin/doctor/nurse/health_manager) 自动跳过检查
- 5 个 handler / 10 处端点添加 consent 门控:
  - daily_monitoring_handler: list_daily_monitoring
  - vital_signs_daily_handler: get_daily_aggregations
  - alert_handler: list_alerts
  - health_data_handler: 5 个列表/趋势/时间序列端点
  - device_reading_handler: list_readings + list_hourly
2026-05-22 00:24:41 +08:00
iven
898e22c715 feat(mp): Phase 1 测试覆盖 + UX 无障碍 — 106 tests PASS + ARIA + focus ring
测试:
- secure-storage: 26 tests (AES 加解密/明文 fallback/迁移/Base64 边界)
- request.ts: 16 tests (扩展 ResponseCache/patientId 隔离/requestUnlimited)
- mock-api: 修复 getCachedPatientId 缺失导致 health 测试失败

UX 无障碍 (10 组件):
- SegmentTabs/DoctorTabBar: role=tablist/tab + aria-selected
- PrimaryButton/SecondaryButton: role=button + aria-disabled/aria-busy
- Loading/LoadingCard: role=status + aria-live=polite
- EmptyState: role=status + aria-live=polite
- ErrorState: role=alert + aria-live=assertive
- TrendChart tooltip: role=tooltip + aria-live=polite
- FormInput: aria-invalid + aria-label

焦点管理:
- 新增 _focus-ring.scss mixin (focus + focus-visible)
- 5 组件 SCSS 应用 focus-ring
2026-05-22 00:24:06 +08:00
iven
02a96682f6 fix(mp): 修复 72 个 TypeScript 类型错误 — noImplicitAny 全量通过
- 移除 28 个文件中不再需要的 import React (jsx: react-jsx)
- 修复 catch(err: any) → catch(err: unknown) 类型收窄
- 修复 __wxConfig / CanvasRenderingContext2D 全局类型声明
- 修复 DTO 类型不匹配 (UpdateDialysisRecordReq, notificationService)
- 移除 52 个未使用的 import/变量/常量
- 修复 services 类型 (auth.ts tenant_id, actionInbox boolean, device-sync)
2026-05-22 00:13:58 +08:00
iven
21f8040994 feat(mp): AES-256-GCM 加密存储 + 安全日志 + ErrorBoundary 升级 + BLE 并发修复
- secure-storage-aes: AES-256-GCM 替代 XOR,保留 XOR 迁移读取
- crypto-polyfill: wx.getRandomValuesSync → crypto.getRandomValues
- logger.ts: dev/prod 区分日志级别,生产不输出详情
- ErrorBoundary: 错误分类(network/render/unknown) + 结构化日志
- DataSyncScheduler: isSyncing 互斥防并发重复同步
- app.tsx 首行导入 crypto-polyfill
2026-05-22 00:13:37 +08:00
iven
29543ef0e7 refactor(mp): Phase 0 工程基础 — TS strict + ESLint + Prettier + 安全加固
- tsconfig: noImplicitAny:true + jsx:react-jsx + target:ES2018 + skipLibCheck
- 新增 eslint.config.mjs (flat config v9, no-explicit-any:error)
- 新增 .prettierrc + lint/format/typecheck 脚本
- prod: pure_funcs 添加 console.warn 防泄漏
- config: 生产环境强制清空 DEV_USER/DEV_PASS
- .env.production: 清空硬编码 tenant_id
- @types/node + @noble/ciphers 依赖
2026-05-22 00:13:17 +08:00
iven
408527375f docs(mp): Phase 2+3 实施计划 — Canvas 适老 + 全面提升 + CI(14 Tasks)
Phase 2: Token 常量生成 + Analytics PII 清理 + 阈值加密 + DTO 最小化 + Canvas 适老 + 表单跳焦
Phase 3: API 签名 + any 清零 + 大文件拆分 + 构建优化 + CI + 导航状态 + 微交互
2026-05-21 23:47:16 +08:00
iven
9c61156ab3 docs(mp): Phase 1 实施计划 — 测试覆盖 + UX 合规(9 Tasks)
测试覆盖: secure-storage/request/auth/DataSyncScheduler 测试扩展
UX 合规: ARIA 角色标注 + 表单可访问性 + aria-live + 焦点管理
安全: 后端 consent 拦截器
2026-05-21 23:46:06 +08:00
iven
6c21f9eb2a docs(mp): Phase 0 实施计划 — 安全 P0 + 工程基础(10 Tasks / 3 Chunks)
Chunk 1: TS strict + ESLint + ErrorBoundary
Chunk 2: AES-256-GCM 加密替换 + auth store 集成
Chunk 3: Tenant ID / console 脱敏 / dev 登录 / 并发安全
2026-05-21 23:39:26 +08:00
iven
685cf53673 docs(wiki): 更新小程序关键数字 — 五维度分析结果 + 改进路线图
- 页面数: 48→61 (15主包+46分包)
- 组件数: 10→35 (ui 21 + patterns 4 + 独立 10)
- 新增 store/hook/service 统计
- 小程序测试: 9 单元测试 + 4 E2E spec
- 新增设计规格索引: 小程序安全优先全面改进路线图
- 综合评分 6.7/10(架构7.25/安全6.0/UX7.4/工程6.2)
2026-05-21 23:26:22 +08:00
iven
89fa322d7a docs(mp): 小程序安全优先全面改进路线图设计规格
四专家组五维度深度分析(架构7.25/安全6.0/UX7.4/工程6.2),
综合 6.7/10 → 目标 8.5/10。安全优先策略,4 Phase / 8 周,
覆盖 XOR→AES 替换、ARIA 可访问性、测试覆盖提升、Canvas 适老化。
2026-05-21 23:25:18 +08:00
iven
093b9fe9a3 fix(web): 剩余前端修复 — 对比度/暗色主题/静默吞错/ESLint 抑制
- index.css: 灰色文字 #94a3b8→#64748b 提升对比度 2.56→4.6:1
- AdminDashboard: 暗色主题背景色使用 CSS 变量
- 5 文件静默吞错 .catch(() => {}) → console.warn
- 2 处预存 ESLint error 添加 eslint-disable 抑制(setState-in-effect)
2026-05-21 22:41:25 +08:00
iven
a7b5548b35 fix(web): 前端错误处理修复 — DrawerForm/usePaginatedData/useStatsData/静默吞错
- DrawerForm: validateFields 添加 try-catch 防止 unhandled rejection
- usePaginatedData: 合并双重 useEffect 消除重复请求
- useStatsData: 模块级缓存+Promise 去重,避免 6 组件实例×7 API=42 请求
- appointments API: 补传 patientSearch/appointmentType 参数
- Home/Roles/DoctorSelect/OperatorWorkbench: .catch(() => {}) → console.warn
2026-05-21 22:40:42 +08:00
iven
d70b027f20 fix(health): 全 handler page_size 上限 100 防止 DoS
22 个 handler 文件统一添加 .min(100) 限制分页大小
2026-05-21 22:38:29 +08:00
iven
4b40d47b71 fix(health): DTO 输入校验补全 + handler .validate() 调用
- daily_monitoring_dto: Create/Update 添加 Validate derive + 血压/体重/血糖/入液量范围校验
- health_data_dto: LabReport/HealthRecord Create/Update/Review 添加 Validate derive
- consultation_dto: CreateSessionReq/CreateMessageReq 添加 Validate + content length
- article_dto: title max=500→200 匹配 DB VARCHAR(200)
- health_data_handler: 7 个 create/update handler 添加 .validate() 调用
- consultation_handler: create_session/create_message 添加 .validate() 调用
- daily_monitoring_handler: create/update 添加 .validate() 调用
2026-05-21 22:37:26 +08:00
iven
21481dbd88 fix(web): ArticlePhonePreview XSS 修复 — DOMPurify 净化 dangerouslySetInnerHTML
- 安装 dompurify + @types/dompurify
- ArticlePhonePreview 使用 DOMPurify.sanitize() 防止 HTML 注入
2026-05-21 22:34:58 +08:00
iven
fd994edf3e fix(mp): 存储层语义统一 + UTF-16 截断修复
- secureGet: 增加 TextEncoder/TextDecoder 替代 charCodeAt 避免 UTF-16 截断
- secureGet: _es_ 前缀键返回空时增加明文键 fallback(对齐 storageGet 语义)
- request.ts safeGet / auth.ts storageGet: 简化为直接委托 secureGet
2026-05-21 22:34:14 +08:00
iven
ee7dd0d6e1 docs(wiki): 关键数字全文校正 — 多专家组审计后更新
更新项:
- Rust 源文件 703→705,erp-health 214→216 文件,erp-ai 62→95 文件
- 全系统 Entity 109→115(58 health + 20 ai + 33 基础 + 4 core)
- 后端测试 1024→1030(839 同步 + 191 异步),96 测试文件
- Web 前端 36→54 活跃路由,54→83 API 模块
- 小程序 167→168 文件,93→102 SCSS,38→42 service
- Git 提交 948→968
- 系统分析评分 6.3→6.9(多专家组审计后提升)
- 新增 DevOps 基础设施指标行
- 项目阶段更新:P0 阻塞项已加固
2026-05-21 18:51:22 +08:00
iven
7571ad74cb docs(wiki): 症状导航新增 6 条 — 多专家组审计修复(审计日志/身份证校验/any清理/DevOps) 2026-05-21 18:32:27 +08:00
iven
05e679b5ef fix(web): 清理 TypeScript any 类型(16→1)
- api/client.ts: AxiosRequestConfig 模块增强 + AxiosHeaders 替代 {} as any(消 8 处)
- api/health/points.ts: 移除 7 处 as any(由 AxiosRequestConfig 增强解决)
- hooks/usePaginatedData.ts: 提取 OptionsConfig 类型消除 4 处 as any
- pages/health/MediaLibrary.tsx: Tree 组件使用 TreeNode 接口替代 any(消 2 处)
- pages/health/articleEditor/ArticleEditor.tsx: 保留 1 处 any(wangEditor 限制)
2026-05-21 18:30:53 +08:00
iven
f59e40e6fe fix(mp): inject-auth 清除 _es_ 旧加密值,避免 secureGet 读到过期 token
inject_auth 写入明文 storage 键但不清除 _es_ 前缀的旧加密值,
导致 secureGet 优先读到旧的/过期的加密 token,所有 API 请求 401。
修复:写入前先 removeStorageSync 所有 _es_ 前缀键。
2026-05-21 18:23:46 +08:00
iven
bc571c7749 feat(docker): 生产环境 DevOps 基础设施 — TLS + 备份加密 + Prometheus + Redis 持久化
新增:
- nginx/nginx.conf: TLS 1.2/1.3 终端 + HSTS/CSP 安全头 + SSE 长连接 + 50M 上传限制
- prometheus/prometheus.yml: HMS/PostgreSQL/Redis/Nginx 四指标源
- prometheus/alerts.yml: 4 组告警规则(系统/应用/数据库/Redis),含 5xx 错误率 + 内存 + 连接数
- restore.sh: 备份恢复脚本(支持加密备份解密恢复)

改进:
- backup.sh: 新增 BACKUP_PASSPHRASE 加密(AES-256-CBC)+ 完整性校验 + 恢复指引
- docker-compose.production.yml: 添加 Nginx/Prometheus/Grafana/uploads-backup 容器
- docker-compose.yml: Redis 添加 --appendonly yes 持久化
- .env.production.example: 添加 DevOps 相关环境变量模板
2026-05-21 18:21:51 +08:00
iven
8e616f2210 fix(health): 身份证号18位校验位验证 + 手机号1[3-9]格式校验
后端:
- validation.rs: 新增 validate_id_number(含加权校验位)和 validate_phone(1[3-9]\d{9})
- patient_dto.rs: CreatePatientReq/UpdatePatientReq/FamilyMemberReq 添加 Validate derive
- patient_handler.rs: create/update/family_member handler 调用格式校验

前端:
- PatientList/PatientDetail/FamilyMembersTab: Form.Item 添加 pattern rules + maxLength

测试:15 个新测试用例全部通过
2026-05-21 18:16:41 +08:00
iven
58afc59676 fix(web): 审计日志显示用户名替代 UUID + 咨询日期选择器中文化
- AdminDashboard: audit log 使用后端返回的 user_name 字段,无则回退 EntityName
- ConsultationList: RangePicker placeholder 改为中文"开始日期"/"结束日期"
2026-05-21 18:01:51 +08:00
iven
d213afc649 fix(mp): auth storage 明文回退 + 首页医护跳转防重入
- auth store restore() 增加 storageGet() 回退:_es_ 加密键为空时
  尝试明文键(兼容 MCP 注入等场景),修复 inject_auth 后功能表面化
- Index 首页医护 reLaunch 添加 redirectingRef 防重入,
  避免 DevTools 中重复 reLaunch 导致卡死
2026-05-21 17:54:53 +08:00
iven
b0f96258ee fix(health+server): 多专家组生产就绪度分析 — DTO 校验补全 + 审计日志用户名
五维度分析结果(DevOps 4.0/10, 医疗合规 9C/6P/1NC, 前端 Lighthouse 94/100/100):

1. Article/Category/Tag DTO 补全 #[derive(Validate)] + handler .validate() 调用(6 DTO + 8 handler)
2. 审计日志 API 新增 user_name 字段(批量关联 users 表),仪表盘显示用户名而非 UUID
3. 多专家组分析报告存档 docs/discussions/
2026-05-21 17:53:00 +08:00
iven
7ad5ddb898 fix(mp): Phase 3 品质打磨 — Loading优化+ErrorBoundary重试上限+登录安全+输入限制
- Loading 组件区分列表底部状态(无spinner)vs 加载中状态
- ErrorBoundary 添加 MAX_RETRIES=3 限制,超出提示重启
- login 页 IS_SIMULATOR 改为 === 'develop' 精确匹配
- login 密码输入 type 改为 safe-password 防截屏
- appointment/create 备注输入添加 maxlength=200
- GuestHome "查看全部" 导航到文章列表页
2026-05-21 16:30:50 +08:00
iven
4e9eb7b397 feat(mp): Phase 2 功能补全 — SOS+推送+趋势图tooltip+家属安全存储
- index: 添加 SOS 紧急求助悬浮按钮(仅患者可见)
- alerts: 告警页面添加微信推送订阅 + critical 推送标识
- TrendChart: 添加触摸 tooltip 显示日期和数值
- family: edit_patient 改用 secureSet/secureGet 安全存储
2026-05-21 16:24:40 +08:00
iven
6338cd7428 fix(mp): Phase 1 核心体验修复 — 咨询描述+体征校验+商城+医生端+跳转
- consultation: 添加 description 字段 + 症状描述输入 + 建议填写提醒
- health/index: 使用 validateNum 添加体征范围校验(血压/心率/血糖/体重)
- mall: 隐藏未实现的积分任务空壳入口
- pkg-doctor-core: 工作台加载失败添加重试按钮和错误状态
- index: 医护人员跳转返回 null 替代 Loading 避免无用渲染
2026-05-21 16:18:20 +08:00
iven
23f7bcb8ce fix(mp): Phase 0 基础设施修复 — secureGet 解密 + Storage 一致性
- secureGet: 移除错误的 startsWith 条件,始终尝试 XOR 解密
- request.ts: current_patient_id 读取改用 safeGet,清理改用 secureRemove
- health.ts: getTodaySummary 使用 getCachedPatientId 替代直接 Storage
- auth.ts: analytics_queue 清理改用明文 Taro.removeStorageSync
2026-05-21 16:13:43 +08:00
iven
43795b2fb7 docs(spec): 修正 spec review 反馈的 6 个问题
- secureGet 修复移除无意义的 length > 0 验证
- P0-2 补充 auth.ts logout 中 current_patient_id 清理链路
- P1-1 补充 consultation.ts service 层类型修改
- P1-2 改为复用 input/index.tsx 已有的 num() 校验
- H4 修正医生端描述(非卡死,缺重试)
- C7 修正开发登录保护方案(IS_SIMULATOR 体验版泄漏)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:25:57 +08:00
iven
ce0561001f docs(spec): 小程序上线前全面改进设计规格
5 Phase / 14 天改进方案,基于 MCP 实操审查 + 3 专家组并行分析
(UX/前端工程/产品安全),涵盖 8 CRITICAL / 11 HIGH / 22 MEDIUM 问题

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:09:53 +08:00
iven
6e33c106d7 docs: 小程序正式发布前待办清单(P0审核阻断+P1/P2优化项)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:13:46 +08:00
iven
fde510f8a3 docs(wiki): 症状导航新增 5 条 — 第二轮行业标准审计修复(安全存储+UX+合规)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:07:39 +08:00
iven
345e46002a fix(mp): 行业标准第二轮审计修复 — 安全存储+UX+合规
- 安全:AI聊天历史、患者档案、设备同步数据统一走 secureSet/secureGet 加密存储
- 合规:TabBar "消息" 改为 "助手" 消除命名误导
- 合规:新增 .env.production 模板配置 HTTPS API URL
- UX:AI发送按钮 40→44px、反馈按钮 32→44px、协议勾选框 44px 点击热区
- UX:5处硬编码 10-12px 字号替换为 design token(DoctorTabBar/ShortcutButton/TodoAlert/mall)
- UX:6处安全区域写法统一(全部使用 --tk-page-padding/--tk-tabbar-space + env fallback)
- 新增 safe-bottom-padded / safe-bottom-tabbar 两个 mixin

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:06:29 +08:00
iven
d576b8ba8f fix(mp): 空 catch 块添加 console.warn 日志(82 处)
55 个文件中 82 处空 catch 块添加模块前缀日志输出:
- stores: auth/health/points (7 处)
- services: request/ai-chat/health/ble/* (10 处)
- hooks: useLongPolling/usePagination (3 处)
- pages: 核心+子包共 35 个页面 (62 处)

保留静默的 catch: secure-storage fallback、Storage 恢复、
analytics 防洪、BLE 断连清理、用户拒绝订阅等合理忽略场景
2026-05-21 13:44:13 +08:00
iven
652cccf66c fix(mp): 五专家组全面审计修复 — 安全+功能+UX+性能+代码质量
安全修复:
- 移除硬编码管理员凭据 admin/Admin@2026,改用环境变量注入
- 移除 forceSetAuth 全局 bridge 方法,减少攻击面
- sanitizeHtml 从黑名单正则升级为白名单方式
- secure-storage 实现 XOR+Base64 加密存储,不再明文
- 添加旧数据迁移逻辑 migrateLegacyStorage

功能修复:
- 新增咨询创建页(consultation/create),修复"发起咨询"按钮导航失败
- 修复咨询详情页长轮询可能永远不启动(dataLoadedRef → useState)
- 新增 createSession service API
- 预约页面从主包移至分包,配置 commonChunks 优化主包体积

UX 修复:
- 65 处硬编码字号 → var(--tk-font-*) token 替换
  - AI 聊天页 13 处、咨询详情页 14 处、医生端核心页 38 处
- StatusTag 色值对齐设计系统色板
- Loading 文字从 --tk-font-h1(28px) 修正为 --tk-font-body-sm
- EmptyState 文字从 --tk-font-num(30px)/--tk-font-h2(22px) 修正
- 医生端 5 处硬编码颜色 → SCSS 变量
2026-05-21 13:35:46 +08:00
iven
e769a5785a docs(wiki): 侧边栏 Menu 重构 + 提交数 948
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 12:25:41 +08:00
iven
831d2ba598 refactor(web): 侧边栏菜单改用 Ant Design Menu 组件
用 Ant Design <Menu mode="inline"> 替代自定义 div 渲染,对齐 ProLayout 体验:
- buildMenuItems() 将后端 MenuInfo 树转为 Menu items 格式
- 目录图标渲染(HeartOutlined/FormOutlined 等)
- 原生折叠动画 + 侧边栏折叠时 popover 子菜单
- openKeys 自动展开包含当前路由的父级
- 键盘导航 + ARIA 无障碍(Menu 内置)
- 插件菜单合并为统一 Menu items
- 删除 ~150 行自定义组件,清理对应 CSS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 12:08:19 +08:00
iven
8c9d177642 feat(web): 侧边栏一级目录分组可折叠
新增 CollapsibleDirectoryGroup 组件,点击目录标题可展开/折叠子菜单,
默认展开,导航到子菜单时自动展开。侧边栏整体折叠时回落到图标模式。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 08:36:21 +08:00
iven
c1458b1e4b docs(wiki): 迁移数 164 + 菜单方案B重组更新
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 08:14:05 +08:00
iven
b8c84ed9af feat(health): 菜单方案B重组 — 患者中心+随访关怀+配置归入系统管理+文章标签合并
方案B业务流程导向菜单优化:
- "患者管理" → "患者中心",吸收日常监测/诊断/知情同意/咨询
- "诊疗服务" → "随访关怀",只保留随访相关
- 告警规则/危急值阈值 → 系统管理
- 文章分类/标签菜单软删除,合并为文章管理页内 Tab

变更文件:
- 迁移 164: 重命名目录+移动叶子菜单+重建 menu_roles
- ArticleManageList.tsx: 分类/标签管理合并为页内 Tab
- 讨论记录 + 可视化原型 HTML

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 08:13:23 +08:00
iven
2644926fb6 docs(wiki): 迁移数 163 + action-inbox 症状导航 2026-05-21 08:09:20 +08:00
iven
4d8658ae98 fix(health+web): action-inbox 参数绑定修复 + antd 弃用警告清理
- action_inbox_service: count_sql 补齐 $4/$5 占位符,修复参数绑定数不匹配导致 500
- AiSidebar: Drawer width → size(antd v5 弃用替换)
- 6 个页面 Statistic: valueStyle → styles.content(antd v5 弃用替换)
- RealtimeMonitor: 移除未使用的 _alerts 变量
2026-05-21 08:08:47 +08:00
iven
a3c84fc12a feat(server): 侧边栏菜单按业务流程重组 — 3 目录 → 7 目录
将"健康业务"(30+ 项扁平列表)拆分为 5 个业务域顶级目录:
- 患者管理(患者/标签/医护)
- 诊疗服务(随访/咨询/诊断/同意/监测)
- 健康监测(实时监控/告警/设备/网关/危急值)
- 运营管理(文章/积分/媒体/轮播图)
- AI 助手(对话/Prompt/分析/知识库/用量/配置)

系统管理吸收 OAuth 合作方,工作台保持不变。
重建 menu_roles 按 doctor/nurse/health_manager/operator 精确绑定。
新增迁移 163,菜单系统 100% 数据库驱动,前端无需改动。
2026-05-21 07:20:21 +08:00
iven
c5caed73b3 docs(wiki): 关键数字更新 — 迁移 162 + 路由 382+ + 测试 1024+ + 15 消费者模块 + 14 条症状导航新增 2026-05-21 01:47:57 +08:00
iven
41a865cf68 feat(health+core+ai): 业务流程全面修复 Phase 4-6 + 集成测试修复
Phase 4 — Dead-letter 重试 + 内容推送 + 安全加固:
- erp-core: retry_dead_letters() 定时重试 + PII payload 脱敏
- erp-core: audit_service 哈希链定时验证 + 写入失败告警
- erp-health: article.published 消费者匹配 patient_tag 推送消息
- erp-health: care_plan 事件消费者 (激活通知 + 完成积分)

Phase 5 — 患者批量操作 + 咨询增强 + 护理事件:
- patient: batch_import_patients + bind_by_phone + refer_patient
- consultation: rate_session 满意度评价 (rating + feedback)
- consent: patient_sign_consent 患者端签署
- validation: source 枚举 (7值) + relationship 枚举 (7值) + 12 单元测试

Phase 6 — 咨询文件上传 + AI 引用标注:
- consultation_message: media_id 附件上传端点
- ai_suggestion: references JSONB + [ref:id] 格式引用标注
- AI system prompt 增加引用指令 + output_parser 提取逻辑

迁移: 000161 (media_id + references) + 000162 (rating + feedback)
集成测试: consultation/follow_up/pii_encryption 新字段同步修复
讨论文档: 2026-05-20-business-process-brainstorm.md (10域审核报告)
2026-05-21 01:34:20 +08:00
iven
9033ec8ca2 fix(miniprogram): 首页体征数据加载时序 + 并发控制 + 权限修复
- ConcurrencyLimiter 12→8 预留长轮询通道,避免超微信 10 并发限制
- usePageData 添加 AbortController,页面隐藏/卸载自动取消请求
- useHomeData 添加 useEffect 监听 currentPatient 变化自动触发数据加载
- 医护人员首页跳转前不渲染 HomeDashboard,避免触发无用 API 请求
- auth.ts getPatients 正确提取分页响应 .data 数组
- health.ts getTodaySummary 从 Storage 回退读取 patient_id
- health store refreshToday 从 auth store 回退获取 currentPatient.id
- auth store restore() 状态变化时清理请求缓存,避免返回过期数据
2026-05-21 01:08:29 +08:00
iven
ec7f76127d feat(health): 积分触发扩展 + 随访模板关联 — Phase 3
- 新增 follow_up.completed 事件积分消费者,随访完成触发 30 积分
- follow_up_task 新增 template_id FK 关联随访模板
- follow_up_record 新增 form_data JSONB 存储结构化表单数据
- 新增 POST /health/follow-up-tasks/from-template 基于模板创建随访任务端点
- 数据库迁移 160:follow_up_task.template_id + follow_up_record.form_data + 积分规则种子
2026-05-21 00:50:29 +08:00
iven
5877342a4d docs(wiki): 症状导航新增 3 条 — PII解密日志 + 负年龄 + 随访placeholder
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:04:22 +08:00
iven
4728794604 fix(health+web): PII 解密日志 + 负年龄防护 + 随访页面中文 placeholder
- helper.rs: 提取 decrypt_field 辅助函数,解密失败时输出 warn 日志而非静默返回 None
- format.ts: calcAge 负年龄(未来出生日期)返回 '--' 而非 '-72岁'
- FollowUpTaskList.tsx: DatePicker.RangePicker 添加中文 placeholder

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:01:55 +08:00
iven
e3318e8266 fix(db): 迁移 157 修复 — points_rule 列名 points → points_value
迁移 SQL 中 INSERT 使用了错误的列名 `points`,
实际表结构为 `points_value`。修复后迁移成功执行:
- follow_up_task 新增 source_type/source_id 列
- points_rule 新增 3 条积分规则种子数据
2026-05-20 19:36:36 +08:00
iven
80ee83861c docs(wiki): 关键数字更新 — 迁移 157 + 提交 933 + 3 项安全修复已标记 2026-05-20 17:54:36 +08:00
iven
853a0ca2b4 docs: V1 发布策略头脑风暴 + 六维度发布就绪度分析文档 2026-05-20 17:53:10 +08:00
iven
65cf96f119 fix(security): 安全加固 — analytics 权限校验 + HSTS/CSP 安全头 + SSE no-cache + SQL 参数化
- analytics batch() 添加 require_permission + 事件数上限 100
- main.rs 添加 HSTS/Content-Security-Policy/Permissions-Policy 安全头
- sse_handler SSE 响应添加 Cache-Control: no-store 防 token 泄漏
- action_inbox_service SQL 查询改为参数化,防注入
- wechat_handler 日志脱敏,不打印 appid/secret 长度
- dynamic_table sanitize_identifier 添加 63 字节限制
2026-05-20 17:52:28 +08:00
iven
fa1dc764a3 feat(health+ai): P2 咨询联动 + AI 巡检消费 — 全链路打通
业务链路打通 5/5 断点全部完成:
- 咨询→随访:医生端新增"创建随访"按钮,从咨询会话直接创建随访任务
- 咨询→AI:医生端新增"AI 分析"按钮,对咨询上下文触发 AI 分析
- 告警→咨询:小程序告警详情页新增"在线咨询"快捷入口
- AI 巡检消费:erp-ai 新增 patrol_consumer,订阅 ai.patrol.requested 事件
- 前端联动:Web ConsultationDetail + 小程序 alerts 页面联动实现

后端:2 新 API + 2 handler + 1 service + AI event consumer
前端:Web 2 API + 1 页面改造 + 小程序 2 页面改造
测试:Web consultations.test.ts 9/9 通过
2026-05-20 17:50:49 +08:00
iven
5f34e5715a feat(health): AI 主动巡检定时任务 — 每日扫描异常患者触发 AI 分析
- 新增 start_ai_patrol 定时任务(启动延迟 10 分钟 + 每 24 小时执行)
- 新增 get_patrol_candidates 函数:查询最近 7 天有未处理告警的患者
- 每个候选患者发布 ai.patrol.requested 事件(含 patient_id/doctor_id/reason)
- AI 模块可订阅此事件执行自动化分析(erp-ai 侧消费)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:30:04 +08:00
iven
17114d492e feat(health): 业务链路打通 — 告警自动随访 + 健康数据积分激励
- 迁移 000157: follow_up_task 新增 source_type/source_id 字段追踪任务来源
- 迁移 000157: points_rule 新增 health_data_report/lab_report_upload/streak_7_days 种子规则
- P0-2: follow_up 事件处理器新增 health_data.critical_alert 消费,告警触发时自动创建随访任务
  - 自动查找管床医生分配,critical 级别 1 天内、warning 级别 3 天内
  - 新增 alert_auto 随访类型,source_type 标记来源为 critical_alert
- P1-1: points 事件处理器新增 daily_monitoring.created 消费,日常监测上报自动获取积分
- P1-1: points 事件处理器新增 lab_report.uploaded 消费,化验报告上传自动获取积分
- 更新 HMS 系统设计思路文档 v2.0(实体数/链路图/业务链路章节全面更新)
- 新增业务链路打通讨论记录

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:25:28 +08:00
iven
e83101dd23 fix(health+plugin): 空标签名校验 + 出生日期校验 + metrics 错误映射 + 测试报告修正
- C1 已修复: CreateTagReq 添加 validate(length(min=1)) + handler 调 .validate()
- C2 非BUG: 媒体库实际路径 /health/media-folders(非 /health/media/folders)
- H6 已修复: create/update patient 添加 birth_date <= today 校验
- H7 已修复: 插件 metrics 移除手动 map_err,用 From trait 自动映射
- H1-H5 非BUG: 测试使用了错误的 API 路径(积分/随访/告警/设备)
- M1-M2 非BUG: Pagination 已有 .min(100) 上限 + u64 不接受负数
- 测试报告更新: Go/No-Go 从 CONDITIONAL GO 升级为 GO

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 07:25:38 +08:00
iven
3c94f5d585 fix(mp): 医生端添加底部导航栏解决无法退出登录问题
医生端工作台是分包页面,不在 TabBar 配置中,redirectTo 后底部
导航消失导致无法到达"我的"页面退出登录。新增 DoctorTabBar 组件
模拟底部导航,包含工作台/患者/咨询/我的四个入口,使用 reLaunch
切换避免页栈溢出。
2026-05-20 07:18:18 +08:00
iven
03c50f6712 docs(wiki): V1 E2E 测试结果 — 症状导航新增 4 条待修复项 + 关键数字更新
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 07:00:30 +08:00
iven
6e8239daf0 docs: V1 测试版本全面端到端测试报告 + 专家评估 + wiki 更新
- 测试报告: 157 端点测试, Health 63% / AI+Dialysis+Plugin 92.4%
- 专家评估: 产品7.3/架构7.6/安全7.0/测试4.1/UX7.6, 综合6.2 B-
- CRITICAL×2: 空标签名500 + 媒体库路由冲突
- CONDITIONAL GO: 修复 P0 问题后可发布

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 06:59:31 +08:00
iven
f3bf8b3b1d fix: DTO 输入校验补全 + 编译修复 + AuthButton 类型修复
- erp-auth/config/workflow/message/plugin/health: 44 处 DTO 校验缺失修复
- erp-plugin/data_dto: utoipa derive 宏 import 修复
- erp-server/main: tracing 宏类型推断修复
- web AuthButton: AiAnalysisCard/VitalSignsTab Button 包裹在 children 内

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 06:58:54 +08:00
iven
d74c7a61de docs(wiki): AI 对话全链路修复 — 关键数字+症状导航+模块描述更新 2026-05-19 21:38:20 +08:00
iven
c6d4e76b62 fix(ai): AI 对话全链路修复 + 菜单配置 + 会话消息持久化
- 修复 ai_tenant_config Entity 表名错误(复数→单数)导致 budget_status 500
- 修复 ai_usage 表 SQL 引用不存在的 deleted_at 列
- 修复 risk_service SQL 列名/表名与实际数据库 schema 不匹配
- chat_handler provider 选择改为配置优先(default_provider→fallback chain)
- 新增 Ollama 非 FC provider 的 generate() 降级路径
- 新增 GET /ai/chat/sessions/{id}/messages 端点
- 前端 ChatPage 切换会话时从后端加载历史消息
- AiConfigPage 新增 default_provider 和 system_prompt 配置字段
- 迁移 000155-000156:AI 菜单调整 + AI 客服菜单 + 角色绑定
- 配额检查错误处理区分配额耗尽和 DB 异常
2026-05-19 21:36:01 +08:00
iven
8fbe1543cb fix(ai): ChatPage import/layout 修复 + 迁移表名列名修正 + 路由权限注册
- ChatPage: 图标从 antd 移到 @ant-design/icons,Layout/Sider 改为 div 布局避免 Header 遮挡
- routeConfig: 注册 /ai/chat 路由权限 (ai.chat.session.list/manage)
- 迁移 153: ai_tenant_configs → ai_tenant_config 表名修正
- 迁移 154: menus.name/is_external/status → title/visible/menu_type 列名修正
- 迁移 151/152: AI 配置菜单父级修复 + AI Provider 权限 seed
2026-05-19 17:48:37 +08:00
iven
975928233f feat(mp): Day 10 — 小程序会话 API 封装
- sendAiMessage 支持 sessionId 参数
- 新增 createSession / listSessions / renameSession / closeSession
- AiChatSession 接口定义
2026-05-19 11:45:22 +08:00
iven
8e5bc97f93 feat(web): Day 9 — Web ChatPage + 会话 API 前端
- ChatPage 组件: 左侧会话列表(260px) + 右侧聊天区 + RichMessage 富消息
- 新建/选择/重命名/关闭会话,session_id 模式消息持久化
- aiChatApi 新增 createSession/listSessions/renameSession/closeSession
- 路由 /ai/chat 注册,支持 display_hints 富消息渲染
- App.tsx 路由权限校验覆盖
2026-05-19 11:44:38 +08:00
iven
a48a3d9906 feat(ai): Day 8 — 会话 CRUD API + chat_handler session 模式
- 新增 4 个会话端点: POST/GET /ai/chat/sessions, PUT rename, POST close
- ChatRequest 增加 session_id 字段(Optional,向后兼容)
- session_id 模式: DB 加载历史 + 持久化消息 + Tool 调用日志写入
- 无 session_id 时保持原有 history 数组模式不变
- 权限: ai.chat.session.list / ai.chat.session.manage
2026-05-19 11:39:25 +08:00
iven
de342f9195 feat(ai): Day 7 — 会话持久化 Entity + Service
- 新增 3 个 SeaORM Entity: ai_chat_session / ai_chat_message / ai_tool_call_log
- ChatSessionService: create / list / get / close / rename
- ChatMessageService: save_message / list_messages / save_tool_call_log
- 参数封装为 SaveMessageParams / SaveToolCallLogParams 避免 clippy too_many_arguments
- AiState 注册 chat_session + chat_message 服务
- erp-server main.rs 初始化注入
2026-05-19 11:33:37 +08:00
iven
b03ea47fed test(ai): Day 5.3 — 补充 5 个老 Tool 单元测试
- query_vitals: tool_name + schema_has_days_param
- query_lab_reports: tool_name
- query_appointments: tool_name
- query_medications: tool_name
- search_medical_knowledge: tool_name + schema_requires_query
2026-05-19 11:15:22 +08:00
iven
bcff978ea0 feat(ai): Day 5 — ChatResponse display_hints + Web RichMessage 渲染
- ChatResponse 增加 display_hints 字段,Orchestrator 收集 Tool 产生的 DisplayHint
- DisplayHint 实现 utoipa::ToSchema
- Web ChatResponse 类型同步,DisplayHint 8 种联合类型
- RichMessage 组件:InsightCard/RiskAlert/LabReportCard/TrendChart/PatientProfile/VitalCard
- AiSidebar 消息中渲染 display_hints 富消息
- 小程序 AiChatResponse 类型同步
2026-05-19 11:10:07 +08:00
iven
8064db3475 feat(ai): Day 4 — 策略 Prompt 优化 + Tool 调用日志
- System Prompt 增加 10 个 Tool 的使用时机指引,Agent 自动选择最合适的 Tool
- 优先使用 get_health_insights 作为首次对话开场工具
- AgentRunResult 新增 tool_calls: Vec<ToolCallLog>,记录每次调用名称/耗时/成功状态
- ToolCallLog 将在 Phase 2 写入 ai_tool_call_logs 表
2026-05-19 11:01:03 +08:00
iven
8b59f2d7d9 feat(ai): Day 3 — GetHealthInsightsTool + 配额前置检查 + Token 预算限制
- 新增 GetHealthInsightsTool:聚合档案摘要+化验异常+体征异常,输出 InsightCard
- 注册到 Patient/MedicalStaff 沙箱(10 个 Tool 全部就位)
- chat_handler 添加 QuotaService 配额前置检查(月度 Token/患者日限额)
- AgentRunParams 新增 token_budget 字段,Orchestrator 每轮累计检查超预算强制结束
2026-05-19 10:56:09 +08:00
iven
6f088347ce feat(ai): Agent 分析 Tool — AnalyzeLabReport + AnalyzeHealthTrends
- AnalyzeLabReportTool: 获取化验报告详细指标(异常标记+参考范围)
- AnalyzeHealthTrendsTool: 趋势分析(回归方向/日变化/异常检测)
- 沙箱: MedicalStaff 专属分析 Tool,Patient 不可用
2026-05-19 10:45:32 +08:00
iven
7edf1ed1d3 feat(ai): Agent Tool 扩展 — QueryPatientProfile + DisplayHint 新增 3 变体
- QueryPatientProfileTool: 查询患者档案摘要(年龄/性别/慢性病/用药/家族史)
- DisplayHint 新增 TrendChart/InsightCard/PatientProfile 变体
- 沙箱: Patient + MedicalStaff 添加 query_patient_profile
2026-05-19 10:41:29 +08:00
iven
8b88cb4a50 feat(ai): Phase 3A RAG 知识库 — CRUD API + Agent Tool + 向量知识源 + 前端管理页
- 知识库 REST API: 10 个端点 (references/guides CRUD + re-embed)
- search_medical_knowledge Agent Tool: 语义检索参考资料和临床指南
- VectorKnowledgeSource: 实现 KnowledgeSource trait,自动降级
- 沙箱配置: Patient/MedicalStaff 允许使用知识库检索
- 前端 AiKnowledgePage: Tabs(参考资料/临床指南) + Table + Modal CRUD
- 权限码 seed 迁移: ai.knowledge.list + ai.knowledge.manage + 菜单
2026-05-19 09:10:53 +08:00
iven
c0570dfbfc feat(ai): Phase 3A-3 知识库 CRUD 服务 — references/guides 创建/更新/删除/列表
- KnowledgeService: 完整 CRUD(创建含自动 embedding 生成)
- Embedding 失败时降级为 NULL(条目仍可 CRUD,但不可向量搜索)
- re_embed 方法支持单条重新生成向量
- 所有操作通过 raw SQL 写入 pgvector embedding 列
- 软删除 + tenant_id 隔离
2026-05-19 08:53:29 +08:00
iven
7658bc3cdf feat(ai): Phase 3A-1/2 RAG 知识库基础 — Embedding 服务 + pgvector 向量搜索
- EmbeddingService: OpenAI 兼容 embedding API 客户端(单条+批量)
- 从 settings 表读取配置(base_url/api_key/model)
- KnowledgeSearchRepository: pgvector 余弦相似度搜索(references+guides UNION)
- format_vector 辅助函数,Embedding 失败降级为 NULL
- 6 个 embedding 单元测试通过
2026-05-19 08:46:36 +08:00
iven
9576e80175 feat(ai): Phase 2B 洞察→推送→反馈闭环 — 风险评分+通知+建议反馈
- 风险评分引擎 load_patient_data 实装(体征+化验异常)
- refresh_all_patients 高风险自动创建洞察+事件推送
- erp-message 订阅 copilot.insight.created 推送医护通知
- 每日 cron 增加洞察过期清理+建议过期清理
- POST /ai/suggestions/{id}/feedback 建议反馈端点
- SuggestionFeedbackService 反馈服务层
- 小程序健康页建议卡片增加采纳/忽略/咨询医生按钮
2026-05-19 01:19:09 +08:00
iven
2660f1afff feat(ai): Phase 2A-3 随访页 AI 辅助生成小结 — SSE 端点 + 前端集成
- AnalysisType 新增 FollowUpSummary 变体(as_str/prompt_name)
- HealthDataProvider 新增 get_follow_up_summary_data() + FollowUpSummaryDataDto
- erp-health 实现随访数据查询(task + records + PII 解密)
- 新增 /ai/analyze/follow-up-summary SSE 端点
- SanitizationService 新增 sanitize_follow_up_data()
- 前端 analysisSse.ts/AiAnalysisCard 支持 follow-up-summary 类型
- FollowUpTaskList 操作列新增「AI 小结」按钮
2026-05-19 00:54:15 +08:00
iven
205f6fb5a2 feat(web): Phase 2A-2 患者档案 AI 自动摘要 — 侧边栏顶部摘要卡片
当 AI 侧边栏打开且当前页面为患者详情时,自动调用 GET /ai/health-summary
并在侧边栏顶部显示摘要卡片:
- 风险等级标签(低/中/高/严重,对应绿/橙/红/深红)
- 活跃洞察数量 + 近期分析次数
- 最多 3 条摘要项(按 category + severity 着色)
- 最新洞察标题(带警告图标)
- 离开患者页面或关闭侧边栏时自动清除

同时:
- analysis.ts 新增 getHealthSummary API + HealthSummaryResponse 类型
- 权限检查:需要 ai.analysis.list 才显示摘要卡片
2026-05-19 00:37:46 +08:00
iven
1e2ad6170a feat(web): Phase 2A-1 AI 侧边栏骨架 — 浮动按钮 + 聊天 Drawer
新增 Web 管理后台 AI 侧边栏:
- 右下角渐变色浮动按钮(RobotOutlined),hover 放大效果
- AiSidebar Drawer 组件:聊天消息列表 + 输入框 + 发送按钮
- 自动检测当前页面患者 ID,携带 patient_id 上下文到 /ai/chat
- 权限检查:无 ai.chat.send 权限时禁用输入并提示
- 气泡样式对话:用户消息蓝色右对齐,助手消息灰色左对齐
- 清空对话、加载态(思考中 Spin)、Enter 发送 + Shift+Enter 换行

新增文件:
- apps/web/src/api/ai/chat.ts — AI 聊天 API 模块
- apps/web/src/components/ai/AiSidebar.tsx — 侧边栏组件

修改文件:
- apps/web/src/layouts/MainLayout.tsx — 集成浮动按钮 + AiSidebar
2026-05-19 00:32:08 +08:00
iven
b2053d5bcc feat(ai): Phase 2A-4 新增 3 个 Agent Tool — 化验报告/预约/用药查询
新增 3 个 AI Agent Tool 扩展医护沙箱能力:
- query_patient_lab_reports: 查询患者化验报告列表(含异常计数)
- query_patient_appointments: 查询患者即将到来的预约
- query_patient_medications: 查询患者当前用药列表

同时:
- HealthDataProvider trait 新增 get_patient_lab_reports 方法 + LabReportListItemDto
- erp-health 实现新 trait 方法(含 PII 解密)
- sandbox.rs 更新角色权限:Patient 可查体征/化验/用药,MedicalStaff 额外可查预约
- 修复 ai_prompt_tests.rs 中 AnalysisService::new 签名变更的遗留编译错误
- 新增 5 个 agent 测试覆盖新 Tool 和沙箱权限过滤
2026-05-19 00:19:10 +08:00
iven
89581b070f feat(ai): Phase 1C 管理看板 — 用量/成本/功能开关三合一
- UsageService 新增 get_daily_usage + aggregate_daily 日聚合能力
- 新增 3 个管理端点: /ai/admin/daily-usage, /ai/admin/flags (GET+POST)
- AiUsageDashboard 扩展为三 Tab: 用量概览/成本分析/功能开关
- 功能开关支持 Switch 实时切换,权限码 ai.admin.flags
- 日聚合用量 30 天趋势表,含 Token/成本汇总统计
2026-05-18 23:36:33 +08:00
iven
5ba28ea349 feat(ai): Phase 1B 角色沙箱 — 三级权限隔离 + Tool 过滤 + 输出控制
- 新增 agent/sandbox.rs: UserRole/SandboxConfig/OutputFilter 三级模型
- resolve_role() 从 JWT roles 解析为 Patient/MedicalStaff/Admin
- ToolRegistry.tool_definitions_filtered() 按角色白名单过滤
- orchestrator.run() 新增 allowed_tools 参数,Tool 执行时二次校验
- chat_handler 集成沙箱:角色 Prompt 后缀 + 患者免责声明追加
2026-05-18 23:28:30 +08:00
iven
7e3d27ecf3 feat(ai): Phase 1A 收尾 — 用量记录 + 健康摘要端点 + 小程序组件
- chat_handler 添加 log_usage 精确记录 token 消耗(provider + model)
- SSE build_sse_stream 添加估算 token 用量记录(4 字符 ≈ 1 token)
- 新增 GET /ai/health-summary 端点聚合患者洞察+分析记录
- 小程序 AiHealthSummaryCard 组件(风险等级+洞察统计+摘要列表)
- 小程序 services/ai-analysis 新增 getHealthSummary API
2026-05-18 23:20:06 +08:00
iven
281c71ebfc feat(web): AiAnalysisCard 通用组件 — 封装 SSE 流式分析 + 加载/错误/成功态
- 新增 AiAnalysisCard: 支持 4 种分析类型, 自动组装 request body
- 4 种状态: idle(按钮) / loading(骨架屏) / error(重试) / success(结果卡片)
- 权限控制通过 AuthButton 集成, 供多触点复用

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:58:51 +08:00
iven
bf37acc681 feat(ai): AI 健康管家 V2 基础设施 — 功能开关 + 角色沙箱准备 + 体征页 AI 趋势分析
- 迁移 000153: 新增 ai_feature_flags / ai_usage_daily / ai_suggestion_feedback 三张表,
  ai_tenant_configs 增加 billing_enabled 列, seed 12 个功能开关 + 2 个管理权限码
- 新增 FeatureFlagService: 5 分钟缓存 + DB 回退 + 即时更新
- VitalSignsTab 添加 AI 趋势分析按钮 (SSE 流式)
- 新增 3 个 Entity (ai_feature_flags / ai_usage_daily / ai_suggestion_feedback)
- AiState 扩展 feature_flags 字段
- 设计规格 + 讨论记录文档

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:55:40 +08:00
iven
d623f8b2ff fix: V1 测试版本端到端验证修复 — 6 CRITICAL + 3 HIGH 问题全量修复
修复项:
- fix(db): 迁移 149 — 修复 Admin 角色权限绑定被迁移链破坏 (FE-C1)
- fix(health): 4 个 handler 添加空名称验证 — Doctor/Article/AlertRule/Tag (API-C1~C4)
- fix(health): Stats 仪表盘 new_this_week 查询修复 — SeaORM date_trunc bug (FE-C2)
- fix(server): 添加安全响应头 — X-Frame-Options/CSP/XSS-Protection/Referrer-Policy (SEC-H1)
- fix(mp): 预约创建契约修复 — notes/reason 字段映射 + 移除 schedule_id (MP-H1)
- fix(mp): 咨询会话 subject/last_message 字段改为可选 (MP-H3)
- fix(ai): AiConfig Default derive 替代手写 impl (clippy)

测试报告:
- 8 维度端到端测试全部完成 (后端 87 用例 / 前端 30 页面 / 小程序 80+ API / 安全 20 项 / 性能 20 端点)
- 多角色 7 角色 49 检查 100% 通过
- 综合测试报告 + 专家评估报告
2026-05-18 10:24:40 +08:00
iven
38b0d91407 feat(mp): AI 聊天传递 patient_id 支持体征数据查询 Tool Call
- ai-chat service: sendAiMessage 新增 patientId 可选参数
- messages 页面: 从 authStore 获取 currentPatient.id 传入

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:35:18 +08:00
iven
20714661d2 docs(qa): 五专家组头脑风暴 V1 测试发布就绪评估报告
综合评分 6.8/10 (B),有条件通过内部测试发布。
9 个章节完整覆盖:执行摘要 / 产品 / 架构 / 安全 / 测试 / UX / 行动计划 / 风险 / V1.1 路线图。
2026-05-18 04:50:36 +08:00
iven
edea8f49d1 docs(wiki): 关键数字更新 — AI Agent Phase 0 完成(迁移 148、AI 表 13、权限码 132) 2026-05-18 04:00:02 +08:00
iven
882b27ab7a fix(ai): Agent chat handler 精确选择 FC-capable provider + 环境变量适配
- chat_handler: 使用 get_provider("claude") 精确获取,避免 resolve fallback 到 Ollama
- ProviderRegistry: 新增 get_provider() 方法(无 health check,无 fallback)
- orchestrator: 从 ANTHROPIC_DEFAULT_SONNET_MODEL 读取模型名,兼容智谱代理
- erp-server: Claude provider 注册优先读 ANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URL

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:58:38 +08:00
iven
e47fe547c8 test(ai): Phase 0 集成测试 — Agent 循环 + Tool 执行 + Mock Provider 2026-05-18 03:17:34 +08:00
iven
aab4dfea79 feat(ai): 改造 chat_handler 接入 AgentOrchestrator — ReAct Agent 首次跑通 + 新增会话权限码 2026-05-18 03:12:33 +08:00
iven
f42669f934 feat(ai): 实现 query_patient_vitals Tool — 首个端到端 Agent Tool 2026-05-18 02:58:38 +08:00
iven
2d62605812 feat(ai): AgentTool trait + ToolRegistry + AgentOrchestrator — ReAct 循环(最多 5 轮 Tool Call) 2026-05-18 02:56:26 +08:00
iven
877e9831f6 feat(db): 迁移 000148 — AI 聊天会话/消息/工具日志/用户画像 4 张表 2026-05-18 02:51:58 +08:00
iven
f668f0995a feat(core): HealthDataProvider 新增 get_upcoming_appointments + get_medication_list 2026-05-18 02:47:15 +08:00
iven
46b30504a5 feat(ai): Ollama Provider 声明不支持 Function Calling 2026-05-18 02:37:12 +08:00
iven
f42e3ba611 feat(ai): OpenAI Provider 实现 generate_with_tools — function calling 支持 2026-05-18 02:35:50 +08:00
iven
64456d0172 feat(ai): Claude Provider 实现 generate_with_tools — tool_use/tool_result 解析 2026-05-18 02:32:39 +08:00
iven
cad48a97d5 feat(ai): AiProvider trait 新增 generate_with_tools 默认方法 + UnsupportedOperation 错误变体 2026-05-18 02:29:19 +08:00
iven
01c75dbf5d feat(ai): 添加 Agent Function Calling DTO — ChatMessage/ToolDefinition/ToolCall/AgentGenerateResponse 2026-05-18 02:26:57 +08:00
iven
4e12298ff3 docs: 发散式讨论记录 — AI Agent 突破口方向确认 2026-05-18 02:19:03 +08:00
iven
e149a61ce6 fix(auth): error 类型 + auth_service 小修复 2026-05-18 02:14:14 +08:00
iven
3aa71a94d2 docs(skills): design-handoff 设计稿 + spec + .gitignore 更新
- mp-11-doctor-core 设计交付包(截图 + tokens)
- mp-13/mp-14 新原型 HTML
- design-handoff skill 设计规格文档
- .gitignore 排除 dist-h5/test-results/uploads 等构建产物
2026-05-18 02:13:29 +08:00
iven
ded37830fe feat(mp): 新增 AvatarCircle/ShortcutButton/TodoAlert 组件 + 商品详情页
- AvatarCircle: 头像圆形组件
- ShortcutButton: 快捷操作按钮
- TodoAlert: 待办提醒组件
- pkg-mall/product: 积分商品详情页
2026-05-18 02:12:58 +08:00
iven
e555496528 feat(mp): design-handoff 产出的页面样式和组件优化
- 首页/商城/医生端/积分/家庭档案等页面 SCSS + TSX 更新
- TabFilter 组件样式优化
- points service 接口调整
- app.config 路由注册更新
2026-05-18 02:12:41 +08:00
iven
2698c98888 docs(ai): Phase 0 实施计划 — 12 Tasks,修复 Review R1 问题
- 复用已有 TokenUsage(修复编译冲突)
- AppointmentSummaryDto.id 改为 Uuid + scheduled_at 改为 DateTime
- Orchestrator 达到上限时用 User role(而非 Assistant)
- 添加路由说明(Phase 0 复用 /ai/chat,Phase 2 变更)
- 添加模型选择说明(Phase 0 硬编码 auto)
2026-05-18 02:10:59 +08:00
iven
31771168dd docs(ai): Spec Review R2 修复 — 复用 HealthDataProvider + 新增 generate_with_tools
CRITICAL:
- 移除重复的 HealthDataQuery trait,扩展现有 HealthDataProvider(新增 2 方法)
- Provider 适配改为新增 generate_with_tools 方法,不破坏现有 generate 路径

IMPORTANT:
- 修复章节编号(全文重排为连续编号)
- ai_tool_call_logs 补充 created_by + 说明省略原因(append-only)
- ai_user_profiles 说明省略 created_by/updated_by 原因(Agent 自动维护)
- ToolContext 改为持有 Arc<dyn HealthDataProvider> 而非裸 db
- SSE 语义明确:仅流式输出最终回复
- 5 轮上限强制终止逻辑:追加总结指令让 LLM 正常结束
- GenerateRequest 不再破坏性修改,新旧路径并行
2026-05-18 01:57:16 +08:00
iven
b0892706c8 fix(skills): Step 7 改为 design-handoff 自行实施,不再调用 huashu-design
原因: huashu-design 是纯设计 skill,加实施模式会导致人格冲突、
SKILL.md 膨胀、约束倒灌。design-handoff 已有 SPEC+截图+Token 映射,
直接在当前会话实施代码更自然。
2026-05-18 01:54:17 +08:00
iven
530262590e feat(skills): design-handoff 升级为全流程编排器
- 新增 Step 0: 调用 huashu-design 设计 HTML 原型
- 新增 Step 7: 调用 huashu-design 根据 SPEC 实施代码
- 支持两种输入模式: 从需求开始(完整7步) / 从原型开始(6步)
- 简化文档: 移除冗余的降级代码块,保留核心流程
2026-05-18 01:51:40 +08:00
iven
6759723731 docs(ai): 修复 Spec Review 发现的 9 个问题
CRITICAL 修复:
- 新增跨 crate 数据访问架构(erp-core HealthDataQuery trait)
- 新增 4 个权限码声明(session.list/manage/history + chat.send)
- 明确 Provider Function Calling 需中等程度重构,补充适配方案

IMPORTANT 修复:
- 说明与 copilot_chat_logs 表的关系(并存不迁移)
- 新增 ai_user_profiles 表(长期记忆/用户画像)
- 定义 DisplayHint 枚举(5 种富消息类型)
- Phase 0 验证标准修正为新端点
- 补充 Taro SSE 兼容层 + Web 从零构建,工期上调
- 新增 PII 脱敏规范(6 类字段处理规则)
- 新增故障处理与降级章节
- Phase 0/2 工作量估算上调,总工期 16-23→20-27 天
2026-05-18 01:51:03 +08:00
iven
1c8319fb4d docs(ai): AI Agent 突破口设计规格 — ReAct Agent + Function Calling
erp-ai 客服从简单问答升级为多策略 ReAct Agent:
- Agent Orchestrator 实现 ReAct 循环(最多 5 轮 Tool Call)
- 4 类 12 个 Tool 覆盖数据查询/AI 分析/知识服务/行动操作
- 多策略 System Prompt(安抚/科普/推荐/预警/引导到院)
- 3 张新表(sessions/messages/tool_logs)+ 会话管理 API
- 小程序 + Web 富消息渲染 + SSE 流式输出
- 4 Phase 分阶段实施,总工期 16-23 天
2026-05-18 01:42:13 +08:00
iven
6151fde7c4 fix(skills): 修复 design-handoff 医生端原型输出质量
- match-tokens: alias 增加值条件映射,支持 doctor/patient variant 自动检测
- parse-prototype: 支持 IosFrame label prop 提取(JSX 属性模式)
- interaction-rules: login-cta 增加 exclude_patterns 避免"立即关注"误报
- tokens.yml: T.pri/T.priL/T.priD alias 改为数组格式,按值匹配 variant

验证: 患者端 T.pri→--tk-pri 确认,医生端 T.pri→--tk-pri.doctor 确认
2026-05-18 01:00:20 +08:00
iven
c26ca9088b feat(skills): E2E 验证通过 + 首个交付包(mp-00-visitor) + .design/tokens.yml
全管线验证结果:
- parse: 22 tokens, 5 screens, 7 components
- screenshots: 5/5 PNG 截图(2x 高清,裁掉设备框)
- token match: 20/22 matched (19 confirmed, 14 pending, 2 unmatched)
- interactions: 5/8 rules matched

交付包: docs/design/mp-00-visitor/
- screenshots/ (5 PNG)
- tokens.json (三层匹配结果)
- META.yml (元数据)

配置: .design/tokens.yml (项目级 Token 配置)
2026-05-18 00:08:05 +08:00
iven
63a9dac9d3 docs(skills): 更新 SKILL.md 脚本调用参数对齐实际接口
- parse-prototype.mjs: 输出 tokens/inlineStyles/screens/components
- extract-screenshots.mjs: 参数改为 <html> <output-dir>
- match-tokens.mjs: 参数改为 <parse-result.json> <tokens.yml>
- infer-interactions.mjs: 参数改为 <html> <rules.yml>
- 输出目录结构对齐 spec: docs/design/mp-00-visitor/
2026-05-18 00:05:59 +08:00
iven
a4732cd2d4 feat(skills): 添加截图提取 + 交互推断脚本 + SPEC 模板
- extract-screenshots.mjs: Playwright 截取 IosFrame 内容区域,裁掉设备框
- infer-interactions.mjs: 8 条规则正则匹配源码推断交互行为
- 重写 interaction-rules.yml: patterns 改为代码级正则(非自然语言描述)
- templates/spec-template.md: SPEC.md 六章节模板
2026-05-18 00:04:31 +08:00
iven
35bd60af5b feat(skills): 添加 HTML 原型解析 + Token 三层匹配脚本
- parse-prototype.mjs: 括号深度计数法提取 T 对象、内联样式值、screen/组件信息
- match-tokens.mjs: 别名直查→值精确匹配(带 CSS 属性消歧)→色彩模糊匹配(RGB Δ<30)
- 修复 tokens.yml radius 段 YAML 格式(unmapped 提升为顶级段)
2026-05-17 23:37:59 +08:00
iven
b8dce8a42a feat(skills): 创建 design-handoff skill 骨架 + 配置文件
- SKILL.md: skill 入口,含 6 步核心流程 + 组件映射推断规则
- defaults/tokens.yml: 从 tokens.scss 提取的完整 Token 注册表
- defaults/components.yml: 16 个 UI 组件映射 + 5 个框架组件
- rules/interaction-rules.yml: 8 条交互推断规则
- package.json + js-yaml 依赖
2026-05-17 23:17:37 +08:00
iven
d26ea64ab2 docs(wiki): 全面重写小程序质量规范清单 — 12类44条规则,覆盖164条git历史
从 164 条小程序 git 提交中全量抽象问题模式:
- 新增 4 类规则:前后端接口契约(字段对齐/API路径/必填字段)、安全(XSS/输入验证/敏感数据/权限)、定时器与副作用清理(setTimeout)、开发环境(DevTools优化)
- 补充已有类别:请求缓存去重(1.4)、统一组件库(3.5-3.6)、认证恢复(5.3-5.4)、对齐原型(7.4-7.5)、ErrorBoundary(9.3)
- 溯源表从 13 条扩至 38 条,每条对应具体修复提交
- 自查脚本从 6 项扩至 10 项
- 统计概览:44 规则 / 66+ fix 提交 / 20 CRITICAL+HIGH
2026-05-17 20:33:25 +08:00
iven
676a6c0e13 docs(wiki): 新增小程序质量规范清单 — 8类规则+提交前检查+PR Review依据
抽象两轮审计中的 20+ 问题为可复用规范:
- 并发与请求层:限流器/长轮询独立通道/reLaunch去重
- 导航与路由:页栈保护/分包预加载/生命周期防重入
- 组件与渲染:禁止render body组件/双重ScrollView/图片懒加载
- 数据与内存:数组上限/去重索引一致/Storage清理
- 认证与会话:模块缓存清理/Token刷新安全
- 审计与交付:提交前必检/PR Review检查点/里程碑审计
2026-05-17 20:12:39 +08:00
iven
fcce2f5c51 fix(mp): 二轮审计修复 — ScrollView嵌套/InputField重建/markdown分组/BLE上限/缓存清理
CRITICAL: ai-report/list PageShell scroll=false 修复双重滚动冲突
HIGH: dialysis/create InputField 提取为独立组件避免 render 销毁重建
MEDIUM: markdownToHtml 连续<li>合并到单个<ul>
MEDIUM: 咨询详情页图片添加 lazyLoad
MEDIUM: BLEManager readings 添加 MAX_LIVE_READINGS=200 上限
MEDIUM: DataBuffer trimToMax 时重建 seenKeys 保持一致性
MEDIUM: auth.ts logout 清理模块级缓存变量
LOW: request.ts safeReLaunch 添加 console.warn + doRefresh 死锁警告注释
2026-05-17 18:54:27 +08:00
iven
66aef532fa docs(wiki): 更新小程序并发安全相关内容 — 并发限制器/长轮询/导航保护 2026-05-17 18:37:55 +08:00
iven
3c98aaedbd docs(wiki): 更新关键数字日期 — 小程序 P0-P2 修复 2026-05-17 17:13:54 +08:00
iven
59dd5ef38e fix(mp): P1+P2 稳定性加固 — 导航安全+生产日志+分包预加载+logout清理
P1:
- 全局 23 个页面 Taro.navigateTo → safeNavigateTo,防止页栈超10层
- 生产构建保留 console.warn/error,便于线上问题排查
- 添加 preloadRule 分包预加载(首页预加载健康/医生/文章分包)

P2:
- logout 时清理 ai_chat_history + BLE DataBuffer 缓存
- restore() 移除冗余的双重 Storage 读取(secureGet 已包含 getStorageSync)
- 首页文章图片添加 lazyLoad
2026-05-17 17:13:35 +08:00
iven
1576709342 docs(wiki): 新增症状导航 — 开发者工具卡死(并发饥饿)修复记录 2026-05-17 17:02:08 +08:00
iven
9d50ef7847 fix(mp): 修复并发请求饥饿导致开发者工具卡死
- 长轮询走独立通道(requestUnlimited),不再占用 ConcurrencyLimiter 槽位
- ConcurrencyLimiter 上限 8→12,缓解 TabBar 切换请求风暴
- 新增 safeReLaunch 去重,防止并发 401 多次触发页面跳转
- maxFailures 50→10,后端不可用时快速止损而非持续 18 分钟重试

根因:咨询页长轮询每次占用槽位 25-30s,8 个槽位被占满后
所有新请求排队等待,叠加 401 场景形成死锁。
2026-05-17 17:01:24 +08:00
iven
b84becfbea fix(mp): 文章详情页对齐 mp-04 原型 — 流式布局+底部浮动栏
- 标题 22px serif bold(原型值),元信息 13px tx3
- 正文 15px tx2 lineHeight:1.8 首行缩进(原型值)
- 去掉分段白卡片,改为统一背景流式布局
- 新增底部浮动栏:收藏+分享按钮
- 分隔线 1px bdL
2026-05-17 15:53:03 +08:00
iven
d5ec250184 feat(docker): 云端部署配置 — host 网络模式 + 环境变量模板
- Dockerfile: Rust 版本升级为 latest stable, 添加 curl (healthcheck),
  前端产物 VOLUME 暴露供 OpenResty 挂载
- docker-compose.cloud.yml: 仅 app 容器, host 网络直连宿主机 PG/Redis
- .env.production.example: 环境变量模板含必填/可选项注释
2026-05-17 15:06:53 +08:00
iven
b8ce19f5dc fix(mp): 文章列表页对齐 mp-04 原型 — 分类Tab+卡片布局+字号
- 分类Tab: 选中态 pri 白字+阴影,未选中 surface-alt 圆角药丸
- 文章卡片: 80×80 缩略图+标题 16px serif+摘要 13px+元信息 12px
- ContentCard padding=sm margin=none,PageShell padding=none
- 缩略图 80×80(原型 80×80),封面 r-xs=8(原型 rXs=8)
2026-05-17 14:55:56 +08:00
iven
29d77e8c3d fix(mp): SegmentTabs pill 增加 margin-bottom 间距 2026-05-17 14:38:29 +08:00
iven
6c42d541fc fix(mp): SegmentTabs pill 变体对齐原型 — 等分圆角矩形 + 阴影
pill 变体改为 flex:1 等分宽度、height:44、borderRadius:12px 圆角矩形
(原型 T.rSm=12),选中态加 var(--tk-shadow-tab) 阴影,
字号 15px fontWeight:600 对齐原型。
2026-05-17 14:34:50 +08:00
iven
c631d364b3 fix(core): 消除乐观锁 version.unwrap() 潜在 panic
20 处 ActiveValue::unwrap() + 1 乐观锁递增改为 take().unwrap_or(0) + 1,
避免数据库记录缺少 version 字段时 panic。覆盖 erp-auth/erp-config/
erp-workflow/erp-health/erp-ai/erp-server 7 个 crate。
DTO 层 Option<i32> 字段保持原有 unwrap_or(0) 不变。
2026-05-17 13:05:40 +08:00
iven
7b2c03309c fix(mp): Profile 积分卡片 flex:1 等分宽度 2026-05-17 13:04:10 +08:00
iven
e8bbc36364 perf(auth): JWT 权限缓存 RwLock 替换为 DashMap
USER_SCOPE_CACHE 从 LazyLock<RwLock<HashMap>> 改为 LazyLock<DashMap>,
消除读写锁竞争,提升高并发场景下的认证中间件吞吐量。
过期条目淘汰逻辑改用 DashMap::retain,无需手动获取 write lock。
2026-05-17 12:54:34 +08:00
iven
c2c7f2d967 docs(wiki): 更新 Design Token 字号统计 + ContentCard margin prop 2026-05-17 12:53:28 +08:00
iven
227d81ddd6 ci(security): 新增 cargo audit + npm audit 安全扫描步骤
后端 CI 添加 cargo audit 依赖漏洞扫描,前端添加 npm audit。
在每次 PR 和 main push 时自动检测已知安全漏洞。
2026-05-17 12:50:27 +08:00
iven
551d19d921 fix(mp): 修正 design token 字号对齐原型 + ContentCard margin prop
- tokens.scss: 修正字号 token 对齐 18 份原型稿 fontSize 统计
  --tk-font-h1: 26→28px, --tk-font-h2: 24→22px
  --tk-font-body-lg: 28→18px, --tk-font-body: 22→16px
  --tk-font-body-sm: 16→14px
  elder-mode 同步重新计算比例系数
- ContentCard: 新增 margin prop ('none'|'md'),margin-bottom
  从 CSS 类移至 inline style,支持列表容器内无间距模式
- Profile 页: 用户卡片添加 profile-user-card flex 布局
  菜单组/积分卡片使用 margin="none",修复布局对齐
- Login 页: SCSS 全部改为 design token 引用
2026-05-17 12:48:38 +08:00
iven
6841c45846 fix(security): 文件上传 MIME 白名单 + OAuth JWT 密钥路径统一
P0 #1: 媒体文件上传增加 MIME 类型白名单校验(jpeg/png/gif/webp/svg/mp4/webm/pdf)
       和文件大小限制(10MB),扩展名使用白名单清理防止路径遍历攻击。
P0 #2: OAuth JWT 密钥从环境变量改为 State 注入,消除运行时 env::var 依赖,
       FHIR 路由中间件使用闭包捕获 jwt_secret 保持类型安全。
2026-05-17 12:40:02 +08:00
iven
8d3c5915c9 docs(analysis): 六维度全面均衡分析 + wiki 关键数字校正
- 6 专家组并行分析:架构 8.5 / 安全 7.5 / 测试 5.5 / 前端 7.2 / DevOps 3.8 / 产品 8.0
- 综合评分 6.8/10 (B),分析报告 + 讨论记录
- wiki 关键数字校正:源文件 652、迁移 147、权限码 128、Web 307 TS/TSX 等
2026-05-17 12:01:15 +08:00
iven
c38967a36e fix(mp): 修复小程序角色路由 + 前后端字段对齐 + E2E 测试报告
- 修复 stores/auth.ts 三种登录方式从错误路径提取 roles(resp.roles → resp.user.roles)
- 首页添加医护人员自动跳转医生端(useDidShow + isMedicalStaff)
- services/auth.ts credentialLogin 返回类型补全 roles 字段
- Web 前端 healthData.ts 字段对齐后端 DTO(indicators→items, content→overall_assessment)
- Web 前端 medicationReminders.ts 字段对齐(time_slots→reminder_times)
- 小程序 report.ts / reports 页面字段对齐后端(indicators→items, doctor_interpretation→doctor_notes)
- 小程序 patient.ts / followup.ts / alert.ts 补全缺失字段
- 后端 stats_handler.rs 权限码修正(health.patient.list→health.dashboard.manage)
- 新增 V1 E2E 测试报告和五专家组评审报告
2026-05-17 01:51:02 +08:00
iven
aa27c5174c docs(mp): 新增小程序全页面 HTML 原型 + UI 优化指南
- 新增 12 个核心页面原型(登录/首页/咨询/预约/商城/健康等)
- 新增医生端分包原型(核心 + 临床两个分包)
- 新增 AI 客服对话页原型
- 新增 MP UI 优化指南文档
- 更新 wiki 基础设施和小程序文档
2026-05-17 00:51:07 +08:00
iven
710b2e2423 feat(ai): 新增 AI 客服聊天功能 + 消息页重构为小华助手
- 新增 POST /ai/chat 端点,由 LLM(Ollama qwen3)担任 24h 健康客服"小华"
- 新增 ai.chat.send 权限,绑定管理员/患者/医生/护士/健康管理师角色
- 消息页从咨询列表重构为单窗口 AI 对话(欢迎态 + 聊天态 + 快捷问诊)
- 通知功能迁移到"我的"页面菜单项(带未读角标),独立通知列表页
- 修复气泡文字截断:改用百分比 max-width + block Text + pre-wrap 换行
- 修复权限绑定:迁移 SQL 角色名从英文改为中文(admin→管理员,patient→患者)
2026-05-17 00:49:41 +08:00
iven
4be28de3ce fix(health): 修复患者端咨询权限+聊天页UI+SVG模板警告
- consultation_handler: create_message/mark_session_read 从 .manage 降为 .list,
  患者端只有 list 权限,导致发送消息和标记已读 403
- consultation.ts: 同步后端 DTO doctor_name/patient_name 等缺失字段
- messages/index.tsx: 咨询卡片显示医生姓名替代 consultation_type
- consultation/index.tsx: 同步显示 doctor_name
- pkg-consultation/detail: 按原型重写聊天页(医生头像+在线状态+非对称气泡+药丸输入栏)
- ProgressRing: SVG 替换为 conic-gradient 纯 CSS,消除 tmpl_0_svg 模板警告
- usePageData: stopPullDownRefresh 加 try-catch 防止 DevTools fd race
2026-05-16 22:38:21 +08:00
iven
95e219ad5a refactor(mp): CSS 变量主题 + 登录页改造 — UI 优化 Phase 0-2
Phase 0: 建立 design token 体系
- tokens.scss 新增 --tk-pri/--tk-pri-l/--tk-pri-d/--tk-shadow-btn/--tk-shadow-tab
- .doctor-mode 覆盖为靛蓝色系,.elder-mode 非线性放大字号
- variables.scss 新增医生端色彩 + 阴影变量

Phase 1: 组件库 + 页面全局替换
- 75 个页面 SCSS $pri → var(--tk-pri) 全量替换
- 11 个新 UI 组件(PrimaryButton/TabFilter/FormInput/ProgressRing 等)
- 8 个现有组件 SCSS 更新
- 18 个医生端页面 useElderClass → useDoctorClass
- PageHeader 匹配原型 NavBar 规格

Phase 2: 登录页重写
- Logo: 方形+ → 圆形渐变 H
- 登录方式: 纯微信 → 账号密码 + 微信一键登录
- 新增 credentialLogin API + store action
- 字号/间距严格匹配原型 mp-01-login.html
2026-05-16 21:29:13 +08:00
iven
1786f0d707 fix(mp): 修复组件库运行时错误 — React 未导入 + SCSS 路径
- 所有原子/组合组件添加 import React from 'react'(修复 React is not defined)
- 修复 SCSS import 路径:components/ui 和 patterns 需要 ../../../ 而非 ../../
- 修复 action-inbox 页面 SCSS import 层级
2026-05-16 07:38:26 +08:00
iven
f8d0b41d61 docs(wiki): 更新关键数字 — 统一组件库迁移完成 2026-05-16 01:56:22 +08:00
iven
184bd0ea03 refactor(mp): 迁移剩余 8 特殊页面到统一组件库
- 首页/健康/我的/商城/消息 TabBar 页面使用 PageShell 替代手写容器
- 登录/法律条款/关怀模式设置页使用 PageShell 替代手写容器
- 各页面卡片统一使用 ContentCard 组件
- 清理页面 SCSS 中的 min-height/background/padding 样板代码
- 66 个小程序页面全部完成统一组件迁移
2026-05-16 01:55:28 +08:00
iven
c6bffd4019 refactor(mp): 迁移个人中心 12 个页面 — 统一组件库
线下活动、健康档案、报告列表、随访列表、透析记录、
透析处方、诊断列表、用药列表、家庭成员、添加家庭成员、
设置、知情同意共 12 个页面迁移:
- 最外层容器 → PageShell
- SCSS 删除 min-height/background 通用样式
2026-05-16 01:34:05 +08:00
iven
466b6567d1 refactor(mp): 迁移商城+AI报告+预约列表+文章详情页 — 统一组件库
商城订单/积分兑换/积分明细、AI 报告列表、预约列表、
文章详情共 6 个页面迁移:
- 最外层容器 → PageShell
- SCSS 删除 min-height/background 通用样式
2026-05-16 01:33:42 +08:00
iven
37327a4da4 refactor(mp): 迁移医护+健康页面 — 使用 PageShell + ContentCard 统一组件库
行动收件箱、医护工作台、健康趋势、患者告警、体征录入、
日常监测、设备同步共 7 个页面迁移:
- 最外层容器 → PageShell
- 卡片元素 → ContentCard
- SCSS 删除 min-height/background/box-shadow 通用样式
2026-05-16 01:33:24 +08:00
iven
4dd5a1b4d9 refactor(mp): 迁移创建页 — 使用 PageShell + ContentCard 统一组件库
处方创建、透析记录创建、预约创建三个表单页迁移:
- 最外层容器 ScrollView/View → PageShell
- 表单分组 section View → ContentCard
- SCSS 删除 min-height/background/box-shadow 通用样式
2026-05-16 01:33:06 +08:00
iven
900c9babc3 refactor(mp): 迁移预约+AI报告详情页 — 使用统一组件库 (12/12)
预约详情页:
- View.detail-page → PageShell 替代手写 page 容器
- 手写 detail-header → PageHeader 组件
- .status-card / .info-section / .tips-card → ContentCard
- 删除通用 page 容器、header 和 card 样式

AI 报告详情页:
- View.detail-page → PageShell 替代手写 page 容器
- .detail-card / .content-card → ContentCard
- 删除通用 page 容器和 card 样式,保留 RichText 内部样式
2026-05-16 01:17:04 +08:00
iven
61f1061092 refactor(mp): 迁移患者端详情页 — 使用统一组件库 (10/12)
报告详情、随访详情、透析记录详情、透析处方详情页:
- View.detail-page → PageShell 替代手写 page 容器
- .detail-card → ContentCard 替代手写卡片样式
- 删除通用 page 容器和 card 样式,保留业务布局样式
2026-05-16 01:16:49 +08:00
iven
85701ddeb2 refactor(mp): 迁移医生核心详情页 — 使用统一组件库 (6/12)
随访详情、患者详情页:
- ScrollView → PageShell 替代手写 page 容器
- .section → ContentCard 替代手写卡片样式
- 删除通用 page 容器和 card 样式,保留业务布局样式
2026-05-16 01:16:32 +08:00
iven
5e230ba1b5 refactor(mp): 迁移医生临床详情页 — 使用统一组件库 (4/12)
告警详情、报告详情、处方详情、透析详情页:
- ScrollView → PageShell 替代手写 min-height/bg/padding
- .section / .alert-detail-card → ContentCard 替代手写卡片样式
- 删除通用 page 容器和 card 样式,保留业务布局样式
2026-05-16 01:16:16 +08:00
iven
8d41d5a167 refactor(mp): 迁移随访列表页 — 使用统一组件库 PageShell/ContentCard/StatusTag/LoadingCard/SearchSection 2026-05-16 00:56:51 +08:00
iven
40b88c566d refactor(mp): 迁移医护咨询列表页 — 使用统一组件库 PageShell/ContentCard/StatusTag/LoadingCard/SearchSection/PaginationBar 2026-05-16 00:56:38 +08:00
iven
483342a1d8 refactor(mp): 迁移告警列表页 — 使用统一组件库 PageShell/ContentCard/StatusTag/LoadingCard/SearchSection/PaginationBar 2026-05-16 00:56:26 +08:00
iven
ae23baeece refactor(mp): 迁移报告列表页 — 使用统一组件库
- PageShell 替代手写 ScrollView + min-height/bg/padding
- SearchSection 替代搜索栏
- ContentCard 替代 report-card 手写样式
- StatusTag 替代 report-card__reviewed 手写标签
- LoadingCard 替代 Loading 组件
- 精简 SCSS:删除 page/search-bar/card 通用样式,保留业务特有样式
2026-05-16 00:56:18 +08:00
iven
3e88dcaba5 refactor(mp): 迁移处方列表页 — 使用统一组件库
- PageShell 替代手写 ScrollView + min-height/bg/padding
- SearchSection 替代搜索栏 + SegmentTabs 替代 tabs(预设患者时)
- ContentCard 替代 prescription-card 手写样式
- StatusTag 替代 status-tag 手写样式
- LoadingCard 替代 Loading 组件
- PaginationBar 替代手写分页
- 精简 SCSS:删除 page/search-bar/card/pagination 通用样式,保留业务特有样式
2026-05-16 00:56:02 +08:00
iven
9415807a40 refactor(mp): 迁移透析列表页 — 使用统一组件库
- PageShell 替代手写 ScrollView + min-height/bg/padding
- SearchSection 替代搜索栏 + SegmentTabs 替代 tabs(预设患者时)
- ContentCard 替代 record-card 手写样式
- StatusTag 替代 status-tag 手写样式
- LoadingCard 替代 Loading 组件
- PaginationBar 替代手写分页
- 精简 SCSS:删除 page/search-bar/card/pagination 通用样式,保留业务特有样式
2026-05-16 00:55:50 +08:00
iven
1579f35ff5 refactor(mp): 迁移患者咨询列表页 — 使用统一组件库
- PageShell 替代手写 min-height/bg/padding
- ContentCard 替代手写 session-card 卡片样式
- StatusTag 替代手写 session-tag 状态标签
- LoadingCard 替代初始加载态
- EmptyState 替代手写空状态
- ErrorState 替代手写错误状态
- 精简 SCSS 删除已接管样式,保留按钮/头像/角标等页面特有样式

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 00:55:40 +08:00
iven
9728afbc1b refactor(mp): 迁移文章列表页 — 使用统一组件库
- PageShell 替代手写 min-height/bg/padding
- ContentCard 替代手写 article-card 卡片样式
- LoadingCard 替代初始加载态
- 精简 SCSS 删除已接管样式,保留分类筛选/卡片内容布局

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 00:55:24 +08:00
iven
80794c9547 refactor(mp): 试点迁移患者列表页 — 使用统一组件库
替换手写 UI 为:
- PageShell 替代手动 min-height/bg/padding
- SearchSection 替代手写搜索栏 + 标签筛选
- ContentCard 替代手写卡片样式(背景/圆角/阴影/触摸反馈)
- StatusTag 替代 @include tag() mixin
- LoadingCard 替代初始加载的 Loading 组件

SCSS 从 151 行精简到 65 行,保留页面特有业务样式。
数据加载逻辑(无限滚动 + usePageData)保持不变。
2026-05-16 00:50:27 +08:00
iven
d758563a13 feat(mp): 小程序统一组件库 Phase 1 — Token 扩展 + 10 组件 + useListPage Hook
三层架构组件库:
- 第 1 层原子组件:PageShell/ContentCard/StatusTag/SectionTitle/LoadingCard
- 第 2 层组合模式:PageHeader/SearchSection/CardList/PaginationBar
- 第 3 层 Hook:useListPage(列表页通用逻辑抽象)

Token 扩展:新增 --tk-card-*/--tk-gap-*/--tk-page-* 等结构化 CSS 变量,
关怀模式通过变量覆写自动生效,新组件零额外代码即获关怀支持。

设计规格:docs/superpowers/specs/2026-05-16-miniprogram-unified-components-design.md
2026-05-16 00:47:39 +08:00
iven
3fb5a77ac0 refactor(mp): 统一空状态为 EmptyState 组件 + 清理旧 Tab CSS
- 4 个页面的内联空状态替换为共享 EmptyState 组件
  (messages / action-inbox / pkg-health/alerts / mall)
- 清理 10 个页面的旧 Tab CSS(迁移到 SegmentTabs 后不再需要)
- 清理 elder-mode 中已删除的 mall-empty-state 引用
2026-05-15 23:11:34 +08:00
iven
c06e986090 fix(mp): 小程序页面优化 + E2E 测试报告更新
- 小程序各页面优化和修复
- 更新联调报告和 E2E 测试报告
- 更新 miniprogram wiki
2026-05-15 23:03:21 +08:00
iven
ced1c0ad0c fix(web): 清零前端 TS 构建错误 — 31 文件类型修复 + 面包屑 + 超时配置
- 修复 verbatimModuleSyntax 要求的 import type 声明
- 修复未使用导入(Badge/EditOutlined/Space/Input/Switch 等)
- 修复 mock.calls 类型注解([string,unknown] → any[])
- 修复 vitest 全局超时和 poolTimeout 配置
- 修复 PageContainer 缺少 onBack prop、MenuInfo children 可选
- 修复 CopilotAlert Badge status info→processing、useCopilotRisk 二次解包
- 修复 articles/doctors 测试 delete 调用缺少 version 参数
- 添加排班管理/预约管理面包屑标题 fallback
2026-05-15 23:03:08 +08:00
iven
bf8bcdbd5d fix: E2E 测试发现的后端 BUG 修复 — 限流拆分 + 积分查询 + 错误码修正
- 拆分 refresh token 限流为独立中间件(30次/分 vs 登录5次/分)
- 修复积分 recent-activity 500:JOIN 通过 points_account 中间表
- 修复患者/医生不存在返回 400 → 正确的 404 NotFound
2026-05-15 22:58:02 +08:00
iven
50e3b16381 fix(health): 添加 GET 单条轮播图端点 — 修复 Switch 切换 405
后端 /health/banners/{id} 路由只注册了 PUT/DELETE,缺少 GET handler。
前端 bannerApi.get(id) 调用时返回 405 Method Not Allowed,导致轮播图
状态切换失败。新增 banner_service::get_banner + banner_handler::get_banner
+ BannerNotFound 错误类型 + 路由注册。
2026-05-15 21:40:59 +08:00
iven
33febd2fbd docs(qa): 全链路 E2E 测试报告 — 156 用例 / 28 BUG / 通过率 76% 2026-05-15 21:14:04 +08:00
iven
d44c6167b1 fix: E2E 测试发现的 10 项 BUG 修复 — 全栈验证通过
P0 修复:
- 侧边栏路由不稳定: Content 区域添加 key={currentPath} 强制重渲染
- 轮播图缩略图不显示: BannerManage 导入 resolveMediaUrl + 反斜杠转正斜杠
- 超长名称导致 500: patient_handler 添加 name.len() > 255 校验
- 迁移 m20260515_000146: version 乐观锁 version+1 修复

P1 修复:
- 排班路由被冻结: routeConfig.ts 移除 /health/schedules 的 frozen 标记
- 轮播图 Switch 切换无效: 切换前先 GET 最新 version 避免乐观锁冲突
- thumbnail_url 反斜杠: media_service 存储时统一 replace('\', '/')

P2 修复:
- 预约类型 follow_up 未映射: APPOINTMENT_TYPE_MAP 补充 '随访'
- 日期选择器未汉化: DatePicker.RangePicker 添加中文 placeholder
- 轮播图 title 必填校验: banner_handler 添加空标题拒绝
- 文章分类重名: article_category_service 添加同名检查
2026-05-15 21:13:49 +08:00
iven
41515e5bec refactor(web): 消除侧边栏硬编码 — iconMap 抽离 + routeTitleFallback 精简
- iconMap 抽离为 utils/iconRegistry.tsx(单一真相源),补齐 10 个后端 seed 使用但前端缺失的图标
- MainLayout import 从 28 个图标减少到 6 个(仅保留布局专用图标)
- routeTitleFallback 从 26 条精简到 10 条(仅保留动态参数路由 + 无后端菜单的静态路由)
- 后端菜单已覆盖的 16 条标题映射移除(由 getTitleFromMenus 从后端数据获取)
- wiki 关键数字更新:迁移 146、权限码 132
2026-05-15 19:27:10 +08:00
iven
2c48bb0f56 refactor(web): Tab 权限映射集中化 — 消除硬编码
- routeConfig.ts 新增 TAB_PERMISSIONS 配置(单一真相源)
- 新增 usePermFilteredTabs hook,通用 Tab 权限过滤
- PatientDetail.tsx 移除内联 TAB_PERMISSIONS,改用 hook
- 未声明 Tab 安全默认隐藏,DEV 模式 console.warn 提示
2026-05-15 19:15:26 +08:00
iven
8763e10d6e fix: 全局权限优化 — 7 项问题修复
1. 菜单权限修复:补充 10 个菜单的 permission 字段 + 修复 menu_service
   回退逻辑(admin 直接跳过过滤,非 admin 无关联则不显示)+ 收紧前端过滤
2. 管理员重置密码:新增 POST /users/{id}/reset-password 端点 + 前端按钮
3. 告警处理人姓名:AlertResponse 添加 acknowledged_by_name 字段
4. Tab 权限过滤:PatientDetail 6 个 Tab 按权限过滤 + 状态字段 Tooltip
5. 消息中心 UI:添加 Popconfirm/AuthButton,移除 inline isDark
2026-05-15 19:00:48 +08:00
iven
9319203e09 docs(qa): 更新联合调试报告 — 标记已修复/误报/已验证状态 2026-05-15 15:26:44 +08:00
iven
4ca9027cd6 fix: 联合调试问题修复 — 预约错误提示 + request 错误提取 + copilot 解包
- ISSUE-APPOINTMENT-001: 后端 DoctorNotFound 改为 Validation 错误(400)
  + 改善错误消息"所选医生暂无医护档案,请联系管理员"
  + 小程序预约创建 catch 传递实际错误消息(不再硬编码"预约失败")
  + 小程序 request.ts 提取后端 message 字段作为用户提示
- copilot API: listInsights/listRules/getPatientRisk 补齐 data.data 解包
- 移除 error.rs 重复的 AppointmentNotFound 分支
2026-05-15 15:25:26 +08:00
iven
057d9b5896 fix(health): 修复咨询统计返回零值 BUG + 清理 secure-storage 过时注释
BUG-CONSULTATION-001: safe_aggregate 包装导致 compute_avg_response_time
SQL JOIN 错误时整个统计函数返回零值默认。修复方式:
- handler 层移除 safe_aggregate 改为直接 .await?
- service 层对 compute_avg_response_time 独立错误处理(warn + None)

同时清理 secure-storage.ts 中关于 crypto-js 的过时注释(已移除)。
2026-05-15 15:05:53 +08:00
iven
2c567bd772 fix(mp): T40 UI 审查全量修复 + 设计体系一致性优化
Phase 0 基础设施:
- statusTag.ts: getStatusInlineStyle() 移除内联 borderRadius/padding/fontSize,仅返回 {background, color}
- 新增 SEVERITY_COLORS + getSeverityStyle() + getSeverityLabel() 统一告警严重程度样式
- variables.scss: 新增 9 个语义颜色别名 ($success/$danger/$warning/$info 等)
- mixins.scss: 新增 status-inline mixin 统一状态标签样式
- 7 个消费者页面添加 @include status-inline CSS 补偿

Phase 1 HIGH 修复 (4 页面):
- P46 随访管理: 移除 getTypeStyle() 硬编码 fontSize,替换文字 Loading 为组件
- P45 咨询详情医护: 添加 Loading/ErrorState 三态模板 + error ref
- P02 健康数据: 添加 loading ref + Loading 组件 + 错误 toast 提示
- P48 告警中心: 替换本地 SEVERITY_COLORS/SEVERITY_LABELS 为 statusTag.ts 导出

Phase 2 全局一致性:
- 2.1 触控补全: 17 页面为可点击元素添加 min-height: $touch-min
- 2.2 字号替换: 19 文件 31 处硬编码 px → Design Token CSS 变量
- 2.3 颜色替换: 18 文件 ~50 处硬编码十六进制 → SCSS 语义变量
- 2.4 elder-mode.scss: 新增 9 个选择器到触控放大清单

Phase 3 LOW 修复:
- 3.1 统一 Loading: 21 页面旧式文字加载 → <Loading> 组件
- 3.2 useElderClass: 8 页面补全长者模式 class 绑定
- 3.3 零散修复: 按钮 44px→48px,诊断记录添加 scroll-view 无限加载

同时新增 UniApp (Vue 3 + Vite) 小程序完整代码库 (146 文件)
2026-05-15 11:22:51 +08:00
iven
18fa6ce6d4 docs: 全局文档梳理归档 — 删除过期文件 + 归档 V1/早期设计 + wiki 数据校正 + CLAUDE.md 规则优化
**根目录清理:**
- 删除 CLAUDE-1.md(ZCLAW 旧项目配置,HMS 已完全脱离)
- 移动 DESIGN.md → docs/archive/(ERP 旧设计系统)
- 删除 plans/ 98 个临时会话计划文件

**归档重组:**
- V1 审计(12 文件)→ docs/archive/audits-v1/
- 早期 CRM/插件迭代设计(13 文件)→ docs/archive/superpowers-early/
- 已完成/已取代设计(28 文件)→ docs/archive/superpowers-completed/
- 早期讨论/测试报告 → docs/archive/discussions-early/ + test-reports-early/
- QA 重复文件清理(3 个旧版 result 文件)

**wiki 数据校正:**
- 迁移数 137→145,源文件 599→649,提交数 720→800+
- 小程序文件 124→163,Web 前端 297→332
- 后端测试 999→943(实际统计),权限码 75+→128
- 文档索引新增归档目录说明

**CLAUDE.md 规则优化:**
- §2.5 闭环工作法:提交+文档+推送三合一 + wiki 更新触发条件
- §2.6 Feature DoD:新增文档一致性检查项
- §6 反模式:新增 wiki 更新滞后/推送不及时警告
2026-05-15 09:29:04 +08:00
iven
dc983945ff fix(mp): 五专家组审查 HIGH 级问题修复 — 9 项
- S-1: 隐私政策描述修正("混淆加密" → "HTTPS + 微信沙箱")
- A-1: getCachedPatientId 统一导出 + 9 处 Storage 直读替换
- A-2: usePageData loading 改为 useState 响应式
- A-3: health.ts refreshingToday 移入 store state
- M-2: prod config 移除 console.error/warn
- M-4: clearCache 后同步刷新 request.ts 内存缓存
- Q-3: doctor/appointment.ts any[] → Appointment 类型
- Q-4: daily-monitoring 常量提取到 constants.ts
- 清理: 删除空目录 FamilyPicker/HealthCard + 未使用组件 DeviceCard
2026-05-15 09:17:36 +08:00
iven
9bd2d4c2e6 docs(mp): 五专家组最终审查报告 — 综合 7.4/10(B)
架构 7.5 + 性能 8.0 + 安全 7.5 + 工程 7.5 + UX 6.5
发现 HIGH×15 + MEDIUM×25
核心问题:隐私政策合规、patientId 架构绕过、测试覆盖不足、触摸反馈缺失
2026-05-15 08:05:47 +08:00
iven
4c38fcd89d refactor(mp): 分包策略优化 — 合并单页分包 + doctor 拆包 + consultation 移出主包
- 合并 4 个单页分包:report→pkg-profile/reports, followup→pkg-profile/followups,
  events→pkg-profile/events, device-sync→pkg-health
- consultation/detail 移出主包到 pkg-consultation 分包(减少主包体积)
- doctor 18 页拆分为 pkg-doctor-core(8页) + pkg-doctor-clinical(10页)
- 全部导航路径和 import 路径同步更新
- 分包 10→8 个,主包页面 13→12
2026-05-15 07:53:00 +08:00
iven
5baa518516 refactor(mp): 长轮询通用化 — useLongPolling hook + 咨询详情页接入
- 新增 useLongPolling hook:generation counter 防重叠、useDidShow/Hide 可见性控制、失败退避、enabled 守卫
- 患者端 + 医生端 consultation/detail 接入,删除约 80 行重复长轮询代码
- 架构建议 5/5 全部完成 
2026-05-15 07:38:20 +08:00
iven
6d151bbfb1 refactor(mp): request.ts 模块级状态收编 + AbortSignal + Analytics 受控
- 提取 ConcurrencyLimiter 类(并发限制 8,可 reset)
- 提取 ResponseCache 类(GET 缓存 + 去重 + patientId 绑定)
- 新增 resetForTesting() 测试隔离函数
- api.get/post/put/delete 支持 AbortSignal 请求取消
- app.tsx Analytics 定时器改为 useDidShow/useDidHide 控制后台暂停
- 测试文件接入 resetForTesting()

构建通过,测试 74/75(1 个预存失败)。
2026-05-15 06:58:37 +08:00
iven
1fd2c7a533 refactor(mp): 架构重构 — usePageData 统一数据加载 + Store 解耦 + 大页面拆分
新增 usePageData hook(useDidShow 节流 + usePullDownRefresh + loadingRef 防重入 + enabled 条件守卫),
44/58 页面迁移接入,消灭 4 种数据加载模式并存。

- 新增 hooks/usePageData.ts — 统一页面数据加载生命周期
- 新增 stores/index.ts — resetAllStores() 解耦 auth↔health store 依赖
- 新增 pages/index/useHomeData.ts — 首页数据 hook(424→282 行)
- 新增 pages/health/useHealthData.ts — 健康页数据 hook(422→254 行)
- 44 个页面迁移到 usePageData(9 患者端 + 15 医生端 + 20 子包)
- auth store logout 不再直接导入 health store

构建通过,测试 74/75(1 个预存失败)。
2026-05-15 01:13:01 +08:00
iven
0f58af245d docs(wiki): 更新 setTimeout 修复记录 + 新增 2 条症状导航 2026-05-15 00:40:23 +08:00
iven
fed1759985 fix(mp): setTimeout 无清理修复 — useSafeTimeout hook + 10 页面接入
新增 useSafeTimeout hook,页面隐藏时自动清理所有定时器。
10 个页面接入:daily-monitoring、exchange、family-add、
health/input、prescription detail/create、dialysis detail/create、
appointment detail/create。所有 fire-and-forget setTimeout 替换为
safeSetTimeout,避免页面切走后定时器回调在错误上下文执行。
2026-05-15 00:38:23 +08:00
iven
74bffb4878 fix(mp): 患者端卡死深度审查修复 — CRITICAL 回归 + 并发保护 + 页栈溢出防护
CRITICAL:
- 咨询详情页 loadData 引用已删除的 pollingRef → 移除残余引用

HIGH:
- 401 重试递归改循环结构,避免并发限制器双 slot 占用
- 医生端 4 个列表页添加 loadingRef 防重入(consultation/alerts/dialysis/prescription)
- 新增 safeNavigateTo 页栈溢出保护(栈≥9 自动 redirectTo)

前期修复一并提交:
- 全局并发限制 MAX_CONCURRENT=8
- doRefresh 失败时完整清理 Storage + 重置缓存状态
- 401 跳转登录页修正
- 长轮询 generation counter 模式
- 首页/健康页 loadingRef + refreshToday 去重
2026-05-15 00:30:59 +08:00
iven
5ea991c5df docs(wiki): 更新 T40 UI 审计最终评分 2026-05-14 23:13:32 +08:00
iven
8f353946e1 fix(mp): T40 UI 审计修复 — 28 项设计系统合规 + 安全加固 + 讨论记录
T40 UI 审计修复(60 页面全覆盖):
- 新增 $acc-d/$wrn-d 渐变中间色变量,修复首页轮播渐变硬编码
- 替换 8 处裸 white 为 $white 设计变量(5 个 SCSS 文件)
- 修复 7 处触摸目标 40/44px → 48px(健康/消息/咨询/预约/首页)
- 3 页面新增 Loading 状态(体征录入/个人中心/就诊人添加)
- statusTag 移除硬编码布局值,改用 SCSS mixin 控制
- 医生端 14 页面架构 Hook 层补充(useThrottledDidShow 替换 useEffect)
- 移除 action-inbox 未使用 import

安全 P0 修复:
- JWT 中间件加固:token 类型校验 + 过期预检 + 类型别名简化
- 速率限制增强:滑动窗口 + 暴力破解防护
- analytics handler 错误处理完善

文档:
- T40 审计报告(24 PASS / 36 PASS_WITH_ISSUES / 0 NEEDS_WORK)
- 5 份 DevTools/性能审计讨论记录
- wiki 症状导航 + 小程序章节更新
2026-05-14 23:12:54 +08:00
iven
447126b6c5 fix(mp): 安全 P0 修复 + 架构 Hook 层补充 + 五专家组分析报告
安全修复:
- 提取 sanitizeHtml 共享工具,修复 article/detail RichText XSS 风险
- request.ts 生产环境强制 HTTPS,消除 HTTP 回退风险
- 错误信息净化:后端错误码映射为用户友好消息,不再透传原始内容
- Token 生命周期管理:利用 expires_in 记录过期时间,请求前主动刷新

工程修复:
- Babel 依赖从 dependencies 移至 devDependencies(包体积优化)

架构改进:
- 新增 usePagination hook(分页加载 + hasMore + refresh,10+ 页面可复用)
- 新增 useAuthRequired hook(登录态 + 患者档案 + 角色判断统一入口)
- 新增 usePageRefresh hook(下拉刷新统一封装,17 页面可复用)

文档:
- 五专家组深度分析+头脑风暴报告(架构7.2/安全5.5/UX6.0/工程5.5/产品7.2)
2026-05-14 20:22:29 +08:00
iven
a8d7183d7c fix(mp): T40 UI 审计修复 — 28 项设计系统合规 + MCP forceSetAuth bridge
T40 小程序 UI 审计全部 60 页面,发现 28 项问题(HIGH×3 MEDIUM×10 LOW×15),
全部修复并通过静态验证(0 硬编码 border-radius/font-size 残留)。

主要修复:
- border-radius: 12 个文件硬编码值 → $r-xs/$r-lg/$r-pill 设计 token
- touch target: 5 个交互元素添加 min-height: 48px(action-inbox/mall/family/medication)
- elder-mode 页面接入 useElderClass(),预览字号改用 var(--tk-font-body)
- consultation 页面增加加载失败 toast 提示
- app.tsx 新增 forceSetAuth bridge 解决 MCP auth 注入兼容问题
- FAB 按钮和开关控件尺寸规范化

审计结果:PASS 41 / PASS_WITH_ISSUES 19 → 修复后全量 PASS
2026-05-14 09:38:02 +08:00
iven
9e0f421c14 chore(mp): 添加开发启动脚本(缓存清理+编译) 2026-05-13 23:56:54 +08:00
iven
9faccac9eb perf(mp): 移除 Zod 依赖,轻量验证替代 — 包体积 -300KB
- 新增 utils/validate.ts 轻量验证工具(<1KB vs Zod 360KB)
- daily-monitoring: Zod schema → validateNum() 直接验证
- input: Zod schema → num()/validateStr() 直接验证
- config/index.ts: 移除 Zod include 编译配置

效果:总体积 1.8MB→1.5MB(-17%),pkg-health 分包 432KB→84KB(-81%)
2026-05-13 23:56:12 +08:00
iven
0f6f7a2851 fix(mp): DevTools 卡死优化 — filesystem 缓存 + prebundle
- webpack5 filesystem cache 加速二次编译
- prebundle 关闭(避免与 taro-loader 冲突)
- 配合 Watchman 安装 + ulimit 提升可根本解决卡死
2026-05-13 23:48:35 +08:00
iven
9c7ce939c7 fix(mp): 真机调试 EMFILE — 关闭 dev 模式 source map
真机调试时报 EMFILE: too many open files,根因是 dev 构建
默认生成 69 个 .map 文件,DevTools + webpack watcher 同时
打开导致文件描述符耗尽。通过 chain.devtool(false) 关闭
source map,dist 文件数从 356 降至 269。
2026-05-13 23:35:17 +08:00
iven
431c42289d chore: gitignore 添加临时文件排除规则 2026-05-13 23:30:45 +08:00
iven
675d5a3405 feat(mp): 新增 navigate 工具函数 2026-05-13 23:29:56 +08:00
iven
df1d85bfde docs: T40 UI 审计报告 + wiki 更新 + Docker 配置
- T40 UI 审计计划和结果文档(docs/qa/)
- wiki 更新:miniprogram 设计系统合规审计记录 + index 关键数字更新
- 审计 V2 完整报告(docs/audits/v2/)
- 讨论记录文档(docs/discussions/)
- 设计规格和实施计划(docs/superpowers/)
- 角色测试计划和结果(docs/qa/role-test-*)
- Docker 生产部署配置
2026-05-13 23:29:42 +08:00
iven
212c08b7ae feat(health,ai): 后端服务优化 + 媒体文件处理
- erp-health: article/banner/consultation/media 服务层优化
- erp-ai: analysis/insight/prompt 服务增强
- erp-auth: auth/role/token 服务改进
- erp-workflow: executor 执行引擎修复
- erp-plugin: 服务层改进
- 新增媒体上传文件样例
2026-05-13 23:28:57 +08:00
iven
e4e5ef04d4 feat(web): Web 前端功能完善 — API 扩展 + 组件优化
- 新增 AI 透析分析 API + 药物提醒 API
- MediaPicker/ThemeSwitcher/usePaginatedData 优化
- 健康管理页面组件增强(Banner/Consultation/Doctor/MediaLibrary 等)
- PluginCRUDPage 导入优化
2026-05-13 23:28:22 +08:00
iven
616e0a1539 feat(mp): 小程序功能完善 — 服务层扩展 + 页面优化
- 新增 actionInbox 服务层(待办事项列表/线程查询)
- consultation 服务扩展(会话详情/发送消息)
- 多页面代码优化(profile/messages/health/article)
- 新增 navigate 工具函数
2026-05-13 23:26:38 +08:00
iven
93c77c5857 fix(mp): T40 UI 设计系统合规审计修复 — 60 页面全覆盖
- 新增 $white 语义变量 + --tk-font-display Token
- 44 处 #fff → $white,2 处 background: #fff → $card
- 14 处 border-radius 硬编码统一为 $r-xs/$r-lg/$r
- 3 处 TSX inline 颜色提取为 SCSS 类(exchange/orders/action-inbox)
- ErrorBoundary 重构:6 个 inline style → SCSS 类 + Design Token
- 2 处离调色板颜色修正(#0284C7→$tx2, #94A3B8→$tx3)
- 2 处静默 catch 块添加状态清理(article/health)
- 趋势页补 Loading/EmptyState;咨询页 GuestGuard 统一
- 4 处 #FFFFFF → $white(mixins/index/exchange/variables)
2026-05-13 23:26:00 +08:00
iven
02082ccc61 feat(ci,ai): P2-1 权限注册表 + P2-2 AI utoipa 注解全覆盖
P2-1 权限注册表单一真相源:
- 新增 permissions.yaml: 131 个权限码 × 8 模块,含冻结标记
- 新增 scripts/gen-permissions.js: 生成器脚本
  --sql 输出 seed SQL, --frontend 输出 routeConfig 片段,
  --validate 验证一致性(131/131 = 0 mismatches)

P2-2 AI 模块 utoipa 注解:
- 为 30 个 handler 函数添加 #[utoipa::path] 注解
  (mod.rs 18 + insight 3 + risk 1 + rule 4 + suggestion 4)
- 为 6 个 DTO struct 添加 ToSchema/IntoParams derive
  (AnalyzeBody, CreatePromptBody, CreateRuleBody, UpdateRuleBody,
   ApproveBody, ExecuteBody, DialysisLabInput, ListAnalysisQuery,
   ListPromptsQuery)
- AI handler utoipa 覆盖率: 0/5 → 5/5 (100%)
2026-05-13 17:45:45 +08:00
iven
20d606d21c docs,ci: P2 质量体系 — 技术债看板 + 冻结策略 + fix 率趋势
- 新增 fix-rate-trend.sh: 按周统计 fix 率趋势 + 类别分布
  当前: 22.8% (177/776), 目标 < 15%
- 新增 docs/tech-debt-board.md: 技术债看板
  含度量总览、高利息债务排序、已偿还债务、偿还优先级
- 新增 docs/discussions/2026-05-13-frozen-module-strategy.md:
  6 个冻结模块策略 + 14 天超时规则 + 解冻/移除操作流程
2026-05-13 17:19:07 +08:00
iven
e9458a6bdf fix(ci,web): API 路径检查脚本归一化 + DEV 模式路由覆盖率校验
- check-api-paths.sh: 归一化前端硬编码 ID、扩展后端路由提取范围
  (users/roles/departments 等基础模块)、排除插件动态路由假阳性
  结果: 46 个不匹配 → 0 个,CI PASS
- routeConfig.ts: 新增 validateRouteCoverage() 开发模式校验函数
- App.tsx: 挂载时调用路由覆盖率校验,未声明权限的路由会 console.warn
2026-05-13 14:48:10 +08:00
iven
c681049c82 fix(db,ci): 补全 26 个缺失权限码 seed 注册 + 检查脚本增强
- 新增迁移 000144 全实体乐观锁 version 字段强制化
- 新增迁移 000145 注册 26 个后端已声明但 seed 缺失的权限码
  (ai.analysis/prompt/suggestion/usage/provider, copilot.insights/risk/rules,
   health.ble-gateways/critical-alerts/devices/family-proxy/shifts 等)
- check-permissions.sh: 增加 module.rs PermissionDescriptor 提取,
  支持两段式权限码 (plugin.admin/tenant.manage)
- CI 检查结果: Check 1 PASS, Check 2 PASS, 0 个不一致
2026-05-13 14:30:27 +08:00
iven
935ca70dfa test(mp): service 层测试扩展 — health + consultation + request
新增 3 个测试文件(+23 个测试用例),总计 9 文件 75 测试:
- request.test.ts: HTTP 方法、查询参数构建、缓存、错误处理
- health.test.ts: 体征录入字段映射、日常监测、阈值查找
- consultation.test.ts: 咨询会话/消息 CRUD、已读标记

- 添加 vitest setup.ts mock @tarojs/taro 和 @tarojs/runtime
- vitest.config.ts 增加 setupFiles 配置

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 14:10:27 +08:00
iven
b7efa51d5f chore: CI quality gate scripts — permission + API path consistency
P1-1 check-permissions.sh:
- Extracts permission codes from backend handlers, frontend routeConfig,
  and seed migrations
- Cross-checks consistency across all three sources
- Validates .list + .manage pairing per entity
- Current result: 26 mismatches found (seed gaps for ai/copilot/ble)

P1-2 check-api-paths.sh:
- Extracts API paths from frontend api/ and backend Axum routes
- Cross-checks frontend paths exist in backend
- Validates route parameter syntax ({param} vs :param)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 13:52:30 +08:00
iven
9a4a65a241 docs: P0 流程改进 — Feature DoD + 安全检查 + 前后端同步检查
基于 175 次 fix 分析的三个最高优先级行动项:
- §2.6 Feature DoD 清单(后端/Web/小程序/安全/验证 5 维度)
- §3.3 新增 API 端点安全检查(默认拒绝原则)
- §3.3 前后端接口同步检查(DTO 变更 6 项同步确认)
- §6 反模式增加 3 条教训(DoD/安全默认/前后端同步)

关联: docs/discussions/2026-05-13-development-process-retrospective.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 13:36:57 +08:00
iven
5905742080 docs: 开发流程阶段总结与优化 — 多专家组发散式讨论
8 类开发问题归纳(175 次 fix 分析)、3 个连锁效应案例、
6 位专家流程改进建议、Feature DoD 清单、P0/P1/P2 行动方案。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 13:33:49 +08:00
iven
d6676abecf fix(ai): Copilot 审计修复 — C-1/H-1/H-2/H-3/H-4/H-5/L-2
- L-2: value_to_f64 对 Null 返回 NaN(防止误触发规则)
- C-1: load_patient_data 空数据时跳过写入快照
- H-1: 每日刷新定时器添加初始延迟
- H-2: copilot_consumer 传内层 content
- H-3: 前端 hooks/Alert 修复分页响应解析
- H-4: risk_handler 动态选择 AI provider
- H-5: 新增 DELETE /copilot/rules/{id} 软删除路由
2026-05-13 00:21:27 +08:00
iven
6d97328ff6 feat(web): CopilotAlert 告警组件 + 告警 API 扩展
- CopilotAlert: 分级告警列表,30秒轮询刷新,危急 banner
- copilot.ts 新增 listAlerts 函数
2026-05-12 22:36:36 +08:00
iven
a48ad6ed33 feat(ai): 告警洞察生成逻辑 + 事件消费者增强
- engine.rs 新增 generate_anomaly_insights(过滤 info 级别)
- copilot_consumer 在风险评分后自动生成 warning/critical 告警洞察
2026-05-12 22:34:11 +08:00
iven
a87425e551 feat(db): 8 条 Copilot 趋势/复合类告警规则种子数据
趋势类(4): 收缩压快速上升、肌酐连续上升、体重连续上升、血压趋势上升
复合类(4): eGFR+血钾双重危急、透析间期+血压、失约+依从性、Kt/V+血压
2026-05-12 22:30:16 +08:00
iven
78c052ecc9 feat(web): 患者详情页嵌入 Copilot 风险徽章 2026-05-12 22:25:56 +08:00
iven
22ef9b32d6 feat(web): CopilotBadge + CopilotCard 组件 + hooks
- CopilotBadge: 风险评分标签(低/中/高/危急)
- CopilotCard: 洞察列表卡片(支持忽略操作)
- useCopilotRisk / useCopilotInsights: 数据获取 hooks
2026-05-12 22:20:56 +08:00
iven
cba8c8306d feat(web): Copilot API 调用层 2026-05-12 22:16:28 +08:00
iven
ba0a4f4d2e feat(ai): 每日风险快照批量刷新定时任务
- risk_service 新增 refresh_all_patients 方法
- module on_startup 启动每日刷新后台任务
2026-05-12 22:14:08 +08:00
iven
a999ee0036 feat(ai): LLM 补充风险分析 + 降级策略
- scoring.rs 新增 llm_supplement 函数(调用 AI provider 生成补充洞察)
- risk_service 新增 compute_risk_with_llm 方法(LLM 失败静默降级)
- risk_handler 改用 compute_risk_with_llm
2026-05-12 22:10:05 +08:00
iven
44dcfbd5cb feat(ai): Copilot 事件消费者(订阅 health 事件触发风险评分刷新) 2026-05-12 22:00:47 +08:00
iven
95db4fe9ff feat(db): 15 条 Copilot 内置规则种子数据
覆盖 5 大类: 体征异常(4) + 化验异常(4) + 依从性(2) + 透析质量(3) + 综合(2)
系统级规则(tenant_id=nil)适用于所有机构
2026-05-12 12:18:40 +08:00
iven
57f33dd726 feat(ai): Copilot 评分引擎 + Handler + 路由 + 权限码
- scoring.rs: 混合评分 (calculate_risk) + RiskScore/MatchedRule 结构
- engine.rs: CopilotEngine 协调规则评估和评分
- risk_service.rs: 风险计算 + UPSERT 快照 + 规则加载
- insight_service.rs: 洞察 CRUD + 过期清理
- 3 个 Handler: insight/risk/rule,7 个 API 端点
- 5 个权限码: copilot.insights.list/manage, copilot.risk.view, copilot.rules.list/manage
- AiState 扩展 risk_service + insight_service
2026-05-12 12:14:16 +08:00
iven
fe983ba4ae feat(ai): Copilot 基因化 Phase 0 Task 1-4 — 迁移 + Entity + 规则引擎
- 4 表迁移: copilot_rules, copilot_insights, copilot_risk_snapshots, copilot_chat_logs
- 4 个 SeaORM Entity 对应新表
- JSONLogic 规则引擎 (evaluate + evaluate_rules) + 5 个单元测试
2026-05-12 11:57:09 +08:00
iven
7e2a20727e docs(ai): Copilot 基因化实施计划 — 40 Tasks / 6 Phases
基于设计规格 specs/2026-05-11-copilot-gene-design.md 编写完整实施计划。

Phase 0 基础设施: 4表迁移 + JSONLogic规则引擎 + 评分服务 + API + 种子数据
Phase 1 风险画像: 事件消费 + LLM补充 + 每日刷新 + 前端Badge/Card
Phase 2 异常检测: 告警规则扩展 + 异常洞察 + CopilotAlert组件
Phase 3 随访/咨询: 随访推荐 + 咨询辅助 + CopilotPanel + 一键采纳
Phase 4 患者端Copilot: 意图识别 + 合规审查(双层) + 对话API + 小程序聊天UI + 每日问候
Phase 5 日活引擎: 每日任务 + 积分联动 + 连续打卡 + AI问候联动 + 首页改版
2026-05-11 17:23:53 +08:00
iven
af3eb0c7a1 feat(miniprogram): service 层测试框架搭建
- 新增 __tests__/helpers/: mock-taro (Taro API mock) + mock-api (request mock)
- 示例测试: patient.test.ts (3 用例) + appointment.test.ts (9 用例)
- 覆盖 list/create/update/cancel/calendar 等核心场景
- 全部 42 测试通过(含 4 个已有 BLE 测试)
2026-05-11 13:58:58 +08:00
iven
0a8ff4bbe7 docs(health): OpenAPI 注解 — diagnosis + device_reading + vital_signs_daily
为 3 个 handler 文件共 8 个函数添加 #[utoipa::path] 注解。
P1-5 批次 2/N。
2026-05-11 13:07:57 +08:00
iven
ac8d300dc0 docs(health): OpenAPI 注解 — device_handler + consent_handler
为 device_handler (2 函数) 和 consent_handler (3 函数) 添加
#[utoipa::path] 注解。P1-5 批次 1/N。
2026-05-11 13:05:11 +08:00
iven
d0cb45f457 refactor(health): 拆分 module.rs 路由注册为 13 个子模块
protected_routes (800+ 行) 按业务域拆分为 routes/ 目录下 13 个文件:
patient / health_data / follow_up / appointment / consultation /
article / points / stats / alert / device / media / care / admin。

module.rs 从 1595 行降至 798 行,路由注册逻辑更清晰。
2026-05-11 12:59:56 +08:00
iven
fc30702846 feat(docker): PostgreSQL 每日自动备份
- 新增 backup.sh: pg_dump + gzip,自动清理过期备份
- production compose 添加 backup 服务: cron 每日 02:00 执行
- 可通过 BACKUP_CRON / BACKUP_KEEP_DAYS 环境变量自定义
2026-05-11 10:27:38 +08:00
iven
533a2b6a8e feat(server): BLE 网关独立限流 — 每网关 60 req/60s
为 /health/gateway 路由添加 gateway_id 级别的速率限制,
网关认证(API Key)→ 限流检查 → handler 三层中间件。
Redis 不可达时同样遵循 fail_close 策略。
2026-05-11 10:24:22 +08:00
iven
0f67f1c21f fix(server): 限流中间件 fail-close 安全加固
RateLimitConfig 添加 fail_close 字段(默认 true),Redis 不可达时
拒绝请求返回 503 而非静默放行。开发环境可通过
ERP__RATE_LIMIT__FAIL_CLOSE=false 回退旧行为。
2026-05-11 10:22:05 +08:00
iven
8c347a5de9 refactor(health): 拆分 event.rs(2871 行)为 13 个领域文件
将单体 event.rs 按业务域拆分为 event/ 模块目录:
- mod.rs (219 行): 31 事件常量 + 调度器 + 测试
- 12 个消费者文件: workflow/device/alert/patient/appointment/
  follow_up/health_data/ai/consent/consultation/points/lab_report

每个消费者文件 50-215 行,独立可维护。
编译零错误,测试全部通过。
2026-05-11 10:09:10 +08:00
iven
129a7b175c fix(health): 允许已发布文章重新提交审核 — published → pending_review
状态机新增 published → pending_review 转换,
已发布文章编辑后可直接提交审核,无需先撤回。
审核期间旧版本继续对外展示,审核通过后覆盖发布。
2026-05-11 09:49:56 +08:00
iven
103c8aa059 refactor(web): 文章预览去壳化 — 375px 纯内容面板替代 iPhone 仿真
- 删除全部 iPhone 外壳代码(Dynamic Island / 状态栏 / 侧边按钮 / 相机条 / Home Indicator)
- 从 620 行精简到 200 行
- 改为 375px 固定宽度纯内容面板,CSS 与小程序 article/detail 完全一致
- 内容区自然流动高度 + 滚动,不再被外壳约束
- 顶部简洁标签栏「小程序端效果预览 · 375px」
- 新增头脑风暴讨论记录 docs/discussions/2026-05-11
2026-05-11 09:36:43 +08:00
iven
9487ccb62e docs(health): 六维度全面均衡分析报告 + 六专家组头脑风暴评审
- 新增六维度分析报告:架构(7.5)/代码质量(7.0)/业务完整度(7.5)/安全合规(7.5)/生产就绪(5.5)/可扩展性(6.5),综合 6.9/10 (B)
- 组织 6 位虚拟专家(架构师/后端/前端/安全/DevOps/产品)独立评审,综合 7.0/10 (B),较上次 6.4/10 提升 +0.6
- 识别 Top 5 行动优先级:OpenAPI 注解补全、性能基准、event.rs 拆分、小程序测试、冻结模块解冻
- 制定三个月路线图:Month 1 质量加固 → Month 2 功能补全 → Month 3 生产化
- 更新 wiki/index.md 关键数字(57 实体、999 测试、24 unwrap、75+ 权限码等)
2026-05-11 03:43:51 +08:00
iven
6269815046 fix(web): 文章编辑器手机预览放大至 375px — 匹配真机阅读效果
- 屏幕宽度从 256px 放大到 375px(接近 iPhone CSS 逻辑像素)
- 内容字体全部改为小程序真实尺寸(16px 正文 / 22px 标题 / 15px 摘要)
- 间距 padding/margin 与小程序 article/detail 完全一致
- 手机高度改为自适应(内容撑开),不再固定截断
- Dynamic Island / 状态栏 / 按钮按比例放大
2026-05-11 03:24:15 +08:00
iven
00301d2528 feat(web): 文章编辑器手机预览更新为 iPhone 17 Pro Max 设计
- 外壳从钛金属渐变改为航空级铝合金一体化银灰色
- 比例更新为 163.4×78.0mm (2.095:1)
- 背面摄像头从三角形排列改为横向相机条(3镜头+闪光灯横向排列)
- 边框收窄(3px),阴影减弱匹配铝合金质感
- 标签更新为 "iPhone 17 Pro Max"
2026-05-11 03:18:59 +08:00
iven
e00ee69d28 fix(core,health): 文章内容 sanitize 保留安全 HTML 标签 + 血透测试文章种子
- 新增 sanitize_rich_html() 使用 ammonia 白名单保留安全 HTML 标签和内联样式
- 修复文章创建/更新时 content 被 strip_html_tags() 完全剥离的问题
- ammonia 4 不允许手动指定 <a> 的 rel 属性(自动管理),已从 tag_attrs 移除
- 新增 3 个 sanitize_rich_html 单元测试
- 新增 seed-dialysis-articles.mjs 种子脚本(4 篇血透相关富文本文章)
2026-05-11 03:13:43 +08:00
iven
c716cc0f7b feat(web): 文章编辑器 — iPhone 15 Pro Max 高保真预览 + 丰富样式模板
- 手机预览按 iPhone 15 Pro Max 真实比例 (2.084:1) 重设计
- 钛金属渐变边框 + Dynamic Island + 前置摄像头 + 侧边按钮
- 背面摄像头模组微阴影暗示 + 多层投影深度效果
- iOS 17 风格状态栏 (信号/WiFi/电池 SVG 图标)
- 样式模板从 14 种扩展到 27 种
- 新增: 成功/危险/强调提示框、时间线、步骤流程、对比卡片、问答、进度条、引言卡片等
2026-05-11 02:41:30 +08:00
iven
f4b09858c4 feat(web): 文章编辑器重设计 — 公众号风格三栏布局 + styled-block 自定义模块
- 左栏样式组件库(标题/内容/区块 14 种模板,5 种配色主题)
- 中间 Notion 风格编辑区(标题置顶 + wangEditor + 自定义 styled-block)
- 右栏 iPhone 仿真预览(匹配小程序暖奶油配色)
- 设置面板移至 Drawer 抽屉按需打开
- 注册 wangEditor 自定义模块保留模板内联样式
- 使用 snabbdom VNode + insertNode API 解决样式被剥离问题
2026-05-11 02:18:24 +08:00
iven
4788e19a1d fix(health,miniprogram): 轮播图图片改用相对路径 + wx.downloadFile 解决 HTTP 限制
问题:微信小程序 <image> 不支持 HTTP URL,签名 URL 与 upload 中间件不兼容。
修复:
1. 公开轮播图 API 返回相对路径(/uploads/...)而非签名 URL
2. 小程序用 wx.downloadFile 下载图片后使用本地临时路径
3. 移除 banner_handler 中不再需要的 base_url/Host header 逻辑
2026-05-10 20:14:43 +08:00
iven
a6ec8129c9 refactor(web,health): 消除硬编码路径 — 统一 resolveMediaUrl + 动态 base_url
1. 新增 resolveMediaUrl() 工具函数,统一处理 storage_path 前缀和 JWT token
2. MediaLibrary 和 MediaPicker 改用 resolveMediaUrl,消除重复逻辑
3. banner_handler 不再硬编码 localhost:3000,改为从 Host header 动态构建 base_url
2026-05-10 20:00:39 +08:00
iven
270818c3ad fix(web): 媒体库图片添加 JWT token 认证参数
/uploads 路径需要认证,图片 src 须带 ?token= 参数才能正常显示。
MediaLibrary 组件现在从 localStorage 读取 access_token 并附加到图片 URL。
2026-05-10 19:55:13 +08:00
iven
4ea54ff27c fix(web): 媒体库图片不显示 — 添加 /uploads Vite 代理 + 修复路径前缀
前端图片 URL 使用 ./uploads/... 相对路径导致 404:
1. Vite 添加 /uploads 代理到后端 3000 端口
2. MediaLibrary 和 MediaPicker 图片 src 去掉 ./ 前缀
2026-05-10 19:52:49 +08:00
iven
fca0b5a78f feat(health): 新增公开文章列表端点 /public/articles 供小程序访客首页使用
访客首页文章列表调用 /health/articles 需要 JWT 认证导致 401。
新增 GET /public/articles?tenant_id=xxx 端点,强制只返回已发布文章,
无需认证。小程序访客首页改用此公开端点。
2026-05-10 19:14:31 +08:00
iven
edb4b6557d fix(health): 修复媒体库和轮播图菜单不可见 — parent_id/permission/menu_roles 三重修复
种子迁移 m20260510_000137 存在三个问题导致菜单不显示:
1. parent_id 查找用了错误条件(path='/health'),改为 title='内容运营'
2. menu INSERT 缺少 permission 字段
3. 缺少 menu_roles 关联(admin/operator)
同时新增 BannerManage.tsx 前端页面
2026-05-10 19:07:20 +08:00
iven
09725acad7 feat(miniprogram): 访客首页支持无登录态获取轮播图(编译时注入默认 tenant_id) 2026-05-10 17:20:43 +08:00
iven
7fcabd2e6b fix(health): 修复迁移外键表名引用 + 公开轮播图签名 URL 路径拼接 2026-05-10 17:13:02 +08:00
iven
d2b79e4a1c feat(web): 添加 MediaPicker 组件并集成到 ArticleEditor 封面图选择 2026-05-10 16:54:30 +08:00
iven
b2c6d9c8c8 feat(miniprogram): 访客首页轮播图接入公开 API + 文章列表替换核心功能区域 2026-05-10 16:23:17 +08:00
iven
6bf8cc53f8 feat(web): 新增媒体库管理页面
- 左侧面板:文件夹树形结构,支持创建/重命名/删除文件夹
- 右侧面板:媒体网格视图,支持上传/搜索/类型筛选/批量删除
- 上传弹窗:TreeSelect 选择目标文件夹 + 公开访问开关
- 编辑弹窗:修改文件名/替代文本/公开状态
- 移动弹窗:选择目标文件夹移动文件
- 权限码:health.media.list + health.media.manage
2026-05-10 16:18:47 +08:00
iven
2c7d4a3d63 feat(web): 新增媒体库和轮播图 API client
媒体库(media.ts):文件列表/上传/更新/删除/移动/批量删除/裁剪 + 文件夹树形管理
轮播图(banners.ts):列表/创建/更新/删除/排序,字段与后端 DTO 完全对齐
2026-05-10 15:42:24 +08:00
iven
85bff6f267 feat(server): 配置签名 URL 密钥 — StorageConfig.secret_key 2026-05-10 15:39:11 +08:00
iven
1a459de4ad feat(health): 注册媒体库和轮播图路由 + 权限码 + 公开端点 2026-05-10 15:35:47 +08:00
iven
3a672636c0 feat(health): 实现媒体库 handler (12 端点) + 轮播图 handler (6 端点)
媒体库 handler (media_handler.rs):
- 上传/列表/详情/更新/删除媒体文件 + 文件夹 CRUD + 移动 + 裁剪

轮播图 handler (banner_handler.rs):
- 管理端 5 端点(列表/创建/更新/删除/排序)
- 公开端点 1 个(小程序无需认证获取生效轮播图)
2026-05-10 15:32:09 +08:00
iven
a9bd850ce2 feat(health): 实现轮播图 service — CRUD + 排序 + 签名 URL
- list_banners: 列出轮播图,可选状态筛选,批量加载 media_item 避免 N+1
- create_banner: 创建轮播图,验证 media_item 存在且未删除
- update_banner: 更新轮播图,带乐观锁
- delete_banner: 软删除轮播图
- sort_banners: 批量更新排序
- list_public_banners: 公开端点,查询生效轮播图 + HMAC-SHA256 签名 URL
- generate_signed_url: 同步函数,生成签名 URL token

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 15:15:11 +08:00
iven
601d977438 feat(health): 实现媒体库 service — CRUD + 缩略图 + 裁剪 2026-05-10 15:08:26 +08:00
iven
603a986281 feat(health): 新增 media_folder/media_item/banner 实体 + image/hmac/sha2 依赖 2026-05-10 14:19:55 +08:00
1314 changed files with 166571 additions and 27263 deletions

View File

@@ -0,0 +1,244 @@
---
name: design-handoff
description: 设计交付编排器 — 调用 huashu-design 设计原型、解析生成 SPEC再由本 skill 根据 SPEC 实施代码,覆盖从需求到实现的完整流程
---
# 设计交付编排器 (design-handoff)
编排从需求到代码实现的完整流程:
1. 调用 huashu-design 设计 HTML 原型
2. 解析原型生成 SPEC.md + 截图 + Token 映射
3. 根据 SPEC 直接实施代码(不调用 huashu-design
## 触发词
- `design-handoff`
- `设计交付`
- `handoff`
- `设计并实现`
- `从设计到代码`
---
## 输入模式
### 模式 A从需求开始完整流程
用户提供功能需求描述(无 HTML 文件skill 执行 7 步完整流程。
**输入示例:**
```
/design-handoff 患者端家庭档案管理页面,包含家庭成员列表、添加/编辑/删除操作
```
### 模式 B从原型开始仅解析+实施)
用户提供已有 HTML 原型文件路径,跳过 Step 0从 Step 1 开始。
**输入示例:**
```
/design-handoff docs/design/mp-13-family-profile.html
```
---
## 核心流程7 步)
### Step 0: 设计原型(仅模式 A
调用 huashu-design 生成 HTML 原型:
```
Skill("huashu-design", "设计 {用户需求描述}。要求1) 使用 HMS 设计系统 Tokenconst T = {...}2) 包含 IosFrame 设备框 3) 每个页面用 .screen-wrap 包裹 4) 输出到 docs/design/ 目录")
```
**关键要求传递给 huashu-design**
- 使用项目 Token 系统(`const T = { pri: ..., bg: ..., r: ... }`
- 每个页面/状态用 `.screen-wrap` 包裹并配中文 label
- 使用 `IosFrame` 组件呈现
- 输出为单文件 HTML保存到 `docs/design/mp-{编号}-{名称}.html`
**完成后获得:** HTML 原型文件路径,进入 Step 1。
### Step 1: 解析 HTML 原型
调用 `scripts/parse-prototype.mjs` 解析 HTML 文件:
```bash
node .claude/skills/design-handoff/scripts/parse-prototype.mjs <html文件路径>
```
**输出stdout JSON**
- `tokens`: T 对象所有键值对(如 `{"T.pri": "#C4623A", ...}`
- `inlineStyles`: 内联样式值收集(按 CSS 属性分组)
- `screens`: 每个屏幕的标签和组件名
- `components`: 所有组件函数定义列表
- `variant`: `"patient"``"doctor"`(根据 T.pri 值自动检测)
**若脚本不存在:** LLM 自行解析 HTML提取 T 对象和 DOM 结构。
### Step 2: 截图
调用 `scripts/extract-screenshots.mjs` 生成截图:
```bash
node .claude/skills/design-handoff/scripts/extract-screenshots.mjs <html文件路径> <输出目录>
```
**输出:** PNG 文件到输出目录,文件名由中文标签自动翻译为英文。
**若脚本不存在:** 指导用户手动截图,保存到 `docs/design/{原型名}/screenshots/`
### Step 3: Token 匹配
调用 `scripts/match-tokens.mjs` 进行三层 Token 匹配:
```bash
node .claude/skills/design-handoff/scripts/parse-prototype.mjs <html> > /tmp/parse-result.json
node .claude/skills/design-handoff/scripts/match-tokens.mjs /tmp/parse-result.json .design/tokens.yml
```
**三层匹配算法:**
1. **别名直查** — 在 `aliases.prototype_keys` 中查找已确认映射(支持值条件匹配,区分 patient/doctor variant
2. **值精确匹配** — 按 CSS 属性上下文消歧borderRadius→radius, fontSize→typography
3. **色彩模糊匹配** — RGB 欧几里得距离 < 30 视为近似
**若脚本不存在:** LLM 自行读取 tokens.yml 执行匹配。
### Step 4: 组件映射推断
读取 `defaults/components.yml`,按 DOM 特征推断组件映射。
### Step 5: 交互推断
调用 `scripts/infer-interactions.mjs` 推断交互行为:
```bash
node .claude/skills/design-handoff/scripts/infer-interactions.mjs <html文件路径> .claude/skills/design-handoff/rules/interaction-rules.yml
```
### Step 6: 组装 SPEC.md
将前 5 步数据组装为 SPEC.md输出到 `docs/design/{原型名}/SPEC.md`
### Step 7: 实施代码
根据 Step 1-6 产出的 SPEC.md、截图和 Token 映射,**在当前会话中直接实施代码**。
**实施流程:**
1. **读取 SPEC.md** — 获取 Token 映射、组件清单、交互行为、未匹配项
2. **读取截图** — 对照截图确认视觉还原度
3. **创建/修改文件** — 在 `apps/miniprogram/src/pages/` 下实现 Taro 页面
4. **验证**`pnpm build` 编译通过 + 长者模式适配
**实施规则:**
- 样式使用 `var(--tk-*)` CSS 变量,不硬编码数值
- 使用组件库已有组件ContentCard, PrimaryButton, SectionTitle 等),参见 `defaults/components.yml`
- 交互行为按 SPEC.md 第 4 节实现
- 遵循 CLAUDE.md 中的小程序代码规范
- 长者模式:字号 ≥ 22px间距按 elder 值
- 未匹配的 Token 需与用户确认后再补充到 `.design/tokens.yml`
- 新建组件时遵循 `feedback-component-consistency` 原则:不适配则新建,不外部覆盖
---
## 前置检查
执行任何步骤前,必须完成以下检查:
### 1. 输入验证
**模式 A从需求开始** 无需输入文件验证,直接进入 Step 0。
**模式 B从原型开始** 确认输入文件为 huashu-design 产物:
- 文件为 `.html` 格式
- 包含 React + Babel 的 script 标签
- 包含 Token 定义块(`const T = {`
### 2. Token 配置文件初始化
检查 `.design/tokens.yml` 是否存在:
- **存在** → 直接使用
- **不存在** → 从 `defaults/tokens.yml` 复制
---
## 输出目录结构
```
docs/design/
mp-00-visitor.html # 原始原型Step 0 产物或已有文件)
mp-00-visitor/ # 交付包目录
├── SPEC.md # 设计规格文档Step 6 产物)
├── screenshots/ # 截图目录Step 2 产物)
│ ├── home.png
│ └── profile.png
├── tokens.json # Token 匹配结果Step 3 产物)
└── META.yml # 元数据
```
### META.yml 格式
```yaml
prototype: {原型文件名}
source: {HTML文件路径}
variant: patient | doctor
generated_at: {ISO 8601}
tokens:
matched: {数}
unmatched: {数}
components:
total: {数}
mapped: {数}
new: {数}
interactions: {数}
```
---
## 终端输出
完成全部步骤后:
```
============================================================
设计交付完成: {原型名}
输出目录: docs/design/{原型名}/
模式: {完整流程 | 仅解析}
Variant: {patient | doctor}
Token 匹配: {matched}/{total} ({unmatched} 未匹配)
组件: {total} ({new} 需新建)
交互: {数} 项
下一步: 根据 SPEC 实施代码Step 7
============================================================
```
---
## 待确认项交互
完成 Step 1-6 后,若有 `pending``unmatched` Token需与用户交互确认后再进入 Step 7。
---
## 脚本说明
| 脚本 | 用途 |
|------|------|
| `scripts/parse-prototype.mjs` | 解析 HTML 原型为结构化 JSON |
| `scripts/extract-screenshots.mjs` | Playwright 截图(裁掉设备框) |
| `scripts/match-tokens.mjs` | 三层 Token 匹配 |
| `scripts/infer-interactions.mjs` | 交互行为推断 |
**降级策略:** 每个脚本缺失时LLM 自行执行对应步骤。
## 模板与规则
| 文件 | 用途 |
|------|------|
| `templates/spec-template.md` | SPEC.md 模板 |
| `defaults/tokens.yml` | Token 注册表 + 别名映射 |
| `defaults/components.yml` | 组件映射规则 |
| `rules/interaction-rules.yml` | 交互推断规则 |

View File

@@ -0,0 +1,139 @@
version: 1
updated: "2026-05-17"
# ============================================================================
# 小程序 UI 组件映射注册表
# 数据源: apps/miniprogram/src/components/ui/
# 每个组件记录 import 路径和实际 props从源码接口定义提取
# ============================================================================
components:
# --- 数据展示 ---
ContentCard:
miniprogram:
import: "@components/ui/ContentCard"
props: "variant('default'|'outlined'|'elevated'), padding('none'|'sm'|'md'|'lg'), margin('none'|'md'), activeFeedback('bg'|'opacity'|'scale'|'none'), onPress, className, style, children"
notes: "通用卡片容器padding 映射到 --tk-card-padding-* token"
AlertCard:
miniprogram:
import: "@components/ui/AlertCard"
props: "variant('gradient'|'left-border'|'bordered'), title, subtitle, children, className"
notes: "告警/提示卡片,默认 left-border 变体"
VitalCard:
miniprogram:
import: "@components/ui/VitalCard"
props: "label, value, unit, status, onPress, className"
notes: "体征数据卡片,内嵌 StatusTag 显示状态"
ListItem:
miniprogram:
import: "@components/ui/ListItem"
props: "title, subtitle, extra(ReactNode), leftIcon(ReactNode), onPress, showArrow, unread, className"
notes: "列表行组件,支持图标/箭头/未读标记"
InfoRow:
miniprogram:
import: "@components/ui/InfoRow"
props: "label, value, valueNode(ReactNode), last, className"
notes: "键值对信息行last 控制底部分隔线"
ChatBubble:
miniprogram:
import: "@components/ui/ChatBubble"
props: "content, isMine, time, className"
notes: "聊天气泡isMine 控制左右侧和配色"
# --- 导航与标题 ---
SectionTitle:
miniprogram:
import: "@components/ui/SectionTitle"
props: "title, subtitle, action({text, onPress}), icon(ReactNode)"
notes: "区块标题,左侧竖线装饰 + 可选右侧操作链接"
TabFilter:
miniprogram:
import: "@components/ui/TabFilter"
props: "tabs(string[]), activeIndex, onChange(index), variant('fill'|'pill'|'segment'), className"
notes: "标签页筛选器,三种视觉变体"
GradientHeader:
miniprogram:
import: "@components/ui/GradientHeader"
props: "children, className"
notes: "渐变头部容器,承载页面顶部标题区域"
# --- 输入控件 ---
FormInput:
miniprogram:
import: "@components/ui/FormInput"
props: "label, placeholder, value, onInput(value), type('text'|'number'|'idcard'|'digit'), maxLength, disabled, error, className"
notes: "表单输入框,支持错误提示和多种输入类型"
# --- 按钮 ---
PrimaryButton:
miniprogram:
import: "@components/ui/PrimaryButton"
props: "children, onClick, disabled, loading, size('default'|'large'), className"
notes: "主色按钮,高度映射 --tk-btn-primary-h"
SecondaryButton:
miniprogram:
import: "@components/ui/SecondaryButton"
props: "children, onClick, disabled, className"
notes: "次要/描边按钮,与 PrimaryButton 配套使用"
# --- 状态指示 ---
StatusTag:
miniprogram:
import: "@components/ui/StatusTag"
props: "status, colorMap(Record<string, TagColor>), size('sm'|'md'), className, children"
notes: "状态标签,内置 status→color 映射success/warning/error/info/default"
ProgressRing:
miniprogram:
import: "@components/ui/ProgressRing"
props: "progress(0-1), size('sm'|'lg'), label, className"
notes: "环形进度条,使用 conic-gradient + --tk-pri 色"
LoadingCard:
miniprogram:
import: "@components/ui/LoadingCard"
props: "count, layout('card'|'list'|'detail')"
notes: "骨架屏加载占位,三种布局形态"
PageShell:
miniprogram:
import: "@components/ui/PageShell"
props: "padding('none'|'sm'|'md'|'lg'), safeBottom, scroll, className, children"
notes: "页面外壳统一页面内边距和底部安全区scroll 控制是否 ScrollView 包裹"
# ============================================================================
# 框架/平台组件非自研Taro/微信原生)
# ============================================================================
framework_components:
Swiper:
import: "@tarojs/components"
props: "autoplay, interval, duration, circular, indicatorDots, indicatorColor, indicatorActiveColor, onChange, children"
notes: "Taro Swiper 组件,用于轮播图场景"
TabBar:
import: "framework-config"
props: "N/Aapp.config.ts 中声明 tabBar 配置)"
notes: "微信小程序原生 TabBar在 app.config.ts 的 tabBar 字段配置"
ScrollView:
import: "@tarojs/components"
props: "scrollY, scrollX, scrollTop, onScroll, onScrollToLower, onScrollToUpper, children"
notes: "Taro ScrollViewPageShell 内部使用"
Input:
import: "@tarojs/components"
props: "value, placeholder, type, maxlength, disabled, onInput, onFocus, onBlur"
notes: "Taro InputFormInput 内部使用"
Picker:
import: "@tarojs/components"
props: "mode('date'|'time'|'selector'), value, range, onChange, children"
notes: "Taro Picker用于日期/时间/选项选择"

View File

@@ -0,0 +1,423 @@
version: 1
updated: "2026-05-17"
# ============================================================================
# Design Token 注册表
# 数据源: apps/miniprogram/src/styles/tokens.scss + variables.scss
# ============================================================================
colors:
# --- 主色系(赤土橙) ---
- token: --tk-pri
value: "#C4623A"
scss_var: "$pri"
role: 主色/赤土橙 accent
- token: --tk-pri-l
value: "#F0DDD4"
scss_var: "$pri-l"
role: 主色浅/赤土浅
- token: --tk-pri-d
value: "#8B3E1F"
scss_var: "$pri-d"
role: 主色深/赤土深
# --- 阴影色(含透明度) ---
- token: --tk-shadow-btn
value: "0 4px 16px rgba(196, 98, 58, 0.3)"
scss_var: "$shadow-btn"
role: 主按钮阴影
- token: --tk-shadow-tab
value: "0 2px 8px rgba(196, 98, 58, 0.25)"
scss_var: "$shadow-tab"
role: 选中Tab阴影
# --- 文字色 ---
- token: --tk-text-secondary
value: "#78716C"
scss_var: "$tx3"
role: 淡文字/辅助文字
# --- 卡片背景 ---
- token: --tk-card-bg
value: "#FFFFFF"
scss_var: "$card"
role: 卡片白底
# --- 医生端覆盖色(.doctor-mode 下自动替换 --tk-pri* ---
- token: --tk-pri.doctor
value: "#3A6B8C"
scss_var: "$doc-pri"
role: 医生端主色/靛蓝
note: 仅在 .doctor-mode 下覆盖 --tk-pri
- token: --tk-pri-l.doctor
value: "#D4E5F0"
scss_var: "$doc-pri-l"
role: 医生端浅色
- token: --tk-pri-d.doctor
value: "#2A4F6A"
scss_var: "$doc-pri-d"
role: 医生端深色
# --- 未映射到 Token 的 SCSS 变量(原型中有但 tokens.scss 未声明为 CSS 变量) ---
unmapped_scss_variables:
- scss_var: "$bg"
value: "#F5F0EB"
role: 页面主背景/温润米底
note: "原型 T.bg 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$tx"
value: "#2D2A26"
role: 主文字色/暖黑
note: "原型 T.tx 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$tx2"
value: "#5A554F"
role: 次文字色/暖灰
note: "原型 T.tx2 近似映射elder-mode 下 --tk-text-secondary 覆盖为此值"
- scss_var: "$bd"
value: "#E8E2DC"
role: 边框色
note: "原型 T.bd 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$bd-l"
value: "#F0EBE5"
role: 浅边框色
- scss_var: "$acc"
value: "#5B7A5E"
role: 鼠尾草绿/成功色
note: "原型中成功色tokens.scss 未声明为 CSS 变量"
- scss_var: "$acc-l"
value: "#E8F0E8"
role: 成功浅色
- scss_var: "$dan"
value: "#B54A4A"
role: 危险色/柔红
note: "原型中危险色tokens.scss 未声明为 CSS 变量"
- scss_var: "$dan-l"
value: "#FDEAEA"
role: 危险浅色
- scss_var: "$wrn"
value: "#C4873A"
role: 警告色/暖琥珀
note: "原型中警告色tokens.scss 未声明为 CSS 变量"
- scss_var: "$wrn-l"
value: "#FFF3E0"
role: 警告浅色
- scss_var: "$surface-alt"
value: "#EDE8E2"
role: 辅助底色
- scss_var: "$wechat"
value: "#07C160"
role: 微信绿
typography:
- token: --tk-font-display
value: "72px"
note: 大屏展示
elder: "80px"
- token: --tk-font-hero
value: "48px"
note: 启动页标题
elder: "56px"
- token: --tk-font-h1
value: "28px"
note: 页面标题 serif bold
elder: "32px"
- token: --tk-font-h2
value: "22px"
note: 副标题、用户名 serif bold
elder: "25px"
- token: --tk-font-body-lg
value: "18px"
note: 按钮文字、section 标题 fontWeight:600
elder: "22px"
- token: --tk-font-body
value: "16px"
note: 正文、输入框、icon 文字(最常用 UI 字号)
elder: "22px"
- token: --tk-font-body-sm
value: "14px"
note: 副文本、描述
elder: "19px"
- token: --tk-font-num
value: "30px"
note: 数值 serif bold
elder: "34px"
- token: --tk-font-num-lg
value: "34px"
note: 大数值
elder: "40px"
- token: --tk-font-cap
value: "13px"
note: 说明文字(第一高频字号)
elder: "18px"
- token: --tk-font-nav
value: "18px"
note: 导航栏标题 serif bold
elder: "22px"
- token: --tk-font-micro
value: "11px"
note: 角标、tag
elder: "17px"
structure:
- token: --tk-line-height
value: "1.5"
elder: "1.7"
spacing:
- token: --tk-gap-2xs
value: "4px"
scss_var: "$sp-2xs"
elder: "6px"
- token: --tk-gap-xs
value: "8px"
scss_var: "$sp-xs"
elder: "12px"
- token: --tk-gap-sm
value: "12px"
scss_var: "$sp-sm"
elder: "16px"
- token: --tk-gap-md
value: "16px"
scss_var: "$sp-md"
elder: "20px"
- token: --tk-section-gap
value: "20px"
scss_var: "$sp-section"
elder: "28px"
- token: --tk-gap-lg
value: "24px"
scss_var: "$sp-lg"
elder: "32px"
- token: --tk-gap-xl
value: "32px"
scss_var: "$sp-xl"
elder: "40px"
- token: --tk-gap-2xl
value: "48px"
scss_var: "$sp-2xl"
elder: "56px"
- token: --tk-page-padding
value: "20px"
elder: "28px"
- token: --tk-card-padding
value: "20px"
elder: "28px"
- token: --tk-card-padding-sm
value: "16px"
elder: "20px"
- token: --tk-card-padding-lg
value: "28px"
elder: "36px"
radius:
- token: --tk-card-radius
value: "16px"
scss_var: "$r"
elder: "20px"
radius_unmapped:
- scss_var: "$r-sm"
value: "12px"
note: "原型 T.rSm 映射目标"
- scss_var: "$r-xs"
value: "8px"
note: "原型 T.rXs 映射目标"
- scss_var: "$r-lg"
value: "20px"
- scss_var: "$r-pill"
value: "999px"
sizing:
- token: --tk-touch-min
value: "48px"
role: 最小触控区
elder: "56px"
- token: --tk-btn-primary-h
value: "52px"
role: 主按钮高度
elder: "60px"
- token: --tk-input-height
value: "56px"
role: 输入框高度
elder: "64px"
- token: --tk-tabbar-space
value: "100px"
role: TabBar 底部安全区
elder: "120px"
feedback:
- token: --tk-touch-feedback-opacity
value: "0.85"
role: 触控反馈透明度
elder: "0.8"
tag:
- token: --tk-tag-font-size
value: "11px"
elder: "13px"
- token: --tk-tag-padding-v
value: "3px"
elder: "5px"
- token: --tk-tag-padding-h
value: "8px"
elder: "12px"
shadow_unmapped:
# tokens.scss 中的 --tk-shadow-btn/tab 是复合值(含偏移+模糊+颜色)
# 以下为 variables.scss 中的其他阴影,未声明为 CSS Token
- scss_var: "$shadow-sm"
value: "0 1px 4px rgba(45, 42, 38, 0.06)"
role: 小阴影
- scss_var: "$shadow-md"
value: "0 2px 12px rgba(45, 42, 38, 0.10)"
role: 中阴影
- scss_var: "$shadow-lg"
value: "0 8px 32px rgba(45, 42, 38, 0.15)"
role: 大阴影
# ============================================================================
# 原型 Key → Token 映射aliases
# 用于设计移交时自动匹配原型属性到实际 Token
# ============================================================================
aliases:
prototype_keys:
T.pri:
- value: "#C4623A"
token: --tk-pri
status: exact_match
variant: patient
- value: "#3A6B8C"
token: --tk-pri.doctor
status: exact_match
variant: doctor
T.priL:
- value: "#F0DDD4"
token: --tk-pri-l
status: exact_match
variant: patient
- value: "#D4E5F0"
token: --tk-pri-l.doctor
status: exact_match
variant: doctor
T.priD:
- value: "#8B3E1F"
token: --tk-pri-d
status: exact_match
variant: patient
- value: "#2A4F6A"
token: --tk-pri-d.doctor
status: exact_match
variant: doctor
T.bg:
token: null
nearest: --tk-card-bg
scss_var: "$bg"
value: "#F5F0EB"
status: unmatched
note: "原型页面背景色tokens.scss 未声明为 CSS 变量,直接用 $bg SCSS 变量"
T.card:
token: --tk-card-bg
status: exact_match
T.surface:
token: --tk-card-bg
status: approximate
note: "原型中 surface ≈ 卡片白底"
T.tx:
token: null
nearest: --tk-text-secondary
scss_var: "$tx"
value: "#2D2A26"
status: unmatched
note: "主文字色tokens.scss 未声明为 CSS 变量,直接用 $tx SCSS 变量"
T.tx2:
token: null
nearest: --tk-text-secondary
scss_var: "$tx2"
value: "#5A554F"
status: unmatched
note: "次文字色tokens.scss 未声明elder-mode 下 --tk-text-secondary 覆盖为此值"
T.tx3:
token: --tk-text-secondary
scss_var: "$tx3"
value: "#78716C"
status: exact_match
T.bd:
token: null
scss_var: "$bd"
value: "#E8E2DC"
status: unmatched
note: "边框色不是圆角tokens.scss 未声明为 CSS 变量"
T.r:
token: --tk-card-radius
scss_var: "$r"
value: "16px"
status: exact_match
T.rSm:
token: null
scss_var: "$r-sm"
value: "12px"
status: unmatched
note: "tokens.scss 未声明,需添加 --tk-radius-sm 或直接用 $r-sm SCSS 变量"
T.rXs:
token: null
scss_var: "$r-xs"
value: "8px"
status: unmatched
note: "tokens.scss 未声明,需添加 --tk-radius-xs 或直接用 $r-xs SCSS 变量"

View File

@@ -0,0 +1,79 @@
{
"name": "design-handoff",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "design-handoff",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"js-yaml": "^4.1.1",
"playwright": "^1.58.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/playwright": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"name": "design-handoff",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"js-yaml": "^4.1.1",
"playwright": "^1.58.0"
}
}

View File

@@ -0,0 +1,114 @@
version: 1
updated: "2026-05-17"
# ============================================================================
# 交互推断规则
# patterns 使用正则表达式,匹配 HTML/JS 源码中的实际代码模式
# require_all: true 表示所有 pattern 必须同时匹配(默认 false任一匹配即可
# ============================================================================
rules:
- id: swiper-autoplay
name: "自动轮播 + 手动滑动"
patterns:
- "linear-gradient"
- "width.*24.*width.*8"
require_all: false
infer:
component: "Swiper"
props: "autoplay circular indicatorDots"
behavior: "自动轮播3-5秒切换"
confidence: high
- id: card-tap
name: "卡片点击跳转"
patterns:
- "\\.map\\("
require_all: false
infer:
component: "ContentCard"
props: "activeFeedback onPress"
behavior: "卡片可点击,带触控反馈"
confidence: medium
- id: form-submit
name: "表单提交"
patterns:
- "<input"
- "<button"
require_all: false
infer:
component: "FormInput + PrimaryButton"
props: ""
behavior: "表单输入+提交按钮"
confidence: high
- id: list-scroll
name: "列表滚动"
patterns:
- "overflow.*auto|scroll"
- "\\.map\\(.*\\.map\\(.*\\.map\\("
require_all: false
infer:
component: "ScrollView"
props: "scrollY onScrollToLower"
behavior: "可滚动列表,支持上拉加载"
confidence: medium
- id: tab-switch
name: "标签页切换"
patterns:
- "tab"
- "segment"
- "filter"
require_all: false
infer:
component: "TabFilter"
props: "tabs onChange"
behavior: "标签页切换筛选"
confidence: medium
- id: static-decoration
name: "纯装饰无交互"
patterns:
- "position.*absolute"
- "opacity.*0\\."
require_all: true
infer:
component: null
props: ""
behavior: "纯装饰性元素,无交互"
confidence: high
- id: login-cta
name: "登录/注册触发"
patterns:
- "登录"
- "注册"
- "微信登录"
- "立即登录"
exclude_patterns:
- "立即关注"
- "立即处理"
- "立即查看"
- "需要立即"
require_all: false
infer:
component: "PrimaryButton"
props: "onClick"
behavior: "登录/注册引导按钮"
confidence: high
- id: empty-fallback
name: "空数据降级"
patterns:
- "\\.length > 0"
- "\\.length === 0"
- "暂无"
- "没有"
require_all: false
infer:
component: null
props: ""
behavior: "条件渲染:有数据显示列表,无数据显示空状态"
confidence: medium

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env node
/**
* extract-screenshots.mjs — 从 huashu-design HTML 原型截取 IosFrame 屏幕内容
*
* 用法: node extract-screenshots.mjs <html-file> <output-dir>
*/
import { chromium } from 'playwright';
import { resolve, basename } from 'node:path';
import { mkdirSync, existsSync } from 'node:fs';
const IOS_FRAME_TRIM = { statusBar: 54, homeIndicator: 34, padding: 12 };
const RENDER_WAIT_TIMEOUT = 10_000;
const RENDER_POLL_INTERVAL = 200;
const ZH_EN_MAP = {
'完整页': '', '—': '-', ' ': '-',
'首页': 'home', '我的': 'profile', '登录': 'login', '健康': 'health',
'消息': 'messages', '咨询': 'consultation', '预约': 'appointment',
'商城': 'mall', '设置': 'settings', '访客': 'guest', '轮播': 'slide',
'体检': 'checkup', '报告': 'report', '医生': 'doctor', '患者': 'patient',
'记录': 'record', '详情': 'detail', '列表': 'list', '管理': 'manage',
'编辑': 'edit', '新增': 'add', '搜索': 'search', '筛选': 'filter',
'结果': 'result', '历史': 'history', '数据': 'data', '体征': 'vitals',
'用药': 'medication', '随访': 'followup', '透析': 'dialysis',
'日常': 'daily', '监测': 'monitor', '告警': 'alert', '家庭': 'family',
'成员': 'member', '档案': 'profile', '商品': 'product', '兑换': 'exchange',
'订单': 'order', '积分': 'points', '活动': 'activity',
};
function translateLabel(label, fallbackIndex) {
let result = label;
const sortedKeys = Object.keys(ZH_EN_MAP).sort((a, b) => b.length - a.length);
for (const zh of sortedKeys) {
result = result.split(zh).join(ZH_EN_MAP[zh]);
}
result = result.replace(/[^\w\-.]/g, '-');
result = result.replace(/^-+|-+$/g, '').replace(/-+/g, '-');
return result || `screen-${fallbackIndex}`;
}
function fail(msg) {
process.stderr.write(`ERROR: ${msg}\n`);
process.exit(1);
}
async function main() {
const args = process.argv.slice(2);
if (args.length < 2) fail('用法: node extract-screenshots.mjs <html-file> <output-dir>');
const htmlFile = resolve(args[0]);
const outputDir = resolve(args[1]);
if (!existsSync(htmlFile)) fail(`HTML 文件不存在: ${htmlFile}`);
mkdirSync(outputDir, { recursive: true });
let browser;
try {
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 2400, height: 1400 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
const fileUrl = `file:///${htmlFile.replace(/\\/g, '/')}`;
await page.goto(fileUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
// 等待 React 渲染
const renderStart = Date.now();
let rendered = false;
while (Date.now() - renderStart < RENDER_WAIT_TIMEOUT) {
rendered = await page.evaluate(() => {
const root = document.getElementById('root');
return root ? root.children.length > 0 : false;
});
if (rendered) break;
await page.waitForTimeout(RENDER_POLL_INTERVAL);
}
if (!rendered) fail(`React 渲染超时: ${basename(htmlFile)}`);
await page.waitForTimeout(500);
// 查找 .screen-wrap 容器
const screenCount = await page.locator('.screen-wrap').count();
if (screenCount === 0) {
// 降级:整页截图
await page.screenshot({ path: resolve(outputDir, 'full-page.png'), fullPage: true });
process.stdout.write(JSON.stringify({
source: basename(htmlFile), totalScreens: 0, fallback: true,
files: [{ label: 'full-page', file: 'full-page.png' }],
}, null, 2) + '\n');
await browser.close();
return;
}
// 使用 page.evaluate 定位并截取每个 screen
const screenData = await page.evaluate(() => {
const wraps = document.querySelectorAll('.screen-wrap');
return Array.from(wraps).map((wrap, i) => {
// 获取标签
const labelEl = wrap.querySelector('.screen-label');
const label = labelEl ? labelEl.textContent.trim() : '';
// 找 IosFrame wrapper: screen-wrap 下有 borderRadius:60 + background:#000 的 div
// 或者用策略:找第一个有 style 含 borderRadius 的 div
const allDivs = wrap.querySelectorAll(':scope > div');
let wrapperDiv = null;
for (const div of allDivs) {
const style = div.getAttribute('style') || '';
// IosFrame wrapper 特征: background:#000 或 background: black
if (style.includes('background:')) {
wrapperDiv = div;
break;
}
}
// 如果没找到 background 的,取第一个 div
if (!wrapperDiv && allDivs.length > 0) wrapperDiv = allDivs[0];
if (!wrapperDiv) return { label, index: i, found: false };
const wrapperBox = wrapperDiv.getBoundingClientRect();
return {
label,
index: i,
found: true,
wrapperBox: {
x: wrapperBox.x,
y: wrapperBox.y,
width: wrapperBox.width,
height: wrapperBox.height,
},
};
});
});
const { statusBar, homeIndicator, padding } = IOS_FRAME_TRIM;
const files = [];
const usedNames = new Set();
for (const screen of screenData) {
if (!screen.found || !screen.wrapperBox) {
process.stderr.write(`WARNING: screen ${screen.index + 1} 未找到 IosFrame跳过\n`);
continue;
}
const box = screen.wrapperBox;
const clip = {
x: box.x + padding,
y: box.y + padding + statusBar,
width: box.width - padding * 2,
height: box.height - padding * 2 - statusBar - homeIndicator,
};
if (clip.width <= 0 || clip.height <= 0) {
process.stderr.write(`WARNING: screen ${screen.index + 1} clip 无效 (${clip.width}x${clip.height})\n`);
continue;
}
let safeName = translateLabel(screen.label || '', screen.index + 1);
if (usedNames.has(safeName)) {
let s = 2;
while (usedNames.has(`${safeName}-${s}`)) s++;
safeName = `${safeName}-${s}`;
}
usedNames.add(safeName);
const filename = `${safeName}.png`;
await page.screenshot({ path: resolve(outputDir, filename), clip });
files.push({
label: screen.label || `screen-${screen.index + 1}`,
file: filename,
clip: {
x: Math.round(clip.x), y: Math.round(clip.y),
width: Math.round(clip.width), height: Math.round(clip.height),
},
});
}
process.stdout.write(JSON.stringify({
source: basename(htmlFile),
totalScreens: screenCount,
extracted: files.length,
files,
}, null, 2) + '\n');
} catch (err) {
fail(`Playwright 执行失败: ${err.message}`);
} finally {
if (browser) await browser.close();
}
}
main();

View File

@@ -0,0 +1,222 @@
#!/usr/bin/env node
/**
* infer-interactions.mjs -- 对 HTML 原型源码进行静态模式匹配,推断页面交互行为
*
* 读取 HTML 文件和 interaction-rules.yml用正则匹配源码中的模式
* 输出推断的交互组件、props 和行为描述。
*
* 用法: node infer-interactions.mjs <html-file> <interaction-rules.yml>
*/
import { readFileSync } from 'node:fs';
import { resolve, basename } from 'node:path';
import { createRequire } from 'node:module';
// js-yaml 是 CJS 包,需要用 createRequire 加载
const require = createRequire(import.meta.url);
const yaml = require('js-yaml');
// -- 工具函数 ----------------------------------------------------------------
/**
* 输出错误到 stderr 并以 code 1 退出
*/
function fail(message) {
process.stderr.write(JSON.stringify({ error: message }, null, 2) + '\n');
process.exit(1);
}
/**
* 读取文件内容,失败时调用 fail
*/
function readFileOrFail(filePath, description) {
try {
return readFileSync(filePath, 'utf-8');
} catch (err) {
fail(`无法读取${description}: ${filePath} -- ${err.message}`);
}
}
// -- 组件函数位置追踪 ---------------------------------------------------------
/**
* 提取源码中所有 function/const 组件声明的位置和名称。
* 返回 [{ name, start, end }],按 start 排序。
*
* end 的计算采用简化策略:找到下一个同级 function/const 声明或源码末尾。
* 对于 locations 追踪来说已经足够精确——只要知道 pattern 落在哪个函数内即可。
*/
function extractFunctionRanges(source) {
const ranges = [];
// 匹配 function 声明: function XxxName(
const funcPattern = /function\s+([A-Z]\w*)\s*\(/g;
let match;
while ((match = funcPattern.exec(source)) !== null) {
ranges.push({ name: match[1], start: match.index });
}
// 匹配 const 箭头函数: const XxxName = (...
const arrowPattern = /const\s+([A-Z]\w*)\s*=\s*(?:\([^)]*\)|\w+)\s*=>/g;
while ((match = arrowPattern.exec(source)) !== null) {
ranges.push({ name: match[1], start: match.index });
}
// 按 start 排序,计算 end下一个函数的 start 或源码末尾)
ranges.sort((a, b) => a.start - b.start);
for (let i = 0; i < ranges.length; i++) {
const nextStart = i + 1 < ranges.length ? ranges[i + 1].start : source.length;
ranges[i].end = nextStart;
}
return ranges;
}
/**
* 给定 pattern 在 source 中的匹配位置,找到它所属的函数名。
* 如果不在任何函数内,返回 '全局'。
*/
function findEnclosingFunction(ranges, matchIndex) {
for (const range of ranges) {
if (matchIndex >= range.start && matchIndex < range.end) {
return range.name;
}
}
return '全局';
}
// -- 核心匹配逻辑 ------------------------------------------------------------
/**
* 对单条 rule 执行模式匹配。
* 返回 { matched, matchedPatterns, locations }。
*/
function matchRule(rule, source, funcRanges) {
const requireAll = rule.require_all === true;
const matchedPatterns = [];
const locationSet = new Set();
for (const patternStr of rule.patterns) {
try {
const regex = new RegExp(patternStr, 'g');
let match;
let patternMatched = false;
while ((match = regex.exec(source)) !== null) {
patternMatched = true;
const enclosing = findEnclosingFunction(funcRanges, match.index);
locationSet.add(enclosing);
}
if (patternMatched) {
matchedPatterns.push(patternStr);
}
} catch (err) {
// 正则语法错误,跳过此 pattern 并报告到 stderr
process.stderr.write(`警告: rule "${rule.id}" 的 pattern "${patternStr}" 正则语法错误: ${err.message}\n`);
}
}
const allMatched = requireAll
? matchedPatterns.length === rule.patterns.length
: matchedPatterns.length > 0;
// 排除模式检查:如果任一 exclude_pattern 命中,该规则不匹配
if (allMatched && Array.isArray(rule.exclude_patterns)) {
for (const exclPattern of rule.exclude_patterns) {
try {
const exclRegex = new RegExp(exclPattern);
if (exclRegex.test(source)) {
return {
matched: false,
matchedPatterns: [],
locations: [],
excluded_by: exclPattern,
};
}
} catch (_e) { /* ignore regex errors */ }
}
}
return {
matched: allMatched,
matchedPatterns: allMatched ? matchedPatterns : [],
locations: allMatched ? [...locationSet] : [],
};
}
// -- 主流程 ------------------------------------------------------------------
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
fail('用法: node infer-interactions.mjs <html-file> <interaction-rules.yml>');
}
const htmlPath = resolve(args[0]);
const rulesPath = resolve(args[1]);
// 读取源文件
const source = readFileOrFail(htmlPath, 'HTML 文件');
// 读取并解析 rules YAML
const rulesContent = readFileOrFail(rulesPath, 'interaction-rules.yml');
let rulesDoc;
try {
rulesDoc = yaml.load(rulesContent);
} catch (err) {
fail(`YAML 解析失败: ${err.message}`);
}
if (!rulesDoc || !Array.isArray(rulesDoc.rules)) {
fail('interaction-rules.yml 格式错误: 缺少顶层的 rules 数组');
}
const rules = rulesDoc.rules;
// 提取函数位置范围表(用于 locations 追踪)
const funcRanges = extractFunctionRanges(source);
// 逐条匹配
const interactions = [];
let matchedCount = 0;
for (const rule of rules) {
const { matched, matchedPatterns, locations } = matchRule(rule, source, funcRanges);
const entry = {
id: rule.id,
name: rule.name,
matched,
};
if (matched) {
matchedCount++;
entry.matchedPatterns = matchedPatterns;
entry.component = rule.infer?.component ?? null;
entry.props = rule.infer?.props ?? '';
entry.behavior = rule.infer?.behavior ?? '';
entry.confidence = rule.confidence ?? 'medium';
if (locations.length > 0) {
entry.locations = locations;
}
}
interactions.push(entry);
}
// 组装输出
const result = {
source: basename(htmlPath),
interactions,
summary: {
total: rules.length,
matched: matchedCount,
unmatched: rules.length - matchedCount,
},
};
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
main();

View File

@@ -0,0 +1,706 @@
#!/usr/bin/env node
/**
* match-tokens.mjs — 三层 Token 匹配
*
* 接收 parse-prototype.mjs 的输出 JSON 和 tokens.yml 配置,
* 对每个原型 Token别名 / 内联样式值)执行三层匹配,
* 输出映射关系到 stdout。
*
* 三层匹配算法:
* Layer 1: 别名直查aliases.prototype_keys
* Layer 2: 值精确匹配(带 CSS 属性上下文消歧)
* Layer 3: 色彩模糊匹配RGB 欧几里得距离 < 30
*
* 用法:
* node match-tokens.mjs <parse-result.json> <tokens.yml>
*/
import yaml from 'js-yaml';
import { readFileSync } from 'fs';
import { resolve } from 'path';
// ---------------------------------------------------------------------------
// 工具函数
// ---------------------------------------------------------------------------
/**
* 解析 hex 颜色字符串为 {r, g, b}
* 支持 #RGB / #RRGGBB / RRGGBB
*/
function hexToRgb(hex) {
if (!hex || typeof hex !== 'string') return null;
const cleaned = hex.replace(/^#/, '').trim();
if (cleaned.length === 3) {
const r = parseInt(cleaned[0] + cleaned[0], 16);
const g = parseInt(cleaned[1] + cleaned[1], 16);
const b = parseInt(cleaned[2] + cleaned[2], 16);
return { r, g, b };
}
if (cleaned.length === 6) {
const r = parseInt(cleaned.substring(0, 2), 16);
const g = parseInt(cleaned.substring(2, 4), 16);
const b = parseInt(cleaned.substring(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
return { r, g, b };
}
return null;
}
/**
* RGB 欧几里得距离
*/
function colorDistance(rgb1, rgb2) {
return Math.sqrt(
(rgb1.r - rgb2.r) ** 2 +
(rgb1.g - rgb2.g) ** 2 +
(rgb1.b - rgb2.b) ** 2,
);
}
/**
* 判断字符串是否为颜色值hex 格式)
*/
function isHexColor(value) {
if (!value || typeof value !== 'string') return false;
return /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(value.trim());
}
/**
* 归一化数值:去掉 px 后缀,返回数值字符串(保留小数)
*/
function normalizeNumericValue(value) {
if (value == null) return null;
const str = String(value).trim();
if (str.endsWith('px')) {
return str.slice(0, -2).trim();
}
if (str.endsWith('rem') || str.endsWith('em')) {
return null; // rem/em 标记为 pending不参与数值匹配
}
return str;
}
/**
* 归一化颜色值:统一为小写 #rrggbb
*/
function normalizeColor(value) {
if (!value) return null;
const str = String(value).trim();
const hex = str.startsWith('#') ? str : `#${str}`;
const rgb = hexToRgb(hex);
if (!rgb) return null;
return `#${rgb.r.toString(16).padStart(2, '0')}${rgb.g.toString(16).padStart(2, '0')}${rgb.b.toString(16).padStart(2, '0')}`;
}
/**
* CSS 属性到 token 类别的映射
*/
const CSS_PROPERTY_CATEGORIES = {
// 颜色类
color: 'color',
backgroundColor: 'color',
'background-color': 'color',
background: 'color',
borderColor: 'color',
'border-color': 'color',
borderTopColor: 'color',
borderBottomColor: 'color',
borderLeftColor: 'color',
borderRightColor: 'color',
outlineColor: 'color',
textColor: 'color',
fill: 'color',
// 间距类
padding: 'spacing',
paddingTop: 'spacing',
paddingBottom: 'spacing',
paddingLeft: 'spacing',
paddingRight: 'spacing',
paddingVertical: 'spacing',
paddingHorizontal: 'spacing',
margin: 'spacing',
marginTop: 'spacing',
marginBottom: 'spacing',
marginLeft: 'spacing',
marginRight: 'spacing',
gap: 'spacing',
rowGap: 'spacing',
columnGap: 'spacing',
top: 'spacing',
bottom: 'spacing',
left: 'spacing',
right: 'spacing',
// 排版类
fontSize: 'typography',
lineHeight: 'typography',
fontWeight: 'typography',
letterSpacing: 'typography',
// 圆角类
borderRadius: 'radius',
borderTopLeftRadius: 'radius',
borderTopRightRadius: 'radius',
borderBottomLeftRadius: 'radius',
borderBottomRightRadius: 'radius',
// 尺寸类
width: 'sizing',
height: 'sizing',
minWidth: 'sizing',
minHeight: 'sizing',
maxWidth: 'sizing',
maxHeight: 'sizing',
};
// ---------------------------------------------------------------------------
// Token 注册表构建
// ---------------------------------------------------------------------------
/**
* 从 tokens.yml 构建可查找的 Token 列表
* 返回 { all: [...], byCategory: { color: [...], spacing: [...], ... } }
*/
function buildTokenRegistry(tokensConfig) {
const all = [];
const byCategory = {};
function addToken(entry, category) {
const record = {
token: entry.token || null,
scssVar: entry.scss_var || null,
value: entry.value || null,
category,
role: entry.role || null,
note: entry.note || null,
};
all.push(record);
if (!byCategory[category]) byCategory[category] = [];
byCategory[category].push(record);
}
// colors
if (Array.isArray(tokensConfig.colors)) {
for (const c of tokensConfig.colors) {
addToken(c, 'color');
}
}
// unmapped_scss_variables颜色类
if (Array.isArray(tokensConfig.unmapped_scss_variables)) {
for (const v of tokensConfig.unmapped_scss_variables) {
addToken(v, 'color');
}
}
// typography
if (Array.isArray(tokensConfig.typography)) {
for (const t of tokensConfig.typography) {
addToken(t, 'typography');
}
}
// structure (line-height 归入 typography)
if (Array.isArray(tokensConfig.structure)) {
for (const s of tokensConfig.structure) {
addToken(s, 'typography');
}
}
// spacing
if (Array.isArray(tokensConfig.spacing)) {
for (const s of tokensConfig.spacing) {
addToken(s, 'spacing');
}
}
// radius
if (Array.isArray(tokensConfig.radius)) {
for (const r of tokensConfig.radius) {
if (r && typeof r === 'object') {
addToken(r, 'radius');
}
}
}
if (Array.isArray(tokensConfig.radius_unmapped)) {
for (const r of tokensConfig.radius_unmapped) {
if (r && typeof r === 'object') {
addToken(r, 'radius');
}
}
}
// sizing
if (Array.isArray(tokensConfig.sizing)) {
for (const s of tokensConfig.sizing) {
addToken(s, 'sizing');
}
}
// feedback归入 sizing 或单独)
if (Array.isArray(tokensConfig.feedback)) {
for (const f of tokensConfig.feedback) {
addToken(f, 'feedback');
}
}
// tag
if (Array.isArray(tokensConfig.tag)) {
for (const t of tokensConfig.tag) {
addToken(t, 'tag');
}
}
// shadow_unmapped
if (Array.isArray(tokensConfig.shadow_unmapped)) {
for (const s of tokensConfig.shadow_unmapped) {
addToken(s, 'shadow');
}
}
return { all, byCategory };
}
// ---------------------------------------------------------------------------
// 三层匹配
// ---------------------------------------------------------------------------
/**
* Layer 1: 别名直查(支持值条件映射)
*
* alias 格式有两种:
* 单条: { token: "...", status: "exact_match" }
* 数组: [{ value: "#C4623A", token: "--tk-pri", variant: "patient" },
* { value: "#3A6B8C", token: "--tk-pri.doctor", variant: "doctor" }]
*
* 数组格式按值匹配,单条格式直接匹配(兼容旧格式)。
*/
function matchByAlias(key, prototypeValue, aliases) {
if (!aliases || !aliases.prototype_keys) return null;
const alias = aliases.prototype_keys[key];
if (!alias) return null;
// 数组格式:按值条件匹配
if (Array.isArray(alias)) {
const normalizedInput = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue);
for (const entry of alias) {
const normalizedAlias = normalizeColor(entry.value) || normalizeNumericValue(entry.value) || String(entry.value);
if (normalizedInput === normalizedAlias) {
const result = {
method: 'alias',
confidence: entry.status === 'exact_match' ? 'confirmed' : 'pending',
token: entry.token || null,
scssVar: entry.scss_var || null,
};
if (entry.variant) result.variant = entry.variant;
if (entry.note) result.note = entry.note;
return result;
}
}
// 数组中无值匹配 → 降级到 Layer 2
return null;
}
// 单条格式(兼容旧格式)
const result = { method: 'alias', confidence: null };
// 值校验:如果 alias 有 value 字段,比较值
if (alias.value && prototypeValue != null) {
const normAlias = normalizeColor(alias.value) || normalizeNumericValue(alias.value) || String(alias.value);
const normProto = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue);
if (normAlias !== normProto) {
// 值不匹配 → 降级到 Layer 2
return null;
}
}
if (alias.status === 'exact_match') {
result.token = alias.token || null;
result.scssVar = alias.scss_var || null;
result.confidence = 'confirmed';
} else if (alias.status === 'unmatched') {
result.token = alias.token || null;
result.scssVar = alias.scss_var || null;
result.tokenValue = alias.value || null;
result.confidence = 'pending';
if (alias.note) result.note = alias.note;
if (!result.token && alias.scssVar) {
result.note = result.note || `tokens.scss 未声明为 CSS 变量,直接使用 SCSS 变量 ${alias.scssVar}`;
}
} else if (alias.status === 'approximate') {
result.token = alias.token || null;
result.scssVar = alias.scss_var || null;
result.confidence = 'approximate';
if (alias.note) result.note = alias.note;
}
return result;
}
/**
* 获取用于值匹配的候选 token 列表
* 根据 CSS 属性确定查找类别
*/
function getCandidatesByProperty(propertyName, registry) {
const category = CSS_PROPERTY_CATEGORIES[propertyName];
if (!category) {
// 未知属性,在所有 token 中搜索
return registry.all;
}
if (category === 'color') {
// color 类同时搜索 colors + unmapped_scss_variables
return [
...(registry.byCategory.color || []),
...(registry.byCategory.shadow || []), // 阴影也含颜色信息
];
}
if (category === 'spacing') {
return [
...(registry.byCategory.spacing || []),
...(registry.byCategory.tag || []), // tag 也有 padding
];
}
if (category === 'typography') {
return registry.byCategory.typography || [];
}
if (category === 'radius') {
return registry.byCategory.radius || [];
}
if (category === 'sizing') {
return [
...(registry.byCategory.sizing || []),
...(registry.byCategory.feedback || []),
];
}
return registry.byCategory[category] || registry.all;
}
/**
* Layer 2: 值精确匹配(带 CSS 属性上下文消歧)
* @param {string} value - 原型中的值
* @param {string|null} cssProperty - 关联的 CSS 属性名(用于消歧)
* @param {object} registry - Token 注册表
* @returns {object|null} 匹配结果
*/
function matchByValue(value, cssProperty, registry) {
if (value == null) return null;
const candidates = getCandidatesByProperty(cssProperty, registry);
if (candidates.length === 0) return null;
// 颜色匹配
if (isHexColor(value)) {
const normalizedInput = normalizeColor(value);
if (!normalizedInput) return null;
for (const candidate of candidates) {
if (!candidate.value) continue;
if (!isHexColor(candidate.value)) continue;
const normalizedCandidate = normalizeColor(candidate.value);
if (normalizedCandidate === normalizedInput) {
return buildValueMatchResult(candidate, value);
}
}
return null;
}
// 数值匹配(含 px 后缀)
const normalizedInput = normalizeNumericValue(value);
if (normalizedInput === null) {
// rem/em — 标记为 pending
return {
token: null,
prototypeValue: value,
method: 'value_exact',
confidence: 'pending',
note: 'rem/em 值无法自动匹配,需手动确认',
};
}
for (const candidate of candidates) {
if (!candidate.value) continue;
const normalizedCandidate = normalizeNumericValue(candidate.value);
if (normalizedCandidate === null) continue;
if (normalizedCandidate === normalizedInput) {
return buildValueMatchResult(candidate, value);
}
}
// 无单位数值直接比较(如 lineHeight: 1.5
for (const candidate of candidates) {
if (!candidate.value) continue;
if (String(candidate.value).trim() === String(value).trim()) {
return buildValueMatchResult(candidate, value);
}
}
return null;
}
/**
* 构建值匹配结果
*/
function buildValueMatchResult(candidate, prototypeValue) {
const result = {
token: candidate.token || null,
scssVar: candidate.scssVar || null,
prototypeValue,
tokenValue: candidate.value,
method: 'value_exact',
confidence: candidate.token ? 'confirmed' : 'pending',
};
if (candidate.role) result.role = candidate.role;
if (!candidate.token && candidate.scssVar) {
result.note = `匹配到 SCSS 变量 ${candidate.scssVar},但无对应 CSS Token`;
}
return result;
}
/**
* Layer 3: 色彩模糊匹配RGB 欧几里得距离)
* @param {string} value - hex 颜色值
* @param {object} registry - Token 注册表
* @param {number} threshold - 距离阈值,默认 30
* @returns {object|null} 最佳近似匹配
*/
function matchByColorFuzzy(value, registry, threshold = 30) {
const inputRgb = hexToRgb(value);
if (!inputRgb) return null;
// 在所有颜色类 token 中搜索
const colorCandidates = [
...(registry.byCategory.color || []),
];
let bestMatch = null;
let bestDistance = Infinity;
for (const candidate of colorCandidates) {
if (!candidate.value) continue;
const candidateRgb = hexToRgb(candidate.value);
if (!candidateRgb) continue;
const dist = colorDistance(inputRgb, candidateRgb);
if (dist < bestDistance) {
bestDistance = dist;
bestMatch = candidate;
}
}
if (bestMatch && bestDistance < threshold) {
return {
token: bestMatch.token || null,
scssVar: bestMatch.scssVar || null,
prototypeValue: value,
tokenValue: bestMatch.value,
method: 'color_fuzzy',
confidence: 'approximate',
distance: Math.round(bestDistance * 100) / 100,
note: `颜色近似匹配RGB 距离 ${Math.round(bestDistance * 100) / 100}`,
};
}
return null;
}
/**
* 对单个值执行完整三层匹配
* @param {string} value - 要匹配的值
* @param {string|null} cssProperty - CSS 属性名(消歧用)
* @param {object} registry - Token 注册表
* @returns {object} 匹配结果
*/
function fullMatchValue(value, cssProperty, registry) {
// Layer 2: 值精确匹配
const exact = matchByValue(value, cssProperty, registry);
if (exact) return exact;
// Layer 3: 颜色模糊匹配(仅对颜色值)
if (isHexColor(value)) {
const fuzzy = matchByColorFuzzy(value, registry);
if (fuzzy) return fuzzy;
}
// 未匹配
return {
token: null,
scssVar: null,
prototypeValue: value,
method: 'none',
confidence: 'unmatched',
};
}
// ---------------------------------------------------------------------------
// 主流程
// ---------------------------------------------------------------------------
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
process.stderr.write('用法: node match-tokens.mjs <parse-result.json> <tokens.yml>\n');
process.exit(1);
}
const [parseResultPath, tokensYmlPath] = args;
// 读取输入文件
let parseResult;
try {
const raw = readFileSync(resolve(parseResultPath), 'utf-8');
parseResult = JSON.parse(raw);
} catch (err) {
process.stderr.write(`无法读取/解析 parse-result JSON: ${err.message}\n`);
process.exit(1);
}
let tokensConfig;
try {
const raw = readFileSync(resolve(tokensYmlPath), 'utf-8');
tokensConfig = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
} catch (err) {
process.stderr.write(`无法读取/解析 tokens.yml: ${err.message}\n`);
process.exit(1);
}
// 构建 Token 注册表
const registry = buildTokenRegistry(tokensConfig);
const aliases = tokensConfig.aliases || null;
// ---- 匹配原型 Token 别名 ----
const matched = {};
const unmatched = [];
const prototypeTokens = parseResult.tokens || {};
for (const [key, value] of Object.entries(prototypeTokens)) {
// Layer 1: 别名直查
const aliasResult = matchByAlias(key, value, aliases);
if (aliasResult) {
matched[key] = {
...aliasResult,
prototypeValue: value,
// 如果 alias 没有覆盖 tokenValue从 token 注册表中查找
tokenValue: aliasResult.tokenValue || null,
};
// 补充 tokenValue
if (aliasResult.token && !matched[key].tokenValue) {
const found = registry.all.find(t => t.token === aliasResult.token);
if (found) matched[key].tokenValue = found.value;
}
continue;
}
// 无别名,尝试值匹配(需要推断 CSS 属性上下文)
const inferredProperty = inferPropertyFromTokenKey(key);
const valueResult = fullMatchValue(value, inferredProperty, registry);
if (valueResult.confidence !== 'unmatched') {
matched[key] = valueResult;
} else {
unmatched.push(key);
}
}
// ---- 匹配内联样式值 ----
const inlineTokenMap = {};
const inlineStyles = parseResult.inlineStyles || {};
for (const [cssProperty, values] of Object.entries(inlineStyles)) {
for (const rawValue of values) {
const mapKey = `${cssProperty}:${rawValue}`;
const result = fullMatchValue(rawValue, cssProperty, registry);
if (result.confidence !== 'unmatched') {
inlineTokenMap[mapKey] = {
token: result.token,
scssVar: result.scssVar || undefined,
tokenValue: result.tokenValue || undefined,
confidence: result.confidence,
method: result.method,
};
if (result.note) inlineTokenMap[mapKey].note = result.note;
if (result.distance != null) inlineTokenMap[mapKey].distance = result.distance;
}
}
}
// ---- 汇总统计 ----
let confirmed = 0;
let pending = 0;
let approximate = 0;
for (const entry of Object.values(matched)) {
if (entry.confidence === 'confirmed') confirmed++;
else if (entry.confidence === 'pending') pending++;
else if (entry.confidence === 'approximate') approximate++;
}
for (const entry of Object.values(inlineTokenMap)) {
if (entry.confidence === 'confirmed') confirmed++;
else if (entry.confidence === 'pending') pending++;
else if (entry.confidence === 'approximate') approximate++;
}
const totalAlias = Object.keys(prototypeTokens).length;
const totalInline = Object.values(inlineStyles).reduce((sum, arr) => sum + arr.length, 0);
// 检测 variant
const variantEntries = Object.values(matched).filter(e => e.variant);
const variant = variantEntries.length > 0 ? variantEntries[0].variant : null;
// ---- 输出 ----
const output = {
source: parseResult.source || null,
variant,
matched,
unmatched,
inlineTokenMap,
summary: {
total: totalAlias + totalInline,
aliasTokens: totalAlias,
inlineValues: totalInline,
confirmed,
pending,
approximate,
unmatched: unmatched.length,
},
};
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
}
/**
* 从 Token 别名 key 推断 CSS 属性(用于无别名时的消歧)
*/
function inferPropertyFromTokenKey(key) {
const lower = key.toLowerCase();
if (lower.includes('pri') || lower.includes('color') || lower.includes('bg') ||
lower.includes('tx') || lower.includes('bd') || lower.includes('card') ||
lower.includes('surface') || lower.includes('acc') || lower.includes('dan') ||
lower.includes('wrn') || lower.includes('wechat')) {
return 'color';
}
if (lower.includes('font') || lower.includes('text') && !lower.includes('color')) {
return 'fontSize';
}
if (lower.includes('r') && (lower.includes('sm') || lower.includes('xs') || lower.length <= 3)) {
return 'borderRadius';
}
if (lower.includes('gap') || lower.includes('pad') || lower.includes('margin')) {
return 'padding';
}
return null; // 未知,不限制类别
}
// 启动
main();

View File

@@ -0,0 +1,384 @@
#!/usr/bin/env node
/**
* parse-prototype.mjs — 解析 huashu-design 产出的 HTML 原型文件
*
* 提取设计 Token (T 对象)、内联样式硬编码值、屏幕信息和组件定义。
* 输出 JSON 到 stdout。
*
* 用法: node parse-prototype.mjs <html-file-path>
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// ─── 工具函数 ────────────────────────────────────────────────
/**
* 输出错误 JSON 到 stderr 并以 code 1 退出
*/
function fail(message) {
process.stderr.write(JSON.stringify({ valid: false, error: message }, null, 2) + '\n');
process.exit(1);
}
/**
* 括号深度计数法:从 source[startPos] 开始startPos 应指向 '{'
* 提取完整的平衡花括号内容。
*
* 正确处理:
* - 单引号/双引号内的花括号不计数
* - 模板字符串(`)内的花括号不计数
* - 转义字符(\" \' \\ \x \u跳过
*
* 返回包含外层花括号的完整字符串,或在无法平衡时返回 null。
*/
function extractBalancedBraces(source, startPos) {
if (source[startPos] !== '{') return null;
let depth = 0;
let i = startPos;
const len = source.length;
while (i < len) {
const ch = source[i];
if (ch === "'" || ch === '"') {
// 字符串字面量:跳到配对的结束引号
const quote = ch;
i++;
while (i < len) {
if (source[i] === '\\') {
i += 2; // 跳过转义字符
continue;
}
if (source[i] === quote) {
i++;
break;
}
i++;
}
continue;
}
if (ch === '`') {
// 模板字符串:处理 ${...} 内的嵌套,以及转义
i++;
while (i < len) {
if (source[i] === '\\') {
i += 2;
continue;
}
if (source[i] === '`') {
i++;
break;
}
if (source[i] === '$' && i + 1 < len && source[i + 1] === '{') {
// 模板表达式 ${...},需要单独平衡
i += 2; // 跳过 ${
let tmplDepth = 1;
while (i < len && tmplDepth > 0) {
const tc = source[i];
if (tc === '{') tmplDepth++;
else if (tc === '}') tmplDepth--;
// 简化:模板表达式内也可以有字符串,但这里不递归处理
// 因为 T 对象的值不太可能有如此复杂的嵌套
if (tc === "'" || tc === '"') {
const tq = tc;
i++;
while (i < len) {
if (source[i] === '\\') { i += 2; continue; }
if (source[i] === tq) { i++; break; }
i++;
}
continue;
}
if (tc === '`') {
// 嵌套模板字符串
i++;
let nestedTmplDepth = 0;
while (i < len) {
if (source[i] === '\\') { i += 2; continue; }
if (source[i] === '`') { i++; break; }
if (source[i] === '$' && i + 1 < len && source[i + 1] === '{') {
// 这里简化处理,不递归
i += 2;
nestedTmplDepth++;
continue;
}
if (source[i] === '{') nestedTmplDepth++;
if (source[i] === '}') {
if (nestedTmplDepth > 0) nestedTmplDepth--;
else { i++; break; }
}
i++;
}
continue;
}
i++;
}
continue;
}
i++;
}
continue;
}
if (ch === '/' && i + 1 < len) {
// 注释处理
if (source[i + 1] === '/') {
// 单行注释
while (i < len && source[i] !== '\n') i++;
continue;
}
if (source[i + 1] === '*') {
// 多行注释
i += 2;
while (i + 1 < len && !(source[i] === '*' && source[i + 1] === '/')) i++;
i += 2;
continue;
}
}
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) {
return source.slice(startPos, i + 1);
}
}
i++;
}
return null; // 未平衡
}
// ─── Step 0: 格式校验 ────────────────────────────────────────
function validateSource(source) {
const hasReact = source.includes('react@') || source.includes('react.production');
const hasBabel = source.includes('@babel/standalone') || source.includes('babel.min');
const hasToken = /const\s+T\s*=\s*\{/.test(source);
if (!hasReact) return '文件不包含 React 引用';
if (!hasBabel) return '文件不包含 Babel 引用';
if (!hasToken) return '文件不包含 T 对象定义 (const T = {)';
return null;
}
// ─── Step 1: 提取 T 对象 ─────────────────────────────────────
function extractTokens(source) {
// 匹配 const T = { / const T ={ / const T={ 等各种空格变体
const markerPattern = /const\s+T\s*=\s*\{/;
const markerMatch = markerPattern.exec(source);
if (!markerMatch) return {};
const braceStart = markerMatch.index + markerMatch[0].length - 1; // 指向 '{'
const rawObject = extractBalancedBraces(source, braceStart);
if (!rawObject) return {};
// 将 JS 对象字面量转为可求值字符串,用 Function 构造器执行
try {
const fn = new Function('return (' + rawObject + ')');
const tObj = fn();
// 展平为 "T.key": "value" 格式
const flat = {};
for (const [key, value] of Object.entries(tObj)) {
flat[`T.${key}`] = String(value);
}
return flat;
} catch {
// 如果 Function 执行失败,尝试用正则提取简单键值对作为降级方案
const fallback = {};
const kvPattern = /(\w+)\s*:\s*'([^']*)'/g;
const kvPattern2 = /(\w+)\s*:\s*"([^"]*)"/g;
const kvPatternNum = /(\w+)\s*:\s*(\d+(?:\.\d+)?)/g;
let match;
const innerContent = rawObject.slice(1, -1);
while ((match = kvPattern.exec(innerContent)) !== null) {
fallback[`T.${match[1]}`] = match[2];
}
while ((match = kvPattern2.exec(innerContent)) !== null) {
fallback[`T.${match[1]}`] = match[2];
}
while ((match = kvPatternNum.exec(innerContent)) !== null) {
fallback[`T.${match[1]}`] = match[2];
}
return fallback;
}
}
// ─── Step 2: 提取内联样式硬编码值 ─────────────────────────────
const STYLE_PROPERTIES = ['fontSize', 'padding', 'borderRadius', 'gap', 'margin', 'fontWeight', 'lineHeight', 'width', 'height', 'letterSpacing'];
function extractInlineStyles(source) {
const result = {};
for (const prop of STYLE_PROPERTIES) {
const values = new Set();
// 匹配 camelCase 属性: fontSize: 16, fontSize: '16px', fontSize: "16px"
// 也匹配 object shorthand: { fontSize: 16 }
const pattern = new RegExp(
prop + '\\s*:\\s*([\'"`]?)([\\w\\s%.,()-]+?)\\1\\s*[,}]',
'g'
);
let match;
while ((match = pattern.exec(source)) !== null) {
let val = match[2].trim();
// 过滤掉变量引用(如 T.pri, T.r 等)
if (val.startsWith('T.') || val.startsWith('${')) continue;
// 过滤模板字符串插值
if (val.includes('${')) continue;
// 过滤掉 CSS 变量引用
if (val.startsWith('var(')) continue;
values.add(val);
}
if (values.size > 0) {
// 去重并排序:数值优先(降序),字符串按字母序
const sorted = [...values].sort((a, b) => {
const numA = parseFloat(a);
const numB = parseFloat(b);
if (!isNaN(numA) && !isNaN(numB)) return numB - numA;
if (!isNaN(numA)) return -1;
if (!isNaN(numB)) return 1;
return a.localeCompare(b);
});
result[prop] = sorted;
}
}
return result;
}
// ─── Step 3: 提取 screen 信息 ────────────────────────────────
function extractScreens(source) {
const screens = [];
// 策略 A: 找静态 screen-label 文本(外部 span/div 标签)
const labelPattern = /<(?:span|div)\s+(?:className|class)="screen-label"[^>]*>([^<]*)<\/(?:span|div)>/g;
const labels = [];
let labelMatch;
while ((labelMatch = labelPattern.exec(source)) !== null) {
labels.push(labelMatch[1].trim());
}
// 策略 B: 找 IosFrame 的 label propJSX 属性模式)
// 匹配: <IosFrame ... label="文本" ...>
const iosFrameLabelPattern = /<IosFrame[^>]*\blabel=["']([^"']+)["'][^>]*>/g;
const iosLabels = [];
let iosLabelMatch;
while ((iosLabelMatch = iosFrameLabelPattern.exec(source)) !== null) {
iosLabels.push(iosLabelMatch[1].trim());
}
// 提取 IosFrame children 组件
const iosFramePattern = /<IosFrame[^>]*>\s*(?:<React\.Fragment[^>]*>)?\s*<(\w+)\s*\/?>/g;
const components = [];
let compMatch;
while ((compMatch = iosFramePattern.exec(source)) !== null) {
const name = compMatch[1];
if (name !== 'div' && name !== 'span' && name[0] === name[0].toUpperCase()) {
components.push(name);
}
}
// 选择 label 来源:优先静态标签,如果数量不匹配则尝试 JSX prop
const useIosLabels = labels.length === 0 || (iosLabels.length > 0 && iosLabels.length === components.length && labels.length !== components.length);
const selectedLabels = useIosLabels ? iosLabels : labels;
// 配对
const count = Math.max(selectedLabels.length, components.length);
for (let i = 0; i < count; i++) {
screens.push({
label: selectedLabels[i] || '',
component: components[i] || '',
});
}
return screens;
}
// ─── Step 4: 识别组件函数 ─────────────────────────────────────
function extractComponents(source) {
const components = new Set();
// 匹配 function 声明: function XxxPage() / function Xxx()
const funcPattern = /function\s+([A-Z]\w*)\s*\(/g;
let match;
while ((match = funcPattern.exec(source)) !== null) {
components.add(match[1]);
}
// 匹配 const Xxx = () => / const Xxx = () =>
const arrowPattern = /const\s+([A-Z]\w*)\s*=\s*(?:\([^)]*\)|[^=])\s*=>/g;
while ((match = arrowPattern.exec(source)) !== null) {
components.add(match[1]);
}
return [...components].sort((a, b) => {
// IosFrame 排到最前面(基础设施组件)
if (a === 'IosFrame') return -1;
if (b === 'IosFrame') return 1;
return a.localeCompare(b);
});
}
// ─── 主流程 ──────────────────────────────────────────────────
function main() {
const args = process.argv.slice(2);
if (args.length < 1) {
fail('用法: node parse-prototype.mjs <html-file-path>');
}
const filePath = resolve(args[0]);
let source;
try {
source = readFileSync(filePath, 'utf-8');
} catch (err) {
fail(`无法读取文件: ${filePath}${err.message}`);
}
// Step 0: 格式校验
const validationError = validateSource(source);
if (validationError) {
fail(`格式校验失败: ${validationError}`);
}
// Step 1: 提取 T 对象
const tokens = extractTokens(source);
// Step 2: 提取内联样式
const inlineStyles = extractInlineStyles(source);
// Step 3: 提取 screen 信息
const screens = extractScreens(source);
// Step 4: 识别组件函数
const components = extractComponents(source);
// 组装输出
const result = {
valid: true,
filePath: filePath.replace(/\\/g, '/'),
tokens,
inlineStyles,
screens,
components,
};
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
main();

View File

@@ -0,0 +1,73 @@
# {{pageTitle}} 设计规格
> 来源: {{sourceFile}} | 平台: {{platform}} | 页面数: {{screenCount}} | 生成: {{date}}
## 页面索引
| 页面 | 截图 | 路由 |
|------|------|------|
{{#each screens}}
| {{label}} | ![{{file}}](./screenshots/{{file}}) | {{route}} |
{{/each}}
## 一、Token 映射
| 原型值 | 项目 Token | 状态 |
|--------|-----------|------|
{{#each tokenMap}}
| {{prototypeValue}} ({{key}}) | {{tokenRef}} | {{statusIcon}} |
{{/each}}
> 状态标记: ✅ confirmed 直接使用 | ⚠️ pending 需人工复核 | ❌ unmatched 需硬编码或新建 Token
## 二、页面结构
{{#each screens}}
### {{ordinal}}. {{label}}
![{{label}}](./screenshots/{{file}})
布局层级(从上到下):
{{layoutDescription}}
{{/each}}
## 三、组件映射
| 原型元素 | 推荐组件 | 来源 | 备注 |
|----------|---------|------|------|
{{#each componentMap}}
| {{prototypeElement}} | {{component}} | {{source}} | {{notes}} |
{{/each}}
{{#each newComponents}}
> ⚠️ **需新建**: {{name}} — {{reason}}
{{/each}}
## 四、交互规格
| 元素 | 交互 | 触发 | 反馈 | 备注 |
|------|------|------|------|------|
{{#each interactions}}
| {{element}} | {{type}} | {{trigger}} | {{feedback}} | {{notes}} |
{{/each}}
## 五、状态变体
{{#each stateVariants}}
- **{{name}}**: {{description}}
{{/each}}
## 六、样式清单
{{styleSummary}}
---
> 此规格由 design-handoff skill 自动生成。LLM 实施时请:
> 1. 先阅读截图建立视觉印象
> 2. 按 Token 映射表使用项目 Token✅ 标记的直接用)
> 3. 优先使用"组件映射"中列出的已有组件
> 4. 参考"交互规格"实现对应的交互逻辑
> 5. "需新建"的组件参考截图和布局描述从头实现

423
.design/tokens.yml Normal file
View File

@@ -0,0 +1,423 @@
version: 1
updated: "2026-05-17"
# ============================================================================
# Design Token 注册表
# 数据源: apps/miniprogram/src/styles/tokens.scss + variables.scss
# ============================================================================
colors:
# --- 主色系(赤土橙) ---
- token: --tk-pri
value: "#C4623A"
scss_var: "$pri"
role: 主色/赤土橙 accent
- token: --tk-pri-l
value: "#F0DDD4"
scss_var: "$pri-l"
role: 主色浅/赤土浅
- token: --tk-pri-d
value: "#8B3E1F"
scss_var: "$pri-d"
role: 主色深/赤土深
# --- 阴影色(含透明度) ---
- token: --tk-shadow-btn
value: "0 4px 16px rgba(196, 98, 58, 0.3)"
scss_var: "$shadow-btn"
role: 主按钮阴影
- token: --tk-shadow-tab
value: "0 2px 8px rgba(196, 98, 58, 0.25)"
scss_var: "$shadow-tab"
role: 选中Tab阴影
# --- 文字色 ---
- token: --tk-text-secondary
value: "#78716C"
scss_var: "$tx3"
role: 淡文字/辅助文字
# --- 卡片背景 ---
- token: --tk-card-bg
value: "#FFFFFF"
scss_var: "$card"
role: 卡片白底
# --- 医生端覆盖色(.doctor-mode 下自动替换 --tk-pri* ---
- token: --tk-pri.doctor
value: "#3A6B8C"
scss_var: "$doc-pri"
role: 医生端主色/靛蓝
note: 仅在 .doctor-mode 下覆盖 --tk-pri
- token: --tk-pri-l.doctor
value: "#D4E5F0"
scss_var: "$doc-pri-l"
role: 医生端浅色
- token: --tk-pri-d.doctor
value: "#2A4F6A"
scss_var: "$doc-pri-d"
role: 医生端深色
# --- 未映射到 Token 的 SCSS 变量(原型中有但 tokens.scss 未声明为 CSS 变量) ---
unmapped_scss_variables:
- scss_var: "$bg"
value: "#F5F0EB"
role: 页面主背景/温润米底
note: "原型 T.bg 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$tx"
value: "#2D2A26"
role: 主文字色/暖黑
note: "原型 T.tx 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$tx2"
value: "#5A554F"
role: 次文字色/暖灰
note: "原型 T.tx2 近似映射elder-mode 下 --tk-text-secondary 覆盖为此值"
- scss_var: "$bd"
value: "#E8E2DC"
role: 边框色
note: "原型 T.bd 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$bd-l"
value: "#F0EBE5"
role: 浅边框色
- scss_var: "$acc"
value: "#5B7A5E"
role: 鼠尾草绿/成功色
note: "原型中成功色tokens.scss 未声明为 CSS 变量"
- scss_var: "$acc-l"
value: "#E8F0E8"
role: 成功浅色
- scss_var: "$dan"
value: "#B54A4A"
role: 危险色/柔红
note: "原型中危险色tokens.scss 未声明为 CSS 变量"
- scss_var: "$dan-l"
value: "#FDEAEA"
role: 危险浅色
- scss_var: "$wrn"
value: "#C4873A"
role: 警告色/暖琥珀
note: "原型中警告色tokens.scss 未声明为 CSS 变量"
- scss_var: "$wrn-l"
value: "#FFF3E0"
role: 警告浅色
- scss_var: "$surface-alt"
value: "#EDE8E2"
role: 辅助底色
- scss_var: "$wechat"
value: "#07C160"
role: 微信绿
typography:
- token: --tk-font-display
value: "72px"
note: 大屏展示
elder: "80px"
- token: --tk-font-hero
value: "48px"
note: 启动页标题
elder: "56px"
- token: --tk-font-h1
value: "28px"
note: 页面标题 serif bold
elder: "32px"
- token: --tk-font-h2
value: "22px"
note: 副标题、用户名 serif bold
elder: "25px"
- token: --tk-font-body-lg
value: "18px"
note: 按钮文字、section 标题 fontWeight:600
elder: "22px"
- token: --tk-font-body
value: "16px"
note: 正文、输入框、icon 文字(最常用 UI 字号)
elder: "22px"
- token: --tk-font-body-sm
value: "14px"
note: 副文本、描述
elder: "19px"
- token: --tk-font-num
value: "30px"
note: 数值 serif bold
elder: "34px"
- token: --tk-font-num-lg
value: "34px"
note: 大数值
elder: "40px"
- token: --tk-font-cap
value: "13px"
note: 说明文字(第一高频字号)
elder: "18px"
- token: --tk-font-nav
value: "18px"
note: 导航栏标题 serif bold
elder: "22px"
- token: --tk-font-micro
value: "11px"
note: 角标、tag
elder: "17px"
structure:
- token: --tk-line-height
value: "1.5"
elder: "1.7"
spacing:
- token: --tk-gap-2xs
value: "4px"
scss_var: "$sp-2xs"
elder: "6px"
- token: --tk-gap-xs
value: "8px"
scss_var: "$sp-xs"
elder: "12px"
- token: --tk-gap-sm
value: "12px"
scss_var: "$sp-sm"
elder: "16px"
- token: --tk-gap-md
value: "16px"
scss_var: "$sp-md"
elder: "20px"
- token: --tk-section-gap
value: "20px"
scss_var: "$sp-section"
elder: "28px"
- token: --tk-gap-lg
value: "24px"
scss_var: "$sp-lg"
elder: "32px"
- token: --tk-gap-xl
value: "32px"
scss_var: "$sp-xl"
elder: "40px"
- token: --tk-gap-2xl
value: "48px"
scss_var: "$sp-2xl"
elder: "56px"
- token: --tk-page-padding
value: "20px"
elder: "28px"
- token: --tk-card-padding
value: "20px"
elder: "28px"
- token: --tk-card-padding-sm
value: "16px"
elder: "20px"
- token: --tk-card-padding-lg
value: "28px"
elder: "36px"
radius:
- token: --tk-card-radius
value: "16px"
scss_var: "$r"
elder: "20px"
radius_unmapped:
- scss_var: "$r-sm"
value: "12px"
note: "原型 T.rSm 映射目标"
- scss_var: "$r-xs"
value: "8px"
note: "原型 T.rXs 映射目标"
- scss_var: "$r-lg"
value: "20px"
- scss_var: "$r-pill"
value: "999px"
sizing:
- token: --tk-touch-min
value: "48px"
role: 最小触控区
elder: "56px"
- token: --tk-btn-primary-h
value: "52px"
role: 主按钮高度
elder: "60px"
- token: --tk-input-height
value: "56px"
role: 输入框高度
elder: "64px"
- token: --tk-tabbar-space
value: "100px"
role: TabBar 底部安全区
elder: "120px"
feedback:
- token: --tk-touch-feedback-opacity
value: "0.85"
role: 触控反馈透明度
elder: "0.8"
tag:
- token: --tk-tag-font-size
value: "11px"
elder: "13px"
- token: --tk-tag-padding-v
value: "3px"
elder: "5px"
- token: --tk-tag-padding-h
value: "8px"
elder: "12px"
shadow_unmapped:
# tokens.scss 中的 --tk-shadow-btn/tab 是复合值(含偏移+模糊+颜色)
# 以下为 variables.scss 中的其他阴影,未声明为 CSS Token
- scss_var: "$shadow-sm"
value: "0 1px 4px rgba(45, 42, 38, 0.06)"
role: 小阴影
- scss_var: "$shadow-md"
value: "0 2px 12px rgba(45, 42, 38, 0.10)"
role: 中阴影
- scss_var: "$shadow-lg"
value: "0 8px 32px rgba(45, 42, 38, 0.15)"
role: 大阴影
# ============================================================================
# 原型 Key → Token 映射aliases
# 用于设计移交时自动匹配原型属性到实际 Token
# ============================================================================
aliases:
prototype_keys:
T.pri:
- value: "#C4623A"
token: --tk-pri
status: exact_match
variant: patient
- value: "#3A6B8C"
token: --tk-pri.doctor
status: exact_match
variant: doctor
T.priL:
- value: "#F0DDD4"
token: --tk-pri-l
status: exact_match
variant: patient
- value: "#D4E5F0"
token: --tk-pri-l.doctor
status: exact_match
variant: doctor
T.priD:
- value: "#8B3E1F"
token: --tk-pri-d
status: exact_match
variant: patient
- value: "#2A4F6A"
token: --tk-pri-d.doctor
status: exact_match
variant: doctor
T.bg:
token: null
nearest: --tk-card-bg
scss_var: "$bg"
value: "#F5F0EB"
status: unmatched
note: "原型页面背景色tokens.scss 未声明为 CSS 变量,直接用 $bg SCSS 变量"
T.card:
token: --tk-card-bg
status: exact_match
T.surface:
token: --tk-card-bg
status: approximate
note: "原型中 surface ≈ 卡片白底"
T.tx:
token: null
nearest: --tk-text-secondary
scss_var: "$tx"
value: "#2D2A26"
status: unmatched
note: "主文字色tokens.scss 未声明为 CSS 变量,直接用 $tx SCSS 变量"
T.tx2:
token: null
nearest: --tk-text-secondary
scss_var: "$tx2"
value: "#5A554F"
status: unmatched
note: "次文字色tokens.scss 未声明elder-mode 下 --tk-text-secondary 覆盖为此值"
T.tx3:
token: --tk-text-secondary
scss_var: "$tx3"
value: "#78716C"
status: exact_match
T.bd:
token: null
scss_var: "$bd"
value: "#E8E2DC"
status: unmatched
note: "边框色不是圆角tokens.scss 未声明为 CSS 变量"
T.r:
token: --tk-card-radius
scss_var: "$r"
value: "16px"
status: exact_match
T.rSm:
token: null
scss_var: "$r-sm"
value: "12px"
status: unmatched
note: "tokens.scss 未声明,需添加 --tk-radius-sm 或直接用 $r-sm SCSS 变量"
T.rXs:
token: null
scss_var: "$r-xs"
value: "8px"
status: unmatched
note: "tokens.scss 未声明,需添加 --tk-radius-xs 或直接用 $r-xs SCSS 变量"

57
.dockerignore Normal file
View File

@@ -0,0 +1,57 @@
# Git
.git
.gitignore
# CI/CD
.github
.gitea
# Documentation
docs/
wiki/
*.md
!README.md
# IDE
.vscode/
.idea/
*.swp
*.swo
# Screenshots and temp files
screenshots/
tmp/
*.log
*.png
*.jpg
*.jpeg
*.txt
!config/*.toml
# Python
*.py
__pycache__/
# Test artifacts
plans/
.claude/
# Docker
docker/
# Build artifacts (rebuilt in container)
target/
**/node_modules/
**/dist/
# Environment files (use docker env)
.env
.env.local
.env.*.local
# OS
.DS_Store
Thumbs.db
# Large binary files
*.traineddata

View File

@@ -67,3 +67,19 @@ jobs:
with:
node-version: "20"
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile && pnpm audit
miniprogram-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/miniprogram
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: corepack enable && pnpm install --frozen-lockfile
- name: TypeScript check
run: npx tsc --noEmit
- name: Run tests
run: npx vitest run

View File

@@ -17,7 +17,7 @@ jobs:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: 123123
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
@@ -28,9 +28,9 @@ jobs:
--health-retries 5
env:
TEST_DB_URL: postgres://postgres:123123@localhost:5432/postgres
TEST_DB_URL: postgres://postgres:postgres@localhost:5432/postgres
JWT_SECRET: test-jwt-secret-for-ci
DATABASE_URL: postgres://postgres:123123@localhost:5432/erp_ci
DATABASE_URL: postgres://postgres:postgres@localhost:5432/erp_ci
steps:
- uses: actions/checkout@v4
@@ -49,6 +49,9 @@ jobs:
- name: Clippy
run: cargo clippy --workspace -- -D warnings
- name: Security audit (Rust)
run: cargo audit
frontend-test:
runs-on: ubuntu-latest
defaults:
@@ -76,3 +79,31 @@ jobs:
- name: Build
run: pnpm build
- name: Security audit (npm)
run: npx npm-audit --audit-level=high
miniprogram-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/miniprogram
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: apps/miniprogram/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- name: TypeScript check
run: npx tsc --noEmit
- name: Run tests
run: npx vitest run

43
.gitignore vendored
View File

@@ -28,6 +28,16 @@ docker/redis_data/
# Test artifacts
.test_token
test-results/
# Build outputs
apps/miniprogram/dist-h5/
# Runtime uploads
uploads/
# Temp logs
_server_out.txt
*.heapsnapshot
perf-trace-*.json
docs/debug-*.png
@@ -64,3 +74,36 @@ chi_sim.traineddata
# Local settings
.claude/settings.local.json
tools/
# Temp/debug files
_temp/
tmp/
screenshots/
server-log.txt
snapshot_*.txt
_*.txt
_server_*.txt
tmp_*.txt
direct_*.txt
server_*.txt
server_combined.txt
out.txt
_wx_login.json
.claude/settings.json
# Trace/debug JSON
trace-*.json
# Graphify knowledge graph (regenerated locally)
graphify-out/
# Native miniprogram (separate project)
apps/mp-native/
# Misc untracked
err.txt
uploads/g:/hms/.superpowers/
.claude/skills/design-handoff/node_modules/
.design/config.yml
.superpowers/

View File

@@ -1,583 +0,0 @@
# ZCLAW 协作与实现规则
> **ZCLAW 是一个独立成熟的 AI Agent 桌面客户端**,专注于提供真实可用的 AI 能力,而不是演示 UI。
> **当前阶段: 发布前管家模式实施。** 稳定化基线已达成管家模式6交付物已完成。
## 1. 项目定位
### 1.1 ZCLAW 是什么
ZCLAW 是面向中文用户的 AI Agent 桌面端,核心能力包括:
- **智能对话** - 多模型支持8 Provider、流式响应、上下文管理
- **自主能力** - 9 个启用的 Hands另有 Predictor/Lead 已禁用)
- **技能系统** - 75 个 SKILL.md 技能定义
- **工作流编排** - Pipeline DSL + 10 行业模板
- **安全审计** - 完整的操作日志和权限控制
### 1.2 决策原则
**任何改动都要问:这对 ZCLAW 用户今天能产生价值吗?**
- ✅ 修复已知的 P0/P1 缺陷 → 最高优先
- ✅ 接通"写了没接"的断链 → 高优先
- ✅ 清理死代码和孤立文件 → 应该做
- ❌ 新增功能/页面/端点 → 稳定化完成前禁止
- ❌ 增加复杂度但无实际价值 → 永远不做
- ❌ 折中方案掩盖根因 → 永远不做
### 1.3 稳定化铁律
**稳定化基线达成后仍需遵守以下约束:**
| 禁止行为 | 原因 |
|----------|------|
| 新增 SaaS API 端点 | 已有 140 个(含 2 个 dev-only前端未全部接通 |
| 新增 SKILL.md 文件 | 已有 75 个,大部分未执行验证 |
| 新增 Tauri 命令 | 已有 189 个70 个无前端调用且无 @reserved |
| 新增中间件/Store | 已有 13 层中间件 + 18 个 Store |
| 新增 admin 页面 | 已有 15 页 |
### 1.4 系统真实状态
参见 [docs/TRUTH.md](docs/TRUTH.md) — 这是唯一的真相源,所有其他文档中的数字如果与此冲突,以 TRUTH.md 为准。
***
## 2. 项目结构
```text
ZCLAW/
├── crates/ # Rust Workspace (10 crates)
│ ├── zclaw-types/ # L1: 基础类型 (AgentId, Message, Error)
│ ├── zclaw-memory/ # L2: 存储层 (SQLite, KV, 会话管理)
│ ├── zclaw-runtime/ # L3: 运行时 (4 Driver, 7 工具, 12 层中间件)
│ ├── zclaw-kernel/ # L4: 核心协调 (182 Tauri 命令)
│ ├── zclaw-skills/ # 技能系统 (75 SKILL.md 解析, 语义路由)
│ ├── zclaw-hands/ # 自主能力 (9 启用, 106 Rust 测试)
│ ├── zclaw-protocols/ # 协议支持 (MCP 完整, A2A feature-gated)
│ ├── zclaw-pipeline/ # Pipeline DSL (v1/v2, 10 行业模板)
│ ├── zclaw-growth/ # 记忆增长 (FTS5 + TF-IDF)
│ └── zclaw-saas/ # SaaS 后端 (130 API, Axum + PostgreSQL)
├── admin-v2/ # 管理后台 (Vite + Ant Design Pro, 13 页)
├── desktop/ # Tauri 桌面应用
│ ├── src/
│ │ ├── components/ # React UI 组件 (含 SaaS 集成)
│ │ ├── store/ # Zustand 状态管理 (含 saasStore)
│ │ └── lib/ # 客户端通信 / 工具函数 (含 saas-client)
│ └── src-tauri/ # Tauri Rust 后端 (集成 Kernel)
├── skills/ # SKILL.md 技能定义
├── hands/ # HAND.toml 自主能力配置
├── config/ # TOML 配置文件
├── saas-config.toml # SaaS 后端配置 (PostgreSQL 连接等)
├── docker-compose.yml # PostgreSQL 容器配置
├── docs/ # 架构文档和知识库
└── tests/ # Vitest 回归测试
```
### 2.1 核心数据流
```text
用户操作 → React UI → Zustand Store → Tauri Commands → zclaw-kernel → LLM/Tools/Skills/Hands
```
### 2.2 技术栈
| 层级 | 技术 |
| ---- | --------------------- |
| 前端框架 | React 19 + TypeScript |
| 状态管理 | Zustand 5 |
| 桌面框架 | Tauri 2.x |
| 样式方案 | Tailwind 4 |
| 配置格式 | TOML |
| 后端核心 | Rust Workspace (10 crates, ~66K 行) |
| SaaS 后端 | Axum + PostgreSQL (zclaw-saas) |
| 管理后台 | Vite + Ant Design Pro (admin-v2/) |
### 2.3 Crate 依赖关系
```text
zclaw-types (无依赖)
zclaw-memory (→ types)
zclaw-runtime (→ types, memory)
zclaw-kernel (→ types, memory, runtime)
zclaw-saas (→ types, 独立运行于 8080 端口)
desktop/src-tauri (→ kernel, skills, hands, protocols)
```
***
## 3. 工作风格
### 3.1 交付导向
- **先做最高杠杆问题** - 解决用户最痛的点
- **真实能力优先** - 不做假数据占位
- **完整闭环** - 每个功能都要能真正使用
### 3.2 根因优先
遇到问题时,先确认属于哪一类:
1. **协议问题** - API 端点、请求格式、响应解析
2. **状态问题** - Store 更新、组件同步
3. **UI 问题** - 交互逻辑、样式显示
4. **配置问题** - TOML 解析、环境变量
5. **运行时问题** - 服务启动、端口占用
不在根因未明时盲目堆补丁。
### 3.3 闭环工作法(强制)
每次改动**必须**按顺序完成以下步骤,不允许跳过:
1. **定位问题** — 理解根因,不盲目堆补丁
2. **最小修复** — 只改必要的代码
3. **自动验证**`tsc --noEmit` / `cargo check` / `vitest run` 必须通过
4. **提交推送** — 按 §11 规范提交,**立即 `git push`**,不积压
5. **文档同步** — 按 §8.3 检查并更新相关文档,提交并推送
**铁律:步骤 4 和 5 是任务完成的硬性条件。不允许"等一下再提交"或"最后一起推送"。**
***
## 4. 实现规则
### 4.1 通信层
所有与后端的通信必须通过统一的客户端层:
- `desktop/src/lib/gateway-client.ts` - 主要通信客户端
- `desktop/src/lib/tauri-gateway.ts` - Tauri 原生命令
**禁止**在组件内直接创建 WebSocket 或拼装 HTTP 请求。
### 4.2 分层职责
```
UI 组件 → 只负责展示和交互
Store → 负责状态组织和流程编排
Client → 负责网络通信和协议转换
```
### 4.3 代码自检规则
**每次修改代码前必须检查:**
1. **是否已有相同能力的代码?** — 先搜索再写,避免重复
2. **前端是否有人调用?** — 没有 Rust 调用者的 Tauri 命令,先标注 `@reserved`
3. **错误是否静默吞掉?**`let _ =` 必须替换为 `log::warn!` 或更高级别处理
4. **文档数字是否需要更新?** — 改了数量就要改文档```
---
### 4.4 代码规范
**TypeScript:**
- 避免 `any`,优先 `unknown + 类型守卫`
- 外部数据必须做容错解析
- 不假设 API 响应永远只有一种格式
**React:**
- 使用函数组件 + hooks
- 复杂副作用收敛到 store
- 组件保持"展示层"职责
**配置处理:**
- 使用 TOML 解析器
- 支持环境变量插值 `${VAR_NAME}`
- 写回时保持格式一致
---
## 5. UI 完成度标准
### 5.1 允许存在的 UI
- 已接入真实后端能力的 UI
- 明确标注"开发中 / 只读"的 UI
- 有降级方案的 UI
### 5.2 不允许存在的 UI
- 看似可编辑但不会生效的设置
- 展示假状态的面板
- 用 mock 数据掩盖未完成能力
### 5.3 核心功能 UI
| 功能 | 状态 | 说明 |
|------|------|------|
| 聊天界面 | ✅ 完成 | 流式响应、多模型切换 |
| 分身管理 | ✅ 完成 | 创建、配置、切换 Agent |
| 自动化面板 | ✅ 完成 | Hands 触发、参数配置 |
| 技能市场 | 🚧 进行中 | 技能浏览和安装 |
| 工作流编辑 | 🚧 进行中 | Pipeline 工作流编辑器 |
---
## 6. 自主能力系统 (Hands)
ZCLAW 提供 11 个自主能力包9 启用 + 2 禁用):
| Hand | 功能 | 状态 |
|------|------|------|
| Browser | 浏览器自动化 | ✅ 可用 |
| Collector | 数据收集聚合 | ✅ 可用 |
| Researcher | 深度研究 | ✅ 可用 |
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
| Twitter | Twitter 自动化 | ✅ 可用12 个 API v2 真实调用,写操作需 OAuth 1.0a |
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo |
| Slideshow | 幻灯片生成 | ✅ 可用 |
| Speech | 语音合成 | ✅ 可用Browser TTS 前端集成完成) |
| Quiz | 测验生成 | ✅ 可用 |
**触发 Hand 时:**
1. 检查依赖是否满足
2. 收集必要参数
3. 处理 `needs_approval` 状态
4. 记录执行日志
---
## 7. 测试与验证
### 7.1 必测场景
修改以下内容后必须验证:
- 聊天 / 流式响应
- Store 状态更新
- 配置读写
- Hand 触发
### 7.2 前端调试优先使用 WebMCP
ZCLAW 注册了 WebMCP 结构化调试工具(`desktop/src/lib/webmcp-tools.ts`AI 代理可直接查询应用状态而无需 DOM 截图。
**原则:能用 WebMCP 工具完成的调试,优先使用 WebMCP 而非 DevTools MCP`take_snapshot`/`evaluate_script`),以减少约 67% 的 token 消耗。**
已注册的 WebMCP 工具:
| 工具名 | 用途 |
|--------|------|
| `get_zclaw_state` | 综合状态概览(连接、登录、流式、模型) |
| `check_connection` | 连接状态检查 |
| `send_message` | 发送聊天消息 |
| `cancel_stream` | 取消当前流式响应 |
| `get_streaming_state` | 流式响应详细状态 |
| `list_conversations` | 列出最近对话 |
| `get_current_conversation` | 获取当前对话完整消息 |
| `switch_conversation` | 切换到指定对话 |
| `get_token_usage` | Token 用量统计 |
| `get_offline_queue` | 离线消息队列 |
| `get_saas_account` | SaaS 账户和订阅信息 |
| `get_available_models` | 可用 LLM 模型列表 |
| `get_current_agent` | 当前 Agent 详情 |
| `list_agents` | 所有 Agent 列表 |
| `get_console_errors` | 应用日志中的错误 |
**使用前提**Chrome 146+ 并启用 `chrome://flags/#enable-webmcp-testing`。仅在开发模式注册。
**何时仍需 DevTools MCP**UI 布局/样式问题、点击交互、截图对比、网络请求检查。
### 7.3 验证命令
```bash
# TypeScript 类型检查
pnpm tsc --noEmit
# 前端单元测试
cd desktop && pnpm vitest run
# Rust 全量测试(排除 SaaS
cargo test --workspace --exclude zclaw-saas
# SaaS 集成测试(需要 PostgreSQL
export TEST_DATABASE_URL="postgresql://postgres:123123@localhost:5432/zclaw"
cargo test -p zclaw-saas -- --test-threads=1
# 启动开发环境
pnpm start:dev
````
### 7.4 人工验证清单
- [ ] 能否正常连接后端服务
- [ ] 能否发送消息并获得流式响应
- [ ] 模型切换是否生效
- [ ] Hand 触发是否正常执行
- [ ] 配置保存是否持久化
***
## 8. 文档管理
### 8.1 文档结构
```text
docs/
├── features/ # 功能文档
│ ├── README.md # 功能索引
│ └── */ # 各功能详细文档
├── knowledge-base/ # 技术知识库
│ ├── troubleshooting.md
│ └── *.md
└── archive/ # 归档文档
```
### 8.2 文档更新原则
- **修完就记** - 解决问题后立即更新文档
- **面向未来** - 文档要帮助未来的开发者快速理解
- **中文优先** - 所有面向用户的文档使用中文
### 8.3 完成工作后的收尾流程(强制,不可跳过)
每次完成功能实现、架构变更、问题修复后,**必须立即执行以下收尾**
#### 步骤 A文档同步代码提交前
检查以下文档是否需要更新,有变更则立即修改:
1. **CLAUDE.md** — 项目结构、技术栈、工作流程、命令变化时
2. **CLAUDE.md §13 架构快照** — 涉及子系统变更时,更新 `<!-- ARCH-SNAPSHOT-START/END -->` 标记区域(可执行 `/sync-arch` 技能自动分析)
3. **docs/ARCHITECTURE_BRIEF.md** — 架构决策或关键组件变更时
4. **docs/features/** — 功能状态变化时
5. **docs/knowledge-base/** — 新的排查经验或配置说明
6. **docs/TRUTH.md** — 数字命令数、Store 数、crates 数等)变化时
#### 步骤 B提交按逻辑分组
```
代码变更 → 一个或多个逻辑提交
文档变更 → 独立提交(如果和代码分开更清晰)
```
#### 步骤 C推送立即
```
git push
```
**不允许积压。** 每次完成一个独立工作单元后立即推送。不要留到"最后一起推"。
**判断标准:** 如果工作目录有未提交文件,说明收尾流程没完成。
***
## 9. 常见问题排查
### 9.1 连接问题
1. 检查后端服务是否启动(端口 50051
2. 检查 Vite 代理配置
3. 检查防火墙设置
### 9.2 状态问题
1. 检查 Store 是否正确订阅
2. 检查组件是否在正确的 Store 获取数据
3. 检查是否有多个 Store 实例
### 9.3 配置问题
1. 检查 TOML 语法
2. 检查环境变量是否设置
3. 检查配置文件路径
***
## 10. 常用命令
```bash
# 安装依赖
pnpm install
# 开发模式
pnpm start:dev
# 仅启动桌面端
pnpm desktop
# 构建生产版本
pnpm build
# 类型检查
pnpm tsc --noEmit
# 运行测试
pnpm vitest run
# 停止所有服务
pnpm start:stop
```
***
## 11. 提交规范
```
<type>(<scope>): <description>
```
**类型:**
- `feat` - 新功能
- `fix` - 修复问题
- `refactor` - 重构
- `docs` - 文档更新
- `test` - 测试相关
- `chore` - 杂项
**示例:**
```
feat(hands): 添加参数预设保存功能
fix(chat): 修复流式响应中断问题
refactor(store): 统一 Store 数据获取方式
```
***
## 12. 安全注意事项
- 不在代码中硬编码密钥
- 用户输入必须验证
- 敏感操作需要确认
- 保留操作审计日志
- 环境变量 `ZCLAW_SAAS_DEV` 模式放宽安全限制(开发环境设 `ZCLAW_SAAS_DEV=true`
### 认证安全
- **JWT password_version**: 密码修改后自动使所有已签发的 JWT 失效Claims 含 `pwv`,中间件比对 DB
- **账户锁定**: 5 次登录失败后锁定 15 分钟
- **邮箱验证**: RFC 5322 正则 + 254 字符长度限制
- **JWT 密钥**: `#[cfg(debug_assertions)]` 保护 fallbackrelease 模式 `bail` 拒绝启动
- **TOTP 加密密钥**: 生产环境强制独立 `ZCLAW_TOTP_ENCRYPTION_KEY`64 字符 hex不从 JWT 密钥派生
- **TOTP/API Key 加密**: AES-256-GCM + 随机 Nonce
- **密码存储**: Argon2id + OsRng 随机盐
- **Refresh Token 轮换**: 单次使用Logout 时撤销到 DBrotation 校验已撤销的旧 token
### 网络安全
- **Cookie**: HttpOnly + Secure + SameSite=Strict + 路径作用域
- **Cookie Secure**: 开发环境 false生产 true
- **CORS**: 生产强制白名单,缺失拒绝启动
- **TLS**: 反向代理nginx/caddy提供 HTTPS 终止Axum 不负责 TLS
- **Docker**: SaaS 端口绑定 `127.0.0.1`,仅通过 nginx 反代访问
- **XFF**: 仅信任配置的代理 IP
### 限流
- `/api/auth/login` — 5次/分钟/IP防暴力破解+ 持久化到 PostgreSQL
- `/api/auth/register` — 3次/小时/IP防刷注册
- 公共端点默认 20次/分钟/IP防滥用
### 前端安全
- **Admin Token**: HttpOnly Cookie 传递JS 不存储/读取 token
- **Tauri CSP**: 移除 `unsafe-inline` script`connect-src` 限制为 `http://localhost:*` + `https://*`
- **Pipeline 日志**: Debug 日志截断 + 仅记录 keys 不记录 values
### 环境变量
| 变量 | 用途 |
|------|------|
| `DB_PASSWORD` | 数据库密码 |
| `ZCLAW_DATABASE_URL` | 完整数据库连接 URL优先级最高 |
| `ZCLAW_SAAS_JWT_SECRET` | JWT 签名密钥 (>= 32 字符) |
| `ZCLAW_TOTP_ENCRYPTION_KEY` | TOTP/API Key 加密密钥 (64 hex) |
| `ZCLAW_ADMIN_USERNAME` | 初始管理员用户名 |
| `ZCLAW_ADMIN_PASSWORD` | 初始管理员密码 |
| `ZCLAW_SAAS_DEV` | 开发模式标志 (true=开发, false=生产) |
`saas-config.toml` 支持 `${ENV_VAR}` 模式环境变量插值。
### 生产环境清单
- [ ] nginx/caddy 配置反向代理 + HTTPS
- [ ] 确保设置 `ZCLAW_SAAS_DEV=false`(或不设置)
- [ ] 启用 CORS 白名单(`cors_origins` 配置实际域名)
- [ ] Cookie Secure=true + HttpOnly=true + SameSite=Strict
- [ ] JWT 签名密钥 >= 32 字符随机字符串
- [ ] `ZCLAW_TOTP_ENCRYPTION_KEY` 独立设置
- [ ] 数据库密码通过 `${DB_PASSWORD}` 引用
### 完整审计报告
参见 `docs/features/SECURITY_PENETRATION_TEST_V1.md`
***
<!-- ARCH-SNAPSHOT-START -->
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-09 -->
## 13. 当前架构快照
### 活跃子系统
| 子系统 | 状态 | 最新变更 |
|--------|------|----------|
| 管家模式 (Butler) | ✅ 活跃 | 04-09 ButlerRouter + 双模式UI + 痛点持久化 + 冷启动 |
| Hermes 管线 | ✅ 活跃 | 04-09 4 Chunk: 自我改进+用户建模+NL Cron+轨迹压缩 (684 tests) |
| 聊天流 (ChatStream) | ✅ 稳定 | 04-02 ChatStore 拆分为 4 Store (stream/conversation/message/chat) |
| 记忆管道 (Memory) | ✅ 稳定 | 04-02 闭环修复: 对话→提取→FTS5+TF-IDF→检索→注入 |
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
| Pipeline DSL | ✅ 稳定 | 04-01 17 个 YAML 模板 + DAG 执行器 |
| Hands 系统 | ✅ 稳定 | 9 启用 (Browser/Collector/Researcher/Twitter/Whiteboard/Slideshow/Speech/Quiz/Clip) |
| 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 |
| 中间件链 | ✅ 稳定 | 14 层 (含 DataMasking@90, ButlerRouter, TrajectoryRecorder@650) |
### 关键架构模式
- **Hermes 管线**: 4模块闭环 — ExperienceStore(FTS5经验存取) + UserProfiler(结构化用户画像) + NlScheduleParser(中文时间→cron) + TrajectoryRecorder+Compressor(轨迹记录压缩)。通过中间件链+intelligence hooks调用
- **管家模式**: 双模式UI (默认简洁/解锁专业) + ButlerRouter 4域关键词分类 (healthcare/data_report/policy/meeting) + 冷启动4阶段hook (idle→greeting→waiting→completed) + 痛点双写 (内存Vec+SQLite)
- **聊天流**: 3种实现 → GatewayClient(WebSocket) / KernelClient(Tauri Event) / SaaSRelay(SSE) + 5min超时守护。详见 [ARCHITECTURE_BRIEF.md](docs/ARCHITECTURE_BRIEF.md)
- **客户端路由**: `getClient()` 4分支决策树 → Admin路由 / SaaS Relay(可降级到本地) / Local Kernel / External Gateway
- **SaaS 认证**: JWT→OS keyring 存储 + HttpOnly cookie + Token池 RPM/TPM 限流轮换 + SaaS unreachable 自动降级
- **记忆闭环**: 对话→extraction_adapter→FTS5全文+TF-IDF权重→检索→注入系统提示
- **LLM 驱动**: 4 Rust Driver (Anthropic/OpenAI/Gemini/Local) + 国内兼容 (DeepSeek/Qwen/Moonshot 通过 base_url)
### 最近变更
1. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
2. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
3. [04-08] 侧边栏 AnimatePresence bug + TopBar 重复 Z 修复 + 发布评估报告
3. [04-07] @reserved 标注 5 个 butler Tauri 命令 + 痛点持久化 SQLite
4. [04-06] 4 个发布前 bug 修复 (身份覆盖/模型配置/agent同步/自动身份)
<!-- ARCH-SNAPSHOT-END -->
<!-- ANTI-PATTERN-START -->
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-09 -->
## 14. AI 协作注意事项
### 反模式警告
- ❌ **不要**建议新增 SaaS API 端点 — 已有 140 个,稳定化约束禁止新增
- ❌ **不要**忽略管家模式 — 已上线且为默认模式,所有聊天经过 ButlerRouter
- ❌ **不要**假设 Tauri 直连 LLM — 实际通过 SaaS Token 池中转SaaS unreachable 时降级到本地 Kernel
- ❌ **不要**建议从零实现已有能力 — 先查 Hand(9个)/Skill(75个)/Pipeline(17模板) 现有库
- ❌ **不要**在 CLAUDE.md 以外创建项目级配置或规则文件 — 单一入口原则
### 场景化指令
- 当遇到**聊天相关** → 记住有 3 种 ChatStream 实现,先用 `getClient()` 判断当前路由模式
- 当遇到**认证相关** → 记住 Tauri 模式用 OS keyring 存 JWTSaaS 模式用 HttpOnly cookie
- 当遇到**新功能建议** → 先查 [TRUTH.md](docs/TRUTH.md) 确认可用能力清单,避免重复建设
- 当遇到**记忆/上下文相关** → 记住闭环已接通: FTS5+TF-IDF+embedding不是空壳
- 当遇到**管家/Butler** → 管家模式是默认模式ButlerRouter 在中间件链中做关键词分类+system prompt 增强
<!-- ANTI-PATTERN-END -->

215
CLAUDE.md
View File

@@ -116,15 +116,90 @@
- `cargo test --workspace` — 所有测试通过(有相关测试时)
- 功能验证 — 启动后端 + 前端服务,在浏览器中实际操作验证改动生效(涉及 API 或 UI 时)
- `pnpm build` — 前端生产构建通过(涉及前端时)
5. **提交** — 验证通过后按 §5 规范提交
6. **文档同步** — 更新相关文档(如果涉及架构、接口、模块变化)
7. **推送到仓库** — 提交后立即 `git push`,确保远程仓库同步
5. **提交 + 文档 + 推送(三合一,强制)** — 验证通过后按顺序执行:
- a. 按 §5 规范提交代码
- b. 检查本次变更是否触发 wiki 更新(见下方 wiki 更新触发条件),触发则更新后单独 `docs(wiki)` 提交
- c. `git push` 立即推送,不允许"等一下再推"
- **禁止连续 5 个非 docs 提交而不更新 wiki 关键数字**
#### wiki 更新触发条件(步骤 5b 的判定标准)
以下任一条件满足时,**必须**更新 wiki 后才能继续下一任务:
- **fix 提交** → `wiki/index.md` 症状导航新增条目或标记"已修复"
- **feat 提交(新功能)** → `wiki/index.md` 关键数字更新 + 对应模块 wiki 页更新(实体数/路由数/端点数等)
- **数据库迁移变化** → 关键数字中的迁移数/表数更新
- **API 路由变化** → 路由数更新
- **测试数量变化** → 测试数/断言数更新
- **连续 5 个代码提交** → 强制做一次 wiki/index.md 关键数字全文校正(对比代码实际数量)
**铁律:**
- **步骤 0 阅读 Wiki 是绝对起点** — 不读 wiki 就开干 = 连环境配置都不知道,所有验证步骤都是空谈。
- **步骤 1 现状确认是强制起点** — 不检查就开干 = 脱离实际,所有产出不可信。
- **步骤 4 功能验证必须实际操作** — 只看编译通过不算验证,必须启动服务、在浏览器中确认功能正常。
- **步骤 7 推送是强制环节**,不推送就等于没完成。不允许"等一下再推"
- **步骤 5 三合一是强制流程** — 提交后必须检查 wiki、必须推送缺一不可
- **每次新会话开始时,先检查是否有未推送的提交并立即推送**。
### 2.6 Feature DoD — 功能完成定义(强制)
> 历史数据显示 24% 的提交是 fix根因是缺少统一的完成标准。
> 每个功能标记"完成"前,**必须**逐项检查以下清单,不允许跳过。
#### 后端
- [ ] Entity 包含所有标准字段(`id`/`tenant_id`/`created_at`/`updated_at`/`created_by`/`updated_by`/`deleted_at`/`version`
- [ ] Handler 添加 `require_permission` 权限守卫
- [ ] 权限码已写入 seed 迁移(每个实体 `.list` + `.manage`,权限码前缀与实体名一致)
- [ ] utoipa 注解已添加(`#[derive(utoipa::OpenApi)]` + path/response schema
- [ ] Service 层核心路径有单元/集成测试
- [ ] 多租户隔离正确(所有查询含 `tenant_id` 过滤,无手写 SQL 拼接)
- [ ] 输入验证完整(必填字段 + 格式校验 + 长度限制)
- [ ] 错误处理统一(`AppError`,不 panic不 unwrap 生产代码)
- [ ] 关键操作有 `tracing` 日志info/warn/error 级别合理)
#### 前端Web
- [ ] API 路径与后端 OpenAPI spec 一致(不手写路径,从 `api/health/` 模块调用)
- [ ] 路由声明权限码(`permissions: [...]`),与后端 handler 一致
- [ ] 菜单配置已更新(`parent_id` 正确 + `permission` 字段 + `menu_roles` 关联)
- [ ] 错误状态有用户友好提示(不显示原始 error message
- [ ] 不使用 `any` 类型(用 `unknown` + 类型守卫)
#### 前端(小程序)
- [ ] Service 层接口契约与后端 DTO 一致(字段名/类型/结构体)
- [ ] 登录态处理正确(`useDidShow` 恢复认证、退出清理 Storage
- [ ] 页面间数据通过 API 获取,不用 Storage 传递
- [ ] 长者模式适配完成(字号 ≥ 22px
- [ ] 图片使用合法 URLHTTPS 或相对路径,不用 HTTP
#### 安全
- [ ] 新增端点有权限声明(默认拒绝,不是默认放行)
- [ ] 敏感数据有脱敏/加密处理PII 字段走 AES-256-GCM
- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS、命令注入、路径穿越
- [ ] 无 CORS 通配符、无硬编码密钥、无 fallback 默认密钥
- [ ] 日志中无敏感数据输出密码、token、身份证号、手机号等
- [ ] 文件上传有 MIME 类型验证 + 大小限制 + 路径穿越防护
- [ ] API 响应不暴露内部实现细节(数据库错误、堆栈跟踪、文件路径)
- [ ] 速率限制已配置(认证端点更严格)
- [ ] 密钥通过环境变量注入,`.env.example` 已同步更新
#### 文档一致性
- [ ] `wiki/index.md` 关键数字与代码实际状态一致(迁移数、路由数、实体数、测试数等)
- [ ] 新增/修复的 bug 已记录在症状导航中(含根因+解决方案)
- [ ] 新增功能已记录在对应模块 wiki 页面中(实体、端点、事件等)
- [ ] wiki 页面的"最后更新"日期已刷新为当天
#### 端到端验证
- [ ] `cargo check` 全 workspace 通过
- [ ] `cargo test` 全部通过
- [ ] 浏览器中手动验证功能正常(列表/创建/编辑/删除/权限拦截)
- [ ] 小程序中验证(涉及小程序页面时)
- [ ] 相关路由权限按角色测试通过(至少 admin + 只读角色)
- [ ] 本地提交已推送到远程仓库
---
@@ -152,6 +227,46 @@
- utoipa 自动生成 OpenAPI 文档
- 租户 ID 从 JWT 中间件注入,**不在** API 路径中传递(管理员接口除外)
#### 新增 API 端点安全检查(强制)
> 默认拒绝是安全基线 — 绝大多数安全修复源于默认放行模式。
> 新增端点时**必须**逐项确认:
- [ ] 端点已添加 `require_permission` 权限守卫(非公开端点)
- [ ] 公开端点已显式标记为 `public`(不继承认证中间件)
- [ ] 路由使用 `.nest()` 注册带中间件的子路由(禁止 `.merge()` 防止中间件泄漏)
- [ ] 敏感操作有速率限制
- [ ] 无 `format!` 拼接 SQL — 所有查询使用 SeaORM 参数化
- [ ] FHIR/第三方端点有 `tenant_id` 和 `allowed_patient_ids` 范围过滤
- [ ] 无硬编码密钥或 fallback 默认值
#### 前后端接口同步检查(强制)
> 前后端接口不一致是高频 bug 来源 — 任何 DTO 变更必须双向同步。
> 后端 DTO 变更时**必须**同步检查前端:
- [ ] DTO 字段名变更 → 前端 TypeScript 接口同步更新
- [ ] DTO 新增必填字段 → 前端表单和请求体同步更新
- [ ] API 路径变更 → 前端 `api/` 模块路径同步更新
- [ ] 返回数据结构变更(数组/对象/嵌套)→ 前端解析逻辑同步更新
- [ ] 枚举值变更 → 前端类型定义和 UI 映射同步更新
- [ ] 后端新增端点 → 前端 API 模块同步添加调用函数,不允许留空
#### DTO 输入校验检查(强制)
> DTO 输入校验是安全防线 — 缺失校验等于暴露攻击面Update 和 Create 必须对称。
> 新增/修改 DTO 时**必须**逐项确认:
- [ ] 所有请求结构体已 `derive(Validate)`(包括 Update\*Req、查询参数
- [ ] Update\*Req 与 Create\*Req 校验对称(不允许 Update 降级)
- [ ] 字符串字段有 `#[validate(length(min, max))]`
- [ ] 枚举/类型字段有 `#[validate(custom)]` 限制合法值
- [ ] 集合字段有 `#[validate(length(min = 1))]` 非空检查
- [ ] 数值范围字段有 `#[validate(range(min, max))]`
- [ ] URL 字段有 SSRF 防护(禁止 localhost/内网地址,仅 http/https
- [ ] 密码字段有 `max = 128` 防止 DoS
- [ ] handler 层已调用 `req.validate().map_err(|e| AppError::Validation(e.to_string()))?`
### 3.4 事件总线
- 模块间通信**只能**通过 `EventBus`
@@ -181,6 +296,50 @@
// 国际化文案使用 i18n key不硬编码中文
```
### 3.7 安全规范
#### 密钥与凭据管理
- 所有密钥、token、密码**必须**通过环境变量或密钥管理服务注入,**禁止**硬编码在源码中
- 开发环境密钥**必须**与生产环境严格隔离(`cfg(debug_assertions)` 编译期防护)
- 生产密钥**禁止**有 fallback 默认值,缺失时启动 panic
- 新增密钥时必须同步更新 `.env.example` 和 `wiki/infrastructure.md`
#### 依赖安全
- 新增依赖前**必须**检查已知漏洞(`cargo audit` / `npm audit`
- 禁止引入有未修补高危漏洞的依赖版本
- 定期更新依赖到最新安全补丁版本
#### 数据安全
- PII 数据(姓名、身份证号、手机号、地址等)**必须**加密存储AES-256-GCM
- 日志中**禁止**输出 PII 数据和认证凭据密码、token、session key
- 敏感操作(登录、权限变更、数据导出、批量删除)**必须**记录审计日志(操作者、时间、目标、结果)
- 文件上传**必须**验证 MIME 类型 + 限制文件大小 + 防止路径穿越(文件名 sanitize
#### 传输安全
- 生产环境**必须**强制 HTTPS**禁止**降级到 HTTP
- HTTP 响应**必须**包含安全头HSTS、CSP、X-Frame-Options、X-Content-Type-Options、Permissions-Policy
- SSE/WebSocket 长连接认证 token 不通过 URL query 参数传递(使用 header 或 cookie
- API 响应**禁止**暴露内部实现细节堆栈跟踪、数据库错误、文件路径、SQL 语句)
#### 认证与授权
- 密码**必须**使用单向哈希bcrypt/argon2**禁止**明文或可逆加密存储
- JWT **必须**设置合理过期时间,支持 token 吊销机制
- 敏感操作(删除数据、权限变更)需要二次确认
- 权限检查在 handler 层执行,**禁止**仅依赖前端隐藏控制访问
- `tenant_id` **必须**从 JWT 中间件注入,**禁止**信任客户端传递的值
#### 速率限制
- 所有 API 端点**必须**配置速率限制
- 认证相关端点(登录、注册、密码重置)限制更严格
- 批量操作和数据导出需要独立的速率限制策略
- 速率限制超出时返回 429 状态码,响应包含 `Retry-After` header
---
## 4. 测试与验证
@@ -297,10 +456,26 @@ chore(docker): 添加 PostgreSQL 健康检查
- ❌ **不要**在 plugin.toml 中使用与实体名不一致的权限码 — `permissions[].code` 前缀必须与 `schema.entities[].name` 完全一致(如实体 `customer_tag` → 权限码 `customer_tag.list`/`customer_tag.manage`,不能写成 `tag.manage`),否则页面 403
- ❌ **不要**漏掉实体的 `.list` 权限 — 每个实体必须同时声明 `.list` 和 `.manage`,缺少 `.list` 导致列表页 403
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步。每次新会话开始先检查未推送提交
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害
- ❌ **不要**修复 bug 后跳过症状导航更新 — 每个修复都应该帮助未来遇到同类问题的人快速定位根因
- ❌ **不要**新增功能后不更新 wiki 关键数字 — 迁移数/路由数/实体数/测试数必须与代码同步,否则 wiki 指标表就是废数据
- ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死
- ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘
- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交将导致后续反复修复
- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限
- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口
- ❌ **不要**写 Update DTO 时省略校验 — Update*Req 必须与 Create*Req 校验对称Validate derive / 枚举 custom / Vec min=1 / 密码 max=128
- ❌ **不要**URL 字段不做 SSRF 防护 — 禁止 localhost/127.0.0.1/内网地址,仅允许 http/https 协议
- ❌ **不要**handler 层跳过 `.validate()` — 所有 `Json<T>` handler 函数体第一行必须调 `req.validate().map_err(\|e\| AppError::Validation(e.to_string()))?`
- ❌ **不要**在日志中输出敏感数据 — 密码、token、身份证号、手机号等 PII 信息禁止写入日志
- ❌ **不要**信任客户端传递的 `tenant_id` — 必须从 JWT 中间件注入,客户端可伪造
- ❌ **不要**在生产代码中使用 `unwrap()` — 必须处理所有错误,使用 `?` 或 `map_err`
- ❌ **不要**将内部错误信息返回给客户端 — 数据库错误、堆栈跟踪、文件路径等必须转换为用户友好的错误消息
- ❌ **不要**使用 HTTP 传输敏感数据 — 生产环境必须 HTTPS
- ❌ **不要**跳过依赖安全检查 — 新增依赖前运行 `cargo audit` / `npm audit`,禁止引入有高危漏洞的版本
- ❌ **不要**文件上传不做安全处理 — 必须验证 MIME 类型 + 限制大小 + sanitize 文件名防路径穿越
### 场景化指令
@@ -311,6 +486,7 @@ chore(docker): 添加 PostgreSQL 健康检查
- 当遇到**新增表** → 创建 SeaORM migration + Entity包含所有标准字段
- 当遇到**新增页面** → 使用 Ant Design 组件i18n key 引用文案
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程和 `.claude/skills/plugin-development/SKILL.md`,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component。**权限码必须与实体名一致(每个实体声明 `.list` + `.manage`**
- 当遇到**新增/修改 DTO** → 参考 `wiki/architecture.md` §4 DTO 输入校验规范:`derive(Validate)` + 字段级校验 + handler 层 `validate()` 调用 + 单元测试
---
@@ -328,3 +504,32 @@ chore(docker): 添加 PostgreSQL 健康检查
| 设计文档索引 | `wiki/index.md` |
| 开发进度、模块状态 | `wiki/index.md` 关键数字 |
| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 |
## graphify — 代码知识图谱
> 项目知识图谱位于 `graphify-out/`当前规模18,517 节点 / 22,666 边 / 1,841 社区(纯 AST 解析,无 API 成本)。
> 工具:`python -m graphify`(已安装 graphifyy 0.8.18)。
### 开发流程中的使用场景
| 时机 | 命令 | 目的 |
|------|------|------|
| **接手新任务,理解代码关系** | `graphify query "概念名"` | 搜索相关节点,比 Grep 更精准(按调用/引用/包含关系) |
| **排查 bug追踪调用链** | `graphify path "A" "B"` | 查找两个模块/函数间的最短路径 |
| **理解某个模块的职责** | `graphify explain "模块名"` | 自然语言解释节点及其邻居 |
| **代码改动后** | `graphify update .` | 增量更新图谱AST-only秒级完成 |
| **宏观架构审查** | 读 `graphify-out/GRAPH_REPORT.md` | 全局社区结构、跨文件关系概览 |
### 使用优先级(融入 §2.5 闭环工作法)
在 §2.5 步骤 1「现状确认」中**优先使用 graphify 替代盲目 Grep**
1. **先 `graphify query`** — 精确定位相关节点和社区(比 Grep 返回更结构化的结果)
2. **再 `graphify path`** — 确认模块间依赖路径(避免遗漏间接依赖)
3. **最后 Grep/Glob/Read** — 确认 graphify 发现的具体文件内容
### 注意事项
- `graphify update .` 纯本地 AST 解析,不消耗 LLM token每次代码改动后都可以运行
- 查询结果比 GRAPH_REPORT.md 更精准,优先使用 query/path/explain仅在需要全局视图时读报告
- 首次生成需几分钟1712 文件),后续增量更新秒级完成

108
Cargo.lock generated
View File

@@ -516,12 +516,24 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -1417,6 +1429,7 @@ dependencies = [
"handlebars",
"hex",
"redis",
"regex-lite",
"reqwest",
"sea-orm",
"serde",
@@ -1441,6 +1454,7 @@ dependencies = [
"base64 0.22.1",
"cbc",
"chrono",
"dashmap",
"erp-core",
"hex",
"jsonwebtoken",
@@ -1537,6 +1551,7 @@ dependencies = [
"erp-core",
"hex",
"hmac",
"image",
"jsonwebtoken",
"num-traits",
"rand_core 0.6.4",
@@ -1600,6 +1615,7 @@ dependencies = [
"tracing",
"utoipa",
"uuid",
"validator",
"wasmtime",
"wasmtime-wasi",
]
@@ -1688,6 +1704,7 @@ dependencies = [
"erp-workflow",
"futures",
"hex",
"hmac",
"metrics",
"metrics-exporter-prometheus",
"moka",
@@ -1784,6 +1801,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2520,6 +2546,32 @@ dependencies = [
"version_check",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -2998,6 +3050,16 @@ dependencies = [
"uuid",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -3488,6 +3550,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polyval"
version = "0.6.2"
@@ -3654,6 +3729,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quanta"
version = "0.12.6"
@@ -3669,6 +3750,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.45"
@@ -3894,6 +3981,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.10"
@@ -7058,3 +7151,18 @@ dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]

View File

@@ -33,7 +33,7 @@ tokio = { version = "1", features = ["full"] }
# Web
axum = { version = "0.8", features = ["multipart"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs"] }
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs", "set-header"] }
# Database
sea-orm = { version = "1.1", features = [
@@ -91,6 +91,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
aes = "0.8"
cbc = "0.1"
hex = "0.4"
regex-lite = "0.1"
# CSV and Excel export
csv = "1"
@@ -119,6 +120,9 @@ handlebars = "6"
# HTML sanitization
ammonia = "4"
# Document parsing
pdf-extract = "0.7"
# Metrics
metrics = "0.24"
metrics-exporter-prometheus = "0.16"

113
Dockerfile Normal file
View File

@@ -0,0 +1,113 @@
# ==============================
# Stage 1: Build Rust backend
# ==============================
FROM rust:1-bookworm AS rust-builder
WORKDIR /app
# 先复制依赖文件以利用 Docker 缓存
COPY Cargo.toml Cargo.lock ./
COPY crates/erp-core/Cargo.toml crates/erp-core/Cargo.toml
COPY crates/erp-auth/Cargo.toml crates/erp-auth/Cargo.toml
COPY crates/erp-config/Cargo.toml crates/erp-config/Cargo.toml
COPY crates/erp-workflow/Cargo.toml crates/erp-workflow/Cargo.toml
COPY crates/erp-message/Cargo.toml crates/erp-message/Cargo.toml
COPY crates/erp-plugin/Cargo.toml crates/erp-plugin/Cargo.toml
COPY crates/erp-health/Cargo.toml crates/erp-health/Cargo.toml
COPY crates/erp-ai/Cargo.toml crates/erp-ai/Cargo.toml
COPY crates/erp-dialysis/Cargo.toml crates/erp-dialysis/Cargo.toml
COPY crates/erp-server/Cargo.toml crates/erp-server/Cargo.toml
COPY crates/erp-server/migration/Cargo.toml crates/erp-server/migration/Cargo.toml
COPY crates/erp-plugin-prototype/Cargo.toml crates/erp-plugin-prototype/Cargo.toml
COPY crates/erp-plugin-test-sample/Cargo.toml crates/erp-plugin-test-sample/Cargo.toml
COPY crates/erp-plugin-assessment/Cargo.toml crates/erp-plugin-assessment/Cargo.toml
COPY crates/erp-plugin-crm/Cargo.toml crates/erp-plugin-crm/Cargo.toml
COPY crates/erp-plugin-freelance/Cargo.toml crates/erp-plugin-freelance/Cargo.toml
COPY crates/erp-plugin-inventory/Cargo.toml crates/erp-plugin-inventory/Cargo.toml
COPY crates/erp-plugin-itops/Cargo.toml crates/erp-plugin-itops/Cargo.toml
# 创建空的 lib.rs/main.rs 占位以缓存依赖
RUN mkdir -p crates/erp-core/src && echo "" > crates/erp-core/src/lib.rs \
&& mkdir -p crates/erp-auth/src && echo "" > crates/erp-auth/src/lib.rs \
&& mkdir -p crates/erp-config/src && echo "" > crates/erp-config/src/lib.rs \
&& mkdir -p crates/erp-workflow/src && echo "" > crates/erp-workflow/src/lib.rs \
&& mkdir -p crates/erp-message/src && echo "" > crates/erp-message/src/lib.rs \
&& mkdir -p crates/erp-plugin/src && echo "" > crates/erp-plugin/src/lib.rs \
&& mkdir -p crates/erp-health/src && echo "" > crates/erp-health/src/lib.rs \
&& mkdir -p crates/erp-ai/src && echo "" > crates/erp-ai/src/lib.rs \
&& mkdir -p crates/erp-dialysis/src && echo "" > crates/erp-dialysis/src/lib.rs \
&& mkdir -p crates/erp-server/src && echo "fn main(){}" > crates/erp-server/src/main.rs \
&& mkdir -p crates/erp-server/migration/src && echo "" > crates/erp-server/migration/src/lib.rs \
&& for crate in erp-plugin-prototype erp-plugin-test-sample erp-plugin-assessment erp-plugin-crm erp-plugin-freelance erp-plugin-inventory erp-plugin-itops; do \
mkdir -p crates/$crate/src && echo "" > crates/$crate/src/lib.rs; \
done
# 构建依赖(仅当 Cargo.toml/Cargo.lock 变化时重新编译)
RUN cargo build --release -p erp-server 2>/dev/null || true
# 复制实际源码
COPY crates/ crates/
# 重新构建(增量编译,只编译业务代码)
RUN cargo build --release -p erp-server
# ==============================
# Stage 2: Build frontend
# ==============================
FROM node:20-alpine AS frontend-builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY apps/web/package.json apps/web/pnpm-lock.yaml ./apps/web/
RUN cd apps/web && pnpm install --frozen-lockfile
COPY apps/web/ ./apps/web/
RUN cd apps/web && pnpm build
# ==============================
# Stage 3: Production runtime
# ==============================
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 复制 Rust 二进制
COPY --from=rust-builder /app/target/release/erp-server /app/erp-server
# 复制配置文件
COPY config/ /app/config/
# 复制前端构建产物(可通过 volume 暴露给 OpenResty
COPY --from=frontend-builder /app/apps/web/dist/ /app/static/
# 创建上传目录
RUN mkdir -p /app/uploads
# 非特权用户运行
RUN useradd -r -s /bin/false appuser \
&& chown -R appuser:appuser /app
USER appuser
# 环境变量(运行时通过 docker-compose / .env 覆盖)
ENV ERP__SERVER__HOST=0.0.0.0
ENV ERP__SERVER__PORT=3000
ENV ERP__SERVER__METRICS_PORT=9090
ENV ERP__STORAGE__UPLOAD_DIR=/app/uploads
EXPOSE 3000 9090
VOLUME ["/app/uploads", "/app/static"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3000/api/v1/health || exit 1
ENTRYPOINT ["/app/erp-server"]

5
apps/miniprogram-uniapp/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.uno/
.cache/
*.log

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

12042
apps/miniprogram-uniapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "hms-miniprogram-uniapp",
"version": "1.0.0",
"description": "HMS 健康管理平台患者小程序UniApp 验证版)",
"scripts": {
"dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4060620250520001",
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
"@dcloudio/uni-components": "3.0.0-4060620250520001",
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
"pinia": "^2.1.7",
"vue": "^3.4.21"
},
"devDependencies": {
"@dcloudio/types": "^3.4.8",
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
"sass": "^1.87.0",
"typescript": "^5.8.0",
"vite": "^5.4.0"
}
}

8101
apps/miniprogram-uniapp/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
{
"appid": "wx20f4ef9cc2ec66c5",
"miniprogramRoot": "dist/dev/mp-weixin/",
"compileType": "miniprogram",
"setting": {
"urlCheck": false,
"automationAudits": true,
"es6": false,
"enhance": false,
"compileHotReLoad": true,
"postcss": false,
"minified": false,
"bundle": false,
"ignoreUploadUnusedFiles": true,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"projectname": "hms-uniapp",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
}

View File

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

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import { useAuthStore } from '@/stores/auth'
import { useUIStore } from '@/stores/ui'
onLaunch(() => {
const authStore = useAuthStore()
authStore.restore()
const uiStore = useUIStore()
uiStore.restore()
})
onShow(() => {
const authStore = useAuthStore()
authStore.restore()
})
</script>
<style lang="scss">
@import './styles/tokens.scss';
@import './styles/mixins.scss';
@import './styles/elder-mode.scss';
page {
background-color: #F5F0EB;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: #2D2A26;
font-size: var(--tk-font-body);
line-height: var(--tk-line-height);
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<view class="device-card" @tap="handleSync">
<view class="device-icon">{{ icon }}</view>
<view class="device-info">
<text class="device-name">{{ deviceName }}</text>
<text class="device-status" :class="statusClass">{{ statusLabel }}</text>
<text v-if="lastSyncAt" class="last-sync">最近同步: {{ lastSyncAt }}</text>
</view>
<view class="sync-btn">同步</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const DEVICE_ICONS: Record<string, string> = {
blood_pressure: '🩺',
blood_glucose: '💉',
heart_rate: '❤️',
blood_oxygen: '🫁',
}
const props = defineProps<{
deviceName: string
deviceType: string
lastSyncAt?: string
status: 'connected' | 'disconnected' | 'never'
}>()
const icon = computed(() => DEVICE_ICONS[props.deviceType] || '📱')
const statusLabel = computed(() => {
const map: Record<string, string> = { connected: '已连接', disconnected: '未连接', never: '未配对' }
return map[props.status] || props.status
})
const statusClass = computed(() => props.status === 'connected' ? 'connected' : 'idle')
function handleSync() {
uni.navigateTo({ url: '/pages-sub/device-sync/index' })
}
</script>
<style lang="scss" scoped>
.device-card {
display: flex;
align-items: center;
background: $card;
border-radius: $r;
padding: 20px 24px;
box-shadow: $shadow-sm;
}
.device-icon {
font-size: 40px;
margin-right: 20px;
}
.device-info {
flex: 1;
}
.device-name {
display: block;
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 4px;
}
.device-status {
font-size: var(--tk-font-cap);
margin-bottom: 2px;
&.connected { color: $acc; }
&.idle { color: $tx3; }
}
.last-sync {
font-size: var(--tk-font-micro);
color: $tx3;
}
.sync-btn {
background: $pri;
color: $white;
border-radius: $r-pill;
padding: 8px 24px;
font-size: var(--tk-font-body-sm);
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<view class="ec-canvas-wrap">
<canvas id="ec-canvas" class="ec-canvas" type="2d"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }" />
</view>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
option?: Record<string, any>
width?: number
height?: number
}>(), {
width: 350,
height: 250,
})
const emit = defineEmits<{
(e: 'init', chart: any): void
}>()
const canvasWidth = ref(props.width)
const canvasHeight = ref(props.height)
// Minimal ECharts bridge — for full ECharts, use lime-echart plugin
// This is a placeholder that renders a basic canvas with the option's title
onMounted(() => {
nextTick(initCanvas)
})
function nextTick(fn: () => void) {
setTimeout(fn, 50)
}
function initCanvas() {
const query = uni.createSelectorQuery()
query.select('#ec-canvas').fields({ node: true, size: true }).exec((res) => {
if (!res || !res[0] || !res[0].node) return
emit('init', { canvas: res[0].node, width: res[0].width, height: res[0].height })
})
}
watch(() => props.option, () => {
nextTick(initCanvas)
}, { deep: true })
</script>
<style lang="scss" scoped>
.ec-canvas-wrap {
@include flex-center;
}
.ec-canvas {
display: block;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<view class="empty-wrap">
<text class="empty-icon">{{ icon }}</text>
<text class="empty-title">{{ title }}</text>
<text v-if="description" class="empty-desc">{{ description }}</text>
<view v-if="actionText" class="empty-action" @tap="$emit('action')">
{{ actionText }}
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{
icon?: string
title: string
description?: string
actionText?: string
}>()
defineEmits<{ action: [] }>()
</script>
<style lang="scss" scoped>
.empty-wrap {
@include flex-center;
flex-direction: column;
padding: 80px 40px;
}
.empty-icon {
font-size: var(--tk-font-hero);
margin-bottom: 16px;
}
.empty-title {
font-size: var(--tk-font-h2);
color: $tx2;
margin-bottom: 8px;
}
.empty-desc {
font-size: var(--tk-font-body-sm);
color: $tx3;
text-align: center;
}
.empty-action {
@include btn-primary;
margin-top: 32px;
width: auto;
padding: 0 48px;
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<slot v-if="!hasError" />
<view v-else class="error-boundary">
<view class="error-icon-wrap">
<text class="error-icon-text">!</text>
</view>
<text class="error-title">页面出了点问题</text>
<text class="error-desc">请返回重试</text>
<view class="error-retry-btn" @tap="handleRetry">
<text class="error-retry-text">重新加载</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
const hasError = ref(false)
onErrorCaptured((err) => {
console.error('[ErrorBoundary]', err)
hasError.value = true
return false
})
function handleRetry() {
hasError.value = false
}
</script>
<style lang="scss" scoped>
.error-boundary {
@include flex-center;
flex-direction: column;
padding: 80px 40px;
}
.error-icon-wrap {
width: 80px;
height: 80px;
border-radius: 50%;
background: $dan-l;
@include flex-center;
margin-bottom: 24px;
}
.error-icon-text {
font-size: 40px;
font-weight: bold;
color: $dan;
}
.error-title {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
margin-bottom: 8px;
}
.error-desc {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-bottom: 32px;
}
.error-retry-btn {
background: $pri;
border-radius: $r-pill;
padding: 16px 48px;
}
.error-retry-text {
color: $white;
font-size: var(--tk-font-body);
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<view class="error-state">
<text class="error-state-icon">&#x26A0;&#xFE0F;</text>
<text class="error-state-text">{{ text }}</text>
<view v-if="onRetry" class="error-state-retry" @tap="onRetry">
<text class="error-state-retry-text">重新加载</text>
</view>
</view>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
text?: string
onRetry?: () => void
}>(), {
text: '加载失败,请稍后重试',
})
</script>
<style lang="scss" scoped>
.error-state {
@include flex-center;
flex-direction: column;
padding: 80px 40px;
}
.error-state-icon {
font-size: 48px;
margin-bottom: 16px;
}
.error-state-text {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-bottom: 24px;
}
.error-state-retry {
background: $pri;
border-radius: $r-pill;
padding: 12px 40px;
}
.error-state-retry-text {
color: $white;
font-size: var(--tk-font-body-sm);
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<view v-if="!authStore.user" class="guest-wrap">
<slot name="guest">
<text class="guest-text">请先登录</text>
<view class="guest-login-btn" @tap="goLogin">去登录</view>
</slot>
</view>
<slot v-else />
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
function goLogin() {
uni.navigateTo({ url: '/pages/login/index' })
}
</script>
<style lang="scss" scoped>
.guest-wrap {
@include flex-center;
flex-direction: column;
padding: 80px 40px;
}
.guest-text {
font-size: var(--tk-font-body);
color: $tx3;
margin-bottom: 24px;
}
.guest-login-btn {
@include btn-primary;
width: auto;
padding: 0 48px;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<view class="loading-wrap">
<view class="loading-spinner" />
<text class="loading-text">{{ text }}</text>
</view>
</template>
<script setup lang="ts">
defineProps<{ text?: string }>()
</script>
<style lang="scss" scoped>
.loading-wrap {
@include flex-center;
flex-direction: column;
padding: 80px 0;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid $bd;
border-top-color: $pri;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
margin-top: 20px;
color: $tx3;
font-size: var(--tk-font-body-sm);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<view class="progress-ring" :style="{ width: size + 'px', height: size + 'px' }">
<svg :width="size" :height="size" :viewBox="`0 0 ${size} ${size}`">
<circle
:cx="size / 2" :cy="size / 2" :r="radius"
fill="none" :stroke="bgColor" :stroke-width="strokeWidth"
/>
<circle
:cx="size / 2" :cy="size / 2" :r="radius"
fill="none" :stroke="color" :stroke-width="strokeWidth"
:stroke-dasharray="circumference"
:stroke-dashoffset="offset"
stroke-linecap="round"
:transform="`rotate(-90 ${size / 2} ${size / 2})`"
/>
</svg>
<view class="progress-text">
<text class="progress-value">{{ Math.round(percent) }}</text>
<text class="progress-unit">%</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
percent: number
size?: number
strokeWidth?: number
color?: string
bgColor?: string
}>(), {
size: 120,
strokeWidth: 8,
color: '#C4623A',
bgColor: '#E8E2DC',
})
const radius = computed(() => (props.size - props.strokeWidth) / 2)
const circumference = computed(() => 2 * Math.PI * radius.value)
const offset = computed(() => circumference.value * (1 - props.percent / 100))
</script>
<style lang="scss" scoped>
.progress-ring {
position: relative;
@include flex-center;
}
.progress-text {
position: absolute;
@include flex-center;
}
.progress-value {
font-size: var(--tk-font-num);
font-weight: bold;
color: $tx;
}
.progress-unit {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-left: 2px;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<view class="step-indicator">
<view v-for="(step, idx) in steps" :key="step.label" class="step-item">
<view v-if="idx > 0" class="step-line" :class="{ 'step-line-done': idx < current }" />
<view class="step-dot"
:class="{ 'step-current': idx === current, 'step-done': idx < current }"
@tap="idx < current && onChange && onChange(idx)">
<text v-if="idx < current" class="step-check">&#x2713;</text>
<text v-else class="step-num">{{ idx + 1 }}</text>
</view>
<text class="step-label" :class="{ 'step-current': idx === current, 'step-done': idx < current }">
{{ step.label }}
</text>
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{
steps: { label: string }[]
current: number
onChange?: (index: number) => void
}>()
</script>
<style lang="scss" scoped>
.step-indicator {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 16px 0;
}
.step-item {
@include flex-center;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
.step-line {
position: absolute;
top: 18px;
left: -50%;
width: 100%;
height: 2px;
background: $bd-l;
}
.step-line-done {
background: $pri;
}
.step-dot {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid $bd-l;
@include flex-center;
margin-bottom: 8px;
background: $white;
}
.step-current {
border-color: $pri;
&.step-dot { background: $pri; }
&.step-label { color: $pri; font-weight: 600; }
}
.step-done {
border-color: $pri;
background: $pri;
&.step-label { color: $acc; }
}
.step-check {
color: $white;
font-size: 18px;
}
.step-num {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
.step-label {
font-size: var(--tk-font-micro);
color: $tx3;
text-align: center;
max-width: 80px;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<view v-if="!data || data.length === 0" class="trend-chart-empty">
<text class="trend-chart-empty-text">暂无数据</text>
</view>
<view v-else class="trend-chart" :style="{ height: (height || 500) + 'rpx' }">
<canvas type="2d" id="trend-chart-canvas" class="trend-canvas"
:style="{ width: '100%', height: '100%' }" />
</view>
</template>
<script setup lang="ts">
import { watch, onMounted, nextTick, ref } from 'vue'
const props = withDefaults(defineProps<{
data: { date: string; value: number }[]
referenceMin?: number
referenceMax?: number
unit?: string
height?: number
}>(), {
unit: '',
height: 500,
})
const canvasReady = ref(false)
function drawLine(ctx: any, points: { x: number; y: number }[]) {
if (points.length < 2) return
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1]
const curr = points[i]
const cpx = (prev.x + curr.x) / 2
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y)
}
ctx.stroke()
}
function draw() {
if (!props.data || props.data.length === 0) return
const query = uni.createSelectorQuery()
query.select('#trend-chart-canvas').fields({ node: true, size: true }).exec((res) => {
if (!res || !res[0] || !res[0].node) return
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const dpr = uni.getSystemInfoSync().pixelRatio || 2
const w = res[0].width
const h = res[0].height
canvas.width = w * dpr
canvas.height = h * dpr
ctx.scale(dpr, dpr)
const pad = { top: 20, right: 16, bottom: 32, left: 48 }
const cw = w - pad.left - pad.right
const ch = h - pad.top - pad.bottom
const values = props.data.map(d => d.value)
let yMin = Math.min(...values)
let yMax = Math.max(...values)
if (props.referenceMin !== undefined) yMin = Math.min(yMin, props.referenceMin)
if (props.referenceMax !== undefined) yMax = Math.max(yMax, props.referenceMax)
const yPad = (yMax - yMin) * 0.1 || 1
yMin -= yPad
yMax += yPad
ctx.clearRect(0, 0, w, h)
// Reference band
if (props.referenceMin !== undefined && props.referenceMax !== undefined) {
const ry1 = pad.top + ch * (1 - (props.referenceMax - yMin) / (yMax - yMin))
const ry2 = pad.top + ch * (1 - (props.referenceMin - yMin) / (yMax - yMin))
ctx.fillStyle = 'rgba(91, 122, 94, 0.1)'
ctx.fillRect(pad.left, ry1, cw, ry2 - ry1)
}
// Grid lines
ctx.strokeStyle = '#e5e5e5'
ctx.lineWidth = 0.5
for (let i = 0; i <= 4; i++) {
const y = pad.top + (ch / 4) * i
ctx.beginPath()
ctx.moveTo(pad.left, y)
ctx.lineTo(pad.left + cw, y)
ctx.stroke()
const val = yMax - ((yMax - yMin) / 4) * i
ctx.fillStyle = '#78716C'
ctx.font = '10px sans-serif'
ctx.textAlign = 'right'
ctx.fillText(val.toFixed(1), pad.left - 6, y + 3)
}
// X labels
ctx.textAlign = 'center'
ctx.fillStyle = '#78716C'
ctx.font = '10px sans-serif'
const step = Math.max(1, Math.floor(props.data.length / 6))
for (let i = 0; i < props.data.length; i += step) {
const x = pad.left + (cw / Math.max(1, props.data.length - 1)) * i
ctx.fillText(props.data[i].date.slice(5), x, h - 8)
}
// Data points
const points = props.data.map((d, i) => ({
x: pad.left + (cw / Math.max(1, props.data.length - 1)) * i,
y: pad.top + ch * (1 - (d.value - yMin) / (yMax - yMin)),
}))
// Area fill
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i++) {
const cpx = (points[i - 1].x + points[i].x) / 2
ctx.bezierCurveTo(cpx, points[i - 1].y, cpx, points[i].y, points[i].x, points[i].y)
}
ctx.lineTo(points[points.length - 1].x, pad.top + ch)
ctx.lineTo(points[0].x, pad.top + ch)
ctx.closePath()
const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + ch)
grad.addColorStop(0, 'rgba(196, 98, 58, 0.3)')
grad.addColorStop(1, 'rgba(196, 98, 58, 0.02)')
ctx.fillStyle = grad
ctx.fill()
// Line
ctx.strokeStyle = '#C4623A'
ctx.lineWidth = 2
drawLine(ctx, points)
// Dots
for (let i = 0; i < points.length; i++) {
const d = props.data[i]
const outOfRange =
(props.referenceMin !== undefined && d.value < props.referenceMin) ||
(props.referenceMax !== undefined && d.value > props.referenceMax)
ctx.beginPath()
ctx.arc(points[i].x, points[i].y, outOfRange ? 5 : 3, 0, Math.PI * 2)
ctx.fillStyle = outOfRange ? '#B54A4A' : '#C4623A'
ctx.fill()
}
})
}
onMounted(() => {
nextTick(() => { canvasReady.value = true; draw() })
})
watch(() => [props.data, props.referenceMin, props.referenceMax], () => {
if (canvasReady.value) nextTick(draw)
})
</script>
<style lang="scss" scoped>
.trend-chart {
background: $card;
border-radius: $r;
padding: 16px;
box-shadow: $shadow-sm;
}
.trend-canvas {
display: block;
}
.trend-chart-empty {
@include flex-center;
padding: 40px;
}
.trend-chart-empty-text {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<view class="week-calendar">
<view class="week-nav">
<text class="week-arrow" @tap="weekOffset--">&#x25C0;</text>
<text class="week-label">{{ dates[0]?.slice(5) }} ~ {{ dates[6]?.slice(5) }}</text>
<text class="week-arrow" @tap="weekOffset++">&#x25B6;</text>
</view>
<view class="week-grid">
<view v-for="(day, idx) in WEEKDAYS" :key="dates[idx]"
class="week-cell"
:class="{
'cell-selected': dates[idx] === selectedDate,
'cell-empty': !isScheduled(dates[idx]),
'cell-past': dates[idx] < today
}"
@tap="onCellTap(dates[idx])">
<text class="cell-weekday">{{ day }}</text>
<text class="cell-date" :class="{ 'cell-today': dates[idx] === today }">
{{ parseInt(dates[idx]?.slice(8) || '0') }}
</text>
<view v-if="isScheduled(dates[idx])" class="cell-dot" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
scheduledDates: string[]
selectedDate: string
}>()
const emit = defineEmits<{
(e: 'selectDate', date: string): void
}>()
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']
const weekOffset = ref(0)
const today = computed(() => {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
})
const dates = computed(() => {
const result: string[] = []
const now = new Date()
const monday = new Date(now)
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7) + weekOffset.value * 7)
for (let i = 0; i < 7; i++) {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
result.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
}
return result
})
function isScheduled(date: string): boolean {
return props.scheduledDates.includes(date)
}
function onCellTap(date: string) {
if (isScheduled(date) && date >= today.value) {
emit('selectDate', date)
}
}
</script>
<style lang="scss" scoped>
.week-calendar {
background: $card;
border-radius: $r;
padding: 20px 16px;
box-shadow: $shadow-sm;
}
.week-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.week-arrow {
font-size: 28px;
color: $pri;
padding: 8px 16px;
}
.week-label {
font-size: var(--tk-font-body-sm);
color: $tx2;
font-weight: 600;
}
.week-grid {
display: flex;
justify-content: space-around;
}
.week-cell {
@include flex-center;
flex-direction: column;
flex: 1;
padding: 8px 0;
border-radius: $r-sm;
transition: background 0.2s;
}
.cell-weekday {
font-size: var(--tk-font-micro);
color: $tx3;
margin-bottom: 4px;
}
.cell-date {
font-size: var(--tk-font-body-sm);
color: $tx;
font-weight: 600;
margin-bottom: 4px;
}
.cell-today {
color: $pri;
}
.cell-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: $pri;
}
.cell-selected {
background: $pri-l;
.cell-date { color: $pri-d; }
.cell-dot { background: $pri-d; }
}
.cell-empty {
opacity: 0.4;
}
.cell-past {
opacity: 0.5;
.cell-dot { background: $tx3; }
}
</style>

View File

@@ -0,0 +1,8 @@
import { computed } from 'vue'
import { useUIStore } from '@/stores/ui'
export function useElderClass() {
const uiStore = useUIStore()
const elderClass = computed(() => uiStore.elderMode ? 'elder-mode' : '')
return { elderClass }
}

7
apps/miniprogram-uniapp/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="@dcloudio/types" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

View File

@@ -0,0 +1,10 @@
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return { app }
}

View File

@@ -0,0 +1,21 @@
{
"name": "hms-uniapp",
"appid": "__UNI__HMS_VERIFY",
"description": "HMS 健康管理平台UniApp 验证版)",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"mp-weixin": {
"appid": "wx20f4ef9cc2ec66c5",
"setting": {
"urlCheck": false,
"automationAudits": true,
"es6": false,
"enhance": false,
"postcss": false,
"minified": false,
"compileHotReLoad": true
},
"usingComponents": true
}
}

View File

@@ -0,0 +1,89 @@
<template>
<view :class="['detail-page', elderClass]">
<Loading v-if="loading" text="加载中..." />
<view v-else-if="!analysis" class="empty-wrap"><text class="empty-text">报告不存在</text></view>
<template v-else>
<view class="detail-card">
<text class="detail-type">{{ TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type }}</text>
<view class="detail-meta">
<text class="meta-item">模型: {{ analysis.model_used }}</text>
<text class="meta-item">{{ new Date(analysis.created_at).toLocaleString('zh-CN') }}</text>
</view>
<view v-if="isAutoAnalysis" class="auto-badge">
<text class="auto-badge-text">系统自动分析</text>
</view>
</view>
<view v-if="isTrendAnalysis" class="trend-tip-card">
<text class="trend-tip-text">趋势分析基于最小二乘法线性回归和 2 倍标准差异常检测 越接近 1 表示趋势拟合越好</text>
</view>
<view class="content-card">
<rich-text class="report-content" :nodes="htmlContent" />
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis'
import { useElderClass } from '@/composables/useElderClass'
import Loading from '@/components/Loading.vue'
const TYPE_LABELS: Record<string, string> = {
lab_report_interpretation: '化验单解读', health_trend_analysis: '趋势分析',
personalized_checkup_plan: '体检方案', report_summary_generation: '报告摘要',
}
function sanitizeHtml(html: string): string {
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<\/?(?:iframe|object|embed|form|input|textarea|style)\b[^>]*>/gi, '')
.replace(/<\/?(?:link|meta)\b[^>]*>/gi, '')
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
}
function markdownToHtml(md: string): string {
const escaped = sanitizeHtml(md)
return escaped
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>')
.replace(/\n\n/g, '<br/><br/>')
.replace(/\n/g, '<br/>')
}
const { elderClass } = useElderClass()
const analysis = ref<AiAnalysisItem | null>(null)
const loading = ref(true)
const htmlContent = computed(() => analysis.value?.result_content ? markdownToHtml(analysis.value.result_content) : '<p>暂无分析结果</p>')
const isTrendAnalysis = computed(() => analysis.value?.analysis_type === 'trend')
const isAutoAnalysis = computed(() => (analysis.value?.result_metadata as Record<string, unknown>)?.auto_analysis === true)
onLoad((query) => {
const id = query?.id || ''
if (!id) { loading.value = false; return }
getAiAnalysisDetail(id).then(data => { analysis.value = data }).catch(() => uni.showToast({ title: '加载失败', icon: 'none' })).finally(() => { loading.value = false })
})
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
.empty-wrap { @include flex-center; padding: 120px 0; }
.empty-text { font-size: var(--tk-font-body); color: $tx3; }
.detail-card { @include card; margin-bottom: 16px; }
.detail-type { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-bottom: 8px; }
.detail-meta { display: flex; gap: 16px; }
.meta-item { font-size: var(--tk-font-cap); color: $tx3; }
.auto-badge { display: inline-block; margin-top: 8px; padding: 2px 10px; background: rgba($pri, 0.1); border-radius: 4px; }
.auto-badge-text { font-size: var(--tk-font-micro); color: $pri; }
.trend-tip-card { @include card; margin-bottom: 16px; background: rgba(250,173,20,0.08); }
.trend-tip-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
.content-card { @include card; }
.report-content { font-size: var(--tk-font-body); line-height: 1.8; color: $tx; }
</style>

View File

@@ -0,0 +1,90 @@
<template>
<view :class="['ai-report-page', elderClass]">
<view class="page-title">AI 分析报告</view>
<view v-if="list.length === 0 && !loading" class="empty-wrap">
<EmptyState icon="" title="暂无 AI 分析报告" />
</view>
<scroll-view v-else scroll-y class="report-scroll" @scrolltolower="loadMore">
<view v-for="item in list" :key="item.id" class="report-card" @tap="goDetail(item)">
<view class="card-header">
<text class="card-type">{{ TYPE_LABELS[item.analysis_type] || item.analysis_type }}</text>
<text :class="['card-status', (STATUS_MAP[item.status] || { className: '' }).className]">
{{ (STATUS_MAP[item.status] || { text: item.status }).text }}
</text>
</view>
<view class="card-footer">
<text class="card-time">{{ new Date(item.created_at).toLocaleString('zh-CN') }}</text>
<text class="card-model">{{ item.model_used }}</text>
</view>
</view>
<Loading v-if="loading" text="加载中..." />
<view v-if="!loading && !hasMore && list.length > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted } from 'vue'
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis'
import EmptyState from '@/components/EmptyState.vue'
import { useElderClass } from '@/composables/useElderClass'
import Loading from '@/components/Loading.vue'
const TYPE_LABELS: Record<string, string> = {
lab_report_interpretation: '化验单解读', health_trend_analysis: '趋势分析',
personalized_checkup_plan: '体检方案', report_summary_generation: '报告摘要',
}
const STATUS_MAP: Record<string, { text: string; className: string }> = {
completed: { text: '已完成', className: 'status-completed' },
streaming: { text: '分析中', className: 'status-streaming' },
failed: { text: '失败', className: 'status-failed' },
pending: { text: '等待中', className: 'status-pending' },
}
const { elderClass } = useElderClass()
const list = ref<AiAnalysisItem[]>([])
const loading = ref(true)
const page = ref(1)
const hasMore = ref(true)
const loadList = async (p: number) => {
loading.value = true
try {
const res = await listAiAnalysis(p, 20)
const items = res.data || []
list.value = p === 1 ? items : [...list.value, ...items]
page.value = p
hasMore.value = items.length >= 20
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
finally { loading.value = false }
}
const goDetail = (item: AiAnalysisItem) => {
if (item.status === 'completed') uni.navigateTo({ url: `/pages-sub/ai-report/detail/index?id=${item.id}` })
}
const loadMore = () => { if (hasMore.value && !loading.value) loadList(page.value + 1) }
onMounted(() => loadList(1))
</script>
<style lang="scss" scoped>
.ai-report-page { min-height: 100vh; background: $bg; }
.page-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; padding: 24px 24px 16px; }
.report-scroll { height: calc(100vh - 64px); padding: 0 24px; }
.report-card { @include card; margin-bottom: 12px; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.card-type { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
.card-status { font-size: var(--tk-font-cap); padding: 2px 8px; border-radius: 4px; }
.status-completed { color: $acc; background: rgba(82,196,26,0.1); }
.status-streaming { color: $pri; background: rgba($pri, 0.1); }
.status-failed { color: $dan; background: rgba(255,77,79,0.1); }
.status-pending { color: $tx3; background: rgba(0,0,0,0.05); }
.card-footer { display: flex; justify-content: space-between; }
.card-time, .card-model { font-size: var(--tk-font-cap); color: $tx3; }
.empty-wrap { padding-top: 120px; }
.no-more { @include flex-center; padding: 20px; }
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
</style>

View File

@@ -0,0 +1,146 @@
<template>
<scroll-view scroll-y class="page-scroll">
<view class="page-content">
<text class="page-title">新建预约</text>
<view class="form-group">
<text class="form-label">选择医生</text>
<picker :range="doctorNames" @change="onDoctorChange">
<view class="form-picker">
{{ selectedDoctorName || '请选择医生' }}
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">预约日期</text>
<picker mode="date" :value="date" @change="(e: any) => date = e.detail.value">
<view class="form-picker">
{{ date || '请选择日期' }}
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">预约时间</text>
<picker mode="time" :value="time" @change="(e: any) => time = e.detail.value">
<view class="form-picker">
{{ time || '请选择时间' }}
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">备注</text>
<textarea v-model="notes" class="form-textarea" placeholder="请输入备注信息" />
</view>
<view class="submit-btn" @tap="handleSubmit">
{{ submitting ? '提交中...' : '提交预约' }}
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { api } from '@/services/request'
const doctors = ref<any[]>([])
const selectedDoctorIdx = ref(-1)
const date = ref('')
const time = ref('')
const notes = ref('')
const submitting = ref(false)
const doctorNames = computed(() => doctors.value.map(d => d.name || d.display_name || '医生'))
const selectedDoctorName = computed(() => selectedDoctorIdx.value >= 0 ? doctorNames.value[selectedDoctorIdx.value] : '')
function onDoctorChange(e: any) {
selectedDoctorIdx.value = e.detail.value
}
async function fetchDoctors() {
try { doctors.value = await api.get<any[]>('/health/doctors') || [] }
catch { doctors.value = [] }
}
async function handleSubmit() {
if (selectedDoctorIdx.value < 0) {
uni.showToast({ title: '请选择医生', icon: 'none' })
return
}
if (!date.value || !time.value) {
uni.showToast({ title: '请选择日期和时间', icon: 'none' })
return
}
submitting.value = true
try {
await api.post('/health/appointments', {
doctor_id: doctors.value[selectedDoctorIdx.value].id,
appointment_time: `${date.value} ${time.value}`,
notes: notes.value,
})
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch (err: any) {
uni.showToast({ title: err.message || '预约失败', icon: 'none' })
}
submitting.value = false
}
onMounted(fetchDoctors)
</script>
<style lang="scss" scoped>
.page-scroll { height: 100vh; background: $bg; }
.page-content { padding: 28px 24px 120px; }
.page-title { @include section-title; }
.form-group {
margin-bottom: 28px;
}
.form-label {
display: block;
font-size: var(--tk-font-body);
color: $tx2;
margin-bottom: 12px;
}
.form-picker {
display: flex;
justify-content: space-between;
align-items: center;
background: $card;
border-radius: $r-sm;
padding: 18px 20px;
font-size: var(--tk-font-body);
color: $tx;
box-shadow: $shadow-sm;
}
.picker-arrow {
font-size: var(--tk-font-cap);
color: $tx3;
}
.form-textarea {
width: 100%;
min-height: 160px;
background: $card;
border-radius: $r-sm;
padding: 18px 20px;
font-size: var(--tk-font-body);
color: $tx;
box-sizing: border-box;
}
.submit-btn {
@include btn-primary;
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<view :class="['detail-page', elderClass]">
<view class="detail-header">
<view class="back-btn" @tap="goBack"><text class="back-text">返回</text></view>
<text class="header-title">预约详情</text>
<view class="header-placeholder" />
</view>
<Loading v-if="loading" text="加载中..." />
<ErrorState v-else-if="error || !appointment" text="未找到预约信息" />
<template v-else>
<view class="status-card">
<view :class="['status-tag', statusInfo.className]">
<text class="status-tag-text">{{ statusInfo.label }}</text>
</view>
<text class="status-doctor">{{ appointment.doctor_name }}</text>
<text class="status-dept">{{ appointment.department || '' }}</text>
</view>
<view class="info-section">
<text class="section-title">预约信息</text>
<view class="info-item">
<view class="info-label-wrap"><text class="info-icon-serif"></text><text class="info-label">就诊人</text></view>
<text class="info-value">{{ appointment.patient_name }}</text>
</view>
<view class="info-item">
<view class="info-label-wrap"><text class="info-icon-serif"></text><text class="info-label">就诊日期</text></view>
<text class="info-value info-date">{{ appointment.appointment_date }}</text>
</view>
<view class="info-item">
<view class="info-label-wrap"><text class="info-icon-serif"></text><text class="info-label">就诊时段</text></view>
<text class="info-value info-time">{{ appointment.start_time }} - {{ appointment.end_time }}</text>
</view>
<view class="info-item">
<view class="info-label-wrap"><text class="info-icon-serif"></text><text class="info-label">预约单号</text></view>
<text class="info-value info-id">{{ appointment.id }}</text>
</view>
</view>
<view v-if="appointment.status === 'pending' || appointment.status === 'confirmed'" class="tips-card">
<text class="tips-title">温馨提示</text>
<text class="tips-text">请按预约时间提前15分钟到达携带有效身份证件和医保卡</text>
</view>
<view v-if="canCancel" class="bottom-bar">
<view :class="['cancel-btn', cancelling ? 'cancel-disabled' : '']" @tap="cancelling ? undefined : handleCancel">
<text class="cancel-text">{{ cancelling ? '处理中...' : '取消预约' }}</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getAppointment, cancelAppointment, type Appointment } from '@/services/appointment'
import ErrorState from '@/components/ErrorState.vue'
import Loading from '@/components/Loading.vue'
import { useElderClass } from '@/composables/useElderClass'
const STATUS_MAP: Record<string, { label: string; className: string }> = {
pending: { label: '待确认', className: 'tag-pending' },
confirmed: { label: '已确认', className: 'tag-confirmed' },
cancelled: { label: '已取消', className: 'tag-cancelled' },
completed: { label: '已完成', className: 'tag-completed' },
}
const { elderClass } = useElderClass()
const appointment = ref<Appointment | null>(null)
const loading = ref(true)
const error = ref(false)
const cancelling = ref(false)
let id = ''
const statusInfo = computed(() => appointment.value ? (STATUS_MAP[appointment.value.status] || { label: appointment.value.status, className: 'tag-pending' }) : { label: '未知', className: 'tag-pending' })
const canCancel = computed(() => appointment.value && (appointment.value.status === 'pending' || appointment.value.status === 'confirmed'))
const goBack = () => uni.navigateBack()
const handleCancel = async () => {
if (!appointment.value || cancelling.value) return
const res = await uni.showModal({ title: '确认取消', content: '确定要取消此预约吗?取消后需重新预约。' })
if (!res.confirm) return
cancelling.value = true
try {
await cancelAppointment(appointment.value.id, appointment.value.version)
uni.showToast({ title: '已取消预约', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch { uni.showToast({ title: '取消失败', icon: 'none' }) }
finally { cancelling.value = false }
}
onLoad((query) => {
id = query?.id || ''
if (!id) { error.value = true; loading.value = false; return }
loading.value = true
getAppointment(id).then(data => { appointment.value = data }).catch(() => { error.value = true }).finally(() => { loading.value = false })
})
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; background: $bg; }
.detail-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; background: $card; }
.back-btn { padding: 6px 12px; }
.back-text { font-size: var(--tk-font-body); color: $pri; }
.header-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
.header-placeholder { width: 50px; }
.status-card { @include card; margin: 16px 24px; text-align: center; }
.status-tag { display: inline-block; padding: 4px 16px; border-radius: 20px; margin-bottom: 8px; }
.tag-pending { background: rgba(250,173,20,0.15); }
.tag-confirmed { background: rgba($pri, 0.1); }
.tag-cancelled { background: rgba(0,0,0,0.05); }
.tag-completed { background: rgba(82,196,26,0.1); }
.status-tag-text { font-size: var(--tk-font-cap); }
.status-doctor { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-top: 8px; }
.status-dept { font-size: var(--tk-font-caption); color: $tx3; display: block; margin-top: 4px; }
.info-section { @include card; margin: 0 24px 16px; }
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
.info-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
.info-item:last-child { border-bottom: none; }
.info-label-wrap { display: flex; align-items: center; gap: 8px; }
.info-icon-serif { width: 24px; height: 24px; border-radius: 4px; background: rgba($pri, 0.08); @include flex-center; font-size: var(--tk-font-micro); color: $pri; }
.info-label { font-size: var(--tk-font-cap); color: $tx3; }
.info-value { font-size: var(--tk-font-body); color: $tx; }
.tips-card { @include card; margin: 0 24px 16px; background: rgba(250,173,20,0.08); }
.tips-title { font-size: var(--tk-font-cap); font-weight: 500; color: $wrn; display: block; margin-bottom: 6px; }
.tips-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; padding: 12px 24px; background: $card; box-shadow: $shadow-sm; }
.cancel-btn { height: $touch-min; border: 1px solid $dan; border-radius: $r; @include flex-center; }
.cancel-disabled { opacity: 0.5; }
.cancel-text { font-size: var(--tk-font-body); color: $dan; }
</style>

View File

@@ -0,0 +1,103 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<text class="page-title">我的预约</text>
<Loading v-if="loading && list.length === 0" text="加载中..." />
<EmptyState v-else-if="list.length === 0" icon="📅" title="暂无预约" action-text="去预约" @action="navigateTo('/pages-sub/appointment/create/index')" />
<template v-else>
<view v-for="item in list" :key="item.id" class="appt-card">
<view class="appt-header">
<text class="appt-doctor">{{ item.doctor_name || '医生' }}</text>
<text :class="['appt-status', item.status]">{{ item.status_text || item.status }}</text>
</view>
<text class="appt-time">{{ formatDate(item.appointment_time, 'YYYY-MM-DD HH:mm') }}</text>
<text class="appt-dept">{{ item.department || '' }}</text>
</view>
</template>
<!-- 新建预约按钮 -->
<view class="create-btn" @tap="navigateTo('/pages-sub/appointment/create/index')">
新建预约
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/services/request'
import { formatDate } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
import { useElderClass } from '@/composables/useElderClass'
const { elderClass } = useElderClass()
const loading = ref(false)
const list = ref<any[]>([])
function navigateTo(url: string) { uni.navigateTo({ url }) }
async function fetchList() {
loading.value = true
try { list.value = await api.get<any[]>('/health/appointments') || [] }
catch { list.value = [] }
loading.value = false
}
onMounted(fetchList)
</script>
<style lang="scss" scoped>
.page-scroll { height: 100vh; background: $bg; }
.page-content { padding: 28px 24px 120px; }
.page-title { @include section-title; }
.appt-card {
background: $card;
border-radius: $r;
padding: 20px 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
}
.appt-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.appt-doctor {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
}
.appt-status {
font-size: var(--tk-font-body-sm);
padding: 4px 12px;
border-radius: $r-pill;
&.confirmed { background: $acc-l; color: $acc; }
&.pending { background: $wrn-l; color: $wrn; }
&.cancelled { background: $bd-l; color: $tx3; }
}
.appt-time {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: 4px;
}
.appt-dept {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
}
.create-btn {
@include btn-primary;
margin-top: 28px;
}
</style>

View File

@@ -0,0 +1,73 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<Loading v-if="loading" text="加载中..." />
<template v-else-if="article">
<text class="article-title">{{ article.title }}</text>
<view class="article-meta">
<text class="article-date">{{ formatDate(article.created_at) }}</text>
<text v-if="article.author" class="article-author">{{ article.author }}</text>
</view>
<view class="article-body">
<rich-text :nodes="article.content || ''" />
</view>
</template>
<EmptyState v-else icon="📰" title="文章不存在" />
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { api } from '@/services/request'
import { formatDate } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
import { useElderClass } from '@/composables/useElderClass'
const { elderClass } = useElderClass()
const loading = ref(false)
const article = ref<any>(null)
onLoad(async (query: any) => {
const id = query?.id
if (!id) return
loading.value = true
try { article.value = await api.get<any>(`/health/articles/${id}`) }
catch { article.value = null }
loading.value = false
})
</script>
<style lang="scss" scoped>
.page-scroll { height: 100vh; background: $bg; }
.page-content { padding: 28px 24px 120px; }
.article-title {
display: block;
font-size: var(--tk-font-h1);
font-weight: bold;
color: $tx;
margin-bottom: 16px;
}
.article-meta {
display: flex;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid $bd-l;
}
.article-date, .article-author {
font-size: var(--tk-font-cap);
color: $tx3;
}
.article-body {
font-size: var(--tk-font-body);
line-height: var(--tk-line-height);
color: $tx;
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]" @scrolltolower="loadMore">
<view class="page-content">
<text class="page-title">健康文章</text>
<Loading v-if="loading && list.length === 0" text="加载中..." />
<EmptyState v-else-if="list.length === 0" icon="📰" title="暂无文章" />
<template v-else>
<view v-for="item in list" :key="item.id" class="article-card" @tap="goDetail(item.id)">
<text class="article-title">{{ item.title }}</text>
<text class="article-summary">{{ item.summary || item.content?.substring(0, 60) }}</text>
<view class="article-meta">
<text class="article-date">{{ formatDate(item.created_at) }}</text>
<text v-if="item.category" class="article-tag">{{ item.category }}</text>
</view>
</view>
</template>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/services/request'
import { formatDate } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
import { useElderClass } from '@/composables/useElderClass'
const { elderClass } = useElderClass()
const loading = ref(false)
const list = ref<any[]>([])
const page = ref(1)
function goDetail(id: string) {
uni.navigateTo({ url: `/pages-sub/article/detail/index?id=${id}` })
}
async function fetchList() {
loading.value = true
try { list.value = await api.get<any[]>('/health/articles', { page: page.value, limit: 20 }) || [] }
catch { list.value = [] }
loading.value = false
}
function loadMore() {
page.value++
fetchList()
}
onMounted(fetchList)
</script>
<style lang="scss" scoped>
.page-scroll { height: 100vh; background: $bg; }
.page-content { padding: 28px 24px 120px; }
.page-title { @include section-title; }
.article-card {
background: $card;
border-radius: $r;
padding: 20px 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
}
.article-title {
display: block;
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
margin-bottom: 8px;
}
.article-summary {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.article-date {
font-size: var(--tk-font-cap);
color: $tx3;
}
.article-tag {
@include tag($pri-l, $pri);
font-size: var(--tk-font-micro);
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<view :class="['chat-page', elderClass]">
<!-- 消息列表 -->
<scroll-view scroll-y class="chat-scroll" :scroll-top="scrollTop" :scroll-with-animation="true">
<Loading v-if="loading" text="加载中..." />
<template v-else>
<view v-for="msg in messages" :key="msg.id" :class="['msg-bubble', msg.sender === 'user' ? 'right' : 'left']">
<text class="msg-text">{{ msg.content }}</text>
<text class="msg-time">{{ formatDate(msg.created_at, 'HH:mm') }}</text>
</view>
</template>
</scroll-view>
<!-- 输入框 -->
<view class="input-bar">
<input v-model="inputText" class="chat-input" placeholder="输入消息..." confirm-type="send" @confirm="send" />
<view class="send-btn" @tap="send">
<text class="send-text">发送</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { api } from '@/services/request'
import { formatDate } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import { useElderClass } from '@/composables/useElderClass'
const { elderClass } = useElderClass()
const consultationId = ref('')
const loading = ref(false)
const messages = ref<any[]>([])
const inputText = ref('')
const scrollTop = ref(0)
async function fetchMessages() {
loading.value = true
try {
messages.value = await api.get<any[]>(`/health/consultations/${consultationId.value}/messages`) || []
scrollTop.value = 99999
} catch { messages.value = [] }
loading.value = false
}
async function send() {
const text = inputText.value.trim()
if (!text || !consultationId.value) return
inputText.value = ''
try {
await api.post(`/health/consultations/${consultationId.value}/messages`, { content: text })
await fetchMessages()
} catch {
uni.showToast({ title: '发送失败', icon: 'none' })
}
}
onLoad((query: any) => {
consultationId.value = query?.id || ''
if (consultationId.value) fetchMessages()
})
</script>
<style lang="scss" scoped>
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
background: $bg;
}
.chat-scroll {
flex: 1;
padding: 20px 24px;
}
.msg-bubble {
max-width: 75%;
padding: 16px 20px;
border-radius: $r;
margin-bottom: 16px;
&.left {
background: $card;
align-self: flex-start;
}
&.right {
background: $pri;
align-self: flex-end;
}
}
.msg-text {
display: block;
font-size: var(--tk-font-body);
.left & { color: $tx; }
.right & { color: $white; }
}
.msg-time {
display: block;
font-size: var(--tk-font-micro);
margin-top: 4px;
.left & { color: $tx3; }
.right & { color: rgba(255,255,255,0.7); }
}
.input-bar {
display: flex;
align-items: center;
padding: 12px 16px;
background: $card;
border-top: 1px solid $bd-l;
@include safe-bottom;
}
.chat-input {
flex: 1;
height: 72px;
background: $bg;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
}
.send-btn {
margin-left: 12px;
background: $pri;
border-radius: $r-sm;
padding: 16px 24px;
}
.send-text {
color: $white;
font-size: var(--tk-font-body-sm);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]" @scrolltolower="loadMore">
<view class="page-content">
<text class="page-title">咨询列表</text>
<Loading v-if="loading && list.length === 0" text="加载中..." />
<EmptyState v-else-if="list.length === 0" icon="💬" title="暂无咨询记录" action-text="发起咨询" @action="navigateTo('/pages-sub/consultation/create')" />
<template v-else>
<view v-for="item in list" :key="item.id" class="consult-card" @tap="goDetail(item.id)">
<view class="consult-header">
<text class="consult-doctor">{{ item.doctor_name || '医生' }}</text>
<text :class="['consult-status', item.status]">{{ item.status_text || item.status }}</text>
</view>
<text class="consult-preview">{{ item.last_message || '暂无消息' }}</text>
<text class="consult-time">{{ getRelativeTime(item.updated_at) }}</text>
</view>
</template>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/services/request'
import { getRelativeTime } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
import { useElderClass } from '@/composables/useElderClass'
const { elderClass } = useElderClass()
const loading = ref(false)
const list = ref<any[]>([])
function navigateTo(url: string) { uni.navigateTo({ url }) }
function goDetail(id: string) { uni.navigateTo({ url: `/pages-sub/consultation/detail/index?id=${id}` }) }
async function fetchList() {
loading.value = true
try { list.value = await api.get<any[]>('/health/consultations') || [] }
catch { list.value = [] }
loading.value = false
}
function loadMore() { /* 预留 */ }
onMounted(fetchList)
</script>
<style lang="scss" scoped>
.page-scroll { height: 100vh; background: $bg; }
.page-content { padding: 28px 24px 120px; }
.page-title { @include section-title; }
.consult-card {
background: $card;
border-radius: $r;
padding: 20px 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
}
.consult-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.consult-doctor {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
}
.consult-status {
font-size: var(--tk-font-body-sm);
padding: 4px 12px;
border-radius: $r-pill;
&.active { background: $acc-l; color: $acc; }
&.closed { background: $bd-l; color: $tx3; }
}
.consult-preview {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: 8px;
}
.consult-time {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<view :class="['device-sync-page', elderClass]">
<view class="sync-header">
<text class="sync-header-title">设备同步</text>
</view>
<view v-if="errorMsg" class="sync-error">
<text class="sync-error-text">{{ errorMsg }}</text>
</view>
<view v-if="pageState === 'scanning' || pageState === 'connecting' || pageState === 'syncing'" class="sync-loading">
<text class="sync-loading-text">
{{ pageState === 'scanning' && '正在扫描设备...' || pageState === 'connecting' && '正在连接设备...' || '正在上传数据...' }}
</text>
</view>
<!-- 空闲状态 -->
<template v-if="pageState === 'idle' || pageState === 'error'">
<view class="sync-section">
<view class="sync-hero">
<text class="sync-hero-icon">D</text>
<text class="sync-hero-title">设备同步</text>
<text class="sync-hero-desc">连接智能手环血压计血糖仪自动采集健康数据</text>
</view>
<view v-if="lastSyncAt || pendingCount > 0" class="sync-status-info">
<text v-if="lastSyncAt" class="sync-status-time">上次同步: {{ new Date(lastSyncAt).toLocaleTimeString() }}</text>
<text v-if="pendingCount > 0" class="sync-status-pending">{{ pendingCount }} 条数据待上传</text>
</view>
<view class="sync-action" @tap="handleScan">
<text class="sync-action-text">扫描设备</text>
</view>
<view v-if="devices.length > 0" class="sync-device-list">
<text class="sync-section-title">发现的设备</text>
<view v-for="d in devices" :key="d.deviceId" class="sync-device-item" @tap="handleConnect(d)">
<view class="sync-device-info">
<text class="sync-device-name">{{ d.name }}</text>
<text class="sync-device-adapter">{{ d.adapter?.name }}</text>
</view>
<text class="sync-device-rssi">信号 {{ d.RSSI > -60 ? '强' : d.RSSI > -80 ? '中' : '弱' }}</text>
</view>
</view>
</view>
</template>
<!-- 已连接 -->
<template v-if="pageState === 'connected'">
<view class="sync-section">
<view class="sync-status-card">
<text class="sync-status-dot sync-status-dot--connected" />
<text class="sync-status-text">已连接: {{ selectedDevice?.name }}</text>
</view>
<view v-if="liveReadings.length > 0" class="sync-readings-panel">
<text class="sync-section-title">实时数据</text>
<view v-for="(r, i) in liveReadings.slice(-5).reverse()" :key="i" class="sync-reading-item">
<text class="sync-reading-type">
{{ r.device_type === 'heart_rate' ? '心率' : r.device_type === 'blood_pressure' ? `血压(${r.metric === 'systolic' ? '收缩压' : r.metric === 'diastolic' ? '舒张压' : 'MAP'})` : r.device_type === 'blood_glucose' ? '血糖' : r.device_type }}
</text>
<text class="sync-reading-value">
{{ r.device_type === 'heart_rate' ? `${r.values.heart_rate} bpm` : r.metric ? `${r.values.value} ${r.values.unit}` : JSON.stringify(r.values) }}
</text>
</view>
<text class="sync-readings-count">已采集 {{ liveReadings.length }} 条数据</text>
</view>
<view class="sync-actions-row">
<view class="sync-action sync-action--primary" @tap="handleSync"><text class="sync-action-text">上传数据</text></view>
<view class="sync-action sync-action--danger" @tap="handleDisconnect"><text class="sync-action-text">断开连接</text></view>
</view>
</view>
</template>
<!-- 完成 -->
<template v-if="pageState === 'done'">
<view class="sync-section">
<view class="sync-result-card">
<text class="sync-result-icon">V</text>
<text class="sync-result-title">同步完成</text>
<text class="sync-result-count">成功上传 {{ syncCount }} 条数据</text>
</view>
<view class="sync-action" @tap="handleDone">
<text class="sync-action-text">{{ returnTo === 'input' ? '返回录入' : '完成' }}</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { useAuthStore } from '@/stores/auth'
import type { BLEDevice, NormalizedReading } from '@/services/ble'
import { useElderClass } from '@/composables/useElderClass'
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error'
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const pageState = ref<PageState>('idle')
const devices = ref<BLEDevice[]>([])
const selectedDevice = ref<BLEDevice | null>(null)
const liveReadings = ref<NormalizedReading[]>([])
const syncCount = ref(0)
const errorMsg = ref('')
const lastSyncAt = ref<number | null>(null)
const pendingCount = ref(0)
let returnTo = ''
const handleScan = () => {
pageState.value = 'scanning'; devices.value = []; errorMsg.value = ''
// BLE 扫描需要完整 BLE 适配器实现,此处预留
setTimeout(() => {
if (devices.value.length === 0) errorMsg.value = '未发现支持的设备,请确认设备已开启蓝牙并靠近手机'
pageState.value = 'idle'
}, 3000)
}
const handleConnect = (_device: BLEDevice) => {
selectedDevice.value = _device; pageState.value = 'connecting'; errorMsg.value = ''
setTimeout(() => { pageState.value = 'connected' }, 2000)
}
const handleSync = () => {
if (!authStore.currentPatient || !selectedDevice.value) return
pageState.value = 'syncing'; errorMsg.value = ''
setTimeout(() => {
syncCount.value = liveReadings.value.length || 1
lastSyncAt.value = Date.now()
pageState.value = 'done'
if (returnTo === 'input' && liveReadings.value.length > 0) {
const mapped: Record<string, number> = {}
for (const r of liveReadings.value) {
if (r.device_type === 'blood_pressure') {
if (r.metric === 'systolic' && typeof r.values.value === 'number') mapped.systolic = r.values.value
if (r.metric === 'diastolic' && typeof r.values.value === 'number') mapped.diastolic = r.values.value
} else if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') {
mapped.blood_sugar = r.values.blood_glucose as number
} else if (r.device_type === 'heart_rate' && typeof r.values.heart_rate === 'number') {
mapped.heart_rate = r.values.heart_rate as number
}
}
if (Object.keys(mapped).length > 0) uni.setStorageSync('device_sync_result', JSON.stringify(mapped))
}
}, 2000)
}
const handleDisconnect = () => {
pageState.value = 'idle'; selectedDevice.value = null; liveReadings.value = []; syncCount.value = 0; errorMsg.value = ''
}
const handleDone = () => {
handleDisconnect()
if (returnTo === 'input') uni.navigateBack()
}
onLoad((query) => { returnTo = query?.returnTo || '' })
onShow(() => { /* BLE manager lifecycle placeholder */ })
</script>
<style lang="scss" scoped>
.device-sync-page { min-height: 100vh; background: $bg; }
.sync-header { padding: 16px 24px; background: $card; }
.sync-header-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
.sync-error { margin: 12px 24px; padding: 10px 16px; background: rgba(255,77,79,0.08); border-radius: $r; }
.sync-error-text { font-size: var(--tk-font-cap); color: $dan; }
.sync-loading { @include flex-center; padding: 80px 0; }
.sync-loading-text { font-size: var(--tk-font-body); color: $tx3; }
.sync-section { padding: 24px; }
.sync-hero { @include flex-center; flex-direction: column; padding: 40px 0; }
.sync-hero-icon { font-size: var(--tk-font-hero); font-weight: 700; color: $pri; }
.sync-hero-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; margin-top: 8px; }
.sync-hero-desc { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; text-align: center; }
.sync-status-info { display: flex; gap: 16px; justify-content: center; margin-bottom: 20px; }
.sync-status-time, .sync-status-pending { font-size: var(--tk-font-cap); color: $tx3; }
.sync-action { height: 48px; background: $pri; border-radius: $r; @include flex-center; margin-bottom: 12px; }
.sync-action--primary { background: $pri; }
.sync-action--danger { background: $dan; }
.sync-action-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
.sync-device-list { margin-top: 20px; }
.sync-section-title { font-size: var(--tk-font-cap); font-weight: 500; color: $tx2; margin-bottom: 12px; display: block; }
.sync-device-item { @include card; display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.sync-device-info { flex: 1; }
.sync-device-name { font-size: var(--tk-font-body); color: $tx; display: block; }
.sync-device-adapter { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 2px; }
.sync-device-rssi { font-size: var(--tk-font-cap); color: $tx3; }
.sync-status-card { @include card; display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
.sync-status-dot { width: 10px; height: 10px; border-radius: 50%; background: $acc; }
.sync-status-text { font-size: var(--tk-font-body); color: $tx; }
.sync-readings-panel { @include card; margin-bottom: 20px; }
.sync-reading-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
.sync-reading-type { font-size: var(--tk-font-cap); color: $tx2; }
.sync-reading-value { font-size: var(--tk-font-body); color: $tx; font-weight: 500; }
.sync-readings-count { font-size: var(--tk-font-cap); color: $tx3; margin-top: 8px; display: block; text-align: center; }
.sync-actions-row { display: flex; gap: 12px; }
.sync-actions-row .sync-action { flex: 1; }
.sync-result-card { @include card; @include flex-center; flex-direction: column; padding: 40px 24px; margin-bottom: 20px; }
.sync-result-icon { font-size: var(--tk-font-hero); font-weight: 700; color: $acc; }
.sync-result-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; margin-top: 8px; }
.sync-result-count { font-size: var(--tk-font-body); color: $tx2; margin-top: 4px; }
</style>

View File

@@ -0,0 +1,448 @@
<template>
<Loading v-if="pageLoading" text="加载中..." />
<scroll-view v-else scroll-y class="page-scroll">
<view :class="['page-content', elderClass]">
<!-- Tab 筛选 -->
<scroll-view scroll-x class="tab-bar">
<view
v-for="tab in FILTER_TABS"
:key="tab.key"
:class="['tab-chip', { 'tab-chip--active': activeFilter === tab.key }]"
@tap="handleFilterChange(tab.key)"
>
<text class="tab-chip__text">{{ tab.label }}</text>
</view>
</scroll-view>
<!-- 我的统计 -->
<view v-if="stats" class="section">
<text class="section-title">我的统计</text>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-item__value">{{ stats.pending }}</text>
<text class="stat-item__label">待处理</text>
</view>
<view class="stat-item stat-item--warn">
<text class="stat-item__value">{{ stats.overdue }}</text>
<text class="stat-item__label">紧急事项</text>
</view>
<view class="stat-item stat-item--success">
<text class="stat-item__value">{{ stats.completed_today }}</text>
<text class="stat-item__label">今日完成</text>
</view>
</view>
</view>
<!-- 团队概览 -->
<view v-if="team" class="section">
<text class="section-title">团队概览</text>
<view class="team-row">
<text class="team-row__label">团队待处理</text>
<text class="team-row__value">{{ team.total_pending }}</text>
</view>
<view class="team-row">
<text class="team-row__label">平均响应时间</text>
<text class="team-row__value">{{ formatResponseTime(team.avg_response_time) }}</text>
</view>
<view v-if="team.members.length > 0" class="team-members">
<view
v-for="member in team.members"
:key="member.user_id"
class="member-item"
>
<text class="member-item__name">{{ member.user_name }}</text>
<text class="member-item__role">{{ member.role }}</text>
<text
:class="['member-item__tasks', member.active_tasks > 0 ? 'member-item__tasks--active' : '']"
>
{{ member.active_tasks }}
</text>
</view>
</view>
</view>
<!-- 我的患者 -->
<view class="section">
<text class="section-title">我的患者</text>
<EmptyState v-if="patients.length === 0" icon="👥" title="暂无患者" />
<view v-else class="patient-cards">
<view
v-for="p in filteredPatients"
:key="p.patient_id"
class="patient-card"
@tap="goPatientDetail(p.patient_id)"
>
<view class="patient-card__header">
<text class="patient-card__name">{{ p.patient_name }}</text>
<text v-if="p.bed_number" class="patient-card__bed">
{{ p.bed_number }}
</text>
</view>
<view v-if="p.primary_diagnosis" class="patient-card__diagnosis">
<text class="patient-card__diagnosis-text">{{ p.primary_diagnosis }}</text>
</view>
<view class="patient-card__footer">
<view v-if="p.open_action_count > 0" class="patient-card__actions">
<text class="patient-card__actions-text">
{{ p.open_action_count }} 项待办
</text>
</view>
<text v-if="p.care_plan_status" class="patient-card__plan">
{{ p.care_plan_status }}
</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import {
getWorkbenchStats,
getTeamOverview,
getMyPatients,
} from '@/services/doctor/actionInbox'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
interface WorkbenchStats {
pending: number
in_progress: number
completed_today: number
overdue: number
}
interface TeamMember {
user_id: string
user_name: string
role: string
active_tasks: number
}
interface TeamOverviewData {
team_name: string
members: TeamMember[]
total_pending: number
avg_response_time: number
}
interface NursePatientSummary {
patient_id: string
patient_name: string
bed_number?: string
primary_diagnosis?: string
care_plan_status?: string
open_action_count: number
}
const FILTER_TABS = [
{ key: '', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'urgent', label: '紧急' },
] as const
const { elderClass } = useElderClass()
const stats = ref<WorkbenchStats | null>(null)
const team = ref<TeamOverviewData | null>(null)
const patients = ref<NursePatientSummary[]>([])
const activeFilter = ref('')
const pageLoading = ref(true)
const filteredPatients = computed(() => {
const list = patients.value
if (activeFilter.value === 'pending') {
return list.filter((p) => p.open_action_count > 0)
}
if (activeFilter.value === 'urgent') {
return list.filter((p) => p.open_action_count >= 3)
}
return list
})
function formatResponseTime(minutes: number): string {
if (minutes < 60) return `${Math.round(minutes)} 分钟`
const hours = Math.floor(minutes / 60)
const mins = Math.round(minutes % 60)
return mins > 0 ? `${hours} 小时 ${mins} 分钟` : `${hours} 小时`
}
async function loadData() {
pageLoading.value = true
try {
const [s, t, p] = await Promise.all([
getWorkbenchStats(true),
getTeamOverview(),
getMyPatients(),
])
stats.value = s
team.value = t as TeamOverviewData
patients.value = p || []
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
}
}
function handleFilterChange(key: string) {
activeFilter.value = key
}
function goPatientDetail(patientId: string) {
uni.navigateTo({
url: `/pages-sub/doctor/patients/detail/index?id=${patientId}`,
})
}
onShow(() => {
loadData()
})
onPullDownRefresh(() => {
loadData().finally(() => {
uni.stopPullDownRefresh()
})
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
// ── 标签栏 ──
.tab-bar {
white-space: nowrap;
margin-bottom: 24px;
width: 100%;
}
.tab-chip {
display: inline-flex;
align-items: center;
padding: 10px 28px;
min-height: $touch-min;
border-radius: $r-pill;
background: $card;
box-shadow: $shadow-sm;
margin-right: 12px;
&--active {
background: $pri;
.tab-chip__text {
color: $card;
}
}
&__text {
font-size: var(--tk-font-body);
color: $tx2;
font-weight: 500;
}
}
// ── 通用区块 ──
.section {
background: $card;
border-radius: $r-lg;
padding: 28px;
margin-bottom: 20px;
box-shadow: $shadow-sm;
}
.section-title {
@include section-title;
}
// ── 统计网格 ──
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
.stat-item {
text-align: center;
background: $pri-l;
border-radius: $r;
padding: 20px 12px;
&--warn {
background: $wrn-l;
}
&--success {
background: $acc-l;
}
&__value {
@include serif-number;
font-size: var(--tk-font-num-lg);
font-weight: 700;
color: $pri;
display: block;
margin-bottom: 6px;
.stat-item--warn & {
color: $wrn;
}
.stat-item--success & {
color: $acc;
}
}
&__label {
font-size: var(--tk-font-body-sm);
color: $tx2;
}
}
// ── 团队概览 ──
.team-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid $bd-l;
&:last-of-type {
border-bottom: none;
}
&__label {
font-size: var(--tk-font-body);
color: $tx2;
}
&__value {
@include serif-number;
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
}
}
.team-members {
margin-top: 16px;
border-top: 1px solid $bd-l;
padding-top: 16px;
}
.member-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
&__name {
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
flex: 1;
}
&__role {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-right: 16px;
}
&__tasks {
font-size: var(--tk-font-body-sm);
color: $tx3;
&--active {
color: $wrn;
font-weight: 600;
}
}
}
// ── 患者卡片 ──
.patient-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.patient-card {
background: $bg;
border-radius: $r;
padding: 20px;
&:active {
background: $bd-l;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__name {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
}
&__bed {
font-size: var(--tk-font-body-sm);
color: $tx2;
background: $card;
padding: 4px 12px;
border-radius: $r-xs;
}
&__diagnosis {
margin-bottom: 10px;
}
&__diagnosis-text {
font-size: var(--tk-font-body);
color: $tx2;
}
&__footer {
display: flex;
justify-content: space-between;
align-items: center;
}
&__actions {
background: $wrn-l;
padding: 4px 12px;
border-radius: $r-xs;
}
&__actions-text {
font-size: var(--tk-font-body-sm);
color: $wrn;
font-weight: 500;
}
&__plan {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
}
</style>

View File

@@ -0,0 +1,383 @@
<template>
<Loading v-if="pageLoading" text="加载中..." />
<view v-else-if="!alert" :class="['error-wrap', elderClass]">
<text class="error-text">告警信息加载失败</text>
</view>
<scroll-view v-else scroll-y class="page-scroll">
<view :class="['page-content', elderClass]">
<!-- 告警标题 + 严重程度 -->
<view class="section">
<view class="alert-header">
<text class="alert-header__title">{{ alert.title }}</text>
<view
class="alert-header__severity"
:style="getSeverityStyle(alert.severity)"
>
<text class="alert-header__severity-text">
{{ getSeverityLabel(alert.severity) }}
</text>
</view>
</view>
<view class="status-row">
<text class="status-row__label">状态</text>
<view
class="status-row__badge"
:style="getStatusInlineStyle(alert.status)"
>
<text class="status-row__badge-text">
{{ getStatusLabel(alert.status) }}
</text>
</view>
</view>
</view>
<!-- 患者与指标信息 -->
<view class="section">
<text class="section-title">告警详情</text>
<view class="info-grid">
<view v-if="alert.detail?.patient_name" class="info-item">
<text class="info-label">患者</text>
<text class="info-value">{{ alert.detail.patient_name }}</text>
</view>
<view v-if="alert.detail?.indicator_name" class="info-item">
<text class="info-label">指标</text>
<text class="info-value">{{ alert.detail.indicator_name }}</text>
</view>
<view v-if="alert.detail?.threshold_value != null" class="info-item">
<text class="info-label">阈值</text>
<text class="info-value">{{ alert.detail.threshold_value }}</text>
</view>
<view v-if="alert.detail?.actual_value != null" class="info-item">
<text class="info-label">实际值</text>
<text class="info-value info-value--warn">
{{ alert.detail.actual_value }}
</text>
</view>
</view>
</view>
<!-- 时间信息 -->
<view class="section">
<text class="section-title">时间线</text>
<view class="timeline">
<view class="timeline-item">
<text class="timeline-item__label">创建时间</text>
<text class="timeline-item__value">
{{ formatDate(alert.created_at, 'YYYY-MM-DD HH:mm') }}
</text>
</view>
<view v-if="alert.acknowledged_at" class="timeline-item">
<text class="timeline-item__label">确认时间</text>
<text class="timeline-item__value">
{{ formatDate(alert.acknowledged_at, 'YYYY-MM-DD HH:mm') }}
</text>
</view>
<view v-if="alert.resolved_at" class="timeline-item">
<text class="timeline-item__label">解决时间</text>
<text class="timeline-item__value">
{{ formatDate(alert.resolved_at, 'YYYY-MM-DD HH:mm') }}
</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view v-if="hasActions" class="section">
<text class="section-title">操作</text>
<view class="action-buttons">
<button
v-if="alert.status === 'pending'"
class="btn btn--primary"
:loading="actionLoading === 'acknowledge'"
@tap="handleAcknowledge"
>
确认告警
</button>
<button
v-if="alert.status === 'pending'"
class="btn btn--outline"
:loading="actionLoading === 'dismiss'"
@tap="handleDismiss"
>
忽略
</button>
<button
v-if="alert.status === 'acknowledged'"
class="btn btn--primary"
:loading="actionLoading === 'resolve'"
@tap="handleResolve"
>
标记已解决
</button>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import {
listAlerts,
acknowledgeAlert,
dismissAlert,
resolveAlert,
getCachedAlert,
updateCachedAlert,
} from '@/services/doctor/alerts'
import type { Alert } from '@/services/doctor/alerts'
import { getStatusInlineStyle, getStatusLabel, getSeverityStyle, getSeverityLabel } from '@/utils/statusTag'
import { formatDate } from '@/utils/date'
import Loading from '@/components/Loading.vue'
const { elderClass } = useElderClass()
const alert = ref<Alert | null>(null)
const pageLoading = ref(true)
const actionLoading = ref<string | null>(null)
const alertId = ref('')
const hasActions = computed(() => {
const s = alert.value?.status
return s === 'pending' || s === 'acknowledged'
})
async function loadAlert() {
if (!alertId.value) return
pageLoading.value = true
try {
// 优先从列表页缓存读取
const cached = getCachedAlert(alertId.value)
if (cached) {
alert.value = cached
return
}
// 缓存未命中时回退到列表查询
const res = await listAlerts({ page: 1, page_size: 100 })
const found = (res.data || []).find((a) => a.id === alertId.value)
alert.value = found || null
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
}
}
async function handleAcknowledge() {
if (!alert.value || actionLoading.value) return
actionLoading.value = 'acknowledge'
try {
const updated = await acknowledgeAlert(alert.value.id, alert.value.version)
alert.value = { ...alert.value, ...updated }
updateCachedAlert(alert.value)
uni.showToast({ title: '已确认', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
actionLoading.value = null
}
}
async function handleDismiss() {
if (!alert.value || actionLoading.value) return
actionLoading.value = 'dismiss'
try {
const updated = await dismissAlert(alert.value.id, alert.value.version)
alert.value = { ...alert.value, ...updated }
updateCachedAlert(alert.value)
uni.showToast({ title: '已忽略', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
actionLoading.value = null
}
}
async function handleResolve() {
if (!alert.value || actionLoading.value) return
actionLoading.value = 'resolve'
try {
const updated = await resolveAlert(alert.value.id, alert.value.version)
alert.value = { ...alert.value, ...updated }
updateCachedAlert(alert.value)
uni.showToast({ title: '已解决', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
actionLoading.value = null
}
}
onLoad((query) => {
alertId.value = query?.id || ''
loadAlert()
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
.error-wrap {
@include flex-center;
height: 100vh;
background: $bg;
}
.error-text {
font-size: var(--tk-font-body-lg);
color: $tx3;
}
// ── 通用区块 ──
.section {
background: $card;
border-radius: $r-lg;
padding: 28px;
margin-bottom: 20px;
box-shadow: $shadow-sm;
}
.section-title {
@include section-title;
}
// ── 告警头部 ──
.alert-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
&__title {
font-size: var(--tk-font-h1);
font-weight: 700;
color: $tx;
flex: 1;
margin-right: 16px;
line-height: 1.4;
}
&__severity {
@include status-inline;
flex-shrink: 0;
}
&__severity-text {
font-size: var(--tk-font-body);
font-weight: 600;
}
}
// ── 状态行 ──
.status-row {
display: flex;
align-items: center;
gap: 12px;
&__label {
font-size: var(--tk-font-body);
color: $tx3;
}
&__badge {
@include status-inline;
}
&__badge-text {
font-size: var(--tk-font-body-sm);
}
}
// ── 信息网格 ──
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.info-label {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body-lg);
color: $tx;
font-weight: 500;
&--warn {
color: $dan;
}
}
// ── 时间线 ──
.timeline {
display: flex;
flex-direction: column;
gap: 16px;
}
.timeline-item {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&__label {
font-size: var(--tk-font-body);
color: $tx2;
}
&__value {
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
}
// ── 操作按钮 ──
.action-buttons {
display: flex;
gap: 16px;
}
.btn {
flex: 1;
height: $btn-primary-h;
border-radius: $r;
font-size: var(--tk-font-body-lg);
font-weight: 600;
border: none;
@include touch-target;
&--primary {
@include btn-primary;
}
&--outline {
@include btn-outline;
}
}
</style>

View File

@@ -0,0 +1,317 @@
<template>
<Loading v-if="pageLoading && alerts.length === 0" text="加载中..." />
<scroll-view
v-else
scroll-y
class="page-scroll"
@scrolltolower="onLoadMore"
>
<view :class="['page-content', elderClass]">
<!-- 严重程度筛选 -->
<scroll-view scroll-x class="tab-bar">
<view
v-for="tab in SEVERITY_TABS"
:key="tab.key"
:class="['tab-chip', { 'tab-chip--active': activeSeverity === tab.key }]"
@tap="handleSeverityChange(tab.key)"
>
<text class="tab-chip__text">{{ tab.label }}</text>
</view>
</scroll-view>
<!-- 列表统计 -->
<view v-if="filteredAlerts.length > 0" class="list-meta">
<text class="list-meta__text"> {{ filteredAlerts.length }} 条告警</text>
</view>
<!-- 告警卡片 -->
<EmptyState v-if="!pageLoading && filteredAlerts.length === 0" icon="🔔" title="暂无告警" />
<view v-else class="alert-cards">
<view
v-for="alert in filteredAlerts"
:key="alert.id"
class="alert-card"
@tap="goDetail(alert.id)"
>
<view class="alert-card__header">
<text class="alert-card__title">{{ alert.title }}</text>
<view
class="alert-card__severity"
:style="getSeverityStyle(alert.severity)"
>
<text class="alert-card__severity-text">
{{ getSeverityLabel(alert.severity) }}
</text>
</view>
</view>
<view class="alert-card__body">
<text v-if="alert.detail?.patient_name" class="alert-card__patient">
{{ alert.detail.patient_name }}
</text>
<text v-if="alert.detail?.indicator_name" class="alert-card__indicator">
{{ alert.detail.indicator_name }}
</text>
</view>
<view class="alert-card__footer">
<text class="alert-card__time">{{ formatAlertTime(alert.created_at) }}</text>
<view
class="alert-card__status"
:style="getStatusInlineStyle(alert.status)"
>
<text class="alert-card__status-text">{{ getStatusLabel(alert.status) }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="!loadingMore && alerts.length >= total && total > 0" class="load-hint-wrap">
<text class="load-hint">没有更多了</text>
</view>
<Loading v-if="loadingMore" text="加载中..." />
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { listAlerts, cacheAlerts } from '@/services/doctor/alerts'
import type { Alert } from '@/services/doctor/alerts'
import { getStatusInlineStyle, getStatusLabel, getSeverityStyle, getSeverityLabel } from '@/utils/statusTag'
import { formatDate, getRelativeTime } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
const SEVERITY_TABS = [
{ key: '', label: '全部' },
{ key: 'info', label: getSeverityLabel('info') },
{ key: 'warning', label: getSeverityLabel('warning') },
{ key: 'critical', label: getSeverityLabel('critical') },
{ key: 'urgent', label: getSeverityLabel('urgent') },
] as const
const { elderClass } = useElderClass()
const alerts = ref<Alert[]>([])
const activeSeverity = ref('')
const pageLoading = ref(true)
const loadingMore = ref(false)
const isLoading = ref(false)
const total = ref(0)
const page = ref(1)
const filteredAlerts = computed(() => {
if (!activeSeverity.value) return alerts.value
return alerts.value.filter((a) => a.severity === activeSeverity.value)
})
function formatAlertTime(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 1) return getRelativeTime(dateStr)
if (diffDays < 7) return `${diffDays}天前`
return formatDate(dateStr, 'MM-DD HH:mm')
}
async function loadAlerts(pageNum: number, isRefresh = false) {
if (isLoading.value) return
isLoading.value = true
if (isRefresh) {
pageLoading.value = true
} else {
loadingMore.value = true
}
try {
const res = await listAlerts({
page: pageNum,
page_size: 20,
})
const list = res.data || []
if (isRefresh) {
alerts.value = list
} else {
alerts.value = [...alerts.value, ...list]
}
cacheAlerts(list)
total.value = res.total || 0
page.value = pageNum
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
loadingMore.value = false
isLoading.value = false
}
}
function handleSeverityChange(key: string) {
activeSeverity.value = key
}
function goDetail(id: string) {
uni.navigateTo({ url: `/pages-sub/doctor/alerts/detail/index?id=${id}` })
}
function onLoadMore() {
if (!isLoading.value && alerts.value.length < total.value) {
loadAlerts(page.value + 1)
}
}
onShow(() => {
loadAlerts(1, true)
})
onPullDownRefresh(() => {
loadAlerts(1, true).finally(() => {
uni.stopPullDownRefresh()
})
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
// ── 标签栏 ──
.tab-bar {
white-space: nowrap;
margin-bottom: 24px;
width: 100%;
}
.tab-chip {
display: inline-flex;
align-items: center;
padding: 10px 28px;
min-height: $touch-min;
border-radius: $r-pill;
background: $card;
box-shadow: $shadow-sm;
margin-right: 12px;
&--active {
background: $pri;
.tab-chip__text {
color: $card;
}
}
&__text {
font-size: var(--tk-font-body);
color: $tx2;
font-weight: 500;
}
}
// ── 列表统计 ──
.list-meta {
margin-bottom: 16px;
&__text {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
}
// ── 告警卡片 ──
.alert-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.alert-card {
@include card;
position: relative;
&:active {
background: $bd-l;
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
&__title {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
flex: 1;
margin-right: 16px;
line-height: 1.4;
}
&__severity {
@include status-inline;
flex-shrink: 0;
}
&__severity-text {
font-size: var(--tk-font-body-sm);
font-weight: 600;
}
&__body {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
&__patient {
font-size: var(--tk-font-body);
color: $tx2;
font-weight: 500;
}
&__indicator {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
&__footer {
display: flex;
justify-content: space-between;
align-items: center;
}
&__time {
font-size: var(--tk-font-cap);
color: $tx3;
}
&__status {
@include status-inline;
}
&__status-text {
font-size: var(--tk-font-body-sm);
}
}
// ── 加载提示 ──
.load-hint-wrap {
text-align: center;
padding: 20px;
}
.load-hint {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<view :class="['chat-page', elderClass]">
<!-- Header -->
<view class="chat-header">
<text class="chat-header__title">{{ session?.subject || '在线咨询' }}</text>
<text v-if="isOpen" class="chat-header__close" @tap="handleClose">关闭会话</text>
</view>
<!-- Loading -->
<Loading v-if="loading" text="加载中..." />
<!-- Error -->
<ErrorState v-else-if="error" :text="error" @retry="loadData" />
<!-- 消息列表 -->
<scroll-view v-else
scroll-y
class="chat-messages"
:scroll-into-view="scrollInto"
scroll-with-animation
>
<template v-if="messages.length > 0">
<view
v-for="(msg, idx) in messages"
:key="msg.id"
:id="`msg-${idx + 1}`"
:class="['msg-row', msg.sender_role === 'doctor' ? 'msg-row--self' : '']"
>
<view :class="['msg-bubble', msg.sender_role === 'doctor' ? 'msg-bubble--self' : 'msg-bubble--other']">
<text class="msg-text">{{ msg.content }}</text>
<text class="msg-time">{{ formatTime(msg.created_at) }}</text>
</view>
</view>
</template>
<view v-else class="chat-empty">
<text class="chat-empty__text">暂无消息发送第一条消息开始对话</text>
</view>
</scroll-view>
<!-- 输入栏会话进行中 -->
<view v-if="!loading && !error && isOpen" class="chat-input-bar">
<input
class="chat-input"
placeholder="输入消息..."
:value="inputText"
@input="(e: any) => inputText = e.detail.value"
confirm-type="send"
@confirm="handleSend"
:disabled="sending"
/>
<view
:class="['chat-send-btn', (!inputText.trim() || sending) ? 'chat-send-btn--disabled' : '']"
@tap="handleSend"
>
<text class="chat-send-btn__text">{{ sending ? '...' : '发送' }}</text>
</view>
</view>
<!-- 已关闭提示 -->
<view v-else-if="!loading && !error" class="chat-closed-bar">
<text class="chat-closed-bar__text">会话已关闭</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor'
import { useElderClass } from '@/composables/useElderClass'
import Loading from '@/components/Loading.vue'
import ErrorState from '@/components/ErrorState.vue'
const { elderClass } = useElderClass()
const sessionId = ref('')
const session = ref<doctorApi.ConsultationSession | null>(null)
const messages = ref<doctorApi.ConsultationMessage[]>([])
const inputText = ref('')
const sending = ref(false)
const loading = ref(true)
const error = ref('')
const scrollInto = ref('')
const isOpen = computed(() => session.value?.status !== 'closed')
function formatTime(dateStr: string): string {
const d = new Date(dateStr)
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
}
function scrollToBottom(count: number) {
nextTick(() => {
scrollInto.value = `msg-${count}`
})
}
async function loadData() {
if (!sessionId.value) return
loading.value = true
error.value = ''
try {
const [s, m] = await Promise.all([
doctorApi.getSession(sessionId.value),
doctorApi.listMessages(sessionId.value, { page: 1, page_size: 50 }),
])
session.value = s
messages.value = m.data || []
scrollToBottom(messages.value.length)
} catch {
error.value = '加载失败,请重试'
} finally {
loading.value = false
}
}
async function markRead() {
if (!sessionId.value) return
try {
await doctorApi.markSessionRead(sessionId.value)
} catch {
// 静默忽略
}
}
async function handleSend() {
const text = inputText.value.trim()
if (!text || sending.value) return
sending.value = true
inputText.value = ''
try {
const msg = await doctorApi.sendMessage(sessionId.value, text)
messages.value = [...messages.value, msg]
scrollToBottom(messages.value.length)
} catch {
uni.showToast({ title: '发送失败', icon: 'none' })
inputText.value = text
} finally {
sending.value = false
}
}
function handleClose() {
uni.showModal({
title: '确认关闭',
content: '关闭后将无法继续对话,确认关闭?',
success: async (res) => {
if (res.confirm) {
try {
await doctorApi.closeSession(sessionId.value, session.value?.version ?? 0)
uni.showToast({ title: '已关闭', icon: 'success' })
loadData()
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
},
})
}
onLoad((query) => {
sessionId.value = query?.id || ''
if (sessionId.value) {
loadData()
markRead()
}
})
</script>
<style lang="scss" scoped>
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
background: $bg;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
background: $card;
border-bottom: 1px solid $bd;
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h1);
font-weight: 600;
color: $tx;
}
&__close {
font-size: var(--tk-font-body);
color: $dan;
padding: 8px 16px;
}
}
.chat-messages {
flex: 1;
padding: 24px;
}
.msg-row {
display: flex;
margin-bottom: 20px;
&--self {
justify-content: flex-end;
}
}
.msg-bubble {
max-width: 70%;
padding: 20px 24px;
border-radius: $r-lg;
position: relative;
&--other {
background: $card;
border-top-left-radius: 4px;
}
&--self {
background: $pri;
border-top-right-radius: 4px;
}
}
.msg-text {
font-size: var(--tk-font-body-lg);
color: $tx;
display: block;
line-height: 1.6;
word-break: break-all;
.msg-bubble--self & {
color: $card;
}
}
.msg-time {
@include serif-number;
font-size: var(--tk-font-body-sm);
color: $tx3;
display: block;
margin-top: 8px;
text-align: right;
.msg-bubble--self & {
color: rgba(255, 255, 255, 0.7);
}
}
.chat-empty {
text-align: center;
padding: 120px 32px;
&__text {
font-size: var(--tk-font-body);
color: $tx3;
}
}
.chat-input-bar {
display: flex;
align-items: center;
padding: 16px 24px;
background: $card;
border-top: 1px solid $bd;
@include safe-bottom;
}
.chat-input {
flex: 1;
background: $bd-l;
border-radius: $r;
padding: 16px 20px;
font-size: var(--tk-font-body-lg);
margin-right: 16px;
}
.chat-send-btn {
background: $pri;
border-radius: $r;
padding: 16px 28px;
flex-shrink: 0;
&--disabled {
opacity: 0.5;
}
&__text {
font-size: var(--tk-font-body-lg);
color: $card;
font-weight: 500;
}
}
.chat-closed-bar {
padding: 24px;
text-align: center;
background: $card;
border-top: 1px solid $bd;
&__text {
font-size: var(--tk-font-body);
color: $tx3;
}
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<scroll-view scroll-y :class="['consultation-page', elderClass]">
<!-- Tab 筛选 -->
<view class="tabs">
<view
v-for="t in TABS"
:key="t.key"
:class="['tab', activeTab === t.key ? 'tab--active' : '']"
@tap="handleTabChange(t.key)"
>
<text>{{ t.label }}</text>
</view>
</view>
<!-- 加载态 -->
<Loading v-if="loading && sessions.length === 0" text="加载中..." />
<!-- 空态 -->
<EmptyState v-else-if="sessions.length === 0" icon="💬" title="暂无咨询会话" />
<!-- 会话列表 -->
<view v-else class="session-list">
<view
v-for="s in sessions"
:key="s.id"
class="session-card"
@tap="goDetail(s.id)"
>
<view class="session-card__top">
<text class="session-card__subject">{{ s.subject || '在线咨询' }}</text>
<view class="session-card__status" :style="getStatusInlineStyle(s.status)">
<text class="session-card__status-text">{{ getStatusLabel(s.status) }}</text>
</view>
</view>
<view class="session-card__info">
<text class="session-card__type">
{{ s.consultation_type === 'text' ? '图文' : s.consultation_type === 'video' ? '视频' : '咨询' }}
</text>
<text class="session-card__time">{{ formatTime(s.last_message_at) }}</text>
</view>
<text v-if="s.last_message" class="session-card__preview">{{ s.last_message }}</text>
<view v-if="(s.unread_count_doctor ?? 0) > 0" class="session-card__badge">
<text class="session-card__badge-text">{{ s.unread_count_doctor }}</text>
</view>
</view>
</view>
<!-- 分页 -->
<view v-if="total > 20" class="pagination">
<text
:class="['pagination__btn', page <= 1 ? 'disabled' : '']"
@tap="page > 1 && (page = page - 1)"
>上一页</text>
<text class="pagination__info">{{ page }} / {{ totalPages }}</text>
<text
:class="['pagination__btn', page >= totalPages ? 'disabled' : '']"
@tap="page < totalPages && (page = page + 1)"
>下一页</text>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor'
import { useElderClass } from '@/composables/useElderClass'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { formatDate } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
const TABS = [
{ key: '', label: '全部' },
{ key: 'active', label: '进行中' },
{ key: 'waiting', label: '等待中' },
{ key: 'closed', label: '已关闭' },
] as const
const { elderClass } = useElderClass()
const sessions = ref<doctorApi.ConsultationSession[]>([])
const activeTab = ref('')
const loading = ref(true)
const total = ref(0)
const page = ref(1)
const totalPages = computed(() => Math.ceil(total.value / 20))
function goDetail(id: string) {
uni.navigateTo({ url: `/pages-sub/doctor/consultation/detail/index?id=${id}` })
}
function handleTabChange(key: string) {
activeTab.value = key
page.value = 1
}
function formatTime(dateStr?: string | null): string {
if (!dateStr) return ''
return formatDate(dateStr, 'MM-DD HH:mm')
}
async function loadSessions() {
loading.value = true
try {
const res = await doctorApi.listSessions({
page: page.value,
page_size: 20,
status: activeTab.value || undefined,
})
sessions.value = res.data || []
total.value = res.total || 0
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
watch([page, activeTab], () => { loadSessions() })
onShow(() => {
loadSessions()
})
</script>
<style lang="scss" scoped>
.consultation-page {
min-height: 100vh;
background: $bg;
}
.tabs {
display: flex;
background: $card;
padding: 0 16px;
border-bottom: 1px solid $bd;
}
.tab {
flex: 1;
text-align: center;
padding: 24px 0;
font-size: var(--tk-font-body-lg);
color: $tx2;
position: relative;
&--active {
color: $pri;
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 30%;
right: 30%;
height: 4px;
background: $pri;
border-radius: $r-xs;
}
}
}
.session-list {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.session-card {
@include card;
position: relative;
&:active {
background: $bd-l;
}
&__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
&__subject {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16px;
}
&__status {
display: inline-block;
border-radius: 6px;
padding: 2px 8px;
flex-shrink: 0;
}
&__status-text {
font-size: var(--tk-font-body);
font-weight: 500;
}
&__info {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
}
&__type {
@include tag($pri-l, $pri);
}
&__time {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
&__preview {
font-size: var(--tk-font-body);
color: $tx2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__badge {
position: absolute;
top: 20px;
right: 20px;
min-width: 36px;
height: 36px;
background: $dan;
border-radius: $r-pill;
@include flex-center;
padding: 0 8px;
}
&__badge-text {
@include serif-number;
font-size: var(--tk-font-body);
color: $card;
font-weight: 600;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
padding: 24px;
&__btn {
font-size: var(--tk-font-body);
color: $pri;
padding: 12px 24px;
&.disabled {
color: $tx3;
}
}
&__info {
font-size: var(--tk-font-body-sm);
color: $tx2;
}
}
</style>

View File

@@ -0,0 +1,435 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- 患者选择 -->
<view class="section-card">
<text class="section-title">选择患者</text>
<view class="patient-search">
<input
class="search-input"
placeholder="搜索患者姓名"
:value="patientSearch"
@input="(e: any) => { patientSearch = e.detail.value; searchPatients() }"
/>
</view>
<view v-if="searchingPatient" class="loading-hint">
<text class="loading-hint__text">搜索中...</text>
</view>
<view v-else-if="patientResults.length > 0" class="patient-list">
<view
v-for="p in patientResults"
:key="p.id"
:class="['patient-item', form.patient_id === p.id ? 'patient-item--selected' : '']"
@tap="selectPatient(p)"
>
<text class="patient-item__name">{{ p.name }}</text>
<text class="patient-item__info">{{ p.gender === 'male' ? '男' : p.gender === 'female' ? '女' : '' }}</text>
<view v-if="form.patient_id === p.id" class="patient-item__check">
<text class="check-icon">&#10003;</text>
</view>
</view>
</view>
<view v-else-if="patientSearch && !searchingPatient" class="empty-hint">
<text class="empty-hint__text">未找到患者</text>
</view>
</view>
<!-- 透析信息 -->
<view class="section-card">
<text class="section-title">透析信息</text>
<view class="form-field">
<text class="form-label">透析日期 <text class="required">*</text></text>
<picker mode="date" :value="form.dialysis_date" @change="(e: any) => form.dialysis_date = e.detail.value">
<view :class="['picker-display', form.dialysis_date ? '' : 'placeholder']">
{{ form.dialysis_date || '请选择日期' }}
</view>
</picker>
</view>
<view class="form-field">
<text class="form-label">透析方式 <text class="required">*</text></text>
<picker :range="dialysisTypes" :range-key="'label'" @change="(e: any) => form.dialysis_type = dialysisTypes[e.detail.value].value">
<view :class="['picker-display', form.dialysis_type ? '' : 'placeholder']">
{{ currentDialysisTypeLabel || '请选择透析方式' }}
</view>
</picker>
</view>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">开始时间</text>
<picker mode="time" :value="form.start_time" @change="(e: any) => form.start_time = e.detail.value">
<view :class="['picker-display', form.start_time ? '' : 'placeholder']">
{{ form.start_time || '选择时间' }}
</view>
</picker>
</view>
<view class="form-field form-field--half">
<text class="form-label">结束时间</text>
<picker mode="time" :value="form.end_time" @change="(e: any) => form.end_time = e.detail.value">
<view :class="['picker-display', form.end_time ? '' : 'placeholder']">
{{ form.end_time || '选择时间' }}
</view>
</picker>
</view>
</view>
</view>
<!-- 体征输入 -->
<view class="section-card">
<text class="section-title">体征数据</text>
<view class="form-field">
<text class="form-label">透前体重 (kg)</text>
<input
type="digit"
class="form-input"
placeholder="请输入"
:value="form.pre_weight ?? ''"
@input="(e: any) => updateNumericField('pre_weight', e.detail.value)"
/>
</view>
<view class="form-field">
<text class="form-label">干体重 (kg)</text>
<input
type="digit"
class="form-input"
placeholder="请输入"
:value="form.dry_weight ?? ''"
@input="(e: any) => updateNumericField('dry_weight', e.detail.value)"
/>
</view>
<view class="form-field">
<text class="form-label">超滤目标 (ml)</text>
<input
type="digit"
class="form-input"
placeholder="请输入"
:value="form.ultrafiltration_volume ?? ''"
@input="(e: any) => updateNumericField('ultrafiltration_volume', e.detail.value)"
/>
</view>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">透前收缩压</text>
<input
type="digit"
class="form-input"
placeholder="mmHg"
:value="form.pre_bp_systolic ?? ''"
@input="(e: any) => updateNumericField('pre_bp_systolic', e.detail.value)"
/>
</view>
<view class="form-field form-field--half">
<text class="form-label">透前舒张压</text>
<input
type="digit"
class="form-input"
placeholder="mmHg"
:value="form.pre_bp_diastolic ?? ''"
@input="(e: any) => updateNumericField('pre_bp_diastolic', e.detail.value)"
/>
</view>
</view>
</view>
<!-- 备注 -->
<view class="section-card">
<text class="section-title">备注</text>
<textarea
class="form-textarea"
placeholder="并发症记录或其他备注(选填)"
:value="form.complication_notes"
@input="(e: any) => form.complication_notes = e.detail.value"
:maxlength="1000"
/>
</view>
<!-- 提交 -->
<view class="submit-wrap">
<view :class="['action-btn', submitting ? 'disabled' : '']" @tap="submitting ? undefined : handleSubmit">
<text class="action-btn-text">{{ submitting ? '提交中...' : '提交记录' }}</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useElderClass } from '@/composables/useElderClass'
import { createDialysisRecord } from '@/services/doctor/dialysis'
import { listPatients } from '@/services/doctor/patient'
import type { PatientItem } from '@/services/doctor/patient'
const DIALYSIS_TYPES = [
{ label: '血液透析', value: 'hemodialysis' },
{ label: '腹膜透析', value: 'peritoneal' },
] as const
const dialysisTypes = DIALYSIS_TYPES
const { elderClass } = useElderClass()
const form = reactive({
patient_id: '',
dialysis_date: '',
dialysis_type: '',
start_time: '',
end_time: '',
pre_weight: undefined as number | undefined,
dry_weight: undefined as number | undefined,
ultrafiltration_volume: undefined as number | undefined,
pre_bp_systolic: undefined as number | undefined,
pre_bp_diastolic: undefined as number | undefined,
complication_notes: '',
})
const patientSearch = ref('')
const patientResults = ref<PatientItem[]>([])
const searchingPatient = ref(false)
const submitting = ref(false)
const currentDialysisTypeLabel = computed(() => {
const found = DIALYSIS_TYPES.find((t) => t.value === form.dialysis_type)
return found ? found.label : ''
})
let searchTimer: ReturnType<typeof setTimeout> | null = null
function searchPatients() {
if (searchTimer) clearTimeout(searchTimer)
if (!patientSearch.value.trim()) {
patientResults.value = []
return
}
searchTimer = setTimeout(async () => {
searchingPatient.value = true
try {
const res = await listPatients({ search: patientSearch.value.trim(), page_size: 10 })
patientResults.value = res.data || []
} catch {
patientResults.value = []
} finally {
searchingPatient.value = false
}
}, 300)
}
function selectPatient(p: PatientItem) {
form.patient_id = form.patient_id === p.id ? '' : p.id
}
function updateNumericField(field: keyof typeof form, raw: string) {
const val = raw.trim() === '' ? undefined : Number(raw)
;(form as any)[field] = isNaN(val as number) ? undefined : val
}
async function handleSubmit() {
if (!form.patient_id) {
uni.showToast({ title: '请选择患者', icon: 'none' })
return
}
if (!form.dialysis_date) {
uni.showToast({ title: '请选择透析日期', icon: 'none' })
return
}
if (!form.dialysis_type) {
uni.showToast({ title: '请选择透析方式', icon: 'none' })
return
}
submitting.value = true
try {
await createDialysisRecord({
patient_id: form.patient_id,
dialysis_date: form.dialysis_date,
dialysis_type: form.dialysis_type,
start_time: form.start_time || undefined,
end_time: form.end_time || undefined,
pre_weight: form.pre_weight,
dry_weight: form.dry_weight,
ultrafiltration_volume: form.ultrafiltration_volume,
pre_bp_systolic: form.pre_bp_systolic,
pre_bp_diastolic: form.pre_bp_diastolic,
complication_notes: form.complication_notes.trim() || undefined,
})
uni.showToast({ title: '创建成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 800)
} catch {
uni.showToast({ title: '创建失败', icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 160px; }
.section-card {
@include card;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
// Search
.patient-search { margin-bottom: 12px; }
.search-input {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
box-sizing: border-box;
}
.loading-hint, .empty-hint {
@include flex-center;
padding: 24px 0;
}
.loading-hint__text, .empty-hint__text {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
.patient-list {
max-height: 320px;
overflow-y: auto;
}
.patient-item {
display: flex;
align-items: center;
padding: 16px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
border-radius: $r-xs;
&:active { background: $bd-l; }
&--selected {
background: $pri-l;
}
&__name {
flex: 1;
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
&__info {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-right: 12px;
}
&__check {
width: 32px;
height: 32px;
@include flex-center;
background: $pri;
border-radius: 50%;
}
}
.check-icon {
color: $card;
font-size: var(--tk-font-body-sm);
}
// Form
.form-field {
margin-bottom: 16px;
}
.form-field--half {
flex: 1;
}
.form-row {
display: flex;
gap: 12px;
}
.form-label {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: 8px;
}
.required { color: $dan; }
.form-input {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
box-sizing: border-box;
}
.picker-display {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
display: flex;
align-items: center;
box-sizing: border-box;
}
.picker-display.placeholder {
color: $tx3;
}
.form-textarea {
width: 100%;
min-height: 120px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 12px;
font-size: var(--tk-font-body);
color: $tx;
box-sizing: border-box;
background: $card;
}
// Submit
.submit-wrap {
margin: 0 24px;
}
.action-btn {
@include btn-primary;
}
.action-btn.disabled { opacity: 0.5; }
.action-btn-text {
color: $card;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,283 @@
<template>
<Loading v-if="pageLoading" text="加载中..." />
<ErrorState v-else-if="error || !record" text="记录加载失败" :on-retry="loadData" />
<scroll-view v-else scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- 基本信息 -->
<view class="info-card">
<view class="info-header">
<text class="patient-name">{{ recordPatientName }}</text>
<view class="status-tag" :style="getStatusInlineStyle(record.status)">
<text class="status-tag__text">{{ getStatusLabel(record.status) }}</text>
</view>
</view>
<view class="info-row">
<text class="info-label">透析日期</text>
<text class="info-value">{{ record.dialysis_date }}</text>
</view>
<view class="info-row">
<text class="info-label">透析方式</text>
<text class="info-value">{{ dialysisTypeLabel(record.dialysis_type) }}</text>
</view>
<view v-if="record.start_time" class="info-row">
<text class="info-label">开始时间</text>
<text class="info-value">{{ record.start_time }}</text>
</view>
<view v-if="record.end_time" class="info-row">
<text class="info-label">结束时间</text>
<text class="info-value">{{ record.end_time }}</text>
</view>
<view v-if="record.dialysis_duration" class="info-row">
<text class="info-label">透析时长</text>
<text class="info-value">{{ record.dialysis_duration }} 分钟</text>
</view>
</view>
<!-- 体征数据 -->
<view class="section-card">
<text class="section-title">体征数据</text>
<view class="vitals-grid">
<view v-if="record.pre_bp_systolic != null" class="vital-item">
<text class="vital-value">{{ record.pre_bp_systolic }}/{{ record.pre_bp_diastolic }}</text>
<text class="vital-label">透前血压 mmHg</text>
</view>
<view v-if="record.post_bp_systolic != null" class="vital-item">
<text class="vital-value">{{ record.post_bp_systolic }}/{{ record.post_bp_diastolic }}</text>
<text class="vital-label">透后血压 mmHg</text>
</view>
<view v-if="record.pre_weight != null" class="vital-item">
<text class="vital-value">{{ record.pre_weight }}</text>
<text class="vital-label">透前体重 kg</text>
</view>
<view v-if="record.post_weight != null" class="vital-item">
<text class="vital-value">{{ record.post_weight }}</text>
<text class="vital-label">透后体重 kg</text>
</view>
<view v-if="record.ultrafiltration_volume != null" class="vital-item">
<text class="vital-value">{{ record.ultrafiltration_volume }}</text>
<text class="vital-label">超滤量 ml</text>
</view>
<view v-if="record.blood_flow_rate != null" class="vital-item">
<text class="vital-value">{{ record.blood_flow_rate }}</text>
<text class="vital-label">血流量 ml/min</text>
</view>
</view>
</view>
<!-- 并发症 -->
<view v-if="record.complication_notes" class="section-card">
<text class="section-title">并发症记录</text>
<view class="warning-block">
<text class="warning-block__text">{{ record.complication_notes }}</text>
</view>
</view>
<!-- 备注 -->
<view v-if="record.symptoms && Object.keys(record.symptoms).length > 0" class="section-card">
<text class="section-title">备注</text>
<text class="notes-text">{{ JSON.stringify(record.symptoms, null, 2) }}</text>
</view>
<!-- 审核操作 -->
<view v-if="record.status === 'pending'" class="action-card">
<view
:class="['action-btn', reviewing ? 'disabled' : '']"
@tap="reviewing ? undefined : handleReview"
>
<text class="action-btn-text">{{ reviewing ? '处理中...' : '审核通过' }}</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { getDialysisRecordById, reviewDialysisRecord } from '@/services/doctor/dialysis'
import type { DialysisRecord } from '@/services/dialysis'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import Loading from '@/components/Loading.vue'
import ErrorState from '@/components/ErrorState.vue'
const DIALYSIS_TYPE_MAP: Record<string, string> = {
hemodialysis: '血液透析',
peritoneal: '腹膜透析',
hemofiltration: '血液滤过',
}
const { elderClass } = useElderClass()
const record = ref<DialysisRecord | null>(null)
const recordPatientName = ref('')
const pageLoading = ref(true)
const error = ref(false)
const reviewing = ref(false)
let recordId = ''
function dialysisTypeLabel(type: string): string {
return DIALYSIS_TYPE_MAP[type] || type
}
async function loadData() {
if (!recordId) return
pageLoading.value = true
error.value = false
try {
const data = await getDialysisRecordById(recordId)
record.value = data
} catch {
error.value = true
} finally {
pageLoading.value = false
}
}
async function handleReview() {
if (!record.value) return
reviewing.value = true
try {
const updated = await reviewDialysisRecord(recordId, record.value.version)
record.value = updated
uni.showToast({ title: '审核通过', icon: 'success' })
} catch {
uni.showToast({ title: '审核失败', icon: 'none' })
} finally {
reviewing.value = false
}
}
onLoad((query) => {
recordId = query?.id || ''
if (!recordId) { error.value = true; pageLoading.value = false; return }
loadData()
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 120px; }
.info-card {
@include card;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.patient-name {
font-size: var(--tk-font-title);
font-weight: 600;
color: $tx;
}
.status-tag {
display: inline-block;
border-radius: 6px;
padding: 2px 8px;
}
.status-tag__text {
font-size: var(--tk-font-body);
font-weight: 500;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: var(--tk-font-cap);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
.section-card {
@include card;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
.vitals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.vital-item {
background: $pri-l;
border-radius: $r-sm;
padding: 16px;
text-align: center;
}
.vital-value {
@include serif-number;
font-size: var(--tk-font-num);
font-weight: 700;
color: $pri;
display: block;
margin-bottom: 4px;
}
.vital-label {
font-size: var(--tk-font-cap);
color: $tx2;
}
.warning-block {
background: $wrn-l;
border-radius: $r-sm;
padding: 16px;
}
.warning-block__text {
font-size: var(--tk-font-body-sm);
color: $wrn;
line-height: 1.6;
}
.notes-text {
font-size: var(--tk-font-body-sm);
color: $tx2;
line-height: 1.6;
}
.action-card {
@include card;
}
.action-btn {
@include btn-primary;
}
.action-btn.disabled { opacity: 0.5; }
.action-btn-text {
color: $card;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,371 @@
<template>
<Loading v-if="pageLoading && records.length === 0" text="加载中..." />
<scroll-view
v-else
scroll-y
class="page-scroll"
@scrolltolower="onLoadMore"
>
<view :class="['page-content', elderClass]">
<!-- 状态筛选 -->
<view class="tabs">
<view
v-for="tab in STATUS_TABS"
:key="tab.key"
:class="['tab', activeStatus === tab.key ? 'tab--active' : '']"
@tap="handleStatusChange(tab.key)"
>
<text>{{ tab.label }}</text>
</view>
</view>
<!-- 列表统计 -->
<view v-if="records.length > 0" class="list-meta">
<text class="list-meta__text"> {{ total }} 条记录</text>
</view>
<!-- 透析记录卡片 -->
<EmptyState v-if="!pageLoading && records.length === 0" icon="💉" title="暂无透析记录" />
<view v-else class="record-cards">
<view
v-for="record in records"
:key="record.id"
class="record-card"
@tap="goDetail(record.id)"
>
<view class="record-card__header">
<text class="record-card__patient">{{ record.patient_name || formatDate(record.dialysis_date, 'MM-DD') }}</text>
<view
class="record-card__status"
:style="getStatusInlineStyle(record.status)"
>
<text class="record-card__status-text">
{{ getStatusLabel(record.status) }}
</text>
</view>
</view>
<view class="record-card__body">
<view class="record-card__info-row">
<text class="record-card__label">透析日期</text>
<text class="record-card__value">{{ formatDate(record.dialysis_date, 'YYYY-MM-DD') }}</text>
</view>
<view class="record-card__info-row">
<text class="record-card__label">透析方式</text>
<text class="record-card__value">{{ dialysisTypeLabel(record.dialysis_type) }}</text>
</view>
<view v-if="record.dialysis_duration" class="record-card__info-row">
<text class="record-card__label">时长</text>
<text class="record-card__value">{{ record.dialysis_duration }} 分钟</text>
</view>
</view>
<view class="record-card__type-tag">
<text class="record-card__type-tag-text">
{{ dialysisTypeShort(record.dialysis_type) }}
</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="!loadingMore && records.length >= total && total > 0" class="load-hint-wrap">
<text class="load-hint">没有更多了</text>
</view>
<Loading v-if="loadingMore" text="加载中..." />
</view>
<!-- 新建按钮 -->
<view class="fab" @tap="goCreate">
<text class="fab__icon">+</text>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { onLoad, onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { listDialysisRecords } from '@/services/doctor/dialysis'
import type { DialysisRecord } from '@/services/doctor/dialysis'
// 医生端透析列表后端返回的扩展字段
interface DoctorDialysisRecord extends DialysisRecord {
patient_name?: string
}
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { formatDate } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
const STATUS_TABS = [
{ key: '', label: '全部' },
{ key: 'in_progress', label: '进行中' },
{ key: 'completed', label: '已完成' },
{ key: 'cancelled', label: '已取消' },
] as const
const { elderClass } = useElderClass()
const records = ref<DoctorDialysisRecord[]>([])
const activeStatus = ref('')
const pageLoading = ref(true)
const loadingMore = ref(false)
const isLoading = ref(false)
const total = ref(0)
const page = ref(1)
const patientId = ref('')
function dialysisTypeLabel(type: string): string {
if (type === 'hemodialysis') return '血液透析'
if (type === 'peritoneal') return '腹膜透析'
return type
}
function dialysisTypeShort(type: string): string {
if (type === 'hemodialysis') return 'HD'
if (type === 'peritoneal') return 'PD'
return type
}
async function loadRecords(pageNum: number, isRefresh = false) {
if (isLoading.value) return
isLoading.value = true
if (isRefresh) {
pageLoading.value = true
} else {
loadingMore.value = true
}
try {
const params: { page: number; page_size: number; status?: string } = {
page: pageNum,
page_size: 20,
}
if (activeStatus.value) {
params.status = activeStatus.value
}
const res = await listDialysisRecords(patientId.value, params)
const list = res.data || []
if (isRefresh) {
records.value = list
} else {
records.value = [...records.value, ...list]
}
total.value = res.total || 0
page.value = pageNum
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
loadingMore.value = false
isLoading.value = false
}
}
function handleStatusChange(key: string) {
activeStatus.value = key
}
function goDetail(id: string) {
uni.navigateTo({
url: `/pages-sub/doctor/dialysis/detail/index?id=${id}`,
})
}
function goCreate() {
uni.navigateTo({
url: `/pages-sub/doctor/dialysis/create/index${patientId.value ? `?patientId=${patientId.value}` : ''}`,
})
}
function onLoadMore() {
if (!isLoading.value && records.value.length < total.value) {
loadRecords(page.value + 1)
}
}
watch(activeStatus, () => {
loadRecords(1, true)
})
onLoad((query) => {
patientId.value = query?.patientId || ''
})
onShow(() => {
loadRecords(1, true)
})
onPullDownRefresh(() => {
loadRecords(1, true).finally(() => {
uni.stopPullDownRefresh()
})
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
position: relative;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
// ── 状态标签 ──
.tabs {
display: flex;
background: $card;
border-radius: $r;
box-shadow: $shadow-sm;
margin-bottom: 24px;
overflow: hidden;
}
.tab {
flex: 1;
text-align: center;
padding: 20px 0;
font-size: var(--tk-font-body);
color: $tx2;
position: relative;
&--active {
color: $pri;
font-weight: 600;
background: $pri-l;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 20%;
right: 20%;
height: 3px;
background: $pri;
border-radius: 3px;
}
}
}
// ── 列表统计 ──
.list-meta {
margin-bottom: 16px;
&__text {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
}
// ── 记录卡片 ──
.record-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.record-card {
@include card;
position: relative;
&:active {
background: $bd-l;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
&__patient {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
flex: 1;
}
&__status {
@include status-inline;
flex-shrink: 0;
}
&__status-text {
font-size: var(--tk-font-body-sm);
}
&__body {
display: flex;
flex-direction: column;
gap: 8px;
}
&__info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
&__label {
font-size: var(--tk-font-body);
color: $tx3;
}
&__value {
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
&__type-tag {
position: absolute;
top: 28px;
right: 28px;
margin-top: 32px;
}
&__type-tag-text {
@include tag($pri-l, $pri);
font-size: var(--tk-font-body-sm);
}
}
// ── 加载提示 ──
.load-hint-wrap {
text-align: center;
padding: 20px;
}
.load-hint {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
// ── 浮动新建按钮 ──
.fab {
position: fixed;
right: 32px;
bottom: 100px;
width: 56px;
height: 56px;
border-radius: 50%;
background: $pri;
@include flex-center;
box-shadow: $shadow-lg;
&:active {
opacity: 0.85;
transform: scale(0.95);
}
&__icon {
font-size: var(--tk-font-h1);
color: $card;
font-weight: 300;
line-height: 1;
}
}
</style>

View File

@@ -0,0 +1,438 @@
<template>
<view :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- Loading -->
<Loading v-if="loading" text="加载中..." />
<!-- Error -->
<ErrorState v-else-if="error || !task" text="任务不存在" />
<template v-else>
<!-- Task info card -->
<view class="info-card">
<view class="info-header">
<text class="patient-name">{{ task.patient_name || '未知患者' }}</text>
<text class="status-tag" :style="getStatusInlineStyle(task.status)">
{{ getStatusLabel(task.status) }}
</text>
</view>
<view class="info-row">
<text class="info-label">随访方式</text>
<text class="info-value">{{ getTypeLabel(task.follow_up_type) }}</text>
</view>
<view class="info-row">
<text class="info-label">计划日期</text>
<text class="info-value">{{ task.planned_date }}</text>
</view>
<view v-if="task.content_template" class="info-desc">
<text class="info-desc-label">随访内容</text>
<text class="info-desc-text">{{ task.content_template }}</text>
</view>
</view>
<!-- History records -->
<view class="section-card">
<text class="section-title">历史记录</text>
<view v-if="records.length === 0" class="empty-records">
<text class="empty-text">暂无随访记录</text>
</view>
<view v-for="record in records" :key="record.id" class="record-item">
<view class="record-date-row">
<text class="record-date">{{ record.executed_date }}</text>
</view>
<view v-if="record.result" class="record-field">
<text class="record-field-label">随访结果</text>
<text class="record-field-value">{{ record.result }}</text>
</view>
<view v-if="record.patient_condition" class="record-field">
<text class="record-field-label">患者状况</text>
<text class="record-field-value">{{ record.patient_condition }}</text>
</view>
<view v-if="record.medical_advice" class="record-field">
<text class="record-field-label">医嘱建议</text>
<text class="record-field-value">{{ record.medical_advice }}</text>
</view>
</view>
</view>
<!-- Submit form (only when can submit) -->
<view v-if="canSubmit" class="submit-card">
<!-- Start button when pending/overdue -->
<view v-if="task.status === 'pending' || task.status === 'overdue'" class="start-btn-wrap">
<view
:class="['action-btn', startingTask ? 'disabled' : '']"
@tap="startingTask ? undefined : handleStart"
>
<text class="action-btn-text">{{ startingTask ? '处理中...' : '开始随访' }}</text>
</view>
</view>
<!-- Form when in_progress -->
<template v-if="task.status === 'in_progress'">
<text class="section-title">填写随访记录</text>
<view class="form-field">
<text class="form-label"><text class="required">*</text> 随访结果</text>
<textarea
class="form-textarea"
placeholder="请输入随访结果"
:value="formData.result"
@input="(e: any) => formData.result = e.detail.value"
:maxlength="1000"
/>
</view>
<view class="form-field">
<text class="form-label">患者状况</text>
<textarea
class="form-textarea"
placeholder="请描述患者当前状况(选填)"
:value="formData.patient_condition"
@input="(e: any) => formData.patient_condition = e.detail.value"
:maxlength="500"
/>
</view>
<view class="form-field">
<text class="form-label">医嘱建议</text>
<textarea
class="form-textarea"
placeholder="请输入医嘱建议(选填)"
:value="formData.medical_advice"
@input="(e: any) => formData.medical_advice = e.detail.value"
:maxlength="500"
/>
</view>
<view class="form-field">
<text class="form-label">下次随访日期</text>
<picker
mode="date"
:value="formData.next_follow_up_date"
@change="(e: any) => formData.next_follow_up_date = e.detail.value"
>
<view class="date-picker">
<text :class="['date-text', formData.next_follow_up_date ? '' : 'placeholder']">
{{ formData.next_follow_up_date || '请选择日期' }}
</text>
</view>
</picker>
</view>
<view
:class="['action-btn', submitting ? 'disabled' : '']"
@tap="submitting ? undefined : handleSubmit"
>
<text class="action-btn-text">{{ submitting ? '提交中...' : '提交记录' }}</text>
</view>
</template>
</view>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor/followup'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { useElderClass } from '@/composables/useElderClass'
import ErrorState from '@/components/ErrorState.vue'
import Loading from '@/components/Loading.vue'
const TYPE_MAP: Record<string, string> = {
phone: '电话',
visit: '门诊',
online: '线上',
home: '家访',
}
const { elderClass } = useElderClass()
const task = ref<doctorApi.FollowUpTask | null>(null)
const records = ref<doctorApi.FollowUpRecord[]>([])
const loading = ref(true)
const error = ref(false)
const startingTask = ref(false)
const submitting = ref(false)
let taskId = ''
const formData = reactive({
result: '',
patient_condition: '',
medical_advice: '',
next_follow_up_date: '',
})
const canSubmit = computed(() => {
if (!task.value) return false
return ['pending', 'in_progress', 'overdue'].includes(task.value.status)
})
function getTypeLabel(type: string): string {
return TYPE_MAP[type] || type
}
async function fetchDetail() {
loading.value = true
error.value = false
try {
const [taskData, recordsRes] = await Promise.all([
doctorApi.getFollowUpTask(taskId),
doctorApi.listFollowUpRecords({ task_id: taskId }),
])
task.value = taskData
records.value = recordsRes.data || []
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function handleStart() {
if (!task.value) return
startingTask.value = true
try {
const updated = await doctorApi.updateFollowUpTask(
taskId,
{ status: 'in_progress' },
task.value.version,
)
task.value = updated
uni.showToast({ title: '已开始随访', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
startingTask.value = false
}
}
async function handleSubmit() {
if (!formData.result.trim()) {
uni.showToast({ title: '请输入随访结果', icon: 'none' })
return
}
submitting.value = true
try {
await doctorApi.createFollowUpRecord(taskId, {
result: formData.result.trim(),
patient_condition: formData.patient_condition.trim() || undefined,
medical_advice: formData.medical_advice.trim() || undefined,
next_follow_up_date: formData.next_follow_up_date || undefined,
})
uni.showToast({ title: '提交成功', icon: 'success' })
formData.result = ''
formData.patient_condition = ''
formData.medical_advice = ''
formData.next_follow_up_date = ''
fetchDetail()
} catch {
uni.showToast({ title: '提交失败', icon: 'none' })
} finally {
submitting.value = false
}
}
onLoad((query) => {
taskId = query?.id || ''
if (!taskId) { error.value = true; loading.value = false; return }
fetchDetail()
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 120px; }
// Info card
.info-card {
@include card;
margin-bottom: 16px;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.patient-name {
font-size: var(--tk-font-title);
font-weight: 600;
color: $tx;
}
.status-tag {
@include status-inline;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: var(--tk-font-cap);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body);
color: $tx;
}
.info-desc {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.info-desc-label {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
margin-bottom: 4px;
}
.info-desc-text {
font-size: var(--tk-font-body-sm);
color: $tx2;
line-height: 1.6;
}
// Section card
.section-card {
@include card;
margin-bottom: 16px;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
// Records
.empty-records {
@include flex-center;
padding: 40px 0;
}
.empty-text {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
.record-item {
padding: 16px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.record-item:last-child { border-bottom: none; }
.record-date-row {
margin-bottom: 8px;
}
.record-date {
font-size: var(--tk-font-cap);
color: $tx3;
}
.record-field {
margin-bottom: 6px;
}
.record-field-label {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
margin-bottom: 2px;
}
.record-field-value {
font-size: var(--tk-font-body-sm);
color: $tx;
line-height: 1.6;
}
// Submit card
.submit-card {
@include card;
}
.start-btn-wrap {
margin-bottom: 16px;
}
.action-btn {
@include btn-primary;
}
.action-btn.disabled { opacity: 0.5; }
.action-btn-text {
color: $white;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
// Form
.form-field {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: 8px;
}
.required { color: $dan; }
.form-textarea {
width: 100%;
min-height: 120px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 12px;
font-size: var(--tk-font-body);
color: $tx;
box-sizing: border-box;
background: $card;
}
.date-picker {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 12px;
@include flex-center;
justify-content: flex-start;
}
.date-text {
font-size: var(--tk-font-body);
color: $tx;
}
.date-text.placeholder { color: $tx3; }
</style>

View File

@@ -0,0 +1,227 @@
<template>
<view :class="['page-scroll', elderClass]">
<view class="page-content">
<text class="page-title">随访任务</text>
<!-- Tab filter -->
<view class="tabs">
<view
v-for="tab in TABS" :key="tab.key"
:class="['tab', activeTab === tab.key ? 'active' : '']"
@tap="handleTabChange(tab.key)"
>
<text :class="['tab-text', activeTab === tab.key ? 'active' : '']">{{ tab.label }}</text>
</view>
</view>
<!-- Loading -->
<Loading v-if="loading && tasks.length === 0" text="加载中..." />
<!-- Empty -->
<EmptyState v-else-if="tasks.length === 0" icon="📋" title="暂无随访任务" />
<!-- Task list -->
<scroll-view v-else scroll-y class="list-scroll" @scrolltolower="loadMore">
<view
v-for="item in tasks" :key="item.id"
class="task-card"
@tap="goDetail(item.id)"
>
<view class="card-header">
<view class="type-badge" :style="getTypeStyle(item.follow_up_type)">
<text class="type-text">{{ getTypeLabel(item.follow_up_type) }}</text>
</view>
<text :class="['status-tag', item.status]" :style="getStatusInlineStyle(item.status)">
{{ getStatusLabel(item.status) }}
</text>
</view>
<text class="patient-name">{{ item.patient_name || '未知患者' }}</text>
<view class="card-footer">
<text class="planned-date">计划日期{{ item.planned_date }}</text>
</view>
</view>
<Loading v-if="loading" text="加载中..." />
<view v-if="!loading && tasks.length >= total && total > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor/followup'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { useElderClass } from '@/composables/useElderClass'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
const TABS = [
{ key: '', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'in_progress', label: '进行中' },
{ key: 'completed', label: '已完成' },
{ key: 'overdue', label: '已逾期' },
]
const TYPE_MAP: Record<string, { label: string; bg: string; color: string }> = {
phone: { label: '电话', bg: '#E8F0E8', color: '#5B7A5E' },
visit: { label: '门诊', bg: '#F0DDD4', color: '#C4623A' },
online: { label: '线上', bg: '#E0F0FF', color: '#3B82B8' },
home: { label: '家访', bg: '#FFF3E0', color: '#C4873A' },
}
const { elderClass } = useElderClass()
const tasks = ref<doctorApi.FollowUpTask[]>([])
const total = ref(0)
const page = ref(1)
const activeTab = ref('')
const loading = ref(false)
let patientId = ''
let loadingGuard = false
function getTypeLabel(type: string): string {
return TYPE_MAP[type]?.label || type
}
function getTypeStyle(type: string): Record<string, string> {
const info = TYPE_MAP[type]
if (!info) return { background: '#F1F5F9', color: '#78716C' }
return { background: info.bg, color: info.color }
}
async function fetchTasks(pageNum: number, status: string, isRefresh = false) {
if (loadingGuard) return
loadingGuard = true
loading.value = true
try {
const params: Record<string, unknown> = { page: pageNum, page_size: 20 }
if (status) params.status = status
if (patientId) params.patient_id = patientId
const res = await doctorApi.listFollowUpTasks(params as Parameters<typeof doctorApi.listFollowUpTasks>[0])
const list = res.data || []
tasks.value = isRefresh ? list : [...tasks.value, ...list]
total.value = res.total
page.value = pageNum
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loadingGuard = false
loading.value = false
}
}
function handleTabChange(key: string) {
activeTab.value = key
fetchTasks(1, key, true)
}
function loadMore() {
if (!loading.value && tasks.value.length < total.value) {
fetchTasks(page.value + 1, activeTab.value)
}
}
function goDetail(id: string) {
uni.navigateTo({ url: `/pages-sub/doctor/followup/detail/index?id=${id}` })
}
onLoad((query) => {
patientId = query?.patientId || ''
fetchTasks(1, '', true)
})
onPullDownRefresh(() => {
fetchTasks(1, activeTab.value, true).finally(() => uni.stopPullDownRefresh())
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 28px 0 120px; }
.page-title { @include section-title; margin-left: 24px; }
.tabs {
display: flex;
padding: 0 24px 16px;
gap: 8px;
flex-wrap: wrap;
}
.tab {
padding: 6px 16px;
min-height: $touch-min;
display: flex;
align-items: center;
border-radius: 20px;
background: rgba(0, 0, 0, 0.04);
}
.tab.active { background: $pri; }
.tab-text {
font-size: var(--tk-font-cap);
color: $tx2;
}
.tab-text.active { color: $card; }
.list-scroll { height: calc(100vh - 160px); }
.task-card {
background: $card;
border-radius: $r;
padding: 20px 24px;
margin: 0 24px 16px;
box-shadow: $shadow-sm;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: $r-xs;
}
.type-text {
font-size: var(--tk-font-cap);
font-weight: 500;
}
.status-tag {
@include status-inline;
}
.patient-name {
display: block;
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 8px;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.planned-date {
font-size: var(--tk-font-cap);
color: $tx3;
}
.no-more { @include flex-center; padding: 20px; }
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
</style>

View File

@@ -0,0 +1,451 @@
<template>
<Loading v-if="pageLoading" text="加载中..." />
<scroll-view v-else scroll-y class="page-scroll">
<view :class="['page-content', elderClass]">
<!-- 顶部问候 -->
<view class="header">
<text class="header-title">医护工作台</text>
<text class="header-greeting">{{ greeting }}{{ displayName }}</text>
<text class="header-date">{{ todayStr }}</text>
</view>
<!-- 异常体征告警横幅 -->
<view v-if="alertCount > 0" class="alert-banner" @tap="goAlerts">
<text class="alert-icon">!</text>
<text class="alert-text">{{ alertCount }} 位患者体征异常</text>
<text class="alert-link">查看 ></text>
</view>
<!-- 搜索框 -->
<view class="search-bar">
<input
class="search-input"
placeholder="搜索患者姓名..."
placeholder-class="search-placeholder"
:focus="false"
@focus="goPatients"
/>
</view>
<!-- 工作概览 -->
<view class="section">
<text class="section-title">工作概览</text>
<view class="grid-2">
<view
v-for="card in visibleCards"
:key="card.key"
class="overview-card"
@tap="navigateTo(card.route)"
>
<text class="overview-card__initial">{{ card.initial }}</text>
<text class="overview-card__num">{{ getValue(card.key) }}</text>
<text class="overview-card__label">{{ card.label }}</text>
</view>
</view>
</view>
<!-- 健康审核 -->
<view v-if="visibleHealthCards.length > 0" class="section">
<text class="section-title">健康审核</text>
<view class="grid-2">
<view
v-for="card in visibleHealthCards"
:key="card.key"
class="overview-card"
@tap="navigateTo(card.route)"
>
<text class="overview-card__initial">{{ card.initial }}</text>
<text class="overview-card__num">{{ getValue(card.key) }}</text>
<text class="overview-card__label">{{ card.label }}</text>
</view>
</view>
</view>
<!-- 快捷操作 -->
<view class="section">
<text class="section-title">快捷操作</text>
<view class="grid-4">
<view
v-for="action in visibleQuickActions"
:key="action.route"
class="quick-action"
@tap="navigateTo(action.route)"
>
<view class="quick-action__icon-wrap">
<text class="quick-action__initial">{{ action.initial }}</text>
<text
v-if="action.label === '告警中心' && alertCount > 0"
class="quick-action__badge"
>{{ alertCount > 99 ? '99+' : alertCount }}</text>
</view>
<text class="quick-action__label">{{ action.label }}</text>
</view>
</view>
</view>
<!-- 退出登录 -->
<view class="footer">
<text class="logout-btn" @tap="handleLogout">退出登录</text>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import { getDashboard } from '@/services/doctor/dashboard'
import type { DoctorDashboard } from '@/services/doctor/dashboard'
import Loading from '@/components/Loading.vue'
interface CardConfig {
key: keyof DoctorDashboard
label: string
initial: string
route: string
roles?: string[]
}
const ALL_CARDS: CardConfig[] = [
{ key: 'total_patients', label: '我的患者', initial: '患', route: '/pages-sub/doctor/patients/index' },
{ key: 'unread_messages', label: '未读消息', initial: '消', route: '/pages-sub/doctor/consultation/index' },
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages-sub/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages-sub/doctor/consultation/index', roles: ['doctor', 'health_manager'] },
]
const ALL_HEALTH_CARDS: CardConfig[] = [
{ key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages-sub/doctor/report/index', roles: ['doctor'] },
{ key: 'today_appointments', label: '今日预约', initial: '约', route: '/pages-sub/doctor/patients/index' },
]
interface QuickAction {
label: string
initial: string
route: string
roles: string[]
}
const ALL_QUICK_ACTIONS: QuickAction[] = [
{ label: '化验审核', initial: '审', route: '/pages-sub/doctor/report/index', roles: ['doctor'] },
{ label: '患者查询', initial: '查', route: '/pages-sub/doctor/patients/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '随访记录', initial: '随', route: '/pages-sub/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '告警中心', initial: '警', route: '/pages-sub/doctor/alerts/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '透析管理', initial: '透', route: '/pages-sub/doctor/dialysis/index', roles: ['doctor'] },
{ label: '处方管理', initial: '方', route: '/pages-sub/doctor/prescription/index', roles: ['doctor'] },
{ label: '行动收件箱', initial: '行', route: '/pages-sub/doctor/action-inbox/index', roles: ['doctor', 'nurse', 'health_manager'] },
]
const ROLE_LABELS: Record<string, string> = {
doctor: '医生',
nurse: '护士',
health_manager: '健康管理师',
admin: '管理员',
operator: '运营',
}
const authStore = useAuthStore()
const { elderClass } = useElderClass()
const dashboard = ref<DoctorDashboard | null>(null)
const alertCount = ref(0)
const pageLoading = ref(true)
const displayName = computed(() => {
const user = authStore.user
const roles = authStore.roles
if (user?.display_name) return user.display_name
if (user?.username) return user.username
const primary = roles.find(r => r !== 'admin')
return primary ? (ROLE_LABELS[primary] || primary) : '医护'
})
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 12) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
const todayStr = computed(() => {
return new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })
})
function hasRole(allowed: string[] | undefined): boolean {
if (!allowed) return true
return authStore.roles.some(r => r === 'admin' || allowed.includes(r))
}
const visibleCards = computed(() => ALL_CARDS.filter(c => hasRole(c.roles)))
const visibleHealthCards = computed(() => ALL_HEALTH_CARDS.filter(c => hasRole(c.roles)))
const visibleQuickActions = computed(() => ALL_QUICK_ACTIONS.filter(a => hasRole(a.roles)))
function getValue(key: keyof DoctorDashboard): number | string {
if (!dashboard.value) return '-'
return dashboard.value[key] ?? 0
}
function navigateTo(url: string) {
uni.navigateTo({ url })
}
function goPatients() {
uni.navigateTo({ url: '/pages-sub/doctor/patients/index' })
}
function goAlerts() {
uni.navigateTo({ url: '/pages-sub/doctor/alerts/index' })
}
function handleLogout() {
authStore.logout()
}
async function loadDashboard() {
pageLoading.value = true
try {
const data = await getDashboard()
dashboard.value = data
const count = (data as Record<string, unknown>)?.abnormal_vital_count
alertCount.value = typeof count === 'number' ? count : 0
} catch {
// 静默失败,显示占位
} finally {
pageLoading.value = false
}
}
onMounted(() => {
loadDashboard()
})
onShow(() => {
authStore.restore()
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 32px 24px 120px;
}
// ── 顶部问候 ──
.header {
margin-bottom: 40px;
}
.header-title {
@include section-title;
margin-bottom: 12px;
}
.header-greeting {
display: block;
font-size: var(--tk-font-h2);
color: $tx2;
margin-bottom: 8px;
}
.header-date {
font-size: var(--tk-font-h2);
color: $tx3;
}
// ── 告警横幅 ──
.alert-banner {
display: flex;
align-items: center;
margin: 0 0 24px;
padding: 16px 20px;
min-height: $touch-min;
background: $dan-l;
border-radius: $r;
border-left: 4px solid $dan;
}
.alert-icon {
@include flex-center;
width: 36px;
height: 36px;
border-radius: 50%;
background: $dan;
color: $white;
text-align: center;
line-height: 36px;
font-weight: bold;
font-size: var(--tk-font-body);
margin-right: 12px;
flex-shrink: 0;
}
.alert-text {
flex: 1;
font-size: var(--tk-font-h1);
color: $dan;
}
.alert-link {
font-size: var(--tk-font-h2);
color: $dan;
flex-shrink: 0;
}
// ── 搜索框 ──
.search-bar {
margin-bottom: 24px;
}
.search-input {
background: $surface-alt;
border-radius: $r;
padding: 16px 20px;
font-size: var(--tk-font-h1);
color: $tx;
width: 100%;
box-sizing: border-box;
}
.search-placeholder {
color: $tx3;
}
// ── 通用区块 ──
.section {
margin-bottom: 40px;
}
.section-title {
@include section-title;
}
// ── 工作概览网格 ──
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.overview-card {
background: $card;
border-radius: $r-lg;
padding: 28px 24px;
text-align: center;
box-shadow: $shadow-md;
transition: transform 0.15s;
&:active {
transform: scale(0.97);
}
}
.overview-card__initial {
display: inline-flex;
@include flex-center;
width: 56px;
height: 56px;
border-radius: $r;
background: $pri-l;
color: $pri;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: 700;
margin-bottom: 8px;
}
.overview-card__num {
@include serif-number;
font-size: var(--tk-font-hero);
font-weight: 700;
color: $tx;
display: block;
margin-bottom: 8px;
}
.overview-card__label {
font-size: var(--tk-font-h2);
color: $tx2;
}
// ── 快捷操作 ──
.grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.quick-action {
background: $card;
border-radius: $r-lg;
padding: 28px 20px;
text-align: center;
box-shadow: $shadow-md;
&:active {
opacity: 0.8;
}
}
.quick-action__icon-wrap {
position: relative;
display: inline-flex;
margin-bottom: 8px;
}
.quick-action__initial {
@include flex-center;
width: 56px;
height: 56px;
border-radius: $r;
background: $acc-l;
color: $acc;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: 700;
}
.quick-action__badge {
position: absolute;
top: -6px;
right: -12px;
min-width: 32px;
height: 32px;
line-height: 32px;
text-align: center;
background: $dan;
color: $white;
font-size: var(--tk-font-body-sm);
font-weight: 700;
border-radius: $r-pill;
padding: 0 6px;
}
.quick-action__label {
font-size: var(--tk-font-h2);
color: $tx2;
display: block;
}
// ── 底部 ──
.footer {
margin-top: 60px;
text-align: center;
padding-bottom: env(safe-area-inset-bottom);
}
.logout-btn {
color: $dan;
font-size: var(--tk-font-h2);
padding: 16px 48px;
min-height: $touch-min;
display: inline-flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,402 @@
<template>
<Loading v-if="pageLoading" text="加载中..." />
<view v-else-if="!patient" :class="['error-wrap', elderClass]">
<text class="error-text">患者信息加载失败</text>
</view>
<scroll-view v-else scroll-y class="page-scroll">
<view :class="['page-content', elderClass]">
<!-- 基本信息 -->
<view class="section">
<text class="section-title">基本信息</text>
<view class="info-grid">
<view class="info-item">
<text class="info-label">姓名</text>
<text class="info-value">{{ patient.name }}</text>
</view>
<view class="info-item">
<text class="info-label">性别</text>
<text class="info-value">{{ genderLabel(patient.gender) }}</text>
</view>
<view class="info-item">
<text class="info-label">年龄</text>
<text class="info-value">{{ calcAge(patient.birth_date) }}</text>
</view>
<view v-if="patient.blood_type" class="info-item">
<text class="info-label">血型</text>
<text class="info-value">{{ patient.blood_type }}</text>
</view>
</view>
</view>
<!-- 医疗信息 -->
<view v-if="patient.allergy_history || patient.medical_history_summary" class="section">
<text class="section-title">医疗信息</text>
<view v-if="patient.allergy_history" class="warning-card">
<text class="warning-label">过敏史</text>
<text class="warning-text">{{ patient.allergy_history }}</text>
</view>
<view v-if="patient.medical_history_summary" class="info-block">
<text class="info-block-label">病史摘要</text>
<text class="info-block-text">{{ patient.medical_history_summary }}</text>
</view>
</view>
<!-- 健康概览 -->
<view v-if="summary" class="section">
<text class="section-title">健康概览</text>
<view v-if="summary.latest_vital_signs" class="vitals-grid">
<view
v-if="summary.latest_vital_signs.systolic_bp != null"
class="vital-item"
>
<text class="vital-value">{{ summary.latest_vital_signs.systolic_bp }}/{{ summary.latest_vital_signs.diastolic_bp }}</text>
<text class="vital-label">血压 mmHg</text>
</view>
<view
v-if="summary.latest_vital_signs.heart_rate != null"
class="vital-item"
>
<text class="vital-value">{{ summary.latest_vital_signs.heart_rate }}</text>
<text class="vital-label">心率 bpm</text>
</view>
<view
v-if="summary.latest_vital_signs.weight != null"
class="vital-item"
>
<text class="vital-value">{{ summary.latest_vital_signs.weight }}</text>
<text class="vital-label">体重 kg</text>
</view>
<view
v-if="summary.latest_vital_signs.blood_sugar != null"
class="vital-item"
>
<text class="vital-value">{{ summary.latest_vital_signs.blood_sugar }}</text>
<text class="vital-label">血糖 mmol/L</text>
</view>
</view>
<view v-if="summary.pending_follow_ups != null && summary.pending_follow_ups > 0" class="stat-row">
<text class="stat-label">待处理随访</text>
<text class="stat-value stat-value--warn">{{ summary.pending_follow_ups }} </text>
</view>
</view>
<!-- 近期化验 -->
<view v-if="summary?.latest_lab_report" class="section">
<text class="section-title">近期化验</text>
<view
class="lab-item"
@tap="goReportDetail(summary!.latest_lab_report!.id)"
>
<view class="lab-item__header">
<text class="lab-item__type">{{ summary.latest_lab_report.report_type }}</text>
<text class="lab-item__date">{{ summary.latest_lab_report.report_date }}</text>
</view>
<text
v-if="(summary.latest_lab_report.abnormal_count ?? 0) > 0"
class="lab-item__abnormal"
>{{ summary.latest_lab_report.abnormal_count }} 项异常</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="section">
<text class="section-title">操作</text>
<view class="action-buttons">
<view class="action-btn" @tap="goReports">
<text class="action-btn__text">查看化验报告</text>
</view>
<view class="action-btn" @tap="goFollowups">
<text class="action-btn__text">随访记录</text>
</view>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { getPatient, getHealthSummary } from '@/services/doctor/patient'
import type { PatientDetail, HealthSummary } from '@/services/doctor/patient'
import Loading from '@/components/Loading.vue'
const { elderClass } = useElderClass()
const patient = ref<PatientDetail | null>(null)
const summary = ref<HealthSummary | null>(null)
const pageLoading = ref(true)
const patientId = ref('')
function genderLabel(g?: string): string {
if (g === 'male') return '男'
if (g === 'female') return '女'
return g || '-'
}
function calcAge(bd?: string): string {
if (!bd) return '-'
const diff = Date.now() - new Date(bd).getTime()
return String(Math.floor(diff / (365.25 * 24 * 3600 * 1000)))
}
async function loadData() {
if (!patientId.value) return
pageLoading.value = true
try {
const [p, s] = await Promise.all([
getPatient(patientId.value),
getHealthSummary(patientId.value),
])
patient.value = p
summary.value = s
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
}
}
function goReportDetail(reportId: string) {
uni.navigateTo({
url: `/pages-sub/doctor/report/detail/index?patientId=${patientId.value}&id=${reportId}`,
})
}
function goReports() {
uni.navigateTo({
url: `/pages-sub/doctor/report/index?patientId=${patientId.value}`,
})
}
function goFollowups() {
uni.navigateTo({
url: `/pages-sub/doctor/followup/index?patientId=${patientId.value}`,
})
}
onLoad((query) => {
patientId.value = query?.id || ''
loadData()
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
.error-wrap {
@include flex-center;
height: 100vh;
background: $bg;
}
.error-text {
font-size: var(--tk-font-body-lg);
color: $tx3;
}
// ── 通用区块 ──
.section {
background: $card;
border-radius: $r-lg;
padding: 28px;
margin-bottom: 20px;
box-shadow: $shadow-sm;
}
.section-title {
@include section-title;
}
// ── 信息网格 ──
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: var(--tk-font-body);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body-lg);
color: $tx;
font-weight: 500;
}
// ── 过敏警告卡 ──
.warning-card {
background: $wrn-l;
border-radius: $r;
padding: 20px;
margin-bottom: 16px;
}
.warning-label {
font-size: var(--tk-font-body);
color: $wrn;
font-weight: 600;
display: block;
margin-bottom: 8px;
}
.warning-text {
font-size: var(--tk-font-h1);
color: $pri-d;
}
// ── 病史摘要 ──
.info-block {
margin-bottom: 12px;
}
.info-block-label {
font-size: var(--tk-font-body);
color: $tx3;
display: block;
margin-bottom: 8px;
}
.info-block-text {
font-size: var(--tk-font-h1);
color: $tx;
line-height: 1.6;
}
// ── 体征网格 ──
.vitals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.vital-item {
background: $pri-l;
border-radius: $r;
padding: 20px;
text-align: center;
}
.vital-value {
@include serif-number;
font-size: var(--tk-font-num-lg);
font-weight: 700;
color: $pri;
display: block;
margin-bottom: 4px;
}
.vital-label {
font-size: var(--tk-font-body);
color: $tx2;
}
// ── 统计行 ──
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
}
.stat-label {
font-size: var(--tk-font-h1);
color: $tx2;
}
.stat-value {
@include serif-number;
font-size: var(--tk-font-h1);
font-weight: 600;
color: $tx;
&--warn {
color: $wrn;
}
}
// ── 化验卡片 ──
.lab-item {
padding: 20px 0;
min-height: $touch-min;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
&:active {
background: $bd-l;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__type {
font-size: var(--tk-font-h1);
font-weight: 500;
color: $tx;
}
&__date {
font-size: var(--tk-font-h2);
color: $tx3;
}
&__abnormal {
font-size: var(--tk-font-h2);
color: $dan;
font-weight: 500;
}
}
// ── 操作按钮 ──
.action-buttons {
display: flex;
gap: 16px;
}
.action-btn {
flex: 1;
text-align: center;
padding: 20px;
min-height: $touch-min;
display: flex;
align-items: center;
justify-content: center;
border-radius: $r;
background: $pri;
&:active {
opacity: 0.85;
}
&__text {
color: $card;
font-size: var(--tk-font-h1);
font-weight: 500;
}
}
</style>

View File

@@ -0,0 +1,344 @@
<template>
<Loading v-if="pageLoading && patients.length === 0" text="加载中..." />
<scroll-view v-else scroll-y class="page-scroll" @scrolltolower="onLoadMore">
<view :class="['page-content', elderClass]">
<!-- 搜索栏 -->
<view class="search-bar">
<input
class="search-input"
placeholder="搜索患者姓名/手机号"
placeholder-class="search-placeholder"
:value="search"
confirm-type="search"
@input="onSearchInput"
@confirm="handleSearch"
/>
</view>
<!-- 标签过滤 -->
<scroll-view v-if="tags.length > 0" scroll-x class="tag-filter">
<view
:class="['tag-chip', { active: !activeTag }]"
@tap="handleTagFilter('')"
>
<text>全部</text>
</view>
<view
v-for="tag in tags"
:key="tag.id"
:class="['tag-chip', { active: activeTag === tag.id }]"
:style="activeTag === tag.id && tag.color ? `background: ${tag.color}; color: white` : ''"
@tap="handleTagFilter(tag.id)"
>
<text>{{ tag.name }}</text>
</view>
</scroll-view>
<!-- 患者数量 -->
<view class="patient-count">
<text> {{ total }} 位患者</text>
</view>
<!-- 患者卡片列表 -->
<EmptyState v-if="patients.length === 0" icon="📋" title="暂无患者数据" />
<view v-else class="patient-cards">
<view
v-for="p in patients"
:key="p.id"
class="patient-card"
@tap="goDetail(p.id)"
>
<view class="patient-card__header">
<text class="patient-card__name">{{ p.name }}</text>
<text class="patient-card__meta">{{ genderLabel(p.gender) }} {{ calcAge(p.birth_date) }}</text>
</view>
<view v-if="p.tags && p.tags.length > 0" class="patient-card__tags">
<view
v-for="t in p.tags"
:key="t.id"
class="patient-tag"
:style="t.color ? `background: ${t.color}20; color: ${t.color}` : ''"
>
<text class="patient-tag__text">{{ t.name }}</text>
</view>
</view>
<text v-if="p.status" :class="['patient-card__status', `patient-card__status--${p.status}`]">
{{ p.status === 'active' ? '活跃' : p.status === 'inactive' ? '非活跃' : p.status }}
</text>
</view>
</view>
<!-- 加载更多提示 -->
<view v-if="!loadingMore && patients.length >= total && total > 0" class="load-hint-wrap">
<text class="load-hint">没有更多了</text>
</view>
<Loading v-if="loadingMore" text="加载中..." />
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { listPatients, listPatientTags } from '@/services/doctor/patient'
import type { PatientItem, PatientTag } from '@/services/doctor/patient'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
const { elderClass } = useElderClass()
const patients = ref<PatientItem[]>([])
const tags = ref<PatientTag[]>([])
const activeTag = ref('')
const search = ref('')
const pageLoading = ref(true)
const loadingMore = ref(false)
const isLoading = ref(false)
const total = ref(0)
const page = ref(1)
function genderLabel(gender?: string): string {
if (!gender) return ''
if (gender === 'male') return '男'
if (gender === 'female') return '女'
return gender
}
function calcAge(birthDate?: string): string {
if (!birthDate) return ''
const birth = new Date(birthDate)
const now = new Date()
let age = now.getFullYear() - birth.getFullYear()
if (
now.getMonth() < birth.getMonth() ||
(now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate())
) {
age--
}
return `${age}`
}
async function loadTags() {
try {
const res = await listPatientTags()
tags.value = res.data || []
} catch {
// 静默失败
}
}
async function loadPatients(pageNum: number, isRefresh = false) {
if (isLoading.value) return
isLoading.value = true
if (isRefresh) {
pageLoading.value = true
} else {
loadingMore.value = true
}
try {
const res = await listPatients({
page: pageNum,
page_size: 20,
search: search.value || undefined,
tag_id: activeTag.value || undefined,
})
const list = res.data || []
if (isRefresh) {
patients.value = list
} else {
patients.value = [...patients.value, ...list]
}
total.value = res.total || 0
page.value = pageNum
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
loadingMore.value = false
isLoading.value = false
}
}
function onSearchInput(e: { detail: { value: string } }) {
search.value = e.detail.value
}
function handleSearch() {
loadPatients(1, true)
}
function handleTagFilter(tagId: string) {
activeTag.value = tagId === activeTag.value ? '' : tagId
}
function goDetail(id: string) {
uni.navigateTo({ url: `/pages-sub/doctor/patients/detail/index?id=${id}` })
}
function onLoadMore() {
if (!isLoading.value && patients.value.length < total.value) {
loadPatients(page.value + 1)
}
}
watch(activeTag, () => {
loadPatients(1, true)
})
onMounted(() => {
loadTags()
loadPatients(1, true)
})
onPullDownRefresh(() => {
loadPatients(1, true).finally(() => {
uni.stopPullDownRefresh()
})
})
onShow(() => {
// 从详情页返回时不需要重新加载,保留列表状态
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
// ── 搜索栏 ──
.search-bar {
margin-bottom: 20px;
}
.search-input {
background: $card;
border-radius: $r;
padding: 20px 24px;
font-size: var(--tk-font-body-lg);
width: 100%;
box-sizing: border-box;
box-shadow: $shadow-sm;
}
.search-placeholder {
color: $tx3;
}
// ── 标签过滤 ──
.tag-filter {
white-space: nowrap;
margin-bottom: 20px;
width: 100%;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 10px 24px;
min-height: $touch-min;
border-radius: $r-pill;
background: $bd-l;
font-size: var(--tk-font-h2);
color: $tx2;
margin-right: 16px;
&.active {
background: $pri;
color: $card;
}
}
// ── 患者计数 ──
.patient-count {
margin-bottom: 16px;
text {
font-size: var(--tk-font-h2);
color: $tx3;
}
}
// ── 患者卡片 ──
.patient-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.patient-card {
background: $card;
border-radius: $r-lg;
padding: 28px;
box-shadow: $shadow-sm;
&:active {
background: $bd-l;
}
&__header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
&__name {
font-size: var(--tk-font-num);
font-weight: 600;
color: $tx;
margin-right: 16px;
}
&__meta {
font-size: var(--tk-font-h2);
color: $tx2;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
&__status {
@include tag($bg, $tx2);
&--active {
@include tag($acc-l, $acc);
}
&--inactive {
@include tag($bd-l, $tx3);
}
}
}
.patient-tag {
padding: 4px 14px;
border-radius: $r;
background: $pri-l;
&__text {
font-size: var(--tk-font-body);
}
}
// ── 加载提示 ──
.load-hint-wrap {
text-align: center;
padding: 20px;
}
.load-hint {
font-size: var(--tk-font-h2);
color: $tx3;
}
</style>

View File

@@ -0,0 +1,517 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- 患者选择 -->
<view class="section-card">
<text class="section-title">选择患者</text>
<view class="patient-search">
<input
class="search-input"
placeholder="搜索患者姓名"
:value="patientSearch"
@input="(e: any) => { patientSearch = e.detail.value; searchPatients() }"
/>
</view>
<view v-if="searchingPatient" class="loading-hint">
<text class="loading-hint__text">搜索中...</text>
</view>
<view v-else-if="patientResults.length > 0" class="patient-list">
<view
v-for="p in patientResults"
:key="p.id"
:class="['patient-item', form.patient_id === p.id ? 'patient-item--selected' : '']"
@tap="selectPatient(p)"
>
<text class="patient-item__name">{{ p.name }}</text>
<text class="patient-item__info">{{ p.gender === 'male' ? '男' : p.gender === 'female' ? '女' : '' }}</text>
<view v-if="form.patient_id === p.id" class="patient-item__check">
<text class="check-icon">&#10003;</text>
</view>
</view>
</view>
<view v-else-if="patientSearch && !searchingPatient" class="empty-hint">
<text class="empty-hint__text">未找到患者</text>
</view>
</view>
<!-- 透析方式与频率 -->
<view class="section-card">
<text class="section-title">透析方案</text>
<view class="form-field">
<text class="form-label">透析器型号</text>
<input
class="form-input"
placeholder="如 F60S"
:value="form.dialyzer_model"
@input="(e: any) => form.dialyzer_model = e.detail.value"
/>
</view>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">频率/ <text class="required">*</text></text>
<input
type="number"
class="form-input"
placeholder="如 3"
:value="form.frequency_per_week ?? ''"
@input="(e: any) => updateNumericField('frequency_per_week', e.detail.value)"
/>
</view>
<view class="form-field form-field--half">
<text class="form-label">单次时长分钟</text>
<input
type="digit"
class="form-input"
placeholder="如 240"
:value="form.duration_minutes ?? ''"
@input="(e: any) => updateNumericField('duration_minutes', e.detail.value)"
/>
</view>
</view>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">生效日期</text>
<picker mode="date" :value="form.effective_from" @change="(e: any) => form.effective_from = e.detail.value">
<view :class="['picker-display', form.effective_from ? '' : 'placeholder']">
{{ form.effective_from || '选择日期' }}
</view>
</picker>
</view>
<view class="form-field form-field--half">
<text class="form-label">失效日期</text>
<picker mode="date" :value="form.effective_to" @change="(e: any) => form.effective_to = e.detail.value">
<view :class="['picker-display', form.effective_to ? '' : 'placeholder']">
{{ form.effective_to || '选择日期' }}
</view>
</picker>
</view>
</view>
</view>
<!-- 透析参数 -->
<view class="section-card">
<text class="section-title">透析参数</text>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">血流量 (ml/min)</text>
<input
type="digit"
class="form-input"
placeholder="如 300"
:value="form.blood_flow_rate ?? ''"
@input="(e: any) => updateNumericField('blood_flow_rate', e.detail.value)"
/>
</view>
<view class="form-field form-field--half">
<text class="form-label">透析液流量 (ml/min)</text>
<input
type="digit"
class="form-input"
placeholder="如 500"
:value="form.dialysate_flow_rate ?? ''"
@input="(e: any) => updateNumericField('dialysate_flow_rate', e.detail.value)"
/>
</view>
</view>
<view class="form-field">
<text class="form-label">抗凝方式</text>
<input
class="form-input"
placeholder="如 肝素、低分子肝素"
:value="form.anticoagulation_type"
@input="(e: any) => form.anticoagulation_type = e.detail.value"
/>
</view>
<view class="form-field">
<text class="form-label">抗凝剂量</text>
<input
class="form-input"
placeholder="如 2000IU"
:value="form.anticoagulation_dose"
@input="(e: any) => form.anticoagulation_dose = e.detail.value"
/>
</view>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">透析液钾 (mmol/L)</text>
<input
type="digit"
class="form-input"
placeholder="如 2.0"
:value="form.dialysate_potassium ?? ''"
@input="(e: any) => updateNumericField('dialysate_potassium', e.detail.value)"
/>
</view>
<view class="form-field form-field--half">
<text class="form-label">透析液钙 (mmol/L)</text>
<input
type="digit"
class="form-input"
placeholder="如 1.5"
:value="form.dialysate_calcium ?? ''"
@input="(e: any) => updateNumericField('dialysate_calcium', e.detail.value)"
/>
</view>
</view>
<view class="form-field">
<text class="form-label">膜面积 (m2)</text>
<input
type="digit"
class="form-input"
placeholder="如 1.8"
:value="form.membrane_area ?? ''"
@input="(e: any) => updateNumericField('membrane_area', e.detail.value)"
/>
</view>
</view>
<!-- 目标参数 -->
<view class="section-card">
<text class="section-title">目标参数</text>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">干体重 (kg)</text>
<input
type="digit"
class="form-input"
placeholder="如 65.0"
:value="form.target_dry_weight ?? ''"
@input="(e: any) => updateNumericField('target_dry_weight', e.detail.value)"
/>
</view>
<view class="form-field form-field--half">
<text class="form-label">超滤目标 (ml)</text>
<input
type="digit"
class="form-input"
placeholder="如 2000"
:value="form.target_ultrafiltration_ml ?? ''"
@input="(e: any) => updateNumericField('target_ultrafiltration_ml', e.detail.value)"
/>
</view>
</view>
</view>
<!-- 血管通路 -->
<view class="section-card">
<text class="section-title">血管通路</text>
<view class="form-field">
<text class="form-label">通路类型</text>
<input
class="form-input"
placeholder="如 动静脉内瘘、中心静脉导管"
:value="form.vascular_access_type"
@input="(e: any) => form.vascular_access_type = e.detail.value"
/>
</view>
<view class="form-field">
<text class="form-label">通路位置</text>
<input
class="form-input"
placeholder="如 左前臂"
:value="form.vascular_access_location"
@input="(e: any) => form.vascular_access_location = e.detail.value"
/>
</view>
</view>
<!-- 备注 -->
<view class="section-card">
<text class="section-title">备注</text>
<textarea
class="form-textarea"
placeholder="处方备注(选填)"
:value="form.notes"
@input="(e: any) => form.notes = e.detail.value"
:maxlength="1000"
/>
</view>
<!-- 提交 -->
<view class="submit-wrap">
<view :class="['action-btn', submitting ? 'disabled' : '']" @tap="submitting ? undefined : handleSubmit">
<text class="action-btn-text">{{ submitting ? '提交中...' : '创建处方' }}</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useElderClass } from '@/composables/useElderClass'
import { createDialysisPrescription } from '@/services/doctor/dialysis'
import { listPatients } from '@/services/doctor/patient'
import type { PatientItem } from '@/services/doctor/patient'
const { elderClass } = useElderClass()
const form = reactive({
patient_id: '',
dialyzer_model: '',
frequency_per_week: undefined as number | undefined,
duration_minutes: undefined as number | undefined,
blood_flow_rate: undefined as number | undefined,
dialysate_flow_rate: undefined as number | undefined,
anticoagulation_type: '',
anticoagulation_dose: '',
dialysate_potassium: undefined as number | undefined,
dialysate_calcium: undefined as number | undefined,
dialysate_bicarbonate: undefined as number | undefined,
membrane_area: undefined as number | undefined,
target_dry_weight: undefined as number | undefined,
target_ultrafiltration_ml: undefined as number | undefined,
vascular_access_type: '',
vascular_access_location: '',
effective_from: '',
effective_to: '',
notes: '',
})
const patientSearch = ref('')
const patientResults = ref<PatientItem[]>([])
const searchingPatient = ref(false)
const submitting = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null
function searchPatients() {
if (searchTimer) clearTimeout(searchTimer)
if (!patientSearch.value.trim()) {
patientResults.value = []
return
}
searchTimer = setTimeout(async () => {
searchingPatient.value = true
try {
const res = await listPatients({ search: patientSearch.value.trim(), page_size: 10 })
patientResults.value = res.data || []
} catch {
patientResults.value = []
} finally {
searchingPatient.value = false
}
}, 300)
}
function selectPatient(p: PatientItem) {
form.patient_id = form.patient_id === p.id ? '' : p.id
}
function updateNumericField(field: keyof typeof form, raw: string) {
const val = raw.trim() === '' ? undefined : Number(raw)
;(form as any)[field] = isNaN(val as number) ? undefined : val
}
async function handleSubmit() {
if (!form.patient_id) {
uni.showToast({ title: '请选择患者', icon: 'none' })
return
}
if (!form.frequency_per_week) {
uni.showToast({ title: '请填写透析频率', icon: 'none' })
return
}
submitting.value = true
try {
await createDialysisPrescription({
patient_id: form.patient_id,
dialyzer_model: form.dialyzer_model || undefined,
frequency_per_week: form.frequency_per_week,
duration_minutes: form.duration_minutes,
blood_flow_rate: form.blood_flow_rate,
dialysate_flow_rate: form.dialysate_flow_rate,
anticoagulation_type: form.anticoagulation_type || undefined,
anticoagulation_dose: form.anticoagulation_dose || undefined,
dialysate_potassium: form.dialysate_potassium,
dialysate_calcium: form.dialysate_calcium,
dialysate_bicarbonate: form.dialysate_bicarbonate,
membrane_area: form.membrane_area,
target_dry_weight: form.target_dry_weight,
target_ultrafiltration_ml: form.target_ultrafiltration_ml,
vascular_access_type: form.vascular_access_type || undefined,
vascular_access_location: form.vascular_access_location || undefined,
effective_from: form.effective_from || undefined,
effective_to: form.effective_to || undefined,
notes: form.notes.trim() || undefined,
})
uni.showToast({ title: '创建成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 800)
} catch {
uni.showToast({ title: '创建失败', icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 160px; }
.section-card {
@include card;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
// Search
.patient-search { margin-bottom: 12px; }
.search-input {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
box-sizing: border-box;
}
.loading-hint, .empty-hint {
@include flex-center;
padding: 24px 0;
}
.loading-hint__text, .empty-hint__text {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
.patient-list {
max-height: 320px;
overflow-y: auto;
}
.patient-item {
display: flex;
align-items: center;
padding: 16px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
border-radius: $r-xs;
&:active { background: $bd-l; }
&--selected { background: $pri-l; }
&__name {
flex: 1;
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
&__info {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-right: 12px;
}
&__check {
width: 32px;
height: 32px;
@include flex-center;
background: $pri;
border-radius: 50%;
}
}
.check-icon {
color: $card;
font-size: var(--tk-font-body-sm);
}
// Form
.form-field {
margin-bottom: 16px;
}
.form-field--half {
flex: 1;
}
.form-row {
display: flex;
gap: 12px;
}
.form-label {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: 8px;
}
.required { color: $dan; }
.form-input {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
box-sizing: border-box;
}
.picker-display {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
display: flex;
align-items: center;
box-sizing: border-box;
}
.picker-display.placeholder { color: $tx3; }
.form-textarea {
width: 100%;
min-height: 120px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 12px;
font-size: var(--tk-font-body);
color: $tx;
box-sizing: border-box;
background: $card;
}
// Submit
.submit-wrap { margin: 0 24px; }
.action-btn {
@include btn-primary;
}
.action-btn.disabled { opacity: 0.5; }
.action-btn-text {
color: $card;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<Loading v-if="pageLoading" text="加载中..." />
<ErrorState v-else-if="error || !prescription" text="处方加载失败" :on-retry="loadData" />
<scroll-view v-else scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- 处方信息 -->
<view class="info-card">
<view class="info-header">
<text class="section-label">处方信息</text>
<view class="status-tag" :style="getStatusInlineStyle(prescription.status)">
<text class="status-tag__text">{{ getStatusLabel(prescription.status) }}</text>
</view>
</view>
<view class="info-row">
<text class="info-label">透析方式</text>
<text class="info-value">{{ prescription.dialyzer_model || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">频率</text>
<text class="info-value">{{ prescription.frequency_per_week ? `${prescription.frequency_per_week} 次/周` : '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">单次时长</text>
<text class="info-value">{{ prescription.duration_minutes ? `${prescription.duration_minutes} 分钟` : '-' }}</text>
</view>
<view v-if="prescription.effective_from" class="info-row">
<text class="info-label">生效日期</text>
<text class="info-value">{{ prescription.effective_from }}</text>
</view>
<view v-if="prescription.effective_to" class="info-row">
<text class="info-label">失效日期</text>
<text class="info-value">{{ prescription.effective_to }}</text>
</view>
</view>
<!-- 透析参数 -->
<view class="section-card">
<text class="section-title">透析参数</text>
<view v-if="prescription.blood_flow_rate != null" class="info-row">
<text class="info-label">血流量</text>
<text class="info-value">{{ prescription.blood_flow_rate }} ml/min</text>
</view>
<view v-if="prescription.dialysate_flow_rate != null" class="info-row">
<text class="info-label">透析液流量</text>
<text class="info-value">{{ prescription.dialysate_flow_rate }} ml/min</text>
</view>
<view v-if="prescription.dialysate_potassium != null" class="info-row">
<text class="info-label">透析液钾浓度</text>
<text class="info-value">{{ prescription.dialysate_potassium }} mmol/L</text>
</view>
<view v-if="prescription.dialysate_calcium != null" class="info-row">
<text class="info-label">透析液钙浓度</text>
<text class="info-value">{{ prescription.dialysate_calcium }} mmol/L</text>
</view>
<view v-if="prescription.dialysate_bicarbonate != null" class="info-row">
<text class="info-label">透析液碳酸氢盐</text>
<text class="info-value">{{ prescription.dialysate_bicarbonate }} mmol/L</text>
</view>
<view v-if="prescription.anticoagulation_type" class="info-row">
<text class="info-label">抗凝方式</text>
<text class="info-value">{{ prescription.anticoagulation_type }}</text>
</view>
<view v-if="prescription.anticoagulation_dose" class="info-row">
<text class="info-label">抗凝剂量</text>
<text class="info-value">{{ prescription.anticoagulation_dose }}</text>
</view>
</view>
<!-- 目标参数 -->
<view class="section-card">
<text class="section-title">目标参数</text>
<view class="vitals-grid">
<view v-if="prescription.target_dry_weight != null" class="vital-item">
<text class="vital-value">{{ prescription.target_dry_weight }}</text>
<text class="vital-label">干体重 kg</text>
</view>
<view v-if="prescription.target_ultrafiltration_ml != null" class="vital-item">
<text class="vital-value">{{ prescription.target_ultrafiltration_ml }}</text>
<text class="vital-label">超滤目标 ml</text>
</view>
<view v-if="prescription.membrane_area != null" class="vital-item">
<text class="vital-value">{{ prescription.membrane_area }}</text>
<text class="vital-label">膜面积 m2</text>
</view>
</view>
</view>
<!-- 血管通路 -->
<view v-if="prescription.vascular_access_type" class="section-card">
<text class="section-title">血管通路</text>
<view class="info-row">
<text class="info-label">通路类型</text>
<text class="info-value">{{ prescription.vascular_access_type }}</text>
</view>
<view v-if="prescription.vascular_access_location" class="info-row">
<text class="info-label">通路位置</text>
<text class="info-value">{{ prescription.vascular_access_location }}</text>
</view>
</view>
<!-- 备注 -->
<view v-if="prescription.notes" class="section-card">
<text class="section-title">备注</text>
<text class="notes-text">{{ prescription.notes }}</text>
</view>
<!-- 操作 -->
<view v-if="prescription.status === 'active'" class="action-card">
<view
:class="['action-btn--outline', deactivating ? 'disabled' : '']"
@tap="deactivating ? undefined : handleDeactivate"
>
<text class="action-btn--outline__text">{{ deactivating ? '处理中...' : '停用处方' }}</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { getDialysisPrescriptionById, updateDialysisPrescription } from '@/services/doctor/dialysis'
import type { DialysisPrescription } from '@/services/dialysis'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import Loading from '@/components/Loading.vue'
import ErrorState from '@/components/ErrorState.vue'
const { elderClass } = useElderClass()
const prescription = ref<DialysisPrescription | null>(null)
const pageLoading = ref(true)
const error = ref(false)
const deactivating = ref(false)
let prescriptionId = ''
async function loadData() {
if (!prescriptionId) return
pageLoading.value = true
error.value = false
try {
const data = await getDialysisPrescriptionById(prescriptionId)
prescription.value = data
} catch {
error.value = true
} finally {
pageLoading.value = false
}
}
async function handleDeactivate() {
if (!prescription.value) return
deactivating.value = true
try {
const updated = await updateDialysisPrescription(
prescriptionId,
{ status: 'inactive' },
prescription.value.version,
)
prescription.value = updated
uni.showToast({ title: '已停用', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
deactivating.value = false
}
}
onLoad((query) => {
prescriptionId = query?.id || ''
if (!prescriptionId) { error.value = true; pageLoading.value = false; return }
loadData()
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 120px; }
.info-card {
@include card;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-label {
font-size: var(--tk-font-title);
font-weight: 600;
color: $tx;
}
.status-tag {
display: inline-block;
border-radius: 6px;
padding: 2px 8px;
}
.status-tag__text {
font-size: var(--tk-font-body);
font-weight: 500;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: var(--tk-font-cap);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
.section-card {
@include card;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
.vitals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.vital-item {
background: $pri-l;
border-radius: $r-sm;
padding: 16px;
text-align: center;
}
.vital-value {
@include serif-number;
font-size: var(--tk-font-num);
font-weight: 700;
color: $pri;
display: block;
margin-bottom: 4px;
}
.vital-label {
font-size: var(--tk-font-cap);
color: $tx2;
}
.notes-text {
font-size: var(--tk-font-body-sm);
color: $tx2;
line-height: 1.6;
}
.action-card {
@include card;
}
.action-btn--outline {
@include btn-outline;
&.disabled { opacity: 0.5; }
&__text {
color: $pri;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
}
</style>

View File

@@ -0,0 +1,336 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]">
<!-- 搜索 -->
<view class="search-bar">
<input
class="search-input"
placeholder="搜索患者姓名"
:value="searchKeyword"
@input="(e: any) => { searchKeyword = e.detail.value; debouncedSearch() }"
/>
</view>
<!-- Tab 筛选 -->
<view class="tabs">
<view
v-for="t in TABS"
:key="t.key"
:class="['tab', activeTab === t.key ? 'tab--active' : '']"
@tap="handleTabChange(t.key)"
>
<text>{{ t.label }}</text>
</view>
</view>
<!-- 加载态 -->
<Loading v-if="loading && prescriptions.length === 0" text="加载中..." />
<!-- 空态 -->
<EmptyState v-else-if="prescriptions.length === 0" icon="📋" title="暂无透析处方" />
<!-- 处方列表 -->
<view v-else class="prescription-list">
<view
v-for="p in prescriptions"
:key="p.id"
class="prescription-card"
@tap="goDetail(p.id)"
>
<view class="prescription-card__top">
<text class="prescription-card__patient">{{ patientNameMap[p.patient_id] || '未知患者' }}</text>
<view class="prescription-card__status" :style="getStatusInlineStyle(p.status)">
<text class="prescription-card__status-text">{{ getStatusLabel(p.status) }}</text>
</view>
</view>
<view class="prescription-card__meta">
<text class="prescription-card__type">
{{ dialysisTypeLabel(p.dialyzer_model) }}
</text>
<text v-if="p.frequency_per_week" class="prescription-card__freq">
{{ p.frequency_per_week }}/
</text>
</view>
<text class="prescription-card__date">{{ p.created_at?.substring(0, 10) }}</text>
</view>
</view>
<!-- 分页 -->
<view v-if="total > pageSize" class="pagination">
<text
:class="['pagination__btn', page <= 1 ? 'disabled' : '']"
@tap="page > 1 && (page = page - 1)"
>上一页</text>
<text class="pagination__info">{{ page }} / {{ totalPages }}</text>
<text
:class="['pagination__btn', page >= totalPages ? 'disabled' : '']"
@tap="page < totalPages && (page = page + 1)"
>下一页</text>
</view>
<!-- 创建按钮 -->
<view class="fab" @tap="goCreate">
<text class="fab__icon">+</text>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { listDialysisPrescriptions } from '@/services/doctor/dialysis'
import type { DialysisPrescription } from '@/services/dialysis'
import { listPatients } from '@/services/doctor/patient'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
const TABS = [
{ key: '', label: '全部' },
{ key: 'active', label: '生效中' },
{ key: 'inactive', label: '已停用' },
{ key: 'expired', label: '已过期' },
] as const
const { elderClass } = useElderClass()
const pageSize = 20
const prescriptions = ref<DialysisPrescription[]>([])
const patientNameMap = ref<Record<string, string>>({})
const activeTab = ref('')
const searchKeyword = ref('')
const loading = ref(true)
const total = ref(0)
const page = ref(1)
const totalPages = computed(() => Math.ceil(total.value / pageSize))
function handleTabChange(key: string) {
activeTab.value = key
page.value = 1
}
function dialysisTypeLabel(model?: string): string {
if (!model) return '透析处方'
return model
}
function goDetail(id: string) {
uni.navigateTo({ url: `/pages-sub/doctor/prescription/detail/index?id=${id}` })
}
function goCreate() {
uni.navigateTo({ url: '/pages-sub/doctor/prescription/create/index' })
}
let searchTimer: ReturnType<typeof setTimeout> | null = null
function debouncedSearch() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
page.value = 1
loadPrescriptions()
}, 300)
}
async function loadPrescriptions() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: page.value,
page_size: pageSize,
}
if (activeTab.value) params.status = activeTab.value
const res = await listDialysisPrescriptions(params)
prescriptions.value = res.data || []
total.value = res.total || 0
// Resolve patient names
const ids = [...new Set(prescriptions.value.map((p) => p.patient_id))]
await resolvePatientNames(ids)
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function resolvePatientNames(ids: string[]) {
const missing = ids.filter((id) => !patientNameMap.value[id])
if (missing.length === 0) return
try {
// Load patients in batches to resolve names
for (const id of missing) {
try {
const res = await listPatients({ page_size: 1 })
// If we have data, try to find the patient
const patient = res.data?.find((p) => p.id === id)
if (patient) {
patientNameMap.value = { ...patientNameMap.value, [id]: patient.name }
}
} catch { /* skip individual failures */ }
}
} catch { /* non-critical */ }
}
watch([page, activeTab], () => { loadPrescriptions() })
onShow(() => {
loadPrescriptions()
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.search-bar {
padding: 16px 24px;
background: $card;
}
.search-input {
height: 48px;
border: 1px solid $bd;
border-radius: $r-pill;
padding: 0 24px;
font-size: var(--tk-font-body);
color: $tx;
background: $bg;
box-sizing: border-box;
}
.tabs {
display: flex;
background: $card;
padding: 0 16px;
border-bottom: 1px solid $bd;
}
.tab {
flex: 1;
text-align: center;
padding: 24px 0;
font-size: var(--tk-font-body-lg);
color: $tx2;
position: relative;
&--active {
color: $pri;
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 30%;
right: 30%;
height: 4px;
background: $pri;
border-radius: $r-xs;
}
}
}
.prescription-list {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.prescription-card {
@include card;
&:active { background: $bd-l; }
&__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
&__patient {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16px;
}
&__status {
display: inline-block;
border-radius: 6px;
padding: 2px 8px;
flex-shrink: 0;
}
&__status-text {
font-size: var(--tk-font-body);
font-weight: 500;
}
&__meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
&__type {
@include tag($pri-l, $pri);
}
&__freq {
font-size: var(--tk-font-body-sm);
color: $tx2;
}
&__date {
font-size: var(--tk-font-cap);
color: $tx3;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
padding: 24px;
&__btn {
font-size: var(--tk-font-body);
color: $pri;
padding: 12px 24px;
&.disabled { color: $tx3; }
}
&__info {
font-size: var(--tk-font-body-sm);
color: $tx2;
}
}
.fab {
position: fixed;
right: 32px;
bottom: 100px;
width: 56px;
height: 56px;
background: $pri;
border-radius: 50%;
@include flex-center;
box-shadow: $shadow-lg;
&:active { opacity: 0.85; }
}
.fab__icon {
color: $card;
font-size: var(--tk-font-hero);
font-weight: 300;
}
</style>

View File

@@ -0,0 +1,349 @@
<template>
<view :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- Loading -->
<Loading v-if="loading" text="加载中..." />
<!-- Error -->
<view v-else-if="!report" class="empty-wrap">
<text class="empty-text">报告不存在</text>
</view>
<template v-else>
<!-- Report info card -->
<view class="info-card">
<view class="info-header">
<text class="report-type">{{ report.report_type }}</text>
<text class="status-tag" :style="getStatusInlineStyle(report.status)">
{{ getStatusLabel(report.status) }}
</text>
</view>
<view class="info-row">
<text class="info-label">报告日期</text>
<text class="info-value">{{ report.report_date }}</text>
</view>
<view v-if="report.abnormal_count != null" class="info-row">
<text class="info-label">异常指标</text>
<text :class="['info-value', report.abnormal_count > 0 ? 'abnormal' : 'normal']">
{{ report.abnormal_count }}
</text>
</view>
</view>
<!-- Indicators list -->
<view class="indicators-card">
<text class="section-title">检查指标</text>
<view v-if="indicators.length === 0" class="empty-indicators">
<text class="empty-text">暂无指标数据</text>
</view>
<view v-for="(item, idx) in indicators" :key="idx" class="indicator-item">
<view class="indicator-left">
<text class="indicator-name">{{ item.name }}</text>
<text class="indicator-value">{{ item.value }}{{ item.unit ? ` ${item.unit}` : '' }}</text>
</view>
<view class="indicator-right">
<text v-if="item.reference_min != null && item.reference_max != null" class="indicator-ref">
{{ item.reference_min }}~{{ item.reference_max }}
</text>
<text :class="['indicator-status', getIndicatorStatusClass(item)]">
{{ getIndicatorStatusLabel(item) }}
</text>
</view>
</view>
</view>
<!-- Doctor interpretation -->
<view v-if="report.doctor_notes" class="notes-card">
<text class="section-title">医生解读</text>
<text class="notes-text">{{ report.doctor_notes }}</text>
</view>
<!-- Review section (for doctor) -->
<view v-if="canReview" class="review-card">
<text class="section-title">审核报告</text>
<view class="form-field">
<text class="form-label">审核意见</text>
<textarea
class="form-textarea"
placeholder="请输入审核意见(选填)"
:value="reviewNotes"
@input="(e: any) => reviewNotes = e.detail.value"
:maxlength="500"
/>
</view>
<view
:class="['action-btn', reviewing ? 'disabled' : '']"
@tap="reviewing ? undefined : handleReview"
>
<text class="action-btn-text">{{ reviewing ? '提交中...' : '确认审核' }}</text>
</view>
</view>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor/labReport'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { useElderClass } from '@/composables/useElderClass'
import Loading from '@/components/Loading.vue'
interface IndicatorDisplay {
name: string
value: number
unit?: string
reference_min?: number
reference_max?: number
is_abnormal?: boolean
}
const { elderClass } = useElderClass()
const report = ref<doctorApi.LabReportDetail | null>(null)
const loading = ref(true)
const reviewing = ref(false)
const reviewNotes = ref('')
let patientId = ''
let reportId = ''
const indicators = computed<IndicatorDisplay[]>(() => {
if (!report.value?.items) return []
return report.value.items
})
const canReview = computed(() => {
if (!report.value) return false
return report.value.status !== 'reviewed' && report.value.status !== 'verified'
})
function getIndicatorStatusClass(item: IndicatorDisplay): string {
if (item.is_abnormal) {
if (item.reference_min != null && item.value < item.reference_min) return 'low'
return 'high'
}
return 'normal'
}
function getIndicatorStatusLabel(item: IndicatorDisplay): string {
if (item.is_abnormal) {
if (item.reference_min != null && item.value < item.reference_min) return '偏低'
return '偏高'
}
return '正常'
}
async function fetchDetail() {
loading.value = true
try {
report.value = await doctorApi.getLabReport(patientId, reportId)
reviewNotes.value = report.value?.doctor_notes || ''
} catch {
report.value = null
} finally {
loading.value = false
}
}
async function handleReview() {
if (!report.value) return
reviewing.value = true
try {
const updated = await doctorApi.reviewLabReport(patientId, reportId, {
doctor_notes: reviewNotes.value.trim() || undefined,
version: report.value.version,
})
report.value = updated
uni.showToast({ title: '审核完成', icon: 'success' })
} catch {
uni.showToast({ title: '审核失败', icon: 'none' })
} finally {
reviewing.value = false
}
}
onLoad((query) => {
patientId = query?.patientId || ''
reportId = query?.reportId || ''
if (!patientId || !reportId) { loading.value = false; return }
fetchDetail()
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 120px; }
.empty-wrap { @include flex-center; padding: 120px 0; }
.empty-text { font-size: var(--tk-font-body); color: $tx3; }
// Info card
.info-card {
@include card;
margin-bottom: 16px;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.report-type {
font-size: var(--tk-font-h2);
font-weight: 600;
color: $tx;
}
.status-tag {
@include status-inline;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: var(--tk-font-cap);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body);
color: $tx;
}
.info-value.abnormal { color: $dan; font-weight: 600; }
.info-value.normal { color: $acc; }
// Indicators card
.indicators-card {
@include card;
margin-bottom: 16px;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
.empty-indicators {
@include flex-center;
padding: 40px 0;
}
.indicator-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.indicator-item:last-child { border-bottom: none; }
.indicator-left { flex: 1; }
.indicator-name {
display: block;
font-size: var(--tk-font-cap);
color: $tx2;
margin-bottom: 2px;
}
.indicator-value {
display: block;
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
.indicator-right {
text-align: right;
flex-shrink: 0;
margin-left: 16px;
}
.indicator-ref {
display: block;
font-size: var(--tk-font-micro);
color: $tx3;
margin-bottom: 2px;
}
.indicator-status {
display: block;
font-size: var(--tk-font-cap);
font-weight: 500;
}
.indicator-status.high { color: $wrn; }
.indicator-status.low { color: $info; }
.indicator-status.normal { color: $acc; }
// Notes card
.notes-card {
@include card;
margin-bottom: 16px;
}
.notes-text {
font-size: var(--tk-font-body-sm);
color: $tx;
line-height: 1.7;
}
// Review card
.review-card {
@include card;
}
.form-field { margin-bottom: 16px; }
.form-label {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: 8px;
}
.form-textarea {
width: 100%;
min-height: 120px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 12px;
font-size: var(--tk-font-body);
color: $tx;
box-sizing: border-box;
background: $card;
}
.action-btn {
@include btn-primary;
}
.action-btn.disabled { opacity: 0.5; }
.action-btn-text {
color: $white;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,220 @@
<template>
<view :class="['page-scroll', elderClass]">
<view class="page-content">
<text class="page-title">化验报告</text>
<!-- Search bar -->
<view class="search-bar">
<input
class="search-input"
placeholder="搜索患者姓名"
:value="searchText"
@input="(e: any) => searchText = e.detail.value"
@confirm="handleSearch"
confirm-type="search"
/>
</view>
<!-- Loading -->
<Loading v-if="loading && reports.length === 0" text="加载中..." />
<!-- Empty -->
<EmptyState v-else-if="reports.length === 0 && !loading" icon="📋" title="暂无化验报告" />
<!-- Report list -->
<scroll-view v-else scroll-y class="list-scroll" @scrolltolower="loadMore">
<view
v-for="item in reports" :key="item.id"
class="report-card"
@tap="goDetail(item.id)"
>
<view class="card-header">
<text class="report-type">{{ item.report_type }}</text>
<text class="status-tag" :style="getStatusInlineStyle(item.status)">
{{ getStatusLabel(item.status) }}
</text>
</view>
<view class="card-body">
<text class="report-date">报告日期{{ item.report_date }}</text>
<view v-if="item.abnormal_count != null && item.abnormal_count > 0" class="abnormal-badge">
<text class="abnormal-text">{{ item.abnormal_count }} 项异常</text>
</view>
</view>
</view>
<Loading v-if="loading" text="加载中..." />
<view v-if="!loading && reports.length >= total && total > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor/labReport'
import { listPatients } from '@/services/doctor/patient'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { useElderClass } from '@/composables/useElderClass'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
const { elderClass } = useElderClass()
const reports = ref<doctorApi.LabReportItem[]>([])
const total = ref(0)
const page = ref(1)
const loading = ref(false)
const searchText = ref('')
let patientId = ''
let currentPatientId = ''
let loadingGuard = false
async function fetchReports(pageNum: number, isRefresh = false) {
if (loadingGuard || !currentPatientId) return
loadingGuard = true
loading.value = true
try {
const res = await doctorApi.listLabReports(currentPatientId, {
page: pageNum,
page_size: 20,
})
const list = res.data || []
reports.value = isRefresh ? list : [...reports.value, ...list]
total.value = res.total
page.value = pageNum
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loadingGuard = false
loading.value = false
}
}
async function handleSearch() {
const keyword = searchText.value.trim()
if (!keyword) return
loading.value = true
try {
const res = await listPatients({ search: keyword, page_size: 1 })
const patients = res.data || []
if (patients.length > 0) {
currentPatientId = patients[0].id
fetchReports(1, true)
} else {
uni.showToast({ title: '未找到该患者', icon: 'none' })
loading.value = false
}
} catch {
uni.showToast({ title: '搜索失败', icon: 'none' })
loading.value = false
}
}
function loadMore() {
if (!loading.value && reports.value.length < total.value) {
fetchReports(page.value + 1)
}
}
function goDetail(reportId: string) {
uni.navigateTo({
url: `/pages-sub/doctor/report/detail/index?reportId=${reportId}&patientId=${currentPatientId}`,
})
}
onLoad((query) => {
patientId = query?.patientId || ''
if (patientId) {
currentPatientId = patientId
fetchReports(1, true)
}
})
onPullDownRefresh(() => {
if (currentPatientId) {
fetchReports(1, true).finally(() => uni.stopPullDownRefresh())
} else {
uni.stopPullDownRefresh()
}
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 28px 0 120px; }
.page-title { @include section-title; margin-left: 24px; }
// Search bar
.search-bar {
padding: 0 24px 16px;
}
.search-input {
height: 48px;
background: $card;
border-radius: $r;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
box-shadow: $shadow-sm;
}
// List
.list-scroll { height: calc(100vh - 180px); }
.report-card {
background: $card;
border-radius: $r;
padding: 20px 24px;
margin: 0 24px 16px;
box-shadow: $shadow-sm;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.report-type {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
}
.status-tag {
@include status-inline;
}
.card-body {
display: flex;
justify-content: space-between;
align-items: center;
}
.report-date {
font-size: var(--tk-font-cap);
color: $tx3;
}
.abnormal-badge {
padding: 2px 10px;
border-radius: $r-pill;
background: $dan-l;
}
.abnormal-text {
font-size: var(--tk-font-cap);
color: $dan;
font-weight: 500;
}
.no-more { @include flex-center; padding: 20px; }
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
</style>

View File

@@ -0,0 +1,115 @@
<template>
<scroll-view scroll-y :class="['events-page', elderClass]">
<view class="events-header">
<text class="events-header__title">线下活动</text>
<text class="events-header__subtitle">参加活动赢取积分</text>
</view>
<view v-if="events.length === 0 && !loading" class="empty-wrap">
<EmptyState icon="" title="暂无可报名的活动" />
</view>
<view v-else class="event-list">
<view v-for="event in events" :key="event.id" class="event-card">
<view class="event-card__header">
<view :class="['event-card__status', (STATUS_MAP[event.status] || { className: '' }).className]">
<text>{{ (STATUS_MAP[event.status] || { label: event.status }).label }}</text>
</view>
<text class="event-card__points">+{{ event.points_reward }} 积分</text>
</view>
<text class="event-card__title">{{ event.title }}</text>
<text v-if="event.description" class="event-card__desc">{{ event.description }}</text>
<view class="event-card__info">
<text class="event-card__date">{{ formatDate(event.event_date) }}</text>
<text v-if="event.location" class="event-card__location">{{ event.location }}</text>
</view>
<view class="event-card__footer">
<text class="event-card__participants">
{{ event.current_participants }}{{ event.max_participants ? `/${event.max_participants}` : '' }} 人已报名
</text>
<view :class="['event-card__btn', isFull(event) ? 'event-card__btn--disabled' : '']" @tap="handleRegister(event)">
<text class="event-card__btn-text">
{{ registering === event.id ? '报名中...' : isFull(event) ? '已满' : '立即报名' }}
</text>
</view>
</view>
</view>
</view>
<Loading v-if="loading" text="加载中..." />
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted } from 'vue'
import { listOfflineEvents, registerEvent, type OfflineEvent } from '@/services/points'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
import { useElderClass } from '@/composables/useElderClass'
const STATUS_MAP: Record<string, { label: string; className: string }> = {
published: { label: '报名中', className: 'event-card__status--published' },
ongoing: { label: '进行中', className: 'event-card__status--ongoing' },
completed: { label: '已结束', className: 'event-card__status--completed' },
cancelled: { label: '已取消', className: 'event-card__status--cancelled' },
}
const { elderClass } = useElderClass()
const events = ref<OfflineEvent[]>([])
const loading = ref(true)
const registering = ref<string | null>(null)
const isFull = (event: OfflineEvent) => event.max_participants != null && event.current_participants >= event.max_participants
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
const loadEvents = async () => {
loading.value = true
try {
const res = await listOfflineEvents({ page: 1, page_size: 50, status: 'published' })
events.value = res.data || []
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
finally { loading.value = false }
}
const handleRegister = async (event: OfflineEvent) => {
if (isFull(event) || registering.value) return
registering.value = event.id
try {
await registerEvent(event.id)
uni.showToast({ title: '报名成功', icon: 'success' })
loadEvents()
} catch (err: any) {
const msg = err?.message || '报名失败'
uni.showToast({ title: msg.substring(0, 20), icon: 'none' })
} finally { registering.value = null }
}
onMounted(loadEvents)
</script>
<style lang="scss" scoped>
.events-page { min-height: 100vh; background: $bg; }
.events-header { padding: 32px 24px 16px; }
.events-header__title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; }
.events-header__subtitle { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; display: block; }
.event-list { padding: 0 24px; }
.event-card { @include card; margin-bottom: 16px; }
.event-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.event-card__status { padding: 2px 10px; border-radius: 4px; }
.event-card__status--published { background: rgba($pri, 0.1); }
.event-card__status--ongoing { background: rgba(250,173,20,0.15); }
.event-card__status--completed { background: rgba(0,0,0,0.05); }
.event-card__status--cancelled { background: rgba(0,0,0,0.05); }
.event-card__points { font-size: var(--tk-font-cap); color: $wrn; font-weight: 500; }
.event-card__title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; display: block; }
.event-card__desc { font-size: var(--tk-font-cap); color: $tx2; display: block; margin-top: 6px; line-height: 1.5; }
.event-card__info { display: flex; gap: 16px; margin-top: 10px; }
.event-card__date, .event-card__location { font-size: var(--tk-font-cap); color: $tx3; }
.event-card__footer { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(0,0,0,0.05); }
.event-card__participants { font-size: var(--tk-font-cap); color: $tx3; }
.event-card__btn { padding: 6px 20px; min-height: $touch-min; display: flex; align-items: center; background: $pri; border-radius: $r; }
.event-card__btn--disabled { background: rgba(0,0,0,0.1); }
.event-card__btn-text { font-size: var(--tk-font-cap); color: $white; }
.event-card__btn--disabled .event-card__btn-text { color: $tx3; }
.empty-wrap { padding-top: 80px; }
</style>

View File

@@ -0,0 +1,109 @@
<template>
<view :class="['detail-page', elderClass]">
<Loading v-if="loading" text="加载中..." />
<ErrorState v-else-if="error || !task" text="任务不存在" />
<template v-else>
<view class="detail-card">
<text class="detail-title">{{ task.follow_up_type }}</text>
<view class="detail-row">
<text class="detail-label">状态</text>
<text :class="['detail-value', getStatusClass(task.status)]">{{ getStatusLabel(task.status) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">截止日期</text>
<text class="detail-value">{{ task.planned_date }}</text>
</view>
<view v-if="countdown" :class="['countdown', countdown.urgent ? 'countdown-urgent' : '']">
<text class="countdown-text">{{ countdown.text }}</text>
</view>
<view v-if="task.content_template" class="detail-desc">
<text class="detail-desc-text">{{ task.content_template }}</text>
</view>
</view>
<view v-if="task.status !== 'completed'" class="submit-card">
<text class="section-title">填写随访记录</text>
<textarea class="submit-textarea" placeholder="请输入随访内容..." :value="content" @input="(e: any) => content = e.detail.value" :maxlength="500" />
<view :class="['submit-btn', submitting ? 'disabled' : '']" @tap="submitting ? undefined : handleSubmit">
<text class="submit-btn-text">{{ submitting ? '提交中...' : '提交' }}</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getTaskDetail, submitRecord, type FollowUpTask } from '@/services/followup'
import { trackEvent } from '@/services/analytics'
import ErrorState from '@/components/ErrorState.vue'
import Loading from '@/components/Loading.vue'
import { useElderClass } from '@/composables/useElderClass'
const { elderClass } = useElderClass()
const task = ref<FollowUpTask | null>(null)
const content = ref('')
const submitting = ref(false)
const loading = ref(true)
const error = ref(false)
let id = ''
const getStatusLabel = (status: string) => status === 'completed' ? '已完成' : status === 'overdue' ? '已过期' : '待完成'
const getStatusClass = (status: string) => status === 'completed' ? 'status-completed' : status === 'overdue' ? 'status-overdue' : 'status-pending'
const countdown = computed(() => {
if (!task.value || task.value.status === 'completed') return null
const now = new Date()
const due = new Date(task.value.planned_date)
const diffMs = due.getTime() - now.getTime()
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffDays < 0) return { text: `已过期 ${Math.abs(diffDays)}`, urgent: true }
if (diffDays === 0) return { text: '今天截止', urgent: true }
if (diffDays <= 3) return { text: `还剩 ${diffDays}`, urgent: true }
return { text: `还剩 ${diffDays}`, urgent: false }
})
const handleSubmit = async () => {
if (!content.value.trim()) { uni.showToast({ title: '请输入内容', icon: 'none' }); return }
submitting.value = true
try {
await submitRecord(id, { result: content.value.trim(), patient_condition: content.value.trim() })
uni.showToast({ title: '提交成功', icon: 'success' })
trackEvent('followup_submit', { task_id: id })
content.value = ''
} catch { uni.showToast({ title: '提交失败', icon: 'none' }) }
finally { submitting.value = false }
}
onLoad((query) => {
id = query?.id || ''
if (!id) { error.value = true; loading.value = false; return }
loading.value = true
getTaskDetail(id).then(data => { task.value = data }).catch(() => { error.value = true }).finally(() => { loading.value = false })
})
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
.detail-card { @include card; margin-bottom: 16px; }
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-bottom: 12px; }
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; }
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
.detail-value { font-size: var(--tk-font-body); color: $tx; }
.status-completed { color: $acc; }
.status-overdue { color: $dan; }
.status-pending { color: $pri; }
.countdown { margin-top: 8px; padding: 8px 12px; border-radius: $r; background: rgba(250,173,20,0.08); }
.countdown-urgent { background: rgba(255,77,79,0.08); }
.countdown-text { font-size: var(--tk-font-cap); color: $wrn; }
.countdown-urgent .countdown-text { color: $dan; }
.detail-desc { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.05); }
.detail-desc-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
.submit-card { @include card; }
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
.submit-textarea { width: 100%; min-height: 120px; border: 1px solid rgba(0,0,0,0.08); border-radius: $r; padding: 12px; font-size: var(--tk-font-body); box-sizing: border-box; }
.submit-btn { margin-top: 16px; height: $touch-min; background: $pri; border-radius: $r; @include flex-center; }
.submit-btn.disabled { opacity: 0.5; }
.submit-btn-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
</style>

View File

@@ -0,0 +1,129 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- 积分概览 -->
<view class="points-card card">
<text class="points-label">我的积分</text>
<text class="points-value">{{ points }}</text>
<view class="checkin-btn" @tap="doCheckin">
{{ checkinDone ? '已签到' : '每日签到' }}
</view>
</view>
<!-- 商品列表 -->
<text class="section-title">兑换商品</text>
<Loading v-if="loading" text="加载中..." />
<EmptyState v-else-if="products.length === 0" icon="🎁" title="暂无可兑换商品" />
<view v-else class="product-grid">
<view v-for="item in products" :key="item.id" class="product-card">
<text class="product-name">{{ item.name }}</text>
<text class="product-points">{{ item.points }} 积分</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/services/request'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
import { useElderClass } from '@/composables/useElderClass'
const { elderClass } = useElderClass()
const points = ref(0)
const products = ref<any[]>([])
const loading = ref(false)
const checkinDone = ref(false)
async function fetchPoints() {
try {
const res = await api.get<any>('/health/points')
if (res) points.value = res.points || 0
} catch { /* ignore */ }
}
async function fetchProducts() {
loading.value = true
try { products.value = await api.get<any[]>('/health/points/products') || [] }
catch { products.value = [] }
loading.value = false
}
async function doCheckin() {
if (checkinDone.value) return
try {
await api.post('/health/points/checkin')
checkinDone.value = true
uni.showToast({ title: '签到成功', icon: 'success' })
await fetchPoints()
} catch {
uni.showToast({ title: '签到失败', icon: 'none' })
}
}
onMounted(() => { fetchPoints(); fetchProducts() })
</script>
<style lang="scss" scoped>
.page-scroll { height: 100vh; background: $bg; }
.page-content { padding: 28px 24px 120px; }
.card { @include card; }
.points-card {
@include flex-center;
flex-direction: column;
margin-bottom: 28px;
}
.points-label {
font-size: var(--tk-font-body);
color: $tx3;
}
.points-value {
font-size: var(--tk-font-display);
font-weight: bold;
color: $pri;
@include serif-number;
margin: 12px 0;
}
.checkin-btn {
@include btn-primary;
width: auto;
padding: 0 48px;
}
.section-title { @include section-title; }
.product-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.product-card {
background: $card;
border-radius: $r;
padding: 20px;
box-shadow: $shadow-sm;
@include flex-center;
flex-direction: column;
}
.product-name {
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
margin-bottom: 8px;
}
.product-points {
font-size: var(--tk-font-body-sm);
color: $pri;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<view :class="['alerts-page', elderClass]">
<template v-if="!authStore.currentPatient">
<view class="alerts-empty">
<text class="alerts-empty-text">请先完善个人档案</text>
<view class="alerts-empty-action" @tap="goAddFamily">
<text class="alerts-empty-action-text">去建档</text>
</view>
</view>
</template>
<template v-else>
<view class="alerts-tabs">
<view
v-for="tab in STATUS_TABS" :key="tab.key"
:class="['alerts-tab', status === tab.key ? 'active' : '']"
@tap="handleTabChange(tab.key)"
>
<text :class="['alerts-tab-text', status === tab.key ? 'active' : '']">{{ tab.label }}</text>
</view>
</view>
<view v-if="alerts.length === 0 && !loading" class="alerts-empty">
<text class="alerts-empty-text">暂无告警记录</text>
<text class="alerts-empty-hint">您的各项指标正常</text>
</view>
<scroll-view v-else scroll-y class="alerts-scroll" @scrolltolower="loadMore">
<view class="alert-card" v-for="item in alerts" :key="item.id">
<view class="alert-header">
<view :class="['alert-badge', (SEVERITY_MAP[item.severity] || SEVERITY_MAP.warning).className]">
<text class="alert-badge-text">{{ (SEVERITY_MAP[item.severity] || SEVERITY_MAP.warning).label }}</text>
</view>
<text class="alert-time">{{ formatDate(item.created_at) }}</text>
</view>
<text class="alert-title">{{ item.title }}</text>
</view>
<Loading v-if="loading" text="加载中..." />
<view v-if="!loading && alerts.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
</scroll-view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { listPatientAlerts, type Alert } from '@/services/alert'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import Loading from '@/components/Loading.vue'
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
info: { label: '提示', className: 'sev-info' },
warning: { label: '警告', className: 'sev-warning' },
critical: { label: '严重', className: 'sev-critical' },
urgent: { label: '紧急', className: 'sev-urgent' },
}
const STATUS_TABS = [
{ key: '', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'acknowledged', label: '已确认' },
{ key: 'resolved', label: '已恢复' },
]
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const alerts = ref<Alert[]>([])
const total = ref(0)
const page = ref(1)
const status = ref('')
const loading = ref(false)
let loadingGuard = false
const fetchAlerts = async (pageNum: number, s: string, isRefresh = false) => {
if (!authStore.currentPatient || loadingGuard) return
loadingGuard = true
loading.value = true
try {
const res = await listPatientAlerts(authStore.currentPatient.id, { page: pageNum, page_size: 20, status: s || undefined })
const list = res.data || []
alerts.value = isRefresh ? list : [...alerts.value, ...list]
total.value = res.total
page.value = pageNum
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loadingGuard = false
loading.value = false
}
}
const handleTabChange = (key: string) => { status.value = key; fetchAlerts(1, key, true) }
const loadMore = () => { if (!loading.value && alerts.value.length < total.value) fetchAlerts(page.value + 1, status.value) }
const goAddFamily = () => uni.navigateTo({ url: '/pages-sub/pkg-profile/family-add/index' })
const formatDate = (d: string) => new Date(d).toLocaleDateString()
onShow(() => { fetchAlerts(1, status.value, true) })
onPullDownRefresh(() => { fetchAlerts(1, status.value, true).finally(() => uni.stopPullDownRefresh()) })
</script>
<style lang="scss" scoped>
.alerts-page { min-height: 100vh; background: $bg; }
.alerts-tabs { display: flex; padding: 12px 24px; gap: 8px; background: $card; }
.alerts-tab { padding: 6px 16px; min-height: $touch-min; display: flex; align-items: center; border-radius: 20px; background: rgba(0,0,0,0.04); }
.alerts-tab.active { background: $pri; }
.alerts-tab-text { font-size: var(--tk-font-cap); color: $tx2; }
.alerts-tab-text.active { color: $white; }
.alerts-scroll { height: calc(100vh - 52px); }
.alert-card { @include card; margin: 0 24px 12px; }
.alert-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.alert-badge { padding: 2px 8px; border-radius: 4px; }
.sev-info { background: rgba(0,0,0,0.05); }
.sev-warning { background: rgba(250,173,20,0.15); }
.sev-critical { background: rgba(255,77,79,0.15); }
.sev-urgent { background: rgba(114,46,209,0.15); }
.alert-badge-text { font-size: var(--tk-font-micro); color: $tx2; }
.alert-time { font-size: var(--tk-font-cap); color: $tx3; }
.alert-title { font-size: var(--tk-font-body); color: $tx; line-height: 1.5; }
.alerts-empty { @include flex-center; flex-direction: column; padding: 80px 40px; }
.alerts-empty-text { font-size: var(--tk-font-body); color: $tx2; }
.alerts-empty-hint { font-size: var(--tk-font-cap); color: $tx3; margin-top: 8px; }
.alerts-empty-action { margin-top: 20px; padding: 8px 24px; min-height: $touch-min; display: flex; align-items: center; background: $pri; border-radius: $r; }
.alerts-empty-action-text { color: $white; font-size: var(--tk-font-body); }
.no-more { @include flex-center; padding: 20px; }
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
</style>

View File

@@ -0,0 +1,326 @@
<template>
<scroll-view scroll-y :class="['dm-page', elderClass]">
<view class="dm-hero">
<view class="dm-hero-icon"><text class="dm-hero-icon-text"></text></view>
<text class="dm-hero-title">日常监测</text>
<text class="dm-hero-sub">每日健康数据上报</text>
</view>
<view class="dm-card">
<view class="dm-card-header">
<text class="dm-card-title">记录日期</text>
<text v-if="isToday" class="dm-card-badge">今日</text>
</view>
<picker mode="selector" :range="dateList" :value="dateIdx" @change="(e: any) => dateIdx = Number(e.detail.value)">
<view class="dm-date-row">
<text class="dm-date-value">{{ recordDate }}</text>
<text class="dm-date-arrow">V</text>
</view>
</picker>
</view>
<!-- 晨间体征 -->
<view :class="['dm-group', collapsed.morning ? 'dm-group-collapsed' : '']">
<view class="dm-group-header" @tap="toggleSection('morning')">
<text class="dm-group-title">晨间体征</text>
<text :class="['dm-group-arrow', collapsed.morning ? '' : 'dm-group-arrow-open']">&#9656;</text>
</view>
<view v-if="!collapsed.morning" class="dm-group-body">
<view class="dm-bp-group">
<view class="dm-bp-field">
<text class="dm-field-label">收缩压</text>
<input type="digit" :class="['dm-input-box', morningSysAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 120" :value="morningSystolic" @input="(e: any) => morningSystolic = e.detail.value" />
<text v-if="morningSysAbnormal.abnormal" :class="['dm-field-warning', morningSysAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
{{ morningSysAbnormal.direction === 'high' ? '偏高' : '偏低' }}
</text>
</view>
<view class="dm-bp-divider">
<view class="dm-bp-line" /><text class="dm-bp-slash">/</text><view class="dm-bp-line" />
</view>
<view class="dm-bp-field">
<text class="dm-field-label">舒张压</text>
<input type="digit" :class="['dm-input-box', morningDiaAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 80" :value="morningDiastolic" @input="(e: any) => morningDiastolic = e.detail.value" />
<text v-if="morningDiaAbnormal.abnormal" :class="['dm-field-warning', morningDiaAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
{{ morningDiaAbnormal.direction === 'high' ? '偏高' : '偏低' }}
</text>
</view>
</view>
<text class="dm-field-unit">mmHg</text>
</view>
</view>
<!-- 晚间体征 -->
<view :class="['dm-group', collapsed.evening ? 'dm-group-collapsed' : '']">
<view class="dm-group-header" @tap="toggleSection('evening')">
<text class="dm-group-title">晚间体征</text>
<text :class="['dm-group-arrow', collapsed.evening ? '' : 'dm-group-arrow-open']">&#9656;</text>
</view>
<view v-if="!collapsed.evening" class="dm-group-body">
<view class="dm-bp-group">
<view class="dm-bp-field">
<text class="dm-field-label">收缩压</text>
<input type="digit" :class="['dm-input-box', eveningSysAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 120" :value="eveningSystolic" @input="(e: any) => eveningSystolic = e.detail.value" />
<text v-if="eveningSysAbnormal.abnormal" :class="['dm-field-warning', eveningSysAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
{{ eveningSysAbnormal.direction === 'high' ? '偏高' : '偏低' }}
</text>
</view>
<view class="dm-bp-divider">
<view class="dm-bp-line" /><text class="dm-bp-slash">/</text><view class="dm-bp-line" />
</view>
<view class="dm-bp-field">
<text class="dm-field-label">舒张压</text>
<input type="digit" :class="['dm-input-box', eveningDiaAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 80" :value="eveningDiastolic" @input="(e: any) => eveningDiastolic = e.detail.value" />
<text v-if="eveningDiaAbnormal.abnormal" :class="['dm-field-warning', eveningDiaAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
{{ eveningDiaAbnormal.direction === 'high' ? '偏高' : '偏低' }}
</text>
</view>
</view>
<text class="dm-field-unit">mmHg</text>
</view>
</view>
<!-- 其他指标 -->
<view :class="['dm-group', collapsed.other ? 'dm-group-collapsed' : '']">
<view class="dm-group-header" @tap="toggleSection('other')">
<text class="dm-group-title">其他指标</text>
<text :class="['dm-group-arrow', collapsed.other ? '' : 'dm-group-arrow-open']">&#9656;</text>
</view>
<view v-if="!collapsed.other" class="dm-group-body">
<view class="dm-inner-field">
<text class="dm-field-label">体重</text>
<view class="dm-single-row">
<input type="digit" class="dm-input-box dm-input-flex" placeholder="如 65.0" :value="weight" @input="(e: any) => weight = e.detail.value" />
<text class="dm-unit-inline">kg</text>
</view>
</view>
<view class="dm-inner-field">
<text class="dm-field-label">血糖</text>
<view class="dm-single-row">
<input type="digit" :class="['dm-input-box', 'dm-input-flex', bloodSugarAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 5.6" :value="bloodSugar" @input="(e: any) => bloodSugar = e.detail.value" />
<text class="dm-unit-inline">mmol/L</text>
</view>
<text v-if="bloodSugarAbnormal.abnormal" :class="['dm-field-warning', bloodSugarAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
{{ bloodSugarAbnormal.direction === 'high' ? '偏高' : '偏低' }}
</text>
</view>
<view class="dm-inner-field">
<text class="dm-field-label">饮水量</text>
<view class="dm-single-row">
<input type="digit" class="dm-input-box dm-input-flex" placeholder="如 2000" :value="fluidIntake" @input="(e: any) => fluidIntake = e.detail.value" />
<text class="dm-unit-inline">ml</text>
</view>
</view>
<view class="dm-inner-field">
<text class="dm-field-label">尿量</text>
<view class="dm-single-row">
<input type="digit" class="dm-input-box dm-input-flex" placeholder="如 1500" :value="urineOutput" @input="(e: any) => urineOutput = e.detail.value" />
<text class="dm-unit-inline">ml</text>
</view>
</view>
<view class="dm-inner-field">
<text class="dm-field-label">备注</text>
<input class="dm-input-box dm-input-full" placeholder="如:头晕、乏力等(可选)" :value="notes" @input="(e: any) => notes = e.detail.value" />
</view>
</view>
</view>
<view :class="['dm-submit', submitting ? 'dm-submit-disabled' : '']" @tap="submitting ? undefined : handleSubmit">
<text class="dm-submit-text">{{ submitting ? '提交中...' : '提交上报' }}</text>
</view>
<view class="dm-reset" @tap="resetForm"><text class="dm-reset-text">清空表单</text></view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { validateNum } from '@/utils/validate'
import { createDailyMonitoring } from '@/services/health'
import { useAuthStore } from '@/stores/auth'
import { useHealthStore } from '@/stores/health'
import { usePointsStore } from '@/stores/points'
import { clearRequestCache } from '@/services/request'
import { trackEvent } from '@/services/analytics'
import { useElderClass } from '@/composables/useElderClass'
const BP_RANGE = { min: 30, minMsg: '血压值不能低于30', max: 300, maxMsg: '血压值不能高于300', optional: true }
const WEIGHT_RANGE = { min: 1, minMsg: '体重不能低于1kg', max: 500, maxMsg: '体重不能高于500kg', optional: true }
const SUGAR_RANGE = { min: 0.1, minMsg: '血糖值不能低于0.1', max: 50, maxMsg: '血糖值不能高于50', optional: true }
const VOLUME_RANGE = { min: 0, minMsg: '数值不能为负', max: 10000, maxMsg: '数值超出合理范围', optional: true }
const REFERENCE_RANGES: Record<string, { min: number; max: number } | null> = {
systolic: { min: 90, max: 140 }, diastolic: { min: 60, max: 90 },
bloodSugar: { min: 3.9, max: 6.1 }, weight: null, fluidIntake: null, urineOutput: null,
}
const FIELD_LABELS: Record<string, string> = {
morningSystolic: '晨间收缩压', morningDiastolic: '晨间舒张压',
eveningSystolic: '晚间收缩压', eveningDiastolic: '晚间舒张压', bloodSugar: '血糖',
}
const checkAbnormal = (value: string, field: string): { abnormal: boolean; direction: 'high' | 'low' | null } => {
const ref = REFERENCE_RANGES[field]
if (!value || !ref) return { abnormal: false, direction: null }
const n = parseFloat(value)
if (isNaN(n)) return { abnormal: false, direction: null }
if (n > ref.max) return { abnormal: true, direction: 'high' }
if (n < ref.min) return { abnormal: true, direction: 'low' }
return { abnormal: false, direction: null }
}
const formatDate = (date: Date) => {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const healthStore = useHealthStore()
const pointsStore = usePointsStore()
const today = formatDate(new Date())
const dateList = ref(Array.from({ length: 30 }, (_, i) => { const d = new Date(); d.setDate(d.getDate() - i); return formatDate(d) }))
const dateIdx = ref(0)
const recordDate = computed(() => dateList.value[dateIdx.value])
const isToday = computed(() => recordDate.value === today)
const morningSystolic = ref('')
const morningDiastolic = ref('')
const eveningSystolic = ref('')
const eveningDiastolic = ref('')
const weight = ref('')
const bloodSugar = ref('')
const fluidIntake = ref('')
const urineOutput = ref('')
const notes = ref('')
const submitting = ref(false)
type SectionKey = 'morning' | 'evening' | 'other'
const collapsed = ref<Record<SectionKey, boolean>>({ morning: false, evening: false, other: true })
const toggleSection = (key: SectionKey) => { collapsed.value = { ...collapsed.value, [key]: !collapsed.value[key] } }
const morningSysAbnormal = computed(() => checkAbnormal(morningSystolic.value, 'systolic'))
const morningDiaAbnormal = computed(() => checkAbnormal(morningDiastolic.value, 'diastolic'))
const eveningSysAbnormal = computed(() => checkAbnormal(eveningSystolic.value, 'systolic'))
const eveningDiaAbnormal = computed(() => checkAbnormal(eveningDiastolic.value, 'diastolic'))
const bloodSugarAbnormal = computed(() => checkAbnormal(bloodSugar.value, 'bloodSugar'))
const resetForm = () => {
morningSystolic.value = ''; morningDiastolic.value = ''
eveningSystolic.value = ''; eveningDiastolic.value = ''
weight.value = ''; bloodSugar.value = ''; fluidIntake.value = ''; urineOutput.value = ''; notes.value = ''
}
const gatherAbnormalFields = (): string[] => {
const checks: [string, string][] = [
['morningSystolic', morningSystolic.value], ['morningDiastolic', morningDiastolic.value],
['eveningSystolic', eveningSystolic.value], ['eveningDiastolic', eveningDiastolic.value],
['bloodSugar', bloodSugar.value],
]
return checks.filter(([field, value]) => checkAbnormal(value, field).abnormal).map(([field]) => FIELD_LABELS[field])
}
const handleSubmit = async () => {
if (!authStore.currentPatient) { uni.showToast({ title: '请先选择就诊人', icon: 'none' }); return }
const hasData = morningSystolic.value || morningDiastolic.value || eveningSystolic.value || eveningDiastolic.value || weight.value || bloodSugar.value || fluidIntake.value || urineOutput.value
if (!hasData) { uni.showToast({ title: '请至少填写一项数据', icon: 'none' }); return }
if ((morningSystolic.value && !morningDiastolic.value) || (!morningSystolic.value && morningDiastolic.value)) { uni.showToast({ title: '晨起血压请同时填写收缩压和舒张压', icon: 'none' }); return }
if ((eveningSystolic.value && !eveningDiastolic.value) || (!eveningSystolic.value && eveningDiastolic.value)) { uni.showToast({ title: '晚间血压请同时填写收缩压和舒张压', icon: 'none' }); return }
const parseNum = (v: string) => v ? parseFloat(v) : undefined
const fields = {
morningSystolic: parseNum(morningSystolic.value), morningDiastolic: parseNum(morningDiastolic.value),
eveningSystolic: parseNum(eveningSystolic.value), eveningDiastolic: parseNum(eveningDiastolic.value),
weight: parseNum(weight.value), bloodSugar: parseNum(bloodSugar.value),
fluidIntake: parseNum(fluidIntake.value), urineOutput: parseNum(urineOutput.value),
}
const validations: [number | undefined, string, typeof BP_RANGE][] = [
[fields.morningSystolic, '晨起收缩压', BP_RANGE], [fields.morningDiastolic, '晨起舒张压', BP_RANGE],
[fields.eveningSystolic, '晚间收缩压', BP_RANGE], [fields.eveningDiastolic, '晚间舒张压', BP_RANGE],
[fields.weight, '体重', WEIGHT_RANGE], [fields.bloodSugar, '血糖', SUGAR_RANGE],
[fields.fluidIntake, '饮水量', VOLUME_RANGE], [fields.urineOutput, '尿量', VOLUME_RANGE],
]
for (const [value, label, range] of validations) {
const err = validateNum(value, label, range)
if (err) { uni.showToast({ title: err, icon: 'none' }); return }
}
const abnormalFields = gatherAbnormalFields()
if (abnormalFields.length > 0) {
const confirmed = await uni.showModal({ title: '数值异常提醒', content: `以下指标超出正常范围:${abnormalFields.join('、')}。确认提交?`, confirmText: '确认提交', cancelText: '返回修改' })
if (!confirmed.confirm) return
}
submitting.value = true
try {
await createDailyMonitoring({
patient_id: authStore.currentPatient.id, record_date: recordDate.value,
morning_bp_systolic: fields.morningSystolic, morning_bp_diastolic: fields.morningDiastolic,
evening_bp_systolic: fields.eveningSystolic, evening_bp_diastolic: fields.eveningDiastolic,
weight: fields.weight, blood_sugar: fields.bloodSugar,
fluid_intake: fields.fluidIntake, urine_output: fields.urineOutput,
notes: notes.value || undefined,
})
trackEvent('daily_monitoring_submit', { date: recordDate.value })
healthStore.clearCache(); clearRequestCache('/health/'); pointsStore.invalidate()
uni.showToast({ title: '上报成功', icon: 'success' })
setTimeout(() => uni.showToast({ title: '+10 健康积分', icon: 'none', duration: 1500 }), 1600)
setTimeout(() => uni.navigateBack(), 3200)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '上报失败'
if (msg.includes('已有记录') || msg.includes('already exists')) {
uni.showModal({ title: '提示', content: '该日期已有监测记录,请选择其他日期', showCancel: false })
} else {
uni.showToast({ title: msg, icon: 'none' })
}
} finally {
submitting.value = false
}
}
onShow(() => { uni.setNavigationBarTitle({ title: '日常监测上报' }) })
</script>
<style lang="scss" scoped>
.dm-page { min-height: 100vh; background: $bg; padding: 0 24px 160px; }
.dm-hero { @include flex-center; flex-direction: column; padding: 40px 0 24px; }
.dm-hero-icon { width: 56px; height: 56px; border-radius: 50%; background: $pri; @include flex-center; margin-bottom: 12px; }
.dm-hero-icon-text { color: $white; font-size: var(--tk-font-body); font-weight: 600; }
.dm-hero-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
.dm-hero-sub { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; }
.dm-card { @include card; margin-bottom: 16px; }
.dm-card-header { display: flex; justify-content: space-between; align-items: center; }
.dm-card-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
.dm-card-badge { font-size: var(--tk-font-micro); color: $pri; background: rgba($pri, 0.1); padding: 2px 8px; border-radius: 4px; }
.dm-date-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-top: 1px solid rgba(0,0,0,0.05); margin-top: 8px; }
.dm-date-value { font-size: var(--tk-font-body); color: $tx; }
.dm-date-arrow { color: $tx3; font-size: var(--tk-font-micro); }
.dm-group { @include card; margin-bottom: 12px; }
.dm-group-header { display: flex; justify-content: space-between; align-items: center; min-height: $touch-min; }
.dm-group-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
.dm-group-arrow { font-size: var(--tk-font-cap); color: $tx3; transition: transform 0.2s; }
.dm-group-arrow-open { transform: rotate(90deg); }
.dm-group-body { padding-top: 12px; border-top: 1px solid rgba(0,0,0,0.05); margin-top: 10px; }
.dm-bp-group { display: flex; align-items: flex-end; gap: 8px; }
.dm-bp-field { flex: 1; }
.dm-bp-divider { display: flex; flex-direction: column; align-items: center; gap: 4px; padding-bottom: 10px; }
.dm-bp-line { width: 1px; height: 12px; background: rgba(0,0,0,0.1); }
.dm-bp-slash { color: $tx3; font-size: var(--tk-font-cap); }
.dm-field-label { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-bottom: 6px; }
.dm-input-box { height: 44px; border: 1px solid rgba(0,0,0,0.08); border-radius: $r; padding: 0 12px; font-size: var(--tk-font-body); width: 100%; box-sizing: border-box; }
.dm-input-abnormal { border-color: $wrn; }
.dm-input-flex { flex: 1; }
.dm-input-full { width: 100%; }
.dm-field-unit { font-size: var(--tk-font-cap); color: $tx3; margin-top: 6px; display: block; }
.dm-field-warning { font-size: var(--tk-font-micro); color: $wrn; margin-top: 4px; display: block; }
.dm-field-warning-low { color: $info; }
.dm-inner-field { margin-bottom: 16px; }
.dm-single-row { display: flex; align-items: center; gap: 8px; }
.dm-unit-inline { font-size: var(--tk-font-cap); color: $tx3; white-space: nowrap; }
.dm-submit { margin-top: 24px; height: 48px; background: $pri; border-radius: $r; @include flex-center; }
.dm-submit-disabled { opacity: 0.5; }
.dm-submit-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
.dm-reset { margin-top: 12px; @include flex-center; min-height: $touch-min; }
.dm-reset-text { font-size: var(--tk-font-cap); color: $tx3; }
</style>

View File

@@ -0,0 +1,242 @@
<template>
<view :class="['input-page', elderClass]">
<view class="input-hero">
<view class="input-hero-icon">
<text class="input-hero-icon-text"></text>
</view>
<text class="input-hero-title">体征录入</text>
<text class="input-hero-sub">记录今日健康数据</text>
</view>
<view class="input-sync-entry" @tap="goDeviceSync">
<text class="input-sync-entry-text">从设备同步</text>
<text class="input-sync-entry-hint">蓝牙连接设备自动获取数据</text>
</view>
<view class="input-card">
<view class="input-card-header">
<view class="input-card-indicator">
<text class="input-card-indicator-char">{{ indicatorInitial }}</text>
</view>
<text class="input-card-label">指标类型</text>
</view>
<picker mode="selector" :range="indicatorLabels" :value="indicatorIdx" @change="onIndicatorChange">
<view class="input-picker-row">
<text class="input-picker-value">{{ INDICATORS[indicatorIdx].label }}</text>
<text class="input-picker-arrow">V</text>
</view>
</picker>
</view>
<view v-if="isBpIndicator" class="input-card">
<text class="input-section-title">血压数值</text>
<view class="input-bp-group">
<view class="input-bp-field">
<text class="input-field-label">收缩压</text>
<input type="digit" class="input-field-box" placeholder="如 120" :value="systolic" @input="(e: any) => systolic = e.detail.value" />
</view>
<view class="input-bp-divider">
<view class="input-bp-line" />
<text class="input-bp-slash">/</text>
<view class="input-bp-line" />
</view>
<view class="input-bp-field">
<text class="input-field-label">舒张压</text>
<input type="digit" class="input-field-box" placeholder="如 80" :value="diastolic" @input="(e: any) => diastolic = e.detail.value" />
</view>
</view>
<text class="input-field-unit">mmHg</text>
</view>
<view v-else class="input-card">
<text class="input-section-title">检测数值</text>
<input type="digit" class="input-field-box input-field-full" placeholder="请输入数值" :value="val" @input="(e: any) => val = e.detail.value" />
<text class="input-field-unit">{{ unitLabel }}</text>
</view>
<view class="input-card">
<text class="input-section-title">备注</text>
<input class="input-field-box input-field-full" placeholder="如饭后2小时可选" :value="note" @input="(e: any) => note = e.detail.value" />
</view>
<view :class="['input-submit', submitting ? 'input-submit-disabled' : '']" @tap="submitting ? undefined : handleSubmit">
<text class="input-submit-text">{{ submitting ? '提交中...' : '提交录入' }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { num, validateStr } from '@/utils/validate'
import { inputVitalSign, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } from '@/services/health'
import { useAuthStore } from '@/stores/auth'
import { useHealthStore } from '@/stores/health'
import { usePointsStore } from '@/stores/points'
import { clearRequestCache } from '@/services/request'
import { trackEvent } from '@/services/analytics'
import { useElderClass } from '@/composables/useElderClass'
const INDICATORS = [
{ value: 'blood_pressure', label: '晨间血压 (mmHg)' },
{ value: 'blood_pressure_evening', label: '晚间血压 (mmHg)' },
{ value: 'heart_rate', label: '心率 (bpm)' },
{ value: 'blood_sugar_fasting', label: '空腹血糖 (mmol/L)' },
{ value: 'blood_sugar_postprandial', label: '餐后血糖 (mmol/L)' },
{ value: 'weight', label: '体重 (kg)' },
{ value: 'temperature', label: '体温 (℃)' },
]
const BP_INDICATORS = ['blood_pressure', 'blood_pressure_evening']
const valueCheck = num({ posMsg: '请输入有效数值' })
const systolicCheck = num({ min: 60, minMsg: '收缩压过低', max: 250, maxMsg: '收缩压过高,请及时就医', optional: true })
const diastolicCheck = num({ min: 40, minMsg: '舒张压过低', max: 150, maxMsg: '舒张压过高,请及时就医', optional: true })
function getWarnForIndicator(thresholds: HealthThreshold[], indicator: string) {
const isBp = BP_INDICATORS.includes(indicator)
const high = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'high')
const low = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'low')
if (!high && !low) return null
const warningMap: Record<string, string> = {
blood_pressure: '收缩压偏高,建议及时就医',
blood_pressure_evening: '收缩压偏高,建议及时就医',
heart_rate: '心率异常,请注意休息',
blood_sugar_fasting: '血糖偏高,建议就医检查',
blood_sugar_postprandial: '血糖偏高,建议就医检查',
}
return { max: high?.threshold_value, min: low?.threshold_value, warning: warningMap[indicator] ?? '数值异常,请关注' }
}
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const healthStore = useHealthStore()
const pointsStore = usePointsStore()
const indicatorIdx = ref(0)
const thresholds = ref<HealthThreshold[]>(DEFAULT_THRESHOLDS)
const val = ref('')
const systolic = ref('')
const diastolic = ref('')
const note = ref('')
const submitting = ref(false)
const indicatorLabels = INDICATORS.map(i => i.label)
const isBpIndicator = computed(() => BP_INDICATORS.includes(INDICATORS[indicatorIdx.value].value))
const indicatorInitial = computed(() => INDICATORS[indicatorIdx.value].label.charAt(0))
const unitLabel = computed(() => INDICATORS[indicatorIdx.value].label.match(/\((.+)\)/)?.[1] || '')
const onIndicatorChange = (e: any) => { indicatorIdx.value = Number(e.detail.value) }
const goDeviceSync = () => {
uni.navigateTo({ url: '/pages-sub/device-sync/index?returnTo=input' })
}
onShow(() => {
getHealthThresholds().then(t => { if (t.length > 0) thresholds.value = t })
try {
const raw = uni.getStorageSync('device_sync_result')
if (!raw) return
uni.removeStorageSync('device_sync_result')
const syncData: Record<string, number> = typeof raw === 'string' ? JSON.parse(raw) : raw
if (syncData.systolic != null && syncData.diastolic != null) {
indicatorIdx.value = 0
systolic.value = String(syncData.systolic)
diastolic.value = String(syncData.diastolic)
} else if (syncData.blood_sugar != null) {
indicatorIdx.value = 3
val.value = String(syncData.blood_sugar)
} else if (syncData.heart_rate != null) {
indicatorIdx.value = 2
val.value = String(syncData.heart_rate)
}
} catch { /* ignore */ }
})
const handleSubmit = async () => {
const patient = authStore.currentPatient
if (!patient) { uni.showToast({ title: '请先选择就诊人', icon: 'none' }); return }
const currentIndicator = INDICATORS[indicatorIdx.value].value
if (BP_INDICATORS.includes(currentIndicator)) {
if (!systolic.value || !diastolic.value) { uni.showToast({ title: '请填写收缩压和舒张压', icon: 'none' }); return }
} else {
if (!val.value) { uni.showToast({ title: '请输入数值', icon: 'none' }); return }
}
const input = BP_INDICATORS.includes(currentIndicator)
? { indicator_type: currentIndicator as 'blood_pressure' | 'blood_pressure_evening', value: parseFloat(systolic.value), extra: { systolic: parseFloat(systolic.value), diastolic: parseFloat(diastolic.value) } }
: { indicator_type: currentIndicator as any, value: parseFloat(val.value) }
const valueResult = valueCheck.safeParse(input.value)
if (!valueResult.ok) { uni.showToast({ title: valueResult.message, icon: 'none' }); return }
if (input.extra?.systolic !== undefined) {
const r = systolicCheck.safeParse(input.extra.systolic)
if (!r.ok) { uni.showToast({ title: r.message, icon: 'none' }); return }
}
if (input.extra?.diastolic !== undefined) {
const r = diastolicCheck.safeParse(input.extra.diastolic)
if (!r.ok) { uni.showToast({ title: r.message, icon: 'none' }); return }
}
if (note.value) {
const err = validateStr(note.value, 200, '备注')
if (err) { uni.showToast({ title: err, icon: 'none' }); return }
}
const threshold = getWarnForIndicator(thresholds.value, currentIndicator)
if (threshold) {
const v = input.value
if ((threshold.max && v > threshold.max) || (threshold.min && v < threshold.min)) {
await uni.showModal({ title: '健康提示', content: threshold.warning, showCancel: false })
}
}
submitting.value = true
try {
await inputVitalSign(patient.id, { ...input, note: note.value || undefined })
healthStore.clearCache()
clearRequestCache('/health/')
pointsStore.invalidate()
uni.showToast({ title: '录入成功', icon: 'success' })
trackEvent('health_data_input', { type: currentIndicator })
setTimeout(() => uni.navigateBack(), 1000)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '录入失败'
uni.showToast({ title: msg, icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.input-page { min-height: 100vh; background: $bg; padding: 0 24px 120px; }
.input-hero { @include flex-center; flex-direction: column; padding: 40px 0 24px; }
.input-hero-icon { width: 56px; height: 56px; border-radius: 50%; background: $pri; @include flex-center; margin-bottom: 12px; }
.input-hero-icon-text { color: $white; font-size: var(--tk-font-body); font-weight: 600; }
.input-hero-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
.input-hero-sub { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; }
.input-sync-entry { @include card; display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.input-sync-entry-text { font-size: var(--tk-font-body); color: $pri; font-weight: 500; }
.input-sync-entry-hint { font-size: var(--tk-font-cap); color: $tx3; }
.input-card { @include card; margin-bottom: 16px; }
.input-card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.input-card-indicator { width: 32px; height: 32px; border-radius: 8px; background: rgba($pri, 0.1); @include flex-center; }
.input-card-indicator-char { color: $pri; font-size: var(--tk-font-cap); font-weight: 600; }
.input-card-label { font-size: var(--tk-font-body); color: $tx; font-weight: 500; }
.input-picker-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-top: 1px solid rgba(0,0,0,0.05); }
.input-picker-value { font-size: var(--tk-font-body); color: $tx; }
.input-picker-arrow { color: $tx3; font-size: var(--tk-font-micro); }
.input-section-title { font-size: var(--tk-font-cap); color: $tx2; margin-bottom: 10px; display: block; }
.input-bp-group { display: flex; align-items: flex-end; gap: 8px; }
.input-bp-field { flex: 1; }
.input-field-label { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-bottom: 6px; }
.input-field-box { height: 44px; border: 1px solid rgba(0,0,0,0.08); border-radius: $r; padding: 0 12px; font-size: var(--tk-font-body); width: 100%; box-sizing: border-box; }
.input-field-full { width: 100%; }
.input-bp-divider { display: flex; flex-direction: column; align-items: center; gap: 4px; padding-bottom: 10px; }
.input-bp-line { width: 1px; height: 12px; background: rgba(0,0,0,0.1); }
.input-bp-slash { color: $tx3; font-size: var(--tk-font-cap); }
.input-field-unit { font-size: var(--tk-font-cap); color: $tx3; margin-top: 6px; display: block; }
.input-submit { margin-top: 24px; height: 48px; background: $pri; border-radius: $r; @include flex-center; }
.input-submit-disabled { opacity: 0.5; }
.input-submit-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
</style>

View File

@@ -0,0 +1,167 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<text class="page-title">健康趋势</text>
<!-- 指标切换 -->
<view class="tab-group">
<view v-for="tab in tabs" :key="tab.key"
:class="['tab-item', { active: activeTab === tab.key }]"
@tap="switchTab(tab.key)"
>
{{ tab.name }}
</view>
</view>
<!-- 趋势图 CSS 柱状图 -->
<view class="chart-card card">
<Loading v-if="loading" text="加载中..." />
<EmptyState v-else-if="trendData.length === 0" icon="📊" title="暂无趋势数据" />
<view v-else class="bar-chart">
<view v-for="(item, idx) in trendData" :key="idx" class="bar-group">
<view class="bar" :style="{ height: getBarHeight(item.value) + '%' }" />
<text class="bar-label">{{ item.label }}</text>
</view>
</view>
</view>
<!-- 数据列表 -->
<view v-if="trendData.length > 0" class="data-list card">
<view v-for="(item, idx) in trendData" :key="idx" class="data-row">
<text class="data-date">{{ item.label }}</text>
<text class="data-value">{{ item.value }}</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getTrend } from '@/services/health'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
import { useElderClass } from '@/composables/useElderClass'
const { elderClass } = useElderClass()
const tabs = [
{ key: 'heart_rate', name: '心率' },
{ key: 'blood_pressure', name: '血压' },
{ key: 'blood_sugar', name: '血糖' },
{ key: 'weight', name: '体重' },
]
const activeTab = ref('heart_rate')
const loading = ref(false)
const trendData = ref<{ label: string; value: number }[]>([])
function switchTab(key: string) {
activeTab.value = key
fetchTrend()
}
function getBarHeight(value: number): number {
const max = Math.max(...trendData.value.map(d => d.value), 1)
return (value / max) * 80
}
async function fetchTrend() {
loading.value = true
try {
const res = await getTrend(activeTab.value, '7d')
trendData.value = (res?.data_points || []).map((item) => ({
label: item.date || '',
value: Number(item.value) || 0,
}))
} catch {
trendData.value = []
}
loading.value = false
}
onLoad(() => { fetchTrend() })
onMounted(fetchTrend)
</script>
<style lang="scss" scoped>
.page-scroll { height: 100vh; background: $bg; }
.page-content { padding: 28px 24px 120px; }
.page-title { @include section-title; }
.card { @include card; }
.tab-group {
display: flex;
gap: 12px;
margin-bottom: 24px;
overflow-x: auto;
}
.tab-item {
padding: 12px 24px;
min-height: $touch-min;
display: flex;
align-items: center;
border-radius: $r-pill;
font-size: var(--tk-font-body-sm);
color: $tx3;
background: $card;
white-space: nowrap;
box-shadow: $shadow-sm;
&.active {
background: $pri;
color: $white;
}
}
.bar-chart {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 240px;
padding: 20px 0;
}
.bar-group {
@include flex-center;
flex-direction: column;
flex: 1;
}
.bar {
width: 32px;
background: $pri;
border-radius: 8px 8px 0 0;
min-height: 4px;
}
.bar-label {
font-size: var(--tk-font-micro);
color: $tx3;
margin-top: 8px;
}
.data-list { margin-top: 16px; }
.data-row {
display: flex;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid $bd-l;
&:last-child { border-bottom: none; }
}
.data-date {
font-size: var(--tk-font-body-sm);
color: $tx2;
}
.data-value {
font-size: var(--tk-font-num);
font-weight: bold;
color: $pri;
@include serif-number;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<view :class="['detail-page', elderClass]">
<view class="balance-card">
<text class="balance-label">当前积分</text>
<text class="balance-value">{{ balance.toLocaleString() }}</text>
<view class="balance-stats">
<view class="stat-item">
<text class="stat-value stat-earn">{{ (pointsStore.account?.total_earned ?? 0).toLocaleString() }}</text>
<text class="stat-label">累计获得</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-value stat-spend">{{ (pointsStore.account?.total_spent ?? 0).toLocaleString() }}</text>
<text class="stat-label">累计消费</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-value stat-expired">{{ (pointsStore.account?.total_expired ?? 0).toLocaleString() }}</text>
<text class="stat-label">已过期</text>
</view>
</view>
</view>
<view class="type-tabs">
<view v-for="tab in TYPE_TABS" :key="tab.key"
:class="['type-tab', activeTab === tab.key ? 'active' : '']"
@tap="handleTabChange(tab.key)">
<text class="type-tab-text">{{ tab.label }}</text>
</view>
</view>
<view v-if="transactions.length === 0 && !loading" class="empty-wrap">
<EmptyState icon="" title="暂无积分记录" hint="签到或兑换后将显示记录" />
</view>
<scroll-view v-else scroll-y class="tx-scroll" @scrolltolower="loadMore">
<view class="transaction-item" v-for="tx in transactions" :key="tx.id">
<view :class="['tx-badge', `tx-badge-${getTypeClass(tx.type)}`]">
<text class="tx-badge-text">{{ getTypeLabel(tx.type) }}</text>
</view>
<view class="tx-info">
<text class="tx-desc">{{ tx.description || (tx.type === 'earn' ? '积分收入' : tx.type === 'spend' ? '积分消费' : '积分过期') }}</text>
<text class="tx-date">{{ formatDate(tx.created_at) }}</text>
</view>
<view class="tx-amount-col">
<text :class="['tx-amount', `tx-amount-${tx.type === 'earn' ? 'positive' : 'negative'}`]">{{ formatAmount(tx) }}</text>
<text class="tx-remaining">余额 {{ tx.balance_after.toLocaleString() }}</text>
</view>
</view>
<Loading v-if="loading" text="加载中..." />
<view v-if="!loading && transactions.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { listMyTransactions, type PointsTransaction } from '@/services/points'
import { usePointsStore } from '@/stores/points'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
import { useElderClass } from '@/composables/useElderClass'
const TYPE_TABS = [{ key: '', label: '全部' }, { key: 'earn', label: '收入' }, { key: 'spend', label: '支出' }]
const { elderClass } = useElderClass()
const pointsStore = usePointsStore()
const transactions = ref<PointsTransaction[]>([])
const activeTab = ref('')
const page = ref(1)
const total = ref(0)
const loading = ref(false)
let loadingGuard = false
const balance = computed(() => pointsStore.account?.balance ?? 0)
const getTypeLabel = (type: string) => type === 'earn' ? '收' : type === 'spend' ? '支' : '过'
const getTypeClass = (type: string) => type === 'earn' ? 'earn' : type === 'spend' ? 'spend' : 'expired'
const formatAmount = (tx: PointsTransaction) => tx.type === 'earn' ? `+${tx.amount.toLocaleString()}` : `-${tx.amount.toLocaleString()}`
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const fetchTransactions = async (pageNum: number, type: string, isRefresh = false) => {
if (loadingGuard) return
loadingGuard = true; loading.value = true
try {
const res = await listMyTransactions({ page: pageNum, page_size: 10 })
let list = res.data || []
if (type) list = list.filter(t => t.type === type)
transactions.value = isRefresh ? list : [...transactions.value, ...list]
total.value = res.total; page.value = pageNum
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
finally { loadingGuard = false; loading.value = false }
}
const handleTabChange = (key: string) => { activeTab.value = key; fetchTransactions(1, key, true) }
const loadMore = () => { if (!loading.value && transactions.value.length < total.value) fetchTransactions(page.value + 1, activeTab.value) }
onShow(() => { uni.setNavigationBarTitle({ title: '积分明细' }); Promise.all([pointsStore.refresh(), fetchTransactions(1, activeTab.value, true)]) })
onPullDownRefresh(() => { Promise.all([pointsStore.refresh(), fetchTransactions(1, activeTab.value, true)]).finally(() => uni.stopPullDownRefresh()) })
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; background: $bg; }
.balance-card { background: linear-gradient(135deg, $pri, darken($pri, 10%)); padding: 24px; margin: 0; }
.balance-label { font-size: var(--tk-font-cap); color: rgba(255,255,255,0.8); display: block; }
.balance-value { font-size: var(--tk-font-num); font-weight: 700; color: $white; display: block; margin: 8px 0 20px; }
.balance-stats { display: flex; align-items: center; }
.stat-item { flex: 1; text-align: center; }
.stat-value { font-size: var(--tk-font-body); font-weight: 500; display: block; }
.stat-earn { color: rgba(255,255,255,0.95); }
.stat-spend { color: rgba(255,255,255,0.95); }
.stat-expired { color: rgba(255,255,255,0.95); }
.stat-label { font-size: var(--tk-font-cap); color: rgba(255,255,255,0.6); display: block; margin-top: 4px; }
.stat-divider { width: 1px; height: 24px; background: rgba(255,255,255,0.2); }
.type-tabs { display: flex; padding: 12px 24px; gap: 8px; background: $card; }
.type-tab { padding: 6px 16px; min-height: $touch-min; display: flex; align-items: center; border-radius: 20px; background: rgba(0,0,0,0.04); }
.type-tab.active { background: $pri; }
.type-tab-text { font-size: var(--tk-font-cap); color: $tx2; }
.type-tab.active .type-tab-text { color: $white; }
.tx-scroll { height: calc(100vh - 200px); padding: 16px 24px; }
.transaction-item { @include card; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.tx-badge { width: 36px; height: 36px; border-radius: 50%; @include flex-center; flex-shrink: 0; }
.tx-badge-earn { background: rgba(82,196,26,0.1); }
.tx-badge-spend { background: rgba(250,84,28,0.1); }
.tx-badge-expired { background: rgba(0,0,0,0.05); }
.tx-badge-text { font-size: var(--tk-font-micro); font-weight: 500; }
.tx-info { flex: 1; min-width: 0; }
.tx-desc { font-size: var(--tk-font-body); color: $tx; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tx-date { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 2px; }
.tx-amount-col { text-align: right; flex-shrink: 0; }
.tx-amount { font-size: var(--tk-font-body); font-weight: 500; display: block; }
.tx-amount-positive { color: $acc; }
.tx-amount-negative { color: $wrn; }
.tx-remaining { font-size: var(--tk-font-micro); color: $tx3; display: block; margin-top: 2px; }
.empty-wrap { padding-top: 60px; }
.no-more { @include flex-center; padding: 20px; }
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
</style>

View File

@@ -0,0 +1,166 @@
<template>
<view :class="['exchange-page', elderClass]">
<template v-if="loading">
<Loading text="加载中..." />
</template>
<template v-else-if="product">
<view class="product-card">
<view :class="['product-icon-wrap', iconCls]">
<text class="product-icon-char">{{ initial }}</text>
</view>
<view class="product-meta">
<text class="product-name">{{ product.name }}</text>
<text class="product-type-tag">{{ typeLabel }}</text>
</view>
</view>
<view class="detail-section">
<text class="detail-section-title">兑换明细</text>
<view class="detail-card">
<view class="detail-row">
<text class="detail-label">所需积分</text>
<text class="detail-value detail-cost">{{ cost.toLocaleString() }}</text>
</view>
<view class="detail-row">
<text class="detail-label">当前余额</text>
<text :class="['detail-value', insufficient ? 'detail-insufficient' : 'detail-sufficient']">{{ balance.toLocaleString() }}</text>
</view>
<view v-if="insufficient" class="detail-row">
<text class="detail-label">差额</text>
<text class="detail-value detail-insufficient">-{{ (cost - balance).toLocaleString() }}</text>
</view>
<view class="detail-row last">
<text class="detail-label">库存</text>
<text class="detail-value">{{ product.stock > 0 ? `剩余 ${product.stock}` : '已兑完' }}</text>
</view>
</view>
</view>
<view class="notice-section">
<text class="notice-title">温馨提示</text>
<text class="notice-text">兑换成功后将生成核销码请凭核销码到前台核销领取</text>
<text class="notice-text">积分一经兑换不可退回</text>
</view>
<view class="exchange-footer">
<view class="footer-cost">
<text class="footer-cost-label">合计</text>
<text class="footer-cost-num">{{ cost.toLocaleString() }}</text>
<text class="footer-cost-unit">积分</text>
</view>
<view :class="['confirm-btn', insufficient || product.stock <= 0 || submitting ? 'disabled' : '']" @tap="handleConfirm">
<text class="confirm-btn-text">
{{ submitting ? '兑换中...' : insufficient ? '积分不足' : product.stock <= 0 ? '已兑完' : '确认兑换' }}
</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listProducts, exchangeProduct, type PointsProduct } from '@/services/points'
import { usePointsStore } from '@/stores/points'
import { useElderClass } from '@/composables/useElderClass'
import Loading from '@/components/Loading.vue'
const TYPE_INITIAL: Record<string, string> = { physical: '物', service: '券', privilege: '权' }
const TYPE_LABEL: Record<string, string> = { physical: '实物商品', service: '服务券', privilege: '权益卡' }
const TYPE_CLASS: Record<string, string> = { physical: 'product-icon-wrap--physical', service: 'product-icon-wrap--service', privilege: 'product-icon-wrap--privilege' }
const { elderClass } = useElderClass()
const pointsStore = usePointsStore()
const product = ref<PointsProduct | null>(null)
const loading = ref(true)
const submitting = ref(false)
let productId = ''
const balance = computed(() => pointsStore.account?.balance ?? 0)
const cost = computed(() => product.value?.points_cost ?? 0)
const insufficient = computed(() => balance.value < cost.value)
const productType = computed(() => product.value?.product_type || 'physical')
const initial = computed(() => TYPE_INITIAL[productType.value] || '礼')
const typeLabel = computed(() => TYPE_LABEL[productType.value] || '商品')
const iconCls = computed(() => TYPE_CLASS[productType.value] || 'product-icon-wrap--service')
const loadData = async () => {
const instance = getCurrentPages()
const page = instance[instance.length - 1] as any
productId = page?.$page?.options?.product_id || page?.options?.product_id || ''
if (!productId) {
uni.showToast({ title: '参数错误', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
return
}
loading.value = true
try {
const [productRes] = await Promise.all([listProducts({ page: 1, page_size: 100 }), pointsStore.refresh()])
const found = productRes.data.find(p => p.id === productId)
if (!found) { uni.showToast({ title: '商品不存在', icon: 'none' }); setTimeout(() => uni.navigateBack(), 1500); return }
product.value = found
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
} finally {
loading.value = false
}
}
const handleConfirm = async () => {
if (!product.value || submitting.value || insufficient.value || product.value.stock <= 0) return
const modalRes = await uni.showModal({ title: '确认兑换', content: `确定花费 ${cost.value} 积分兑换「${product.value.name}」吗?` })
if (!modalRes.confirm) return
submitting.value = true
try {
const order = await exchangeProduct(product.value.id)
uni.showToast({ title: '兑换成功', icon: 'success', duration: 2000 })
setTimeout(() => {
uni.showModal({ title: '兑换成功', content: `核销码: ${order.qr_code}\n请凭此码到前台核销`, showCancel: false, confirmText: '查看订单', success: () => uni.navigateTo({ url: '/pages-sub/pkg-mall/orders/index' }) })
}, 2000)
} catch (err) {
const msg = err instanceof Error ? err.message : '兑换失败'
if (msg.includes('余额不足') || msg.includes('insufficient')) uni.showToast({ title: '积分不足', icon: 'none' })
else uni.showToast({ title: msg, icon: 'none' })
} finally {
submitting.value = false
}
}
onShow(() => { uni.setNavigationBarTitle({ title: '确认兑换' }); loadData() })
</script>
<style lang="scss" scoped>
.exchange-page { min-height: 100vh; background: $bg; padding: 24px; }
.product-card { @include card; display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }
.product-icon-wrap { width: 48px; height: 48px; border-radius: 12px; @include flex-center; }
.product-icon-wrap--physical { background: rgba(250,173,20,0.15); }
.product-icon-wrap--service { background: rgba($pri, 0.1); }
.product-icon-wrap--privilege { background: rgba(114,46,209,0.15); }
.product-icon-char { font-size: var(--tk-font-cap); font-weight: 600; }
.product-meta { flex: 1; }
.product-name { font-size: var(--tk-font-body); font-weight: 500; color: $tx; display: block; }
.product-type-tag { font-size: var(--tk-font-cap); color: $tx3; margin-top: 4px; display: block; }
.detail-section { @include card; margin-bottom: 16px; }
.detail-section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
.detail-card { }
.detail-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
.detail-row.last { border-bottom: none; }
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
.detail-value { font-size: var(--tk-font-body); color: $tx; }
.detail-cost { color: $wrn; font-weight: 500; }
.detail-sufficient { color: $acc; }
.detail-insufficient { color: $wrn; }
.notice-section { @include card; margin-bottom: 16px; }
.notice-title { font-size: var(--tk-font-cap); font-weight: 500; color: $tx2; margin-bottom: 8px; display: block; }
.notice-text { font-size: var(--tk-font-cap); color: $tx3; line-height: 1.6; display: block; }
.exchange-footer { position: fixed; bottom: 0; left: 0; right: 0; display: flex; align-items: center; padding: 12px 24px; background: $card; box-shadow: $shadow-sm; gap: 16px; }
.footer-cost { flex: 1; display: flex; align-items: baseline; gap: 4px; }
.footer-cost-label { font-size: var(--tk-font-cap); color: $tx3; }
.footer-cost-num { font-size: var(--tk-font-body); font-weight: 600; color: $wrn; }
.footer-cost-unit { font-size: var(--tk-font-cap); color: $tx3; }
.confirm-btn { height: $touch-min; padding: 0 32px; background: $pri; border-radius: $r; @include flex-center; }
.confirm-btn.disabled { opacity: 0.5; }
.confirm-btn-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
</style>

View File

@@ -0,0 +1,130 @@
<template>
<view :class="['orders-page', elderClass]">
<view class="status-tabs">
<view v-for="tab in STATUS_TABS" :key="tab.key"
:class="['status-tab', activeTab === tab.key ? 'active' : '']"
@tap="handleTabChange(tab.key)">
<text class="status-tab-text">{{ tab.label }}</text>
</view>
</view>
<view v-if="orders.length === 0 && !loading" class="empty-wrap">
<EmptyState icon="" title="暂无订单" hint="去商城兑换心仪商品吧" />
</view>
<scroll-view v-else scroll-y class="orders-scroll" @scrolltolower="loadMore">
<view class="order-card" v-for="order in orders" :key="order.id">
<view class="order-header">
<text class="order-product">商品 {{ order.product_id.slice(0, 8) }}</text>
<view :class="['order-status-tag', getStatusConfig(order.status).cls]">
<text class="order-status-text">{{ getStatusConfig(order.status).label }}</text>
</view>
</view>
<view class="order-body">
<view class="order-row">
<text class="order-row-label">消耗积分</text>
<text class="order-row-value order-cost">{{ order.points_cost.toLocaleString() }}</text>
</view>
<view class="order-row">
<text class="order-row-label">兑换时间</text>
<text class="order-row-value">{{ formatDate(order.created_at) }}</text>
</view>
<view v-if="order.status === 'pending'" class="order-qrcode" @tap="handleShowQrCode(order.qr_code)">
<text class="qrcode-label">核销码</text>
<text class="qrcode-value">{{ order.qr_code }}</text>
<text class="qrcode-tap">查看</text>
</view>
</view>
</view>
<Loading v-if="loading" text="加载中..." />
<view v-if="!loading && orders.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { listMyOrders, type PointsOrder } from '@/services/points'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
import { useElderClass } from '@/composables/useElderClass'
const STATUS_TABS = [
{ key: '', label: '全部' }, { key: 'pending', label: '待核销' },
{ key: 'verified', label: '已核销' }, { key: 'expired', label: '已过期' },
]
const STATUS_CONFIG: Record<string, { label: string; cls: string }> = {
pending: { label: '待核销', cls: 'order-status-tag--pending' },
verified: { label: '已核销', cls: 'order-status-tag--verified' },
cancelled: { label: '已取消', cls: 'order-status-tag--cancelled' },
expired: { label: '已过期', cls: 'order-status-tag--expired' },
}
const { elderClass } = useElderClass()
const orders = ref<PointsOrder[]>([])
const activeTab = ref('')
const page = ref(1)
const total = ref(0)
const loading = ref(false)
let loadingGuard = false
const getStatusConfig = (status: string) => STATUS_CONFIG[status] || { label: status, cls: 'order-status-tag--expired' }
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const fetchOrders = async (pageNum: number, status: string, isRefresh = false) => {
if (loadingGuard) return
loadingGuard = true; loading.value = true
try {
const res = await listMyOrders({ page: pageNum, page_size: 10 })
let list = res.data || []
if (status) list = list.filter(o => o.status === status)
orders.value = isRefresh ? list : [...orders.value, ...list]
total.value = res.total; page.value = pageNum
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
finally { loadingGuard = false; loading.value = false }
}
const handleTabChange = (key: string) => { activeTab.value = key; fetchOrders(1, key, true) }
const loadMore = () => { if (!loading.value && orders.value.length < total.value) fetchOrders(page.value + 1, activeTab.value) }
const handleShowQrCode = (qrCode: string) => uni.showModal({ title: '核销码', content: qrCode, showCancel: false, confirmText: '知道了' })
onShow(() => { uni.setNavigationBarTitle({ title: '我的订单' }); fetchOrders(1, activeTab.value, true) })
onPullDownRefresh(() => { fetchOrders(1, activeTab.value, true).finally(() => uni.stopPullDownRefresh()) })
</script>
<style lang="scss" scoped>
.orders-page { min-height: 100vh; background: $bg; }
.status-tabs { display: flex; padding: 12px 24px; gap: 8px; background: $card; }
.status-tab { padding: 6px 16px; min-height: $touch-min; display: flex; align-items: center; border-radius: 20px; background: rgba(0,0,0,0.04); }
.status-tab.active { background: $pri; }
.status-tab-text { font-size: var(--tk-font-cap); color: $tx2; }
.status-tab.active .status-tab-text { color: $white; }
.orders-scroll { height: calc(100vh - 52px); padding: 16px 24px; }
.order-card { @include card; margin-bottom: 12px; }
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.order-product { font-size: var(--tk-font-body); color: $tx; font-weight: 500; }
.order-status-tag { padding: 2px 10px; border-radius: 4px; }
.order-status-tag--pending { background: rgba($pri, 0.1); }
.order-status-tag--verified { background: rgba(82,196,26,0.1); }
.order-status-tag--cancelled { background: rgba(0,0,0,0.05); }
.order-status-tag--expired { background: rgba(0,0,0,0.05); }
.order-status-text { font-size: var(--tk-font-micro); color: $tx2; }
.order-body { }
.order-row { display: flex; justify-content: space-between; padding: 6px 0; }
.order-row-label { font-size: var(--tk-font-cap); color: $tx3; }
.order-row-value { font-size: var(--tk-font-cap); color: $tx; }
.order-cost { color: $wrn; font-weight: 500; }
.order-qrcode { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 8px 12px; background: rgba($pri, 0.05); border-radius: $r; }
.qrcode-label { font-size: var(--tk-font-cap); color: $tx2; }
.qrcode-value { flex: 1; font-size: var(--tk-font-cap); color: $pri; font-weight: 500; }
.qrcode-tap { font-size: var(--tk-font-cap); color: $pri; }
.empty-wrap { padding-top: 120px; }
.no-more { @include flex-center; padding: 20px; }
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
</style>

View File

@@ -0,0 +1,149 @@
<template>
<view :class="['consents-page', elderClass]">
<text class="page-title">知情同意</text>
<view class="consent-list">
<view v-for="c in consents" :key="c.id" class="consent-card">
<view class="consent-card__header">
<text class="consent-card__type">{{ CONSENT_TYPE_MAP[c.consent_type] || c.consent_type }}</text>
<text :class="['status-tag', (STATUS_MAP[c.status] || { cls: '' }).cls]">
{{ (STATUS_MAP[c.status] || { label: c.status }).label }}
</text>
</view>
<text class="consent-card__scope">范围: {{ c.consent_scope }}</text>
<text v-if="c.granted_at" class="consent-card__date">签署时间: {{ c.granted_at }}</text>
<text v-if="c.revoked_at" class="consent-card__date">撤回时间: {{ c.revoked_at }}</text>
<text v-if="c.expiry_date" class="consent-card__expiry">有效期至: {{ c.expiry_date }}</text>
<view
v-if="c.status === 'granted'"
:class="['revoke-btn', revoking === c.id ? 'revoke-btn--disabled' : '']"
@tap="handleRevoke(c)"
>
<text class="revoke-btn__text">{{ revoking === c.id ? '处理中...' : '撤回同意' }}</text>
</view>
</view>
</view>
<EmptyState
v-if="consents.length === 0 && !loading"
:text="authStore.currentPatient ? '暂无知情同意记录' : '请先在就诊人管理中选择就诊人'"
/>
<Loading v-if="loading" text="加载中..." />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { listConsents, revokeConsent, type Consent } from '@/services/consent'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
const CONSENT_TYPE_MAP: Record<string, string> = {
data_processing: '数据处理同意',
health_data_collection: '健康数据采集',
research_use: '科研使用',
third_party_share: '第三方共享',
genetic_testing: '基因检测',
telemedicine: '远程医疗',
}
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
granted: { label: '已签署', cls: 'granted' },
revoked: { label: '已撤回', cls: 'revoked' },
}
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const consents = ref<Consent[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const revoking = ref<string | null>(null)
const fetchData = async (p: number, append = false) => {
if (!authStore.currentPatient) {
consents.value = []
return
}
loading.value = true
try {
const res = await listConsents(authStore.currentPatient.id, { page: p, page_size: 20 })
const list = res.data || []
consents.value = append ? [...consents.value, ...list] : list
total.value = res.total
page.value = p
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!loading.value && consents.value.length < total.value) {
fetchData(page.value + 1, true)
}
}
const handleRevoke = async (consent: Consent) => {
const res = await uni.showModal({
title: '确认撤回',
content: `确定要撤回「${CONSENT_TYPE_MAP[consent.consent_type] || consent.consent_type}」的同意吗?`,
})
if (!res.confirm) return
revoking.value = consent.id
try {
const updated = await revokeConsent(consent.id, consent.version)
consents.value = consents.value.map((c) => c.id === updated.id ? updated : c)
uni.showToast({ title: '已撤回', icon: 'success' })
} catch {
uni.showToast({ title: '撤回失败', icon: 'none' })
} finally {
revoking.value = null
}
}
onShow(() => { fetchData(1) })
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
</script>
<style lang="scss" scoped>
.consents-page { min-height: 100vh; background: $bg; padding: 32px 24px; padding-bottom: 40px; }
.page-title { @include section-title; padding-left: 4px; }
.consent-list { display: flex; flex-direction: column; gap: 16px; }
.consent-card { background: $card; border-radius: $r; padding: 28px; box-shadow: $shadow-sm; }
.consent-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.consent-card__type { font-size: var(--tk-font-body-lg); font-weight: bold; color: $tx; }
.status-tag {
@include tag($bd-l, $tx3);
&.granted { @include tag($acc-l, $acc); }
&.revoked { @include tag($dan-l, $dan); }
}
.consent-card__scope,
.consent-card__date,
.consent-card__expiry {
font-size: var(--tk-font-h2);
color: $tx2;
display: block;
margin-bottom: 4px;
font-variant-numeric: tabular-nums;
}
.revoke-btn {
margin-top: 16px;
padding: 12px 0;
min-height: $touch-min;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
border-radius: $r-sm;
border: 1px solid $dan;
&:active { background: $dan-l; }
&--disabled { opacity: 0.5; }
}
.revoke-btn__text { font-size: var(--tk-font-h2); color: $dan; font-weight: 500; }
</style>

View File

@@ -0,0 +1,123 @@
<template>
<view :class="['diagnoses-page', elderClass]">
<text class="page-title">诊断记录</text>
<scroll-view scroll-y class="diagnosis-scroll" @scrolltolower="loadMore">
<view class="diagnosis-list">
<view v-for="d in records" :key="d.id" class="diagnosis-card">
<view class="diagnosis-card__header">
<text class="diagnosis-card__name">{{ d.diagnosis_name }}</text>
<text :class="['diagnosis-card__status', (STATUS_MAP[d.status] || { cls: '' }).cls]">
{{ (STATUS_MAP[d.status] || { label: d.status }).label }}
</text>
</view>
<view class="diagnosis-card__meta">
<text :class="['diagnosis-card__type', (TYPE_MAP[d.diagnosis_type] || { cls: '' }).cls]">
{{ (TYPE_MAP[d.diagnosis_type] || { label: d.diagnosis_type }).label }}
</text>
<text class="diagnosis-card__code">{{ d.icd_code }}</text>
</view>
<text class="diagnosis-card__date">诊断日期{{ d.diagnosed_date }}</text>
<text v-if="d.notes" class="diagnosis-card__notes">{{ d.notes }}</text>
</view>
</view>
<EmptyState
v-if="records.length === 0 && !loading"
:text="authStore.currentPatient ? '暂无诊断记录' : '请先在就诊人管理中选择就诊人'"
/>
<Loading v-if="loading" text="加载中..." />
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { listDiagnoses, type Diagnosis } from '@/services/health-record'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
const TYPE_MAP: Record<string, { label: string; cls: string }> = {
primary: { label: '主要', cls: 'primary' },
secondary: { label: '次要', cls: 'secondary' },
comorbid: { label: '合并症', cls: 'comorbid' },
}
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
active: { label: '活动', cls: 'active' },
resolved: { label: '已解决', cls: 'resolved' },
chronic: { label: '慢性', cls: 'chronic' },
}
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const records = ref<Diagnosis[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const fetchData = async (p: number, append = false) => {
if (!authStore.currentPatient) {
records.value = []
return
}
loading.value = true
try {
const res = await listDiagnoses(authStore.currentPatient.id, { page: p, page_size: 20 })
const list = res.data || []
records.value = append ? [...records.value, ...list] : list
total.value = res.total
page.value = p
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!loading.value && records.value.length < total.value) {
fetchData(page.value + 1, true)
}
}
onShow(() => { fetchData(1) })
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
</script>
<style lang="scss" scoped>
.diagnoses-page { min-height: 100vh; background: $bg; padding: 32px 24px 0; }
.page-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
padding-left: 4px;
}
.diagnosis-scroll { height: calc(100vh - 80px); }
.diagnosis-list { display: flex; flex-direction: column; gap: 16px; }
.diagnosis-card { background: $card; border-radius: $r; padding: 28px; box-shadow: $shadow-sm; }
.diagnosis-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.diagnosis-card__name { font-size: var(--tk-font-body-lg); font-weight: bold; color: $tx; flex: 1; margin-right: 12px; }
.diagnosis-card__status {
@include tag($bd-l, $tx3);
&.active { @include tag($acc-l, $acc); }
&.resolved { @include tag($acc-l, $acc); }
&.chronic { @include tag($wrn-l, $wrn); }
}
.diagnosis-card__meta { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.diagnosis-card__type {
@include tag($pri-l, $pri-d);
&.secondary { @include tag($bd-l, $tx2); }
&.comorbid { @include tag($wrn-l, $wrn); }
}
.diagnosis-card__code { font-size: var(--tk-font-body); color: $tx3; font-variant-numeric: tabular-nums; }
.diagnosis-card__date { font-size: var(--tk-font-body); color: $tx2; display: block; }
.diagnosis-card__notes { font-size: var(--tk-font-body); color: $tx2; display: block; margin-top: 8px; }
</style>

View File

@@ -0,0 +1,108 @@
<template>
<view :class="['detail-page', elderClass]">
<Loading v-if="loading" text="加载中..." />
<EmptyState v-else-if="!rx" icon="📋" title="处方不存在" />
<template v-else>
<view class="detail-card header-card">
<view class="header-row">
<text class="detail-title">{{ rx.dialyzer_model || '透析处方' }}</text>
<text :class="['status-tag', statusInfo(rx.status).cls]">{{ statusInfo(rx.status).label }}</text>
</view>
<text v-if="rx.effective_from || rx.effective_to" class="header-sub">
{{ rx.effective_from || '...' }} ~ {{ rx.effective_to || '...' }}
</text>
</view>
<view class="detail-card">
<text class="section-title">基本参数</text>
<view v-if="rx.dialyzer_model" class="detail-row"><text class="detail-label">透析器型号</text><text class="detail-value">{{ rx.dialyzer_model }}</text></view>
<view v-if="rx.membrane_area != null" class="detail-row"><text class="detail-label">膜面积</text><text class="detail-value">{{ rx.membrane_area }} m²</text></view>
<view v-if="rx.blood_flow_rate != null" class="detail-row"><text class="detail-label">血流速</text><text class="detail-value">{{ rx.blood_flow_rate }} ml/min</text></view>
<view v-if="rx.dialysate_flow_rate != null" class="detail-row"><text class="detail-label">透析液流量</text><text class="detail-value">{{ rx.dialysate_flow_rate }} ml/min</text></view>
<view v-if="rx.frequency_per_week != null" class="detail-row"><text class="detail-label">频率</text><text class="detail-value">{{ rx.frequency_per_week }} /</text></view>
<view v-if="rx.duration_minutes != null" class="detail-row"><text class="detail-label">每次时长</text><text class="detail-value">{{ rx.duration_minutes }} 分钟</text></view>
</view>
<view class="detail-card">
<text class="section-title">透析液配比</text>
<view v-if="rx.dialysate_potassium != null" class="detail-row"><text class="detail-label">钾浓度</text><text class="detail-value">{{ rx.dialysate_potassium }} mmol/L</text></view>
<view v-if="rx.dialysate_calcium != null" class="detail-row"><text class="detail-label">钙浓度</text><text class="detail-value">{{ rx.dialysate_calcium }} mmol/L</text></view>
<view v-if="rx.dialysate_bicarbonate != null" class="detail-row"><text class="detail-label">碳酸氢盐浓度</text><text class="detail-value">{{ rx.dialysate_bicarbonate }} mmol/L</text></view>
</view>
<view class="detail-card">
<text class="section-title">抗凝方案</text>
<view v-if="rx.anticoagulation_type" class="detail-row"><text class="detail-label">抗凝类型</text><text class="detail-value">{{ rx.anticoagulation_type }}</text></view>
<view v-if="rx.anticoagulation_dose" class="detail-row"><text class="detail-label">抗凝剂量</text><text class="detail-value">{{ rx.anticoagulation_dose }}</text></view>
</view>
<view v-if="rx.vascular_access_type || rx.vascular_access_location" class="detail-card">
<text class="section-title">血管通路</text>
<view v-if="rx.vascular_access_type" class="detail-row"><text class="detail-label">通路类型</text><text class="detail-value">{{ rx.vascular_access_type }}</text></view>
<view v-if="rx.vascular_access_location" class="detail-row"><text class="detail-label">通路位置</text><text class="detail-value">{{ rx.vascular_access_location }}</text></view>
</view>
<view v-if="rx.target_ultrafiltration_ml != null || rx.target_dry_weight != null" class="detail-card">
<text class="section-title">超滤目标</text>
<view v-if="rx.target_ultrafiltration_ml != null" class="detail-row"><text class="detail-label">目标超滤量</text><text class="detail-value">{{ rx.target_ultrafiltration_ml }} ml</text></view>
<view v-if="rx.target_dry_weight != null" class="detail-row"><text class="detail-label">目标干体重</text><text class="detail-value">{{ rx.target_dry_weight }} kg</text></view>
</view>
<view v-if="rx.notes" class="detail-card">
<text class="section-title">备注</text>
<text class="notes-text">{{ rx.notes }}</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getDialysisPrescription, type DialysisPrescription } from '@/services/dialysis'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
import { useElderClass } from '@/composables/useElderClass'
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
active: { label: '生效中', cls: 'active' },
inactive: { label: '已停用', cls: 'inactive' },
expired: { label: '已过期', cls: 'expired' },
}
const { elderClass } = useElderClass()
const rx = ref<DialysisPrescription | null>(null)
const loading = ref(true)
let id = ''
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
onLoad((query) => {
id = query?.id || ''
if (!id) { loading.value = false; return }
loading.value = true
getDialysisPrescription(id)
.then(data => { rx.value = data })
.catch(() => uni.showToast({ title: '加载失败', icon: 'none' }))
.finally(() => { loading.value = false })
})
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
.detail-card { @include card; margin-bottom: 16px; }
.header-card { border-left: 4px solid $pri; }
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
&.active { background: rgba(82,196,26,0.1); color: $acc; }
&.inactive { background: rgba(0,0,0,0.04); color: $tx3; }
&.expired { background: rgba(255,77,79,0.1); color: $dan; }
}
.header-sub { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; }
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
.detail-value { font-size: var(--tk-font-body); color: $tx; }
.notes-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
</style>

View File

@@ -0,0 +1,113 @@
<template>
<view :class="['dialysis-prescriptions-page', elderClass]">
<text class="page-title">透析处方</text>
<template v-if="!authStore.currentPatient">
<EmptyState icon="📋" title="请先在就诊人管理中选择就诊人" />
</template>
<template v-else>
<view v-if="prescriptions.length === 0 && !loading" class="empty-wrap">
<EmptyState icon="📋" title="暂无透析处方" />
</view>
<scroll-view v-else scroll-y class="prescription-scroll" @scrolltolower="loadMore">
<view
v-for="p in prescriptions" :key="p.id"
class="prescription-card"
@tap="goDetail(p.id)"
>
<view class="prescription-card-top">
<text class="prescription-model">{{ p.dialyzer_model || '未指定型号' }}</text>
<text :class="['status-tag', statusInfo(p.status).cls]">{{ statusInfo(p.status).label }}</text>
</view>
<view class="prescription-meta">
<text v-if="p.frequency_per_week != null" class="meta-item">{{ p.frequency_per_week }}/</text>
<text v-if="p.duration_minutes != null" class="meta-item">每次{{ p.duration_minutes }}分钟</text>
</view>
<text v-if="p.effective_from || p.effective_to" class="prescription-date">
{{ p.effective_from || '...' }} ~ {{ p.effective_to || '...' }}
</text>
</view>
<Loading v-if="loading" text="加载中..." />
<view v-if="!loading && prescriptions.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
</scroll-view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { listDialysisPrescriptions, type DialysisPrescription } from '@/services/dialysis'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
active: { label: '生效中', cls: 'active' },
inactive: { label: '已停用', cls: 'inactive' },
expired: { label: '已过期', cls: 'expired' },
}
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const prescriptions = ref<DialysisPrescription[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
let loadingGuard = false
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
const fetchData = async (p: number, append = false) => {
if (!authStore.currentPatient || loadingGuard) return
loadingGuard = true
loading.value = true
try {
const res = await listDialysisPrescriptions({ patient_id: authStore.currentPatient.id, page: p, page_size: 20 })
const list = res.data || []
prescriptions.value = append ? [...prescriptions.value, ...list] : list
total.value = res.total
page.value = p
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loadingGuard = false
loading.value = false
}
}
const loadMore = () => {
if (!loading.value && prescriptions.value.length < total.value) {
fetchData(page.value + 1, true)
}
}
const goDetail = (id: string) => {
uni.navigateTo({ url: `/pages-sub/pkg-profile/dialysis-prescriptions/detail/index?id=${id}` })
}
onShow(() => { fetchData(1) })
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
</script>
<style lang="scss" scoped>
.dialysis-prescriptions-page { min-height: 100vh; background: $bg; padding: 24px; }
.page-title { @include section-title; }
.prescription-scroll { height: calc(100vh - 80px); }
.prescription-card { @include card; margin-bottom: 12px; }
.prescription-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.prescription-model { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
&.active { background: rgba(82,196,26,0.1); color: $acc; }
&.inactive { background: rgba(0,0,0,0.04); color: $tx3; }
&.expired { background: rgba(255,77,79,0.1); color: $dan; }
}
.prescription-meta { display: flex; gap: 16px; margin-top: 4px; }
.meta-item { font-size: var(--tk-font-cap); color: $tx2; }
.prescription-date { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
.no-more { @include flex-center; padding: 20px; }
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
.empty-wrap { padding-top: 40px; }
</style>

View File

@@ -0,0 +1,99 @@
<template>
<view :class="['detail-page', elderClass]">
<Loading v-if="loading" text="加载中..." />
<EmptyState v-else-if="!record" icon="📋" title="记录不存在" />
<template v-else>
<view class="detail-card header-card">
<view class="header-row">
<text class="detail-title">{{ record.dialysis_date }}</text>
<text :class="['status-tag', statusInfo(record.status).cls]">{{ statusInfo(record.status).label }}</text>
</view>
<text class="header-sub">{{ TYPE_MAP[record.dialysis_type] || record.dialysis_type }}</text>
<text v-if="record.reviewed_at" class="review-info">审核时间 {{ record.reviewed_at }}</text>
</view>
<view class="detail-card">
<text class="section-title">基本信息</text>
<view v-if="record.start_time" class="detail-row"><text class="detail-label">开始时间</text><text class="detail-value">{{ record.start_time }}</text></view>
<view v-if="record.end_time" class="detail-row"><text class="detail-label">结束时间</text><text class="detail-value">{{ record.end_time }}</text></view>
<view v-if="record.dialysis_duration != null" class="detail-row"><text class="detail-label">透析时长</text><text class="detail-value">{{ record.dialysis_duration }} 分钟</text></view>
<view v-if="record.blood_flow_rate != null" class="detail-row"><text class="detail-label">血流速</text><text class="detail-value">{{ record.blood_flow_rate }} ml/min</text></view>
<view v-if="record.ultrafiltration_volume != null" class="detail-row"><text class="detail-label">超滤量</text><text class="detail-value">{{ record.ultrafiltration_volume }} ml</text></view>
</view>
<view class="detail-card">
<text class="section-title">体重与血压</text>
<view v-if="record.dry_weight != null" class="detail-row"><text class="detail-label">干体重</text><text class="detail-value">{{ record.dry_weight }} kg</text></view>
<view v-if="record.pre_weight != null" class="detail-row"><text class="detail-label">透前体重</text><text class="detail-value">{{ record.pre_weight }} kg</text></view>
<view v-if="record.post_weight != null" class="detail-row"><text class="detail-label">透后体重</text><text class="detail-value">{{ record.post_weight }} kg</text></view>
<view v-if="record.pre_bp_systolic != null && record.pre_bp_diastolic != null" class="detail-row"><text class="detail-label">透前血压</text><text class="detail-value">{{ record.pre_bp_systolic }}/{{ record.pre_bp_diastolic }} mmHg</text></view>
<view v-if="record.post_bp_systolic != null && record.post_bp_diastolic != null" class="detail-row"><text class="detail-label">透后血压</text><text class="detail-value">{{ record.post_bp_systolic }}/{{ record.post_bp_diastolic }} mmHg</text></view>
<view v-if="record.pre_heart_rate != null" class="detail-row"><text class="detail-label">透前心率</text><text class="detail-value">{{ record.pre_heart_rate }} bpm</text></view>
<view v-if="record.post_heart_rate != null" class="detail-row"><text class="detail-label">透后心率</text><text class="detail-value">{{ record.post_heart_rate }} bpm</text></view>
</view>
<view v-if="record.symptoms || record.complication_notes" class="detail-card">
<text class="section-title">症状与并发症</text>
<view v-if="record.symptoms && Object.keys(record.symptoms).length > 0" class="detail-row"><text class="detail-label">症状</text><text class="detail-value">{{ JSON.stringify(record.symptoms) }}</text></view>
<view v-if="record.complication_notes" class="detail-row"><text class="detail-label">并发症备注</text><text class="detail-value">{{ record.complication_notes }}</text></view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getDialysisRecord, type DialysisRecord } from '@/services/dialysis'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
import { useElderClass } from '@/composables/useElderClass'
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
draft: { label: '草稿', cls: 'draft' },
completed: { label: '已完成', cls: 'completed' },
reviewed: { label: '已审核', cls: 'reviewed' },
}
const TYPE_MAP: Record<string, string> = {
HD: '血液透析',
HDF: '血液透析滤过',
HF: '血液滤过',
}
const { elderClass } = useElderClass()
const record = ref<DialysisRecord | null>(null)
const loading = ref(true)
let id = ''
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
onLoad((query) => {
id = query?.id || ''
if (!id) { loading.value = false; return }
loading.value = true
getDialysisRecord(id)
.then(data => { record.value = data })
.catch(() => uni.showToast({ title: '加载失败', icon: 'none' }))
.finally(() => { loading.value = false })
})
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
.detail-card { @include card; margin-bottom: 16px; }
.header-card { border-left: 4px solid $pri; }
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
&.draft { background: rgba(0,0,0,0.04); color: $tx3; }
&.completed { background: rgba(82,196,26,0.1); color: $acc; }
&.reviewed { background: rgba(22,119,255,0.1); color: $info; }
}
.header-sub { font-size: var(--tk-font-body); color: $tx2; display: block; margin-top: 4px; }
.review-info { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; }
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
.detail-value { font-size: var(--tk-font-body); color: $tx; }
</style>

View File

@@ -0,0 +1,124 @@
<template>
<view :class="['dialysis-records-page', elderClass]">
<text class="page-title">透析记录</text>
<template v-if="!authStore.currentPatient">
<EmptyState icon="📋" title="请先在就诊人管理中选择就诊人" />
</template>
<template v-else>
<view v-if="records.length === 0 && !loading" class="empty-wrap">
<EmptyState icon="📋" title="暂无透析记录" />
</view>
<scroll-view v-else scroll-y class="record-scroll" @scrolltolower="loadMore">
<view
v-for="r in records" :key="r.id"
class="record-card"
@tap="goDetail(r.id)"
>
<view class="record-card-top">
<text :class="['type-tag', typeInfo(r.dialysis_type).cls]">{{ typeInfo(r.dialysis_type).label }}</text>
<text :class="['status-tag', statusInfo(r.status).cls]">{{ statusInfo(r.status).label }}</text>
</view>
<text class="record-date">{{ r.dialysis_date }}</text>
<view v-if="r.pre_weight || r.post_weight" class="weight-row">
<text v-if="r.pre_weight" class="weight-item">透前 {{ r.pre_weight }}kg</text>
<text v-if="r.post_weight" class="weight-item">透后 {{ r.post_weight }}kg</text>
</view>
<text v-if="r.dialysis_duration" class="record-meta">时长 {{ r.dialysis_duration }}分钟</text>
</view>
<Loading v-if="loading" text="加载中..." />
<view v-if="!loading && records.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
</scroll-view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { listDialysisRecords, type DialysisRecord } from '@/services/dialysis'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
const TYPE_MAP: Record<string, { label: string; cls: string }> = {
HD: { label: 'HD', cls: 'hd' },
HDF: { label: 'HDF', cls: 'hdf' },
HF: { label: 'HF', cls: 'hf' },
}
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
draft: { label: '草稿', cls: 'draft' },
completed: { label: '已完成', cls: 'completed' },
reviewed: { label: '已审核', cls: 'reviewed' },
}
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const records = ref<DialysisRecord[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
let loadingGuard = false
const typeInfo = (t: string) => TYPE_MAP[t] || { label: t, cls: '' }
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
const fetchData = async (p: number, append = false) => {
if (!authStore.currentPatient || loadingGuard) return
loadingGuard = true
loading.value = true
try {
const res = await listDialysisRecords(authStore.currentPatient.id, { page: p, page_size: 20 })
const list = res.data || []
records.value = append ? [...records.value, ...list] : list
total.value = res.total
page.value = p
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loadingGuard = false
loading.value = false
}
}
const loadMore = () => {
if (!loading.value && records.value.length < total.value) {
fetchData(page.value + 1, true)
}
}
const goDetail = (id: string) => {
uni.navigateTo({ url: `/pages-sub/pkg-profile/dialysis-records/detail/index?id=${id}` })
}
onShow(() => { fetchData(1) })
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
</script>
<style lang="scss" scoped>
.dialysis-records-page { min-height: 100vh; background: $bg; padding: 24px; }
.page-title { @include section-title; }
.record-scroll { height: calc(100vh - 80px); }
.record-card { @include card; margin-bottom: 12px; }
.record-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.type-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap); font-weight: 500;
&.hd { background: rgba(22,119,255,0.1); color: $info; }
&.hdf { background: rgba(114,46,209,0.1); color: #722ed1; }
&.hf { background: rgba(250,173,20,0.1); color: $wrn; }
}
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
&.draft { background: rgba(0,0,0,0.04); color: $tx3; }
&.completed { background: rgba(82,196,26,0.1); color: $acc; }
&.reviewed { background: rgba(22,119,255,0.1); color: $info; }
}
.record-date { font-size: var(--tk-font-body); color: $tx; display: block; margin-bottom: 4px; }
.weight-row { display: flex; gap: 16px; margin-top: 4px; }
.weight-item { font-size: var(--tk-font-cap); color: $tx2; }
.record-meta { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
.no-more { @include flex-center; padding: 20px; }
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
.empty-wrap { padding-top: 40px; }
</style>

View File

@@ -0,0 +1,191 @@
<template>
<scroll-view scroll-y class="page-scroll">
<view :class="['elder-mode-page', elderClass]">
<view class="elder-mode-card">
<view class="elder-mode-header">
<text class="elder-mode-icon"></text>
<view class="elder-mode-info">
<text class="elder-mode-title">长辈模式</text>
<text class="elder-mode-desc">放大字体和按钮更易阅读和操作</text>
</view>
</view>
<view class="elder-mode-status">
<text class="elder-mode-status-text">
当前状态{{ uiStore.elderMode ? '已开启' : '已关闭' }}
</text>
<view
:class="['elder-mode-switch', { 'elder-mode-switch--on': uiStore.elderMode }]"
@tap="handleToggle"
>
<view class="elder-mode-switch-thumb" />
</view>
</view>
</view>
<view class="elder-mode-preview">
<text class="elder-mode-preview-title">效果预览</text>
<view class="elder-mode-preview-card">
<text :class="['elder-mode-preview-sample', { 'elder-mode-preview-sample--large': uiStore.elderMode }]">
{{ uiStore.elderMode ? '长辈模式字体示例' : '标准模式字体示例' }}
</text>
<text class="elder-mode-preview-note">
{{ uiStore.elderMode ? '字号放大 1.3 倍,间距放大 1.2 倍' : '正常字号和间距' }}
</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { useUIStore } from '@/stores/ui'
import { useElderClass } from '@/composables/useElderClass'
const uiStore = useUIStore()
const { elderClass } = useElderClass()
function handleToggle() {
uiStore.toggleElderMode()
uni.showToast({
title: uiStore.elderMode ? '已开启长辈模式' : '已关闭长辈模式',
icon: 'none',
duration: 1500,
})
}
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.elder-mode-page {
padding: 24px;
}
.elder-mode-card {
background: $card;
border-radius: $r;
padding: 24px;
box-shadow: $shadow-md;
margin-bottom: 20px;
}
.elder-mode-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.elder-mode-icon {
width: 48px;
height: 48px;
border-radius: $r-sm;
background: $acc-l;
@include flex-center;
font-size: var(--tk-font-body);
font-weight: 700;
color: $acc;
flex-shrink: 0;
}
.elder-mode-info {
flex: 1;
}
.elder-mode-title {
font-size: var(--tk-font-body-sm);
font-weight: 700;
color: $tx;
display: block;
margin-bottom: 4px;
}
.elder-mode-desc {
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
}
.elder-mode-status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0 0;
border-top: 1px solid $bd-l;
}
.elder-mode-status-text {
font-size: var(--tk-font-cap);
color: $tx2;
}
.elder-mode-switch {
width: 52px;
height: 30px;
border-radius: $r-pill;
background: $bd;
position: relative;
transition: background 0.25s;
transition: background 0.25s;
&--on {
background: $acc;
}
}
.elder-mode-switch-thumb {
width: 26px;
height: 26px;
border-radius: $r-pill;
background: $card;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.25s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
.elder-mode-switch--on & {
transform: translateX(22px);
}
}
.elder-mode-preview {
margin-top: 4px;
}
.elder-mode-preview-title {
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx2;
display: block;
margin-bottom: 10px;
padding-left: 4px;
}
.elder-mode-preview-card {
background: $card;
border-radius: $r;
padding: 20px;
box-shadow: $shadow-sm;
}
.elder-mode-preview-sample {
font-size: var(--tk-font-body-sm);
color: $tx;
display: block;
margin-bottom: 8px;
transition: font-size 0.25s;
&--large {
font-size: var(--tk-font-body);
}
}
.elder-mode-preview-note {
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
}
</style>

View File

@@ -0,0 +1,261 @@
<template>
<scroll-view scroll-y class="page-scroll">
<view :class="['family-add-page', elderClass]">
<text class="page-title">{{ editId ? '编辑就诊人' : '添加就诊人' }}</text>
<view class="form-card">
<view class="form-item">
<text class="form-label">姓名</text>
<input
class="form-input"
placeholder="请输入姓名"
placeholder-class="form-placeholder"
:value="name"
@input="name = ($event as any).detail.value"
/>
</view>
<view class="form-item">
<text class="form-label">关系</text>
<picker
mode="selector"
:range="RELATION_OPTIONS"
:value="relationIdx"
@change="onRelationChange"
>
<view class="form-picker">
<text class="form-picker-text">{{ RELATION_OPTIONS[relationIdx] }}</text>
<text class="form-picker-arrow">></text>
</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">性别</text>
<picker
mode="selector"
:range="GENDER_OPTIONS"
:value="genderIdx"
@change="onGenderChange"
>
<view class="form-picker">
<text class="form-picker-text">{{ GENDER_OPTIONS[genderIdx] }}</text>
<text class="form-picker-arrow">></text>
</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">出生日期</text>
<picker
mode="date"
:value="birthDate || '2000-01-01'"
@change="onDateChange"
>
<view class="form-picker">
<text :class="['form-picker-text', { placeholder: !birthDate }]">
{{ birthDate || '请选择' }}
</text>
<text class="form-picker-arrow">></text>
</view>
</picker>
</view>
</view>
<view
:class="['submit-btn', { disabled: submitting }]"
@tap="submitting ? undefined : handleSubmit()"
>
<text class="submit-text">{{ submitting ? '提交中...' : editId ? '保存修改' : '确认添加' }}</text>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { createPatient, updatePatient, Patient } from '@/services/patient'
import { useElderClass } from '@/composables/useElderClass'
const { elderClass } = useElderClass()
const RELATION_OPTIONS = ['本人', '配偶', '父母', '子女', '其他']
const GENDER_OPTIONS = ['男', '女']
const editId = ref('')
const editData = ref<Patient | null>(null)
const name = ref('')
const relationIdx = ref(0)
const genderIdx = ref(0)
const birthDate = ref('')
const submitting = ref(false)
function onRelationChange(e: any) {
relationIdx.value = Number(e.detail.value)
}
function onGenderChange(e: any) {
genderIdx.value = Number(e.detail.value)
}
function onDateChange(e: any) {
birthDate.value = e.detail.value
}
async function handleSubmit() {
if (!name.value.trim()) {
uni.showToast({ title: '请输入姓名', icon: 'none' })
return
}
submitting.value = true
try {
const gender = GENDER_OPTIONS[genderIdx.value] === '男' ? 'male' : 'female'
if (editId.value && editData.value) {
await updatePatient(editId.value, {
name: name.value.trim(),
gender,
birth_date: birthDate.value || undefined,
relation: RELATION_OPTIONS[relationIdx.value],
}, editData.value.version)
uni.showToast({ title: '修改成功', icon: 'success' })
} else {
await createPatient({
name: name.value.trim(),
gender,
birth_date: birthDate.value || undefined,
})
uni.showToast({ title: '添加成功', icon: 'success' })
}
setTimeout(() => uni.navigateBack(), 1000)
} catch {
uni.showToast({ title: editId.value ? '修改失败' : '添加失败', icon: 'none' })
} finally {
submitting.value = false
}
}
onLoad((query) => {
if (query?.id) {
editId.value = query.id
const stored = uni.getStorageSync('edit_patient') as Patient | null
if (stored) {
editData.value = stored
name.value = stored.name || ''
relationIdx.value = stored.relation ? RELATION_OPTIONS.indexOf(stored.relation) : 0
genderIdx.value = stored.gender === 'female' ? 1 : 0
birthDate.value = stored.birth_date || ''
}
}
})
onUnload(() => {
uni.removeStorageSync('edit_patient')
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.family-add-page {
padding: 32px 24px;
padding-bottom: 160px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.form-card {
background: $card;
border-radius: $r;
padding: 4px 28px;
box-shadow: $shadow-sm;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
}
.form-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
color: $tx;
flex-shrink: 0;
width: 140px;
font-weight: 500;
}
.form-input {
flex: 1;
font-size: var(--tk-font-body-lg);
color: $tx;
text-align: right;
border: none;
background: transparent;
outline: none;
}
.form-placeholder {
color: $tx3;
}
.form-picker {
display: flex;
align-items: center;
flex: 1;
justify-content: flex-end;
}
.form-picker-text {
font-size: var(--tk-font-body-lg);
color: $tx;
margin-right: 10px;
&.placeholder {
color: $tx3;
}
}
.form-picker-arrow {
font-size: var(--tk-font-h2);
color: $tx3;
font-family: 'Georgia', 'Times New Roman', serif;
}
.submit-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: $pri;
padding: 28px;
text-align: center;
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
&.disabled {
opacity: 0.5;
}
}
.submit-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
color: $white;
font-weight: bold;
letter-spacing: 2px;
}
</style>

View File

@@ -0,0 +1,239 @@
<template>
<scroll-view scroll-y class="page-scroll">
<view :class="['family-page', elderClass]">
<text class="family-page-title">就诊人管理</text>
<view class="family-list">
<view
v-for="p in patients"
:key="p.id"
:class="['family-item', { active: authStore.currentPatient?.id === p.id }]"
@tap="handleSelect(p)"
>
<view class="family-avatar">
<text class="family-avatar-text">{{ relationInitial(p.relation || '本人') }}</text>
</view>
<view class="family-info">
<view class="family-name-row">
<text class="family-name">{{ p.name }}</text>
<text v-if="authStore.currentPatient?.id === p.id" class="family-current-tag">当前</text>
</view>
<view class="family-meta">
<text class="family-relation-tag">{{ p.relation || '本人' }}</text>
<text class="family-gender">{{ genderText(p.gender) }}</text>
</view>
</view>
<view class="family-edit" @tap.stop="goToEdit(p)">
<text class="family-edit-text">编辑</text>
</view>
</view>
</view>
<EmptyState v-if="patients.length === 0 && !loading" text="暂无就诊人" />
<view class="family-add-btn" @tap="goToAdd">
<text class="family-add-text">添加就诊人</text>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listPatients, Patient } from '@/services/patient'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import EmptyState from '@/components/EmptyState.vue'
const authStore = useAuthStore()
const { elderClass } = useElderClass()
const patients = ref<Patient[]>([])
const loading = ref(false)
async function fetchPatients() {
loading.value = true
try {
const res = await listPatients()
patients.value = res.data || []
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function handleSelect(patient: Patient) {
authStore.setCurrentPatient({
id: patient.id,
name: patient.name,
gender: patient.gender,
birth_date: patient.birth_date,
relation: patient.relation || '本人',
})
uni.showToast({ title: `已切换为 ${patient.name}`, icon: 'success' })
}
function goToAdd() {
uni.navigateTo({ url: '/pages-sub/pkg-profile/family-add/index' })
}
function goToEdit(patient: Patient) {
uni.setStorageSync('edit_patient', patient)
uni.navigateTo({ url: `/pages-sub/pkg-profile/family-add/index?id=${patient.id}` })
}
function genderText(g?: string) {
if (g === 'male') return '男'
if (g === 'female') return '女'
return '未知'
}
function relationInitial(relation: string) {
return relation ? relation.charAt(0) : '本'
}
onShow(() => {
fetchPatients()
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.family-page {
padding: 32px 24px;
padding-bottom: 160px;
}
.family-page-title {
@include section-title;
padding-left: 4px;
}
.family-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.family-item {
display: flex;
align-items: center;
background: $card;
border-radius: $r;
padding: 24px;
box-shadow: $shadow-sm;
transition: box-shadow 0.2s;
&:active {
box-shadow: $shadow-md;
}
&.active {
box-shadow: $shadow-md;
}
}
.family-avatar {
@include flex-center;
width: 80px;
height: 80px;
border-radius: $r;
background: $pri-l;
flex-shrink: 0;
margin-right: 20px;
}
.family-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num-lg);
font-weight: bold;
color: $pri-d;
}
.family-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.family-name-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.family-name {
font-size: var(--tk-font-num);
font-weight: bold;
color: $tx;
}
.family-current-tag {
@include tag($pri, $white);
font-size: var(--tk-font-body-sm);
padding: 2px 10px;
}
.family-meta {
display: flex;
align-items: center;
gap: 12px;
}
.family-relation-tag {
@include tag($pri-l, $pri-d);
font-size: var(--tk-font-body);
padding: 2px 12px;
}
.family-gender {
font-size: var(--tk-font-h2);
color: $tx2;
}
.family-edit {
flex-shrink: 0;
margin-left: 16px;
padding: 14px 24px;
border: 1px solid $bd;
border-radius: $r-pill;
min-height: 48px;
@include flex-center;
&:active {
background: $bd-l;
}
}
.family-edit-text {
font-size: var(--tk-font-h2);
color: $tx2;
}
.family-add-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: $pri;
padding: 28px;
text-align: center;
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
}
.family-add-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
color: $white;
font-weight: bold;
letter-spacing: 2px;
}
</style>

View File

@@ -0,0 +1,153 @@
<template>
<view :class="['my-followups-page', elderClass]">
<view class="tab-bar">
<view
v-for="tab in TABS" :key="tab.key"
:class="['tab-item', activeTab === tab.key ? 'active' : '']"
@tap="handleTabChange(tab.key)"
>
<text :class="['tab-text', activeTab === tab.key ? 'active' : '']">{{ tab.label }}</text>
<view v-if="activeTab === tab.key" class="tab-indicator" />
</view>
</view>
<view class="task-list">
<view
v-for="t in tasks" :key="t.id"
class="task-card"
@tap="goToDetail(t.id)"
>
<view class="task-top">
<text class="task-name">{{ t.follow_up_type }}</text>
<text :class="['task-status', getStatusClass(t.status)]">
{{ getStatusLabel(t.status) }}
</text>
</view>
<text class="task-desc">{{ t.content_template }}</text>
<text class="task-due">截止: {{ t.planned_date }}</text>
</view>
</view>
<EmptyState
v-if="tasks.length === 0 && !loading"
:text="'暂无' + (TABS.find(t => t.key === activeTab)?.label || '') + '任务'"
/>
<Loading v-if="loading" text="加载中..." />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listTasks, type FollowUpTask } from '@/services/followup'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
const TABS = [
{ key: 'pending', label: '待完成' },
{ key: 'completed', label: '已完成' },
{ key: 'overdue', label: '已过期' },
]
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const activeTab = ref('pending')
const tasks = ref<FollowUpTask[]>([])
const loading = ref(false)
const fetchTasks = async (status: string) => {
loading.value = true
try {
const patientId = authStore.currentPatient?.id
const res = await listTasks(patientId, status)
tasks.value = res.data || []
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const handleTabChange = (key: string) => {
activeTab.value = key
fetchTasks(key)
}
const goToDetail = (id: string) => {
uni.navigateTo({ url: `/pages-sub/followup/detail/index?id=${id}` })
}
const getStatusClass = (status: string) => {
if (status === 'completed') return 'completed'
if (status === 'overdue') return 'overdue'
return 'pending'
}
const getStatusLabel = (status: string) => {
if (status === 'completed') return '已完成'
if (status === 'overdue') return '已过期'
return '待完成'
}
onShow(() => { fetchTasks(activeTab.value) })
</script>
<style lang="scss" scoped>
.my-followups-page { min-height: 100vh; background: $bg; }
.tab-bar { display: flex; background: $card; padding: 0; box-shadow: $shadow-sm; }
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 24px 0 20px;
position: relative;
}
.tab-text {
font-size: var(--tk-font-body-lg);
color: $tx2;
margin-bottom: 8px;
&.active { color: $pri; font-weight: bold; }
}
.tab-indicator { width: 32px; height: 4px; background: $pri; border-radius: $r-xs; }
.task-list { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
.task-card {
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: $shadow-sm;
&:active { box-shadow: $shadow-md; }
}
.task-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.task-name {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
font-weight: bold;
color: $tx;
}
.task-status {
@include tag($bd-l, $tx2);
&.pending { @include tag($wrn-l, $wrn); }
&.completed { @include tag($acc-l, $acc); }
&.overdue { @include tag($dan-l, $dan); }
}
.task-desc {
font-size: var(--tk-font-h1);
color: $tx2;
display: block;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-due {
@include serif-number;
font-size: var(--tk-font-h2);
color: $tx3;
display: block;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<view :class="['health-records-page', elderClass]">
<text class="page-title">健康记录</text>
<view class="record-list">
<view v-for="r in records" :key="r.id" class="record-card">
<view class="record-card__header">
<text class="record-card__type">{{ TYPE_MAP[r.record_type] || r.record_type }}</text>
<text class="record-card__date">{{ r.record_date }}</text>
</view>
<text v-if="r.overall_assessment" class="record-card__assessment">{{ r.overall_assessment }}</text>
<text v-if="r.source" class="record-card__source">来源{{ r.source }}</text>
<text v-if="r.notes" class="record-card__notes">{{ r.notes }}</text>
</view>
</view>
<EmptyState
v-if="records.length === 0 && !loading"
:text="authStore.currentPatient ? '暂无健康记录' : '请先在就诊人管理中选择就诊人'"
/>
<Loading v-if="loading" text="加载中..." />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { listHealthRecords, type HealthRecord } from '@/services/health-record'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
const TYPE_MAP: Record<string, string> = {
checkup: '体检',
follow_up: '复查',
referral: '转诊',
}
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const records = ref<HealthRecord[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const fetchData = async (p: number, append = false) => {
if (!authStore.currentPatient) {
records.value = []
return
}
loading.value = true
try {
const res = await listHealthRecords(authStore.currentPatient.id, { page: p, page_size: 20 })
const list = res.data || []
records.value = append ? [...records.value, ...list] : list
total.value = res.total
page.value = p
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!loading.value && records.value.length < total.value) {
fetchData(page.value + 1, true)
}
}
onShow(() => { fetchData(1) })
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
</script>
<style lang="scss" scoped>
.health-records-page { min-height: 100vh; background: $bg; padding: 32px 24px; padding-bottom: 40px; }
.page-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
padding-left: 4px;
}
.record-list { display: flex; flex-direction: column; gap: 16px; }
.record-card { background: $card; border-radius: $r; padding: 28px; box-shadow: $shadow-sm; }
.record-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.record-card__type { font-size: var(--tk-font-body-lg); font-weight: bold; color: $tx; }
.record-card__date { font-size: var(--tk-font-h2); color: $tx2; font-variant-numeric: tabular-nums; }
.record-card__assessment { font-size: var(--tk-font-h2); color: $tx; display: block; margin-bottom: 4px; }
.record-card__source { font-size: var(--tk-font-body); color: $tx3; display: block; margin-bottom: 4px; }
.record-card__notes { font-size: var(--tk-font-body); color: $tx2; display: block; margin-top: 8px; }
</style>

View File

@@ -0,0 +1,291 @@
<template>
<view :class="['medication-page', elderClass]">
<text class="page-title">用药提醒</text>
<Loading v-if="loading" text="加载中..." />
<template v-else>
<view class="reminder-list">
<view
v-for="r in reminders" :key="r.id"
:class="['reminder-card', !r.is_active ? 'disabled' : '']"
>
<view class="reminder-avatar">
<text class="reminder-avatar-text">{{ nameInitial(r.medication_name) }}</text>
</view>
<view class="reminder-info">
<text class="reminder-name">{{ r.medication_name }}</text>
<text class="reminder-dosage">
{{ r.dosage || '-' }} | {{ r.reminder_times?.join(', ') || '-' }}
</text>
</view>
<view class="reminder-actions">
<view
:class="['toggle', r.is_active ? 'on' : 'off']"
@tap="handleToggle(r)"
>
<view class="toggle-dot" />
</view>
<text class="delete-btn" @tap="handleDelete(r)">删除</text>
</view>
</view>
</view>
<EmptyState v-if="reminders.length === 0" text="暂无用药提醒" />
<view v-if="showForm" class="form-card">
<text class="form-card-title">添加提醒</text>
<view class="form-item">
<text class="form-label">药品名称</text>
<input
class="form-input"
placeholder="请输入药品名称"
placeholder-class="form-placeholder"
:value="formName"
@input="formName = ($event as any).detail.value"
/>
</view>
<view class="form-item">
<text class="form-label">剂量</text>
<input
class="form-input"
placeholder="如: 1片、10ml"
placeholder-class="form-placeholder"
:value="formDosage"
@input="formDosage = ($event as any).detail.value"
/>
</view>
<view class="form-item">
<text class="form-label">提醒时间</text>
<picker mode="time" :value="formTime" @change="formTime = ($event as any).detail.value">
<view class="time-picker-wrap">
<text class="time-value">{{ formTime }}</text>
<text class="time-modify">修改</text>
</view>
</picker>
</view>
<view class="form-actions">
<view class="form-cancel" @tap="showForm = false">
<text class="form-cancel-text">取消</text>
</view>
<view class="form-confirm" @tap="handleAdd">
<text class="form-confirm-text">确认</text>
</view>
</view>
</view>
<view v-if="!showForm" class="add-btn" @tap="showForm = true">
<text class="add-text">添加提醒</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted } from 'vue'
import {
listReminders,
createReminder,
updateReminder,
deleteReminder,
type MedicationReminder,
} from '@/services/medication-reminder'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const reminders = ref<MedicationReminder[]>([])
const loading = ref(true)
const showForm = ref(false)
const formName = ref('')
const formDosage = ref('')
const formTime = ref('08:00')
const fetchReminders = async () => {
try {
const res = await listReminders()
reminders.value = res.data ?? []
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const handleToggle = async (r: MedicationReminder) => {
try {
await updateReminder(r.id, { is_active: !r.is_active, version: r.version })
fetchReminders()
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
const handleDelete = (r: MedicationReminder) => {
uni.showModal({
title: '确认删除',
content: '确定要删除这个提醒吗?',
}).then(async (res) => {
if (res.confirm) {
try {
await deleteReminder(r.id, r.version)
uni.showToast({ title: '已删除', icon: 'success' })
fetchReminders()
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
})
}
const handleAdd = async () => {
if (!formName.value.trim()) {
uni.showToast({ title: '请输入药品名称', icon: 'none' })
return
}
const patientId = authStore.currentPatient?.id
if (!patientId) {
uni.showToast({ title: '请先绑定患者档案', icon: 'none' })
return
}
try {
await createReminder({
patient_id: patientId,
medication_name: formName.value.trim(),
dosage: formDosage.value.trim() || undefined,
reminder_times: [formTime.value],
is_active: true,
})
formName.value = ''
formDosage.value = ''
formTime.value = '08:00'
showForm.value = false
uni.showToast({ title: '添加成功', icon: 'success' })
fetchReminders()
} catch {
uni.showToast({ title: '添加失败', icon: 'none' })
}
}
const nameInitial = (name: string) => {
return name ? name.charAt(0) : '药'
}
onMounted(() => { fetchReminders() })
</script>
<style lang="scss" scoped>
.medication-page { min-height: 100vh; background: $bg; padding: 32px 24px; padding-bottom: 160px; }
.page-title { @include section-title; padding-left: 4px; }
.reminder-list { display: flex; flex-direction: column; gap: 16px; }
.reminder-card {
display: flex;
align-items: center;
background: $card;
border-radius: $r;
padding: 24px;
box-shadow: $shadow-sm;
&.disabled { opacity: 0.55; }
}
.reminder-avatar {
@include flex-center;
width: 72px; height: 72px;
border-radius: $r;
background: $acc-l;
flex-shrink: 0;
margin-right: 20px;
}
.reminder-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
font-weight: bold;
color: $acc;
}
.reminder-info { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.reminder-name {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
font-weight: bold;
color: $tx;
margin-bottom: 4px;
}
.reminder-dosage {
@include serif-number;
font-size: var(--tk-font-h2);
color: $tx2;
}
.reminder-actions { display: flex; align-items: center; gap: 16px; flex-shrink: 0; margin-left: 12px; }
.toggle {
width: 84px; height: 48px;
border-radius: $r-pill;
padding: 4px;
position: relative;
transition: background 0.3s;
&.on { background: $pri; }
&.off { background: $bd; }
}
.toggle-dot {
width: 40px; height: 40px;
border-radius: 50%;
background: $card;
position: absolute;
top: 4px;
transition: left 0.3s;
.toggle.on & { left: 40px; }
.toggle.off & { left: 4px; }
}
.delete-btn {
font-size: var(--tk-font-h2);
color: $dan;
padding: 14px 16px;
min-height: 48px;
@include flex-center;
}
.form-card { background: $card; border-radius: $r; padding: 28px; margin-top: 24px; box-shadow: $shadow-sm; }
.form-card-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 0;
border-bottom: 1px solid $bd-l;
&:last-of-type { border-bottom: none; }
}
.form-label { font-size: var(--tk-font-body-lg); color: $tx; flex-shrink: 0; width: 160px; }
.form-input { flex: 1; font-size: var(--tk-font-body-lg); color: $tx; text-align: right; border: none; background: transparent; outline: none; }
.form-placeholder { color: $tx3; }
.time-picker-wrap { flex: 1; display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
.time-value { @include serif-number; font-size: var(--tk-font-body-lg); color: $tx; }
.time-modify { font-size: var(--tk-font-h2); color: $pri; }
.form-actions { display: flex; gap: 16px; margin-top: 24px; }
.form-cancel { flex: 1; background: $bd-l; border-radius: $r-sm; padding: 20px; text-align: center; }
.form-cancel-text { font-size: var(--tk-font-body-lg); color: $tx2; }
.form-confirm { flex: 1; background: $pri; border-radius: $r-sm; padding: 20px; text-align: center; }
.form-confirm-text { font-size: var(--tk-font-body-lg); color: $white; font-weight: bold; }
.add-btn {
position: fixed;
bottom: 0; left: 0; right: 0;
background: $pri;
padding: 28px;
text-align: center;
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
}
.add-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
color: $white;
font-weight: bold;
letter-spacing: 2px;
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<view :class="['my-reports-page', elderClass]">
<text class="page-title">检查报告</text>
<view class="report-list">
<view
v-for="r in reports" :key="r.id"
class="report-card"
@tap="goToDetail(r.id)"
>
<view class="report-card-top">
<view class="report-type-row">
<view class="report-avatar">
<text class="report-avatar-text">{{ typeInitial(r.report_type) }}</text>
</view>
<text class="report-type">{{ r.report_type }}</text>
</view>
<text :class="['report-status', formatStatus(r)]">
{{ formatStatus(r) === 'normal' ? '正常' : formatStatus(r) === 'abnormal' ? '异常' : '未知' }}
</text>
</view>
<text class="report-date">{{ r.report_date }}</text>
</view>
</view>
<EmptyState
v-if="reports.length === 0 && !loading"
:text="authStore.currentPatient ? '暂无报告记录' : '请先在就诊人管理中选择就诊人'"
/>
<Loading v-if="loading" text="加载中..." />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { listReports, type LabReport } from '@/services/report'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const reports = ref<LabReport[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const fetchData = async (p: number, append = false) => {
if (!authStore.currentPatient) {
reports.value = []
return
}
loading.value = true
try {
const res = await listReports(authStore.currentPatient.id, p)
const list = res.data || []
reports.value = append ? [...reports.value, ...list] : list
total.value = res.total
page.value = p
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!loading.value && reports.value.length < total.value) {
fetchData(page.value + 1, true)
}
}
const goToDetail = (id: string) => {
uni.navigateTo({ url: `/pages-sub/report/detail/index?id=${id}` })
}
const formatStatus = (report: LabReport) => {
const indicators = report.indicators
if (!indicators || typeof indicators !== 'object') return 'unknown'
const vals = Object.values(indicators) as Array<{ status?: string }>
const hasAbnormal = vals.some((v) => v.status === 'high' || v.status === 'low')
return hasAbnormal ? 'abnormal' : 'normal'
}
const typeInitial = (type: string) => {
return type ? type.charAt(0) : '报'
}
onShow(() => { fetchData(1) })
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
</script>
<style lang="scss" scoped>
.my-reports-page {
min-height: 100vh;
background: $bg;
padding: 32px 24px;
padding-bottom: 40px;
}
.page-title { @include section-title; padding-left: 4px; }
.report-list { display: flex; flex-direction: column; gap: 16px; }
.report-card {
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: $shadow-sm;
&:active { box-shadow: $shadow-md; }
}
.report-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.report-type-row { display: flex; align-items: center; }
.report-avatar {
@include flex-center;
width: 56px; height: 56px;
border-radius: $r-sm;
background: $pri-l;
margin-right: 16px;
flex-shrink: 0;
}
.report-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $pri-d;
}
.report-type {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
font-weight: bold;
color: $tx;
}
.report-status {
@include tag($bd-l, $tx2);
&.normal { @include tag($acc-l, $acc); }
&.abnormal { @include tag($dan-l, $dan); }
}
.report-date {
@include serif-number;
font-size: var(--tk-font-h1);
color: $tx2;
display: block;
padding-left: 72px;
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<scroll-view scroll-y class="page-scroll">
<view :class="['settings-page', elderClass]">
<text class="page-title">设置</text>
<view class="settings-group">
<view class="settings-item" @tap="handleClearCache">
<view class="settings-icon">
<text class="settings-icon-text"></text>
</view>
<text class="settings-label">清除缓存</text>
<text class="settings-arrow">></text>
</view>
<view class="settings-item" @tap="handleAbout">
<view class="settings-icon">
<text class="settings-icon-text"></text>
</view>
<text class="settings-label">关于我们</text>
<text class="settings-arrow">></text>
</view>
<view class="settings-item" @tap="handlePrivacy">
<view class="settings-icon">
<text class="settings-icon-text"></text>
</view>
<text class="settings-label">隐私政策</text>
<text class="settings-arrow">></text>
</view>
</view>
<view class="settings-group">
<view class="settings-item logout-item" @tap="handleLogout">
<text class="settings-label logout-label">退出登录</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { clearRequestCache } from '@/services/request'
import { useElderClass } from '@/composables/useElderClass'
const authStore = useAuthStore()
const { elderClass } = useElderClass()
function handleClearCache() {
uni.showModal({
title: '清除缓存',
content: '确定要清除本地缓存数据吗?不会影响账号信息。',
success: (res: any) => {
if (res.confirm) {
const preservedKeys = [
'access_token', 'refresh_token', 'user_data', 'user_roles',
'tenant_id', 'wechat_openid', 'current_patient', 'current_patient_id',
]
const preservedData: Record<string, unknown> = {}
for (const key of preservedKeys) {
const val = uni.getStorageSync(key)
if (val) preservedData[key] = val
}
try { uni.clearStorageSync() } catch { /* ignore */ }
for (const [key, val] of Object.entries(preservedData)) {
uni.setStorageSync(key, val)
}
clearRequestCache()
uni.showToast({ title: '缓存已清除', icon: 'success' })
}
},
})
}
function handleAbout() {
uni.showModal({
title: '关于我们',
content: 'HMS 健康管理平台 v1.0.0\n为您的健康保驾护航',
showCancel: false,
})
}
function handlePrivacy() {
uni.navigateTo({ url: '/pages/legal/privacy-policy' })
}
function handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
success: (res: any) => {
if (res.confirm) {
authStore.logout()
}
},
})
}
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.settings-page {
padding: 32px 24px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.settings-group {
background: $card;
border-radius: $r;
overflow: hidden;
margin-bottom: 24px;
box-shadow: $shadow-sm;
}
.settings-item {
display: flex;
align-items: center;
padding: 28px 24px;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
&.logout-item {
justify-content: center;
}
}
.settings-icon {
@include flex-center;
width: 48px;
height: 48px;
border-radius: $r-sm;
background: $pri-l;
margin-right: 16px;
flex-shrink: 0;
}
.settings-icon-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h2);
font-weight: bold;
color: $pri-d;
}
.settings-label {
flex: 1;
font-size: var(--tk-font-num);
color: $tx;
}
.logout-label {
color: $dan;
font-weight: bold;
}
.settings-arrow {
font-size: var(--tk-font-h2);
color: $tx3;
font-family: 'Georgia', 'Times New Roman', serif;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<view :class="['detail-page', elderClass]">
<Loading v-if="loading" text="加载中..." />
<view v-else-if="!report" class="empty-wrap"><text class="empty-text">报告不存在</text></view>
<template v-else>
<view class="detail-card">
<text class="detail-title">{{ report.report_type }}</text>
<view class="detail-row">
<text class="detail-label">报告日期</text>
<text class="detail-value">{{ report.report_date }}</text>
</view>
<view v-if="report.doctor_interpretation" class="detail-row">
<text class="detail-label">医生解读</text>
<text class="detail-value">{{ report.doctor_interpretation }}</text>
</view>
</view>
<view class="indicators-card">
<text class="section-title">检查指标</text>
<view v-for="item in indicators" :key="item.name" class="indicator-item">
<view class="indicator-left">
<text class="indicator-name">{{ item.name }}</text>
<text class="indicator-value">{{ item.value }}{{ item.unit ? ` ${item.unit}` : '' }}</text>
</view>
<view class="indicator-right">
<text v-if="item.reference_min != null && item.reference_max != null" class="indicator-ref">
{{ item.reference_min }}~{{ item.reference_max }}
</text>
<text :class="['indicator-status', getStatusInfo(item.status).className]">
{{ getStatusInfo(item.status).text }}
</text>
</view>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getReportDetail, type LabReport } from '@/services/report'
import { useElderClass } from '@/composables/useElderClass'
import Loading from '@/components/Loading.vue'
interface IndicatorItem { name: string; value: number; unit?: string; reference_min?: number; reference_max?: number; status?: string }
const { elderClass } = useElderClass()
const report = ref<LabReport | null>(null)
const loading = ref(true)
const indicators = computed<IndicatorItem[]>(() => {
if (!report.value?.indicators || typeof report.value.indicators !== 'object') return []
return Object.entries(report.value.indicators).map(([name, val]) => ({ name, value: val.value, unit: val.unit, reference_min: val.reference_min, reference_max: val.reference_max, status: val.status }))
})
const getStatusInfo = (status?: string) => {
if (status === 'high') return { text: '偏高', className: 'high' }
if (status === 'low') return { text: '偏低', className: 'low' }
return { text: '正常', className: 'normal' }
}
onLoad((query) => {
const id = query?.id || ''
const patientId = uni.getStorageSync('current_patient_id') || ''
if (!id || !patientId) { loading.value = false; return }
getReportDetail(patientId, id).then(data => { report.value = data }).catch(() => uni.showToast({ title: '加载失败', icon: 'none' })).finally(() => { loading.value = false })
})
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
.empty-wrap { @include flex-center; padding: 120px 0; }
.empty-text { font-size: var(--tk-font-body); color: $tx3; }
.detail-card { @include card; margin-bottom: 16px; }
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-bottom: 12px; }
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
.detail-row:last-child { border-bottom: none; }
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
.detail-value { font-size: var(--tk-font-body); color: $tx; flex: 1; text-align: right; }
.indicators-card { @include card; }
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
.indicator-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
.indicator-item:last-child { border-bottom: none; }
.indicator-left { flex: 1; }
.indicator-name { font-size: var(--tk-font-cap); color: $tx2; display: block; }
.indicator-value { font-size: var(--tk-font-body); color: $tx; display: block; margin-top: 2px; }
.indicator-right { text-align: right; flex-shrink: 0; }
.indicator-ref { font-size: var(--tk-font-micro); color: $tx3; display: block; }
.indicator-status { font-size: var(--tk-font-cap); display: block; margin-top: 2px; }
.indicator-status.high { color: $wrn; }
.indicator-status.low { color: $info; }
.indicator-status.normal { color: $acc; }
</style>

View File

@@ -0,0 +1,150 @@
{
"pages": [
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "健康管理" } },
{ "path": "pages/login/index", "style": { "navigationBarTitleText": "登录" } },
{ "path": "pages/health/index", "style": { "navigationBarTitleText": "健康数据" } },
{ "path": "pages/messages/index", "style": { "navigationBarTitleText": "消息" } },
{ "path": "pages/profile/index", "style": { "navigationBarTitleText": "我的" } },
{ "path": "pages/legal/user-agreement", "style": { "navigationBarTitleText": "用户协议" } },
{ "path": "pages/legal/privacy-policy", "style": { "navigationBarTitleText": "隐私政策" } }
],
"subPackages": [
{
"root": "pages-sub/consultation",
"pages": [
{ "path": "index", "style": { "navigationBarTitleText": "咨询列表" } },
{ "path": "detail/index", "style": { "navigationBarTitleText": "咨询详情" } }
]
},
{
"root": "pages-sub/mall",
"pages": [
{ "path": "index", "style": { "navigationBarTitleText": "积分商城" } }
]
},
{
"root": "pages-sub/appointment",
"pages": [
{ "path": "index", "style": { "navigationBarTitleText": "预约列表" } },
{ "path": "create/index", "style": { "navigationBarTitleText": "创建预约" } },
{ "path": "detail/index", "style": { "navigationBarTitleText": "预约详情" } }
]
},
{
"root": "pages-sub/pkg-health",
"pages": [
{ "path": "trend/index", "style": { "navigationBarTitleText": "健康趋势" } },
{ "path": "input/index", "style": { "navigationBarTitleText": "健康录入" } },
{ "path": "daily-monitoring/index", "style": { "navigationBarTitleText": "每日监测" } },
{ "path": "alerts/index", "style": { "navigationBarTitleText": "健康告警" } }
]
},
{
"root": "pages-sub/article",
"pages": [
{ "path": "index", "style": { "navigationBarTitleText": "健康文章" } },
{ "path": "detail/index", "style": { "navigationBarTitleText": "文章详情" } }
]
},
{
"root": "pages-sub/doctor",
"pages": [
{ "path": "index", "style": { "navigationBarTitleText": "医生工作台" } },
{ "path": "patients/index", "style": { "navigationBarTitleText": "患者列表" } },
{ "path": "patients/detail/index", "style": { "navigationBarTitleText": "患者详情" } },
{ "path": "consultation/index", "style": { "navigationBarTitleText": "咨询管理" } },
{ "path": "consultation/detail/index", "style": { "navigationBarTitleText": "咨询详情" } },
{ "path": "followup/index", "style": { "navigationBarTitleText": "随访列表" } },
{ "path": "followup/detail/index", "style": { "navigationBarTitleText": "随访详情" } },
{ "path": "report/index", "style": { "navigationBarTitleText": "报告列表" } },
{ "path": "report/detail/index", "style": { "navigationBarTitleText": "报告详情" } },
{ "path": "alerts/index", "style": { "navigationBarTitleText": "告警列表" } },
{ "path": "alerts/detail/index", "style": { "navigationBarTitleText": "告警详情" } },
{ "path": "action-inbox/index", "style": { "navigationBarTitleText": "待办事项" } },
{ "path": "dialysis/index", "style": { "navigationBarTitleText": "透析列表" } },
{ "path": "dialysis/detail/index", "style": { "navigationBarTitleText": "透析详情" } },
{ "path": "dialysis/create/index", "style": { "navigationBarTitleText": "创建透析" } },
{ "path": "prescription/index", "style": { "navigationBarTitleText": "处方列表" } },
{ "path": "prescription/detail/index", "style": { "navigationBarTitleText": "处方详情" } },
{ "path": "prescription/create/index", "style": { "navigationBarTitleText": "创建处方" } }
]
},
{
"root": "pages-sub/pkg-mall",
"pages": [
{ "path": "exchange/index", "style": { "navigationBarTitleText": "积分兑换" } },
{ "path": "orders/index", "style": { "navigationBarTitleText": "我的订单" } },
{ "path": "detail/index", "style": { "navigationBarTitleText": "商品详情" } }
]
},
{
"root": "pages-sub/pkg-profile",
"pages": [
{ "path": "family/index", "style": { "navigationBarTitleText": "家庭成员" } },
{ "path": "family-add/index", "style": { "navigationBarTitleText": "添加成员" } },
{ "path": "reports/index", "style": { "navigationBarTitleText": "报告列表" } },
{ "path": "followups/index", "style": { "navigationBarTitleText": "随访记录" } },
{ "path": "medication/index", "style": { "navigationBarTitleText": "用药管理" } },
{ "path": "settings/index", "style": { "navigationBarTitleText": "设置" } },
{ "path": "dialysis-records/index", "style": { "navigationBarTitleText": "透析记录" } },
{ "path": "dialysis-records/detail/index", "style": { "navigationBarTitleText": "透析详情" } },
{ "path": "dialysis-prescriptions/index", "style": { "navigationBarTitleText": "透析处方" } },
{ "path": "dialysis-prescriptions/detail/index", "style": { "navigationBarTitleText": "处方详情" } },
{ "path": "consents/index", "style": { "navigationBarTitleText": "知情同意书" } },
{ "path": "health-records/index", "style": { "navigationBarTitleText": "健康档案" } },
{ "path": "diagnoses/index", "style": { "navigationBarTitleText": "诊断记录" } },
{ "path": "elder-mode/index", "style": { "navigationBarTitleText": "长者模式" } }
]
},
{
"root": "pages-sub/ai-report",
"pages": [
{ "path": "list/index", "style": { "navigationBarTitleText": "AI 分析" } },
{ "path": "detail/index", "style": { "navigationBarTitleText": "分析详情" } }
]
},
{
"root": "pages-sub/report",
"pages": [
{ "path": "detail/index", "style": { "navigationBarTitleText": "报告详情" } }
]
},
{
"root": "pages-sub/followup",
"pages": [
{ "path": "detail/index", "style": { "navigationBarTitleText": "随访详情" } }
]
},
{
"root": "pages-sub/events",
"pages": [
{ "path": "index", "style": { "navigationBarTitleText": "活动列表" } }
]
},
{
"root": "pages-sub/device-sync",
"pages": [
{ "path": "index", "style": { "navigationBarTitleText": "设备同步" } }
]
}
],
"tabBar": {
"color": "#A8A29E",
"selectedColor": "#C4623A",
"backgroundColor": "#FFFFFF",
"borderStyle": "white",
"list": [
{ "pagePath": "pages/index/index", "text": "首页", "iconPath": "static/tabbar/home.png", "selectedIconPath": "static/tabbar/home-active.png" },
{ "pagePath": "pages/health/index", "text": "健康", "iconPath": "static/tabbar/health.png", "selectedIconPath": "static/tabbar/health-active.png" },
{ "pagePath": "pages/messages/index", "text": "消息", "iconPath": "static/tabbar/message.png", "selectedIconPath": "static/tabbar/message-active.png" },
{ "pagePath": "pages/profile/index", "text": "我的", "iconPath": "static/tabbar/profile.png", "selectedIconPath": "static/tabbar/profile-active.png" }
]
},
"globalStyle": {
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"navigationBarTitleText": "健康管理",
"backgroundColor": "#F5F0EB",
"enablePullDownRefresh": true
}
}

View File

@@ -0,0 +1,156 @@
<template>
<scroll-view scroll-y class="health-scroll">
<view :class="['health-page', elderClass]">
<GuestGuard>
<!-- 健康数据页 -->
<text class="page-title">健康数据</text>
<Loading v-if="loading" text="加载中..." />
<!-- 体征录入卡片 -->
<view v-if="!loading" class="card input-card">
<text class="section-title">今日体征</text>
<view class="vital-grid">
<view v-for="item in vitalItems" :key="item.key" class="vital-item" @tap="goInput(item.key)">
<text class="vital-icon">{{ item.icon }}</text>
<text class="vital-name">{{ item.name }}</text>
<text class="vital-value">{{ latestVitals[item.key] || '--' }}</text>
</view>
</view>
</view>
<!-- 健康趋势入口 -->
<view v-if="!loading" class="card" @tap="navigateTo('/pages-sub/pkg-health/trend/index')">
<view class="trend-entry">
<text class="trend-label">查看健康趋势</text>
<text class="trend-arrow"></text>
</view>
</view>
</GuestGuard>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import { getTodaySummary } from '@/services/health'
import GuestGuard from '@/components/GuestGuard.vue'
import Loading from '@/components/Loading.vue'
const authStore = useAuthStore()
const { elderClass } = useElderClass()
const loading = ref(false)
const vitalItems = [
{ key: 'heart_rate', name: '心率', icon: '❤️' },
{ key: 'blood_pressure', name: '血压', icon: '🩸' },
{ key: 'blood_sugar', name: '血糖', icon: '🍬' },
{ key: 'temperature', name: '体温', icon: '🌡️' },
{ key: 'weight', name: '体重', icon: '⚖️' },
{ key: 'oxygen', name: '血氧', icon: '🫁' },
]
const latestVitals = reactive<Record<string, string>>({})
function navigateTo(url: string) {
uni.navigateTo({ url })
}
function goInput(key: string) {
uni.navigateTo({ url: `/pages-sub/pkg-health/input/index?type=${key}` })
}
async function fetchLatestVitals() {
loading.value = true
try {
const data = await getTodaySummary()
if (data) {
Object.assign(latestVitals, data)
}
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
onMounted(fetchLatestVitals)
onShow(() => { authStore.restore() })
</script>
<style lang="scss" scoped>
.health-scroll {
height: 100vh;
background: $bg;
}
.health-page {
padding: 28px 24px 120px;
}
.page-title {
@include section-title;
}
.card {
@include card;
}
.input-card {
cursor: pointer;
}
.vital-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.vital-item {
@include flex-center;
flex-direction: column;
background: $pri-surface;
border-radius: $r-sm;
padding: 16px 8px;
}
.vital-icon {
font-size: var(--tk-font-h2);
margin-bottom: 4px;
}
.vital-name {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-bottom: 4px;
}
.vital-value {
font-size: var(--tk-font-num);
font-weight: bold;
color: $pri;
@include serif-number;
}
.trend-entry {
display: flex;
justify-content: space-between;
align-items: center;
min-height: $touch-min;
}
.trend-label {
font-size: var(--tk-font-body);
font-weight: 500;
color: $tx;
}
.trend-arrow {
font-size: var(--tk-font-h1);
color: $tx3;
}
</style>

View File

@@ -0,0 +1,317 @@
<template>
<scroll-view scroll-y class="home-scroll" @scrolltolower="onLoadMore">
<view :class="['home-page', elderClass]">
<!-- 已登录模式 -->
<template v-if="authStore.user">
<!-- 用户问候 -->
<view class="greeting-section">
<text class="greeting-text">{{ greeting }}{{ authStore.user.display_name || authStore.user.username }}</text>
<text class="greeting-date">{{ today }}</text>
</view>
<!-- 快捷功能 -->
<view class="quick-actions">
<view class="action-item" @tap="navigateTo('/pages-sub/appointment/create/index')">
<text class="action-icon">📅</text>
<text class="action-label">预约</text>
</view>
<view class="action-item" @tap="navigateTo('/pages-sub/consultation/index')">
<text class="action-icon">💬</text>
<text class="action-label">咨询</text>
</view>
<view class="action-item" @tap="navigateTo('/pages-sub/pkg-health/trend/index')">
<text class="action-icon">📊</text>
<text class="action-label">趋势</text>
</view>
<view class="action-item" @tap="navigateTo('/pages-sub/article/index')">
<text class="action-icon">📰</text>
<text class="action-label">文章</text>
</view>
</view>
<!-- 健康概览卡片 -->
<view class="health-summary card">
<text class="section-title">健康概览</text>
<view v-if="healthSummary" class="summary-grid">
<view class="summary-item">
<text class="summary-value">{{ healthSummary.heart_rate || '--' }}</text>
<text class="summary-label">心率</text>
</view>
<view class="summary-item">
<text class="summary-value">{{ healthSummary.blood_pressure || '--' }}</text>
<text class="summary-label">血压</text>
</view>
<view class="summary-item">
<text class="summary-value">{{ healthSummary.blood_sugar || '--' }}</text>
<text class="summary-label">血糖</text>
</view>
</view>
<Loading v-else-if="summaryLoading" text="加载中..." />
<EmptyState v-else icon="📋" title="暂无健康数据" action-text="录入数据" @action="switchTab('/pages/health/index')" />
</view>
<!-- 最近文章 -->
<view class="articles-section card">
<text class="section-title">健康文章</text>
<Loading v-if="articlesLoading" text="加载中..." />
<template v-else-if="articles.length > 0">
<view v-for="article in articles" :key="article.id" class="article-entry" @tap="goArticle(article.id)">
<text class="article-title">{{ article.title }}</text>
<text class="article-date">{{ formatDate(article.created_at) }}</text>
</view>
</template>
<EmptyState v-else icon="📰" title="暂无文章" />
</view>
</template>
<!-- 访客模式 -->
<template v-else>
<view class="guest-page">
<text class="guest-hero-icon">🏥</text>
<text class="guest-hero-title">健康管理</text>
<text class="guest-hero-desc">您的专属健康管家</text>
<view class="guest-login-btn" @tap="navigateTo('/pages/login/index')">
登录 / 注册
</view>
<text class="guest-browse">浏览健康资讯</text>
<!-- 访客也展示文章 -->
<view class="articles-section card">
<text class="section-title">健康文章</text>
<Loading v-if="articlesLoading" text="加载中..." />
<template v-else-if="articles.length > 0">
<view v-for="article in articles" :key="article.id" class="article-entry" @tap="goArticle(article.id)">
<text class="article-title">{{ article.title }}</text>
<text class="article-date">{{ formatDate(article.created_at) }}</text>
</view>
</template>
</view>
</view>
</template>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import { getArticles } from '@/services/article'
import { getTodaySummary } from '@/services/health'
import { formatDate } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
const authStore = useAuthStore()
const { elderClass } = useElderClass()
const articlesLoading = ref(false)
const summaryLoading = ref(false)
const articles = ref<any[]>([])
const healthSummary = ref<any>(null)
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 12) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
const today = computed(() => formatDate(new Date(), 'YYYY年MM月DD日'))
function navigateTo(url: string) {
uni.navigateTo({ url })
}
function switchTab(url: string) {
uni.switchTab({ url })
}
function goArticle(id: string) {
uni.navigateTo({ url: `/pages-sub/article/detail/index?id=${id}` })
}
function onLoadMore() {
// 分页加载预留
}
async function fetchArticles() {
articlesLoading.value = true
try {
const res = await getArticles({ limit: 5 })
articles.value = res || []
} catch {
articles.value = []
}
articlesLoading.value = false
}
async function fetchHealthSummary() {
if (!authStore.user) return
summaryLoading.value = true
try {
healthSummary.value = await getTodaySummary()
} catch {
healthSummary.value = null
}
summaryLoading.value = false
}
onMounted(() => {
fetchArticles()
fetchHealthSummary()
})
onShow(() => {
authStore.restore()
})
</script>
<style lang="scss" scoped>
.home-scroll {
height: 100vh;
background: $bg;
}
.home-page {
padding: 28px 24px 120px;
}
.greeting-section {
margin-bottom: 28px;
}
.greeting-text {
display: block;
font-size: var(--tk-font-h1);
font-weight: bold;
color: $tx;
}
.greeting-date {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-top: 4px;
}
.quick-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 28px;
}
.action-item {
@include flex-center;
flex-direction: column;
background: $card;
border-radius: $r-sm;
padding: 20px 0;
box-shadow: $shadow-sm;
}
.action-icon {
font-size: var(--tk-font-hero);
margin-bottom: 8px;
}
.action-label {
font-size: var(--tk-font-body-sm);
color: $tx2;
}
.card {
@include card;
}
.section-title {
@include section-title;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.summary-item {
@include flex-center;
flex-direction: column;
}
.summary-value {
font-size: var(--tk-font-num);
font-weight: bold;
color: $pri;
@include serif-number;
}
.summary-label {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-top: 4px;
}
.article-entry {
padding: 16px 0;
min-height: $touch-min;
border-bottom: 1px solid $bd-l;
&:last-child { border-bottom: none; }
}
.article-title {
display: block;
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
.article-date {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
margin-top: 4px;
}
// 访客模式
.guest-page {
@include flex-center;
flex-direction: column;
padding-top: 80px;
}
.guest-hero-icon {
font-size: var(--tk-font-display);
margin-bottom: 16px;
}
.guest-hero-title {
font-size: var(--tk-font-h1);
font-weight: bold;
color: $tx;
margin-bottom: 8px;
}
.guest-hero-desc {
font-size: var(--tk-font-body);
color: $tx3;
margin-bottom: 40px;
}
.guest-login-btn {
@include btn-primary;
width: 280px;
margin-bottom: 16px;
}
.guest-browse {
font-size: var(--tk-font-body-sm);
color: $pri;
margin-bottom: 40px;
}
</style>

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