Compare commits
85 Commits
22e33114b1
...
feat/media
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d256fbf52 | ||
|
|
984fca627b | ||
|
|
288c73fd14 | ||
|
|
c814a4a8f3 | ||
|
|
a78673ef41 | ||
|
|
c87760f938 | ||
|
|
75f0dc4354 | ||
|
|
1945ef3f78 | ||
|
|
ffbe5a797f | ||
|
|
6457c53d9c | ||
|
|
3351c68d10 | ||
|
|
57192b2ec0 | ||
|
|
3d683dfe82 | ||
|
|
ee5ae9e1fb | ||
|
|
01a0fffc43 | ||
|
|
976b9d94a0 | ||
|
|
5d61f19966 | ||
|
|
1982698b79 | ||
|
|
76a89dc7de | ||
|
|
201a91580c | ||
|
|
a5c67d6bec | ||
|
|
958110cc73 | ||
|
|
13705a3eaf | ||
|
|
92ffd8cecb | ||
|
|
6d073840aa | ||
|
|
f96e88b17b | ||
|
|
dc5d689d11 | ||
|
|
695b61f850 | ||
|
|
8d3b3a0491 | ||
|
|
bc3c056c8d | ||
|
|
3e36e31cf6 | ||
|
|
ec404a3e25 | ||
|
|
7924768df3 | ||
|
|
ac9896d375 | ||
|
|
a86219c8a0 | ||
|
|
432c5d96f2 | ||
|
|
aa6d93129d | ||
|
|
9a67bf80c1 | ||
|
|
03ead44385 | ||
|
|
ddf5c196e4 | ||
|
|
23cd0b14a7 | ||
|
|
803a27fb84 | ||
|
|
a4d09269a4 | ||
|
|
b0323ec89c | ||
|
|
2324d770bc | ||
|
|
823d69a3c3 | ||
|
|
7d1b1f9c7c | ||
|
|
e94f5bc00c | ||
|
|
0a1f4cb9a9 | ||
|
|
23c5bbdb40 | ||
|
|
2ccf0801b7 | ||
|
|
86dbd74f3f | ||
|
|
0edb475638 | ||
|
|
a7526455b4 | ||
|
|
dda8be9079 | ||
|
|
af2484e63b | ||
|
|
10c28df152 | ||
|
|
3c7b48b6f6 | ||
|
|
3972db4f98 | ||
|
|
9d6a92e1d7 | ||
|
|
42299a6722 | ||
|
|
a2864713d6 | ||
|
|
ba93e6585c | ||
|
|
d7fb5da873 | ||
|
|
8027cdd1d9 | ||
|
|
8ad4329632 | ||
|
|
1a376a255d | ||
|
|
485b9bb926 | ||
|
|
185f411495 | ||
|
|
a24c18155f | ||
|
|
ef1b8eb348 | ||
|
|
befdeba77c | ||
|
|
b14d0d347f | ||
|
|
1e59007bd5 | ||
|
|
675f8a4b10 | ||
|
|
e56ed9814a | ||
|
|
f11dd59382 | ||
|
|
f7d98a59f0 | ||
|
|
b3f53cd437 | ||
|
|
7f324466bf | ||
|
|
0748d20b4c | ||
|
|
09013ab94a | ||
|
|
1d443ab894 | ||
|
|
c81c3b73d0 | ||
|
|
5816ebb5e6 |
@@ -46,6 +46,47 @@ jobs:
|
||||
ERP__JWT__SECRET: ci-test-secret
|
||||
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
|
||||
|
||||
# PP-10: 覆盖率 baseline(软门禁阶段)
|
||||
# 当前 continue-on-error=true,先让覆盖率可见、生成报告 artifact。
|
||||
# 后续根据 baseline 真实数据提高 fail-under 阈值(目标 service 层 ≥60%)并去掉
|
||||
# continue-on-error 硬化门禁。见 docs/discussions/2026-06-25-analysis/ PP-10。
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: erp_test
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
- name: Install cargo-tarpaulin
|
||||
run: cargo install cargo-tarpaulin --locked
|
||||
- name: Run coverage (fail-under 20% baseline)
|
||||
run: cargo tarpaulin --workspace --out Xml --output-dir coverage --fail-under 20 -- --test-threads=2
|
||||
env:
|
||||
ERP__DATABASE__URL: postgres://test:test@localhost:5432/erp_test
|
||||
ERP__JWT__SECRET: ci-test-secret
|
||||
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
if-no-files-found: warn
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -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
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Security audit (npm)
|
||||
run: npx npm-audit --audit-level=high || true
|
||||
run: npx npm-audit --audit-level=high
|
||||
|
||||
miniprogram-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -82,6 +82,28 @@ 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/
|
||||
|
||||
105
CLAUDE.md
105
CLAUDE.md
@@ -177,8 +177,13 @@
|
||||
|
||||
- [ ] 新增端点有权限声明(默认拒绝,不是默认放行)
|
||||
- [ ] 敏感数据有脱敏/加密处理(PII 字段走 AES-256-GCM)
|
||||
- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS)
|
||||
- [ ] 无 CORS 通配符、无硬编码密钥
|
||||
- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS、命令注入、路径穿越)
|
||||
- [ ] 无 CORS 通配符、无硬编码密钥、无 fallback 默认密钥
|
||||
- [ ] 日志中无敏感数据输出(密码、token、身份证号、手机号等)
|
||||
- [ ] 文件上传有 MIME 类型验证 + 大小限制 + 路径穿越防护
|
||||
- [ ] API 响应不暴露内部实现细节(数据库错误、堆栈跟踪、文件路径)
|
||||
- [ ] 速率限制已配置(认证端点更严格)
|
||||
- [ ] 密钥通过环境变量注入,`.env.example` 已同步更新
|
||||
|
||||
#### 文档一致性
|
||||
|
||||
@@ -224,7 +229,7 @@
|
||||
|
||||
#### 新增 API 端点安全检查(强制)
|
||||
|
||||
> 历史数据:25 次安全 fix 中 80% 源于默认放行模式。
|
||||
> 默认拒绝是安全基线 — 绝大多数安全修复源于默认放行模式。
|
||||
> 新增端点时**必须**逐项确认:
|
||||
|
||||
- [ ] 端点已添加 `require_permission` 权限守卫(非公开端点)
|
||||
@@ -237,7 +242,7 @@
|
||||
|
||||
#### 前后端接口同步检查(强制)
|
||||
|
||||
> 历史数据:35 次 fix 源于前后端接口不一致。
|
||||
> 前后端接口不一致是高频 bug 来源 — 任何 DTO 变更必须双向同步。
|
||||
> 后端 DTO 变更时**必须**同步检查前端:
|
||||
|
||||
- [ ] DTO 字段名变更 → 前端 TypeScript 接口同步更新
|
||||
@@ -249,7 +254,7 @@
|
||||
|
||||
#### DTO 输入校验检查(强制)
|
||||
|
||||
> 历史数据:2026-05-19 全系统审计发现 44 处校验缺失。
|
||||
> DTO 输入校验是安全防线 — 缺失校验等于暴露攻击面,Update 和 Create 必须对称。
|
||||
> 新增/修改 DTO 时**必须**逐项确认:
|
||||
|
||||
- [ ] 所有请求结构体已 `derive(Validate)`(包括 Update\*Req、查询参数)
|
||||
@@ -291,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. 测试与验证
|
||||
@@ -409,17 +458,24 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
|
||||
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步。每次新会话开始先检查未推送提交
|
||||
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
|
||||
- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害(5 月实测:89 fix 仅 11 有 wiki 更新,关键数字迁移数差 8 个)
|
||||
- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害
|
||||
- ❌ **不要**修复 bug 后跳过症状导航更新 — 每个修复都应该帮助未来遇到同类问题的人快速定位根因
|
||||
- ❌ **不要**新增功能后不更新 wiki 关键数字 — 迁移数/路由数/实体数/测试数必须与代码同步,否则 wiki 指标表就是废数据
|
||||
- ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死
|
||||
- ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘
|
||||
- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交 = 后续 5 次 fix(媒体库教训)
|
||||
- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限(安全教训:25 次 fix 源于默认放行)
|
||||
- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口(35 次 fix 教训)
|
||||
- ❌ **不要**写 Update DTO 时省略校验 — Update*Req 必须与 Create*Req 校验对称(44 处缺失教训:Update 无 Validate derive / 枚举字段无 custom 校验 / Vec 无 min=1 / 密码无 max)
|
||||
- ❌ **不要**跳过 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 文件名防路径穿越
|
||||
|
||||
### 场景化指令
|
||||
|
||||
@@ -448,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 文件),后续增量更新秒级完成
|
||||
|
||||
@@ -120,6 +120,9 @@ handlebars = "6"
|
||||
# HTML sanitization
|
||||
ammonia = "4"
|
||||
|
||||
# Document parsing
|
||||
pdf-extract = "0.7"
|
||||
|
||||
# Metrics
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.16"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
TARO_APP_API_URL=https://api.hms.example.com/api/v1
|
||||
TARO_APP_DEFAULT_TENANT_ID=
|
||||
TARO_APP_ENCRYPTION_KEY=
|
||||
# TARO_APP_ENCRYPTION_KEY 不在此文件设置
|
||||
# 生产密钥通过 CI/CD 环境变量注入(dotenv 不覆盖已有 env var)
|
||||
# 本地 build:weapp 测试时自动回退到 .env 中的开发密钥
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock('@tarojs/taro', () => ({
|
||||
getStorageSync: vi.fn(() => ''),
|
||||
setStorageSync: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
reLaunch: vi.fn(),
|
||||
reLaunch: vi.fn(() => Promise.resolve()),
|
||||
getCurrentPages: vi.fn(() => []),
|
||||
},
|
||||
}));
|
||||
@@ -205,4 +205,161 @@ describe('request module', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConcurrencyLimiter', () => {
|
||||
it('should queue requests when at capacity', async () => {
|
||||
const { ConcurrencyLimiter } = await import('@/services/request/limiter');
|
||||
const limiter = new ConcurrencyLimiter(2);
|
||||
const order: number[] = [];
|
||||
|
||||
const acquire1 = limiter.acquire();
|
||||
const acquire2 = limiter.acquire();
|
||||
// Third acquire should queue
|
||||
const acquire3 = limiter.acquire().then(() => order.push(3));
|
||||
|
||||
order.push(1);
|
||||
order.push(2);
|
||||
|
||||
// Release one to unblock the third
|
||||
limiter.release();
|
||||
await acquire3;
|
||||
|
||||
expect(order).toContain(3);
|
||||
limiter.release();
|
||||
limiter.release();
|
||||
});
|
||||
|
||||
it('should release in FIFO order', async () => {
|
||||
const { ConcurrencyLimiter } = await import('@/services/request/limiter');
|
||||
const limiter = new ConcurrencyLimiter(1);
|
||||
const order: string[] = [];
|
||||
|
||||
await limiter.acquire(); // fills the slot
|
||||
|
||||
const p2 = limiter.acquire().then(() => order.push('second'));
|
||||
const p3 = limiter.acquire().then(() => order.push('third'));
|
||||
|
||||
limiter.release(); // releases second
|
||||
await p2;
|
||||
limiter.release(); // releases third
|
||||
await p3;
|
||||
|
||||
expect(order).toEqual(['second', 'third']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResponseCache LRU', () => {
|
||||
it('should update insertion order on cache hit', async () => {
|
||||
const { ResponseCache } = await import('@/services/request/cache');
|
||||
const cache = new ResponseCache(3, 60_000);
|
||||
cache.setPatientId('p1');
|
||||
|
||||
cache.set('/a', 'data-a');
|
||||
cache.set('/b', 'data-b');
|
||||
cache.set('/c', 'data-c');
|
||||
|
||||
// Access /a to move it to the end (most recently used)
|
||||
cache.get('/a');
|
||||
|
||||
// Adding /d should evict /b (oldest after /a was accessed)
|
||||
cache.set('/d', 'data-d');
|
||||
|
||||
expect(cache.get('/b')).toBeNull();
|
||||
expect(cache.get('/a')).toBe('data-a');
|
||||
expect(cache.get('/d')).toBe('data-d');
|
||||
});
|
||||
|
||||
it('should expire entries based on TTL', async () => {
|
||||
const { ResponseCache } = await import('@/services/request/cache');
|
||||
vi.useFakeTimers();
|
||||
const cache = new ResponseCache(100, 1000);
|
||||
cache.setPatientId('p1');
|
||||
|
||||
cache.set('/expiring', 'data', 500);
|
||||
expect(cache.get('/expiring')).toBe('data');
|
||||
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(cache.get('/expiring')).toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('token refresh & 401 retry', () => {
|
||||
it('should throw immediately when isLoggingOut is true', async () => {
|
||||
const { markLoggingOut } = await import('@/services/request');
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 401, data: {} } as any);
|
||||
|
||||
markLoggingOut();
|
||||
await expect(api.get('/protected')).rejects.toThrow('登录已过期');
|
||||
});
|
||||
|
||||
it('should attempt token refresh on 401', async () => {
|
||||
const mockStore: Record<string, string> = {
|
||||
access_token: 'expired-token',
|
||||
refresh_token: 'valid-refresh',
|
||||
tenant_id: 'test-tenant',
|
||||
};
|
||||
|
||||
// Override secureGet for this test
|
||||
const { secureGet } = await import('@/utils/secure-storage');
|
||||
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
|
||||
|
||||
// First call: 401 → triggers refresh
|
||||
// Refresh call: success
|
||||
// Retry call: success
|
||||
vi.mocked(Taro.request)
|
||||
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any)
|
||||
.mockResolvedValueOnce({ statusCode: 200, data: { success: true, data: { access_token: 'new-token', refresh_token: 'new-refresh', expires_in: 3600 } } } as any)
|
||||
.mockResolvedValueOnce({ statusCode: 200, data: { success: true, data: { result: 'ok' } } } as any);
|
||||
|
||||
const result = await api.get('/needs-auth');
|
||||
|
||||
expect(result).toEqual({ result: 'ok' });
|
||||
// 3 calls: initial 401 + refresh + retry
|
||||
expect(Taro.request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should redirect to login when refresh fails', async () => {
|
||||
const mockStore: Record<string, string> = {
|
||||
access_token: 'expired-token',
|
||||
refresh_token: 'bad-refresh',
|
||||
tenant_id: 'test-tenant',
|
||||
};
|
||||
|
||||
const { secureGet } = await import('@/utils/secure-storage');
|
||||
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
|
||||
vi.mocked(Taro.getCurrentPages).mockReturnValue([{ path: 'pages/home' } as any]);
|
||||
|
||||
vi.mocked(Taro.request)
|
||||
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any)
|
||||
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any); // refresh fails
|
||||
|
||||
await expect(api.get('/protected-resource')).rejects.toThrow('登录已过期');
|
||||
expect(Taro.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/index' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeReLaunch dedup', () => {
|
||||
it('should only call reLaunch once for concurrent requests', async () => {
|
||||
const { markLoggingOut } = await import('@/services/request');
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 401, data: {} } as any);
|
||||
vi.mocked(Taro.getCurrentPages).mockReturnValue([{ path: 'pages/home' } as any]);
|
||||
|
||||
const mockStore: Record<string, string> = {
|
||||
access_token: 'expired',
|
||||
refresh_token: 'bad',
|
||||
tenant_id: 't1',
|
||||
};
|
||||
const { secureGet } = await import('@/utils/secure-storage');
|
||||
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
|
||||
|
||||
// First call sets isLoggingOut, second call hits early exit
|
||||
await expect(api.get('/test1')).rejects.toThrow();
|
||||
await expect(api.get('/test2')).rejects.toThrow();
|
||||
|
||||
// reLaunch should be called at most once
|
||||
expect(vi.mocked(Taro.reLaunch).mock.calls.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,12 @@ import { defineConfig } from '@tarojs/cli';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig(async (merge) => {
|
||||
// 生产构建缺少加密密钥时发出警告(不阻断构建,但提示开发者/CI 配置)
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.TARO_APP_ENCRYPTION_KEY) {
|
||||
console.warn('[config] ⚠ TARO_APP_ENCRYPTION_KEY 未设置,将回退到 .env 中的开发密钥');
|
||||
console.warn('[config] 生产部署应通过 CI/CD 环境变量注入独立密钥');
|
||||
}
|
||||
|
||||
const baseConfig = {
|
||||
projectName: 'hms-miniprogram',
|
||||
date: '2026-4-23',
|
||||
@@ -19,6 +25,7 @@ export default defineConfig(async (merge) => {
|
||||
'process.env.TARO_APP_WX_TEMPLATE_REPORT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_REPORT || ''),
|
||||
'process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT || ''),
|
||||
'process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL || ''),
|
||||
'process.env.TARO_APP_WX_TEMPLATE_MEDICATION': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_MEDICATION || ''),
|
||||
'process.env.TARO_APP_DEFAULT_TENANT_ID': JSON.stringify(process.env.TARO_APP_DEFAULT_TENANT_ID || ''),
|
||||
'process.env.TARO_APP_DEV_USER': JSON.stringify(
|
||||
process.env.NODE_ENV === 'production' ? '' : (process.env.TARO_APP_DEV_USER || '')
|
||||
@@ -37,15 +44,34 @@ export default defineConfig(async (merge) => {
|
||||
resource: ['src/styles/variables.scss'],
|
||||
},
|
||||
mini: {
|
||||
virtualHost: true,
|
||||
copy: {
|
||||
patterns: [
|
||||
{ from: 'src/native-components/', to: 'dist/native-components/', ignore: ['*.ts'] },
|
||||
],
|
||||
options: {},
|
||||
},
|
||||
compile: {
|
||||
exclude: [],
|
||||
include: [],
|
||||
},
|
||||
commonChunks: ['runtime', 'vendors', 'taro', 'common'],
|
||||
addChunkPages(pages) {
|
||||
// 主包 TabBar 页面保持 common chunk
|
||||
const tabBarPages = new Set([
|
||||
'pages/index/index',
|
||||
'pages/health/index',
|
||||
'pages/mall/index',
|
||||
'pages/messages/index',
|
||||
'pages/profile/index',
|
||||
]);
|
||||
pages.forEach((page) => {
|
||||
if (page.name === 'app') return;
|
||||
page.chunks?.unshift('common');
|
||||
// 分包页面不注入 common chunk,由分包自己的 vendors.js 承载
|
||||
if (page.name.startsWith('pages/pkg-')) return;
|
||||
if (tabBarPages.has(page.name) || !page.name.startsWith('pages/')) {
|
||||
page.chunks?.unshift('common');
|
||||
}
|
||||
});
|
||||
},
|
||||
miniCssExtractPluginOption: {
|
||||
|
||||
863
apps/miniprogram/native/pkg-veepoo/index.js
Normal file
863
apps/miniprogram/native/pkg-veepoo/index.js
Normal file
@@ -0,0 +1,863 @@
|
||||
/**
|
||||
* Veepoo M2 原生小程序页面 — 连接 + 测量
|
||||
*
|
||||
* 完全脱离 Taro 框架,直接使用微信原生 API + Veepoo SDK。
|
||||
* 流程严格对齐官方 Demo:
|
||||
* onLoad 注册全局监听器
|
||||
* → scan → stopScan → connect(等待 connection:true)
|
||||
* → delay 500ms → authenticate
|
||||
* → SDK 事件(type=1) / Storage 轮询 → ready
|
||||
*/
|
||||
// eslint-disable-next-line no-undef
|
||||
const { veepooBle, veepooFeature, veepooLogger } = require('./libs/veepoo-sdk');
|
||||
|
||||
// ── 常量 ──
|
||||
|
||||
var SDK_EVENT_AUTH = 1;
|
||||
var SDK_EVENT_BATTERY = 2;
|
||||
var SDK_EVENT_SLEEP = 4;
|
||||
var SDK_EVENT_DAILY = 5;
|
||||
var SDK_EVENT_TEMPERATURE = 6;
|
||||
var SDK_EVENT_BLOOD_PRESSURE = 18;
|
||||
var SDK_EVENT_BLOOD_OXYGEN = 31;
|
||||
var SDK_EVENT_HEART_RATE = 51;
|
||||
var SDK_EVENT_PRESSURE = 58;
|
||||
var SDK_EVENT_AUTO_TEST = 54;
|
||||
|
||||
var MEASURE_TYPES = [
|
||||
{ type: 'heart_rate', label: '心率', unit: 'bpm', icon: '♥', color: '#EF4444', sdkType: SDK_EVENT_HEART_RATE },
|
||||
{ type: 'blood_oxygen', label: '血氧', unit: '%', icon: 'O₂', color: '#3B82F6', sdkType: SDK_EVENT_BLOOD_OXYGEN },
|
||||
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', icon: '↕', color: '#8B5CF6', sdkType: SDK_EVENT_BLOOD_PRESSURE },
|
||||
{ type: 'temperature', label: '体温', unit: '°C', icon: 'T', color: '#F59E0B', sdkType: SDK_EVENT_TEMPERATURE },
|
||||
{ type: 'pressure', label: '压力', unit: '', icon: '~', color: '#6366F1', sdkType: SDK_EVENT_PRESSURE },
|
||||
];
|
||||
|
||||
var MEASURE_TIMEOUTS = {
|
||||
heart_rate: 60000,
|
||||
blood_oxygen: 60000,
|
||||
blood_pressure: 120000,
|
||||
temperature: 60000,
|
||||
pressure: 90000,
|
||||
};
|
||||
|
||||
var MEASURE_SETTLE_DELAY = 1500;
|
||||
|
||||
function _findConfig(type) {
|
||||
for (var i = 0; i < MEASURE_TYPES.length; i++) {
|
||||
if (MEASURE_TYPES[i].type === type) return MEASURE_TYPES[i];
|
||||
}
|
||||
return MEASURE_TYPES[0];
|
||||
}
|
||||
|
||||
// ── Page ──
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
Page({
|
||||
data: {
|
||||
phase: 'idle',
|
||||
deviceId: '',
|
||||
deviceName: 'M2',
|
||||
batteryLevel: null,
|
||||
error: '',
|
||||
selectedType: 'heart_rate',
|
||||
selectedIcon: '♥',
|
||||
selectedColor: '#EF4444',
|
||||
selectedLabel: '心率',
|
||||
selectedUnit: 'bpm',
|
||||
measureTypes: MEASURE_TYPES,
|
||||
measurePhase: 'idle',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
results: {},
|
||||
hasResults: false,
|
||||
// 自动测量状态
|
||||
autoMeasuring: false,
|
||||
autoMeasureDone: false,
|
||||
autoMeasureStatus: {},
|
||||
autoMeasureValues: {},
|
||||
autoMeasureProgress: 0,
|
||||
},
|
||||
|
||||
_authTimer: null,
|
||||
_authTimeout: null,
|
||||
_scanTimer: null,
|
||||
_scanFound: null,
|
||||
_measureTimer: null,
|
||||
_settleTimer: null,
|
||||
_lastValues: null,
|
||||
_connected: false,
|
||||
_eventChannel: null,
|
||||
_connecting: false,
|
||||
_listenersRegistered: false,
|
||||
_autoQueue: null,
|
||||
_autoQueueIndex: 0,
|
||||
|
||||
// ── 生命周期 ──
|
||||
|
||||
onLoad: function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
this._eventChannel = this.getOpenerEventChannel();
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setNavigationBarTitle({ title: 'M2 手环测量' });
|
||||
this._updateSelectedDisplay('heart_rate');
|
||||
|
||||
// 注意:不在 onLoad 注册 veepooWeiXinSDKNotifyMonitorValueChange!
|
||||
// 该函数内部会调用 wx.notifyBLECharacteristicValueChange,需要蓝牙适配器已初始化。
|
||||
// onLoad 时适配器未初始化 → 返回 "notifyBLECharacteristicValueChange:fail:not init"
|
||||
// 监听器改在 _doConnect 的 connection:true 回调中注册。
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 页面已加载');
|
||||
},
|
||||
|
||||
onUnload: function () {
|
||||
this._cleanup();
|
||||
},
|
||||
|
||||
_cleanup: function () {
|
||||
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
|
||||
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
|
||||
if (this._scanTimer) { clearTimeout(this._scanTimer); this._scanTimer = null; }
|
||||
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
|
||||
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
|
||||
this._connecting = false;
|
||||
if (this._connected) {
|
||||
try { veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(function () {}); } catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 断开异常:', e);
|
||||
}
|
||||
this._connected = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── 全局监听器(onLoad 注册一次) ──
|
||||
|
||||
_registerGlobalListeners: function () {
|
||||
if (this._listenersRegistered) return;
|
||||
this._listenersRegistered = true;
|
||||
var self = this;
|
||||
|
||||
// SDK 数据监听 — 接收所有解析后的事件(auth/measure/battery 等)
|
||||
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(function (data) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] SDK 数据事件:', JSON.stringify(data).substring(0, 500));
|
||||
self._handleSdkEvent(data);
|
||||
});
|
||||
|
||||
// BLE 连接状态变化
|
||||
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(function (res) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 连接状态变化:', JSON.stringify(res));
|
||||
if (!res.connected) {
|
||||
self._connected = false;
|
||||
self._connecting = false;
|
||||
self._cancelPendingMeasure();
|
||||
self.setData({ phase: 'disconnected' });
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] SDK 函数类型:', {
|
||||
scanFn: typeof veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice,
|
||||
connectFn: typeof veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager,
|
||||
dataFn: typeof veepooBle.veepooWeiXinSDKNotifyMonitorValueChange,
|
||||
authFn: typeof veepooFeature.veepooBlePasswordCheckManager,
|
||||
});
|
||||
},
|
||||
|
||||
_updateSelectedDisplay: function (type) {
|
||||
var cfg = _findConfig(type);
|
||||
this.setData({
|
||||
selectedType: type,
|
||||
selectedIcon: cfg.icon,
|
||||
selectedColor: cfg.color,
|
||||
selectedLabel: cfg.label,
|
||||
selectedUnit: cfg.unit,
|
||||
});
|
||||
},
|
||||
|
||||
// ── 连接流程 ──
|
||||
|
||||
handleConnect: function () {
|
||||
if (this.data.phase !== 'idle' && this.data.phase !== 'error' && this.data.phase !== 'disconnected') return;
|
||||
if (this._connecting) return;
|
||||
this._connecting = true;
|
||||
|
||||
this.setData({ phase: 'scanning', error: '' });
|
||||
veepooLogger.setLevel(0);
|
||||
|
||||
var self = this;
|
||||
self._scanFound = null;
|
||||
|
||||
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(function (res) {
|
||||
var device = Array.isArray(res) ? res[0] : res;
|
||||
if (!device) return;
|
||||
var name = (device.localName || device.name || '').toUpperCase();
|
||||
var deviceId = device.deviceId || device.mac || '';
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 扫描到:', name, deviceId);
|
||||
if (!self._scanFound && (name.indexOf('M2') !== -1 || name.indexOf('VPM') !== -1 || name.indexOf('VEEPOO') !== -1)) {
|
||||
self._scanFound = device;
|
||||
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {
|
||||
self._doConnect(device);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this._scanTimer = setTimeout(function () {
|
||||
if (!self._scanFound) {
|
||||
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {});
|
||||
self._connecting = false;
|
||||
self.setData({ phase: 'error', error: '未找到 M2 设备,请确保手环已开机且蓝牙已开启' });
|
||||
}
|
||||
}, 15000);
|
||||
},
|
||||
|
||||
_doConnect: function (device) {
|
||||
this.setData({ phase: 'connecting' });
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 开始连接:', device.deviceId || device.mac);
|
||||
var self = this;
|
||||
|
||||
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(device, function (result) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 连接阶段回调:', JSON.stringify(result).substring(0, 300));
|
||||
|
||||
// 只响应最终就绪回调(connection:true)
|
||||
if (result.connection === true) {
|
||||
self._connected = true;
|
||||
self._connecting = false;
|
||||
self.setData({
|
||||
deviceId: device.deviceId || device.mac || '',
|
||||
});
|
||||
|
||||
// 关键:在连接就绪后注册数据监听器
|
||||
// veepooWeiXinSDKNotifyMonitorValueChange 内部会调用
|
||||
// wx.notifyBLECharacteristicValueChange,需要蓝牙适配器已初始化+已连接
|
||||
self._registerGlobalListeners();
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 连接就绪,监听器已注册,500ms 后发送认证');
|
||||
|
||||
setTimeout(function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 调用 veepooBlePasswordCheckManager');
|
||||
veepooFeature.veepooBlePasswordCheckManager();
|
||||
self.setData({ phase: 'authenticating' });
|
||||
}, 500);
|
||||
|
||||
// Storage 轮询兜底
|
||||
self._authTimer = setInterval(function () {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
var status = wx.getStorageSync('deviceChipStatus');
|
||||
// SDK 可能写入字符串或布尔值 true
|
||||
if (status === 'successfulVerification' || status === 'passTheVerification' || status === true) {
|
||||
clearInterval(self._authTimer);
|
||||
self._authTimer = null;
|
||||
self._onReady();
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}, 500);
|
||||
|
||||
self._authTimeout = setTimeout(function () {
|
||||
if (self._authTimer) {
|
||||
clearInterval(self._authTimer);
|
||||
self._authTimer = null;
|
||||
self._connecting = false;
|
||||
// eslint-disable-next-line no-undef
|
||||
console.error('[veepoo-native] 认证超时 deviceChipStatus=', wx.getStorageSync('deviceChipStatus'));
|
||||
self.setData({ phase: 'error', error: '设备认证超时,请重新连接' });
|
||||
}
|
||||
}, 8000);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_onReady: function () {
|
||||
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
|
||||
this._connecting = false;
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 认证成功,设备就绪');
|
||||
this.setData({ phase: 'ready' });
|
||||
veepooFeature.veepooReadElectricQuantityManager();
|
||||
|
||||
// 认证成功后自动读取 3 天睡眠数据 + 开启自动测量
|
||||
this._readSleepData();
|
||||
this._enableAutoMeasurement();
|
||||
|
||||
// 自动依次测量所有指标(面向中老年人,减少操作)
|
||||
this._startAutoMeasureQueue();
|
||||
},
|
||||
|
||||
// ── SDK 事件路由 ──
|
||||
|
||||
_handleSdkEvent: function (data) {
|
||||
if (!data || data.type === undefined) return;
|
||||
var type = data.type;
|
||||
|
||||
if (type === SDK_EVENT_AUTH) {
|
||||
var content = data.content || {};
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 认证事件: VPDeviceAck=' + content.VPDeviceAck + ' VPDevicepassword=' + content.VPDevicepassword);
|
||||
// VPDeviceAck 是认证结果(successfulVerification/passTheVerification)
|
||||
// VPDevicepassword 是设备密码原始值(如 "0000"),不是认证结果
|
||||
if (content.VPDeviceAck === 'successfulVerification' || content.VPDeviceAck === 'passTheVerification') {
|
||||
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
|
||||
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
|
||||
this._onReady();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === SDK_EVENT_BATTERY) {
|
||||
var pct = (data.content || {}).VPDeviceElectricPercent;
|
||||
if (pct !== undefined && pct !== null) {
|
||||
this.setData({ batteryLevel: Number(pct) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 睡眠数据回调(type=4)
|
||||
if (type === SDK_EVENT_SLEEP) {
|
||||
this._handleSleepEvent(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 日常数据回调(type=5)
|
||||
if (type === SDK_EVENT_DAILY) {
|
||||
// 日常数据用于历史同步,原生页面暂不处理
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动测量配置回调(type=54)
|
||||
if (type === SDK_EVENT_AUTO_TEST) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量配置回调');
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < MEASURE_TYPES.length; i++) {
|
||||
if (MEASURE_TYPES[i].sdkType === type) {
|
||||
this._handleMeasureEvent(MEASURE_TYPES[i].type, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── 测量流程 ──
|
||||
|
||||
handleSelectType: function (e) {
|
||||
var type = e.currentTarget.dataset.type;
|
||||
if (this.data.measurePhase === 'measuring') return;
|
||||
this._updateSelectedDisplay(type);
|
||||
this.setData({ measureError: '' });
|
||||
},
|
||||
|
||||
handleStartMeasure: function () {
|
||||
var type = this.data.selectedType;
|
||||
if (this.data.measurePhase === 'measuring') return;
|
||||
if (!this._connected) {
|
||||
this.setData({ measureError: '设备未连接' });
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
self._lastValues = null;
|
||||
|
||||
self.setData({
|
||||
measurePhase: 'measuring',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
});
|
||||
|
||||
self._sendMeasureCommand(type, true);
|
||||
|
||||
self._measureTimer = setTimeout(function () {
|
||||
self._onMeasureError('测量超时,请重试');
|
||||
}, MEASURE_TIMEOUTS[type] || 60000);
|
||||
},
|
||||
|
||||
handleCancelMeasure: function () {
|
||||
this._cancelPendingMeasure();
|
||||
this.setData({
|
||||
measurePhase: 'idle',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
});
|
||||
},
|
||||
|
||||
handleDisconnect: function () {
|
||||
this._cleanup();
|
||||
this.setData({ phase: 'idle', deviceId: '', batteryLevel: null, error: '' });
|
||||
},
|
||||
|
||||
handleBack: function () {
|
||||
var results = this.data.results;
|
||||
if (Object.keys(results).length > 0) {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
if (this._eventChannel) {
|
||||
this._eventChannel.emit('measureComplete', { results: results, count: Object.keys(results).length });
|
||||
}
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.navigateBack({ delta: 1 });
|
||||
},
|
||||
|
||||
handleResetResult: function () {
|
||||
var type = this.data.selectedType;
|
||||
var newResults = Object.assign({}, this.data.results);
|
||||
delete newResults[type];
|
||||
this.setData({
|
||||
measurePhase: 'idle',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
results: newResults,
|
||||
hasResults: Object.keys(newResults).length > 0,
|
||||
});
|
||||
},
|
||||
|
||||
// ── 测量事件处理 ──
|
||||
|
||||
_handleMeasureEvent: function (type, data) {
|
||||
// 自动测量模式下,路由到自动测量处理器
|
||||
if (this.data.autoMeasuring) {
|
||||
this._handleAutoMeasureEvent(type, data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 手动测量模式
|
||||
if (this.data.selectedType !== type || this.data.measurePhase !== 'measuring') return;
|
||||
|
||||
var content = data.content || {};
|
||||
var self = this;
|
||||
|
||||
if (content.deviceBusy === true) { self._onMeasureError('设备正忙,请稍后重试'); return; }
|
||||
if (content.notWear === true || data.state === 6) { self._onMeasureError('请将手环佩戴到手腕上'); return; }
|
||||
if (data.state === 7) { self._onMeasureError('设备正在充电'); return; }
|
||||
if (data.state === 8) { self._onMeasureError('设备电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 2) { self._onMeasureError('设备电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 3) { self._onMeasureError('设备正在测量其他数据'); return; }
|
||||
if (type === 'pressure' && data.ack === 4) { self._onMeasureError('佩戴检测未通过'); return; }
|
||||
|
||||
var values = self._extractValues(type, content);
|
||||
if (!values) return;
|
||||
|
||||
self._lastValues = values;
|
||||
|
||||
var displayVal = self._formatValues(type, values);
|
||||
var progress = data.Progress !== undefined ? data.Progress : 0;
|
||||
self.setData({
|
||||
measureDisplayValue: displayVal,
|
||||
measureProgress: Math.max(progress, 0),
|
||||
});
|
||||
|
||||
if (progress >= 100) {
|
||||
self._onMeasureSuccess(type, values);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self._settleTimer) {
|
||||
self._settleTimer = setTimeout(function () {
|
||||
if (self._lastValues && self.data.measurePhase === 'measuring') {
|
||||
self._onMeasureSuccess(type, self._lastValues);
|
||||
}
|
||||
}, MEASURE_SETTLE_DELAY);
|
||||
}
|
||||
},
|
||||
|
||||
_extractValues: function (type, content) {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
var hr = Number(content.heartRate);
|
||||
return (hr >= 30 && hr <= 250) ? { heart_rate: hr } : null;
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
var bo = Number(content.bloodOxygen);
|
||||
return (bo >= 70 && bo <= 100) ? { blood_oxygen: bo } : null;
|
||||
}
|
||||
case 'blood_pressure': {
|
||||
var high = Number(content.bloodPressureHigh);
|
||||
var low = Number(content.bloodPressureLow);
|
||||
return (high > 0 && low > 0) ? { systolic: high, diastolic: low } : null;
|
||||
}
|
||||
case 'temperature': {
|
||||
var temp = Number(content.bodyTemperature);
|
||||
return (temp > 30 && temp < 45) ? { temperature: temp } : null;
|
||||
}
|
||||
case 'pressure': {
|
||||
var p = Number(content.pressure);
|
||||
return (p >= 0 && p <= 100) ? { pressure: p } : null;
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
},
|
||||
|
||||
_formatValues: function (type, values) {
|
||||
if (type === 'blood_pressure') {
|
||||
return (values.systolic != null ? values.systolic : '--') + '/' + (values.diastolic != null ? values.diastolic : '--');
|
||||
}
|
||||
var v = Object.values(values)[0];
|
||||
return (v !== undefined && v !== null) ? String(v) : '--';
|
||||
},
|
||||
|
||||
_onMeasureSuccess: function (type, values) {
|
||||
this._cancelPendingMeasure();
|
||||
|
||||
var result = { type: type, values: values, measuredAt: Date.now() };
|
||||
var newResults = Object.assign({}, this.data.results);
|
||||
newResults[type] = result;
|
||||
|
||||
this.setData({
|
||||
measurePhase: 'success',
|
||||
measureProgress: 100,
|
||||
measureDisplayValue: this._formatValues(type, values),
|
||||
results: newResults,
|
||||
hasResults: true,
|
||||
});
|
||||
|
||||
if (this._eventChannel) {
|
||||
this._eventChannel.emit('measureResult', result);
|
||||
}
|
||||
},
|
||||
|
||||
_onMeasureError: function (msg) {
|
||||
this._cancelPendingMeasure();
|
||||
this.setData({ measurePhase: 'error', measureError: msg });
|
||||
},
|
||||
|
||||
_cancelPendingMeasure: function () {
|
||||
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
|
||||
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
|
||||
this._lastValues = null;
|
||||
|
||||
var type = this.data.selectedType;
|
||||
if (type) this._sendMeasureCommand(type, false);
|
||||
},
|
||||
|
||||
_sendMeasureCommand: function (type, on) {
|
||||
switch (type) {
|
||||
case 'heart_rate':
|
||||
veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: !!on });
|
||||
break;
|
||||
case 'blood_oxygen':
|
||||
veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: on ? 'start' : 'stop' });
|
||||
break;
|
||||
case 'blood_pressure':
|
||||
veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: on ? 'start' : 'stop' });
|
||||
break;
|
||||
case 'temperature':
|
||||
veepooFeature.veepooSendTemperatureMeasurementSwitchManager({ switch: !!on });
|
||||
break;
|
||||
case 'pressure':
|
||||
veepooFeature.veepooSendPressureTestManager({ switch: !!on });
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
// ── 睡眠数据读取 ──
|
||||
|
||||
_sleepResults: null,
|
||||
_sleepDay: 0,
|
||||
|
||||
_readSleepData: function () {
|
||||
this._sleepResults = [];
|
||||
this._sleepDay = 0;
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 开始读取睡眠数据(3天)');
|
||||
|
||||
// 依次读取 3 天睡眠
|
||||
var self = this;
|
||||
veepooFeature.veepooSendReadPreciseSleepManager({ day: 0 });
|
||||
|
||||
// 延迟读取后续天(避免并发冲突)
|
||||
// eslint-disable-next-line no-undef
|
||||
setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 1 }); }, 3000);
|
||||
// eslint-disable-next-line no-undef
|
||||
setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 2 }); }, 6000);
|
||||
},
|
||||
|
||||
_handleSleepEvent: function (data) {
|
||||
var progress = data.Progress || 0;
|
||||
if (progress < 100) return;
|
||||
|
||||
var content = data.content || {};
|
||||
var readDay = data.readDay || 0;
|
||||
var totalTime = Number(content.sleepTotalTime || 0);
|
||||
|
||||
if (totalTime <= 0) return;
|
||||
|
||||
var sleepResult = {
|
||||
day: readDay,
|
||||
deepSleepMinutes: Number(content.deepSleepTime || 0),
|
||||
lightSleepMinutes: Number(content.lightSleepTime || 0),
|
||||
totalSleepMinutes: totalTime,
|
||||
qualityScore: Number(content.sleepQuality || 0),
|
||||
fallAsleepTime: String(content.fallAsleepTime || ''),
|
||||
exitSleepTime: String(content.exitSleepTime || ''),
|
||||
};
|
||||
|
||||
if (!this._sleepResults) this._sleepResults = [];
|
||||
this._sleepResults.push(sleepResult);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 睡眠数据 day=' + readDay + ' 总时长=' + totalTime + '分钟 质量=' + sleepResult.qualityScore + '星');
|
||||
|
||||
// 保存到 Storage 供 Taro 页面读取
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_sleep_results', JSON.stringify(this._sleepResults));
|
||||
} catch (_) { /* ignore */ }
|
||||
},
|
||||
|
||||
// ── 自动测量 ──
|
||||
|
||||
_enableAutoMeasurement: function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 开启自动测量功能');
|
||||
|
||||
// 开启心率自动监测
|
||||
try {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticHRTest: 'open',
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 开启心率自动监测失败:', e);
|
||||
}
|
||||
|
||||
// 开启血压自动监测
|
||||
try {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticBPTest: 'open',
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 开启血压自动监测失败:', e);
|
||||
}
|
||||
|
||||
// 开启体温自动监测
|
||||
try {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticTemperatureTest: 'open',
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 开启体温自动监测失败:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// ── 自动测量队列 ──
|
||||
|
||||
_startAutoMeasureQueue: function () {
|
||||
var types = [];
|
||||
var status = {};
|
||||
for (var i = 0; i < MEASURE_TYPES.length; i++) {
|
||||
types.push(MEASURE_TYPES[i].type);
|
||||
status[MEASURE_TYPES[i].type] = 'pending';
|
||||
}
|
||||
|
||||
this._autoQueue = types;
|
||||
this._autoQueueIndex = 0;
|
||||
|
||||
this.setData({
|
||||
autoMeasuring: true,
|
||||
autoMeasureDone: false,
|
||||
autoMeasureStatus: status,
|
||||
autoMeasureValues: {},
|
||||
autoMeasureProgress: 0,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 启动自动测量队列,共 ' + types.length + ' 项');
|
||||
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
self._startNextAutoMeasure();
|
||||
}, 800);
|
||||
},
|
||||
|
||||
_startNextAutoMeasure: function () {
|
||||
if (!this._autoQueue || this._autoQueueIndex >= this._autoQueue.length) {
|
||||
this._onAutoMeasureComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
var type = this._autoQueue[this._autoQueueIndex];
|
||||
this._updateSelectedDisplay(type);
|
||||
|
||||
var status = Object.assign({}, this.data.autoMeasureStatus);
|
||||
status[type] = 'measuring';
|
||||
this.setData({
|
||||
autoMeasureStatus: status,
|
||||
measurePhase: 'measuring',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量 [' + (this._autoQueueIndex + 1) + '/' + this._autoQueue.length + ']: ' + type);
|
||||
|
||||
this._lastValues = null;
|
||||
this._sendMeasureCommand(type, true);
|
||||
|
||||
var self = this;
|
||||
var timeout = MEASURE_TIMEOUTS[type] || 60000;
|
||||
this._measureTimer = setTimeout(function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 自动测量超时: ' + type);
|
||||
self._onAutoMeasureError(type, '测量超时');
|
||||
}, timeout);
|
||||
},
|
||||
|
||||
_handleAutoMeasureEvent: function (type, data) {
|
||||
if (!this._autoQueue || this._autoQueueIndex >= this._autoQueue.length) return;
|
||||
if (type !== this._autoQueue[this._autoQueueIndex]) return;
|
||||
|
||||
var content = data.content || {};
|
||||
var self = this;
|
||||
|
||||
if (content.deviceBusy === true) { self._onAutoMeasureError(type, '设备正忙'); return; }
|
||||
if (content.notWear === true || data.state === 6) { self._onAutoMeasureError(type, '未佩戴'); return; }
|
||||
if (data.state === 7) { self._onAutoMeasureError(type, '设备充电中'); return; }
|
||||
if (data.state === 8) { self._onAutoMeasureError(type, '电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 2) { self._onAutoMeasureError(type, '电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 3) { self._onAutoMeasureError(type, '设备正忙'); return; }
|
||||
if (type === 'pressure' && data.ack === 4) { self._onAutoMeasureError(type, '未佩戴'); return; }
|
||||
|
||||
var values = self._extractValues(type, content);
|
||||
if (!values) return;
|
||||
|
||||
self._lastValues = values;
|
||||
|
||||
var progress = data.Progress !== undefined ? data.Progress : 0;
|
||||
self.setData({ measureProgress: Math.max(progress, 0) });
|
||||
|
||||
if (progress >= 100) {
|
||||
self._onAutoMeasureSuccess(type, values);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self._settleTimer) {
|
||||
self._settleTimer = setTimeout(function () {
|
||||
if (self._lastValues && self.data.autoMeasuring) {
|
||||
self._onAutoMeasureSuccess(type, self._lastValues);
|
||||
}
|
||||
}, MEASURE_SETTLE_DELAY);
|
||||
}
|
||||
},
|
||||
|
||||
_onAutoMeasureSuccess: function (type, values) {
|
||||
this._cancelPendingMeasure();
|
||||
|
||||
var result = { type: type, values: values, measuredAt: Date.now() };
|
||||
var newResults = Object.assign({}, this.data.results);
|
||||
newResults[type] = result;
|
||||
|
||||
var status = Object.assign({}, this.data.autoMeasureStatus);
|
||||
status[type] = 'done';
|
||||
|
||||
var newValues = Object.assign({}, this.data.autoMeasureValues);
|
||||
newValues[type] = this._formatValues(type, values);
|
||||
|
||||
var doneCount = 0;
|
||||
var keys = Object.keys(status);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (status[keys[i]] === 'done' || status[keys[i]] === 'error') doneCount++;
|
||||
}
|
||||
var progress = Math.round((doneCount / this._autoQueue.length) * 100);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量完成: ' + type + ' = ' + newValues[type] + ' (' + progress + '%)');
|
||||
|
||||
this.setData({
|
||||
results: newResults,
|
||||
hasResults: true,
|
||||
autoMeasureStatus: status,
|
||||
autoMeasureValues: newValues,
|
||||
autoMeasureProgress: progress,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
|
||||
this._autoQueueIndex++;
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
self._startNextAutoMeasure();
|
||||
}, 800);
|
||||
},
|
||||
|
||||
_onAutoMeasureError: function (type, msg) {
|
||||
this._cancelPendingMeasure();
|
||||
|
||||
var status = Object.assign({}, this.data.autoMeasureStatus);
|
||||
status[type] = 'error';
|
||||
|
||||
var doneCount = 0;
|
||||
var keys = Object.keys(status);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (status[keys[i]] === 'done' || status[keys[i]] === 'error') doneCount++;
|
||||
}
|
||||
var progress = Math.round((doneCount / this._autoQueue.length) * 100);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 自动测量失败: ' + type + ' - ' + msg + ' (' + progress + '%)');
|
||||
|
||||
this.setData({
|
||||
autoMeasureStatus: status,
|
||||
autoMeasureProgress: progress,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
|
||||
this._autoQueueIndex++;
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
self._startNextAutoMeasure();
|
||||
}, 500);
|
||||
},
|
||||
|
||||
_onAutoMeasureComplete: function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量全部完成');
|
||||
|
||||
var results = this.data.results;
|
||||
if (Object.keys(results).length > 0) {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
this.setData({
|
||||
autoMeasureDone: true,
|
||||
autoMeasuring: false,
|
||||
autoMeasureProgress: 100,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
},
|
||||
|
||||
handleCancelAutoMeasure: function () {
|
||||
this._cancelPendingMeasure();
|
||||
this._autoQueue = null;
|
||||
|
||||
var results = this.data.results;
|
||||
if (Object.keys(results).length > 0) {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
this.setData({
|
||||
autoMeasuring: false,
|
||||
autoMeasureDone: false,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
},
|
||||
});
|
||||
6
apps/miniprogram/native/pkg-veepoo/index.json
Normal file
6
apps/miniprogram/native/pkg-veepoo/index.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "M2 手环测量",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#F5F5F4"
|
||||
}
|
||||
215
apps/miniprogram/native/pkg-veepoo/index.wxml
Normal file
215
apps/miniprogram/native/pkg-veepoo/index.wxml
Normal file
@@ -0,0 +1,215 @@
|
||||
<!--
|
||||
Veepoo M2 原生小程序页面 — 连接 + 测量
|
||||
设计原型: docs/design/veepoo-measure-prototype.html
|
||||
完全匹配 SDK 官方 Demo 流程,不依赖 Taro
|
||||
-->
|
||||
|
||||
<!-- ═══ 未连接 / 错误 / 断开 ═══ -->
|
||||
<block wx:if="{{phase === 'idle' || phase === 'error' || phase === 'disconnected'}}">
|
||||
<view class="connect-screen">
|
||||
<view class="connect-anim">
|
||||
<view class="connect-ring"></view>
|
||||
<view class="connect-center">
|
||||
<text class="connect-bt">BT</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="connect-title">M2 手环健康测量</text>
|
||||
<text class="connect-hint">请确保手环已开机且蓝牙已开启</text>
|
||||
|
||||
<view wx:if="{{error}}" class="connect-error">
|
||||
<text class="connect-error-text">{{error}}</text>
|
||||
</view>
|
||||
|
||||
<view class="connect-btn-wrap">
|
||||
<view class="btn-primary" bindtap="handleConnect">
|
||||
{{phase === 'error' ? '重新连接' : phase === 'disconnected' ? '重新连接' : '连接 M2 手环'}}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{hasResults}}" class="connect-back">
|
||||
<view class="btn-text" bindtap="handleBack">查看测量结果并返回</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- ═══ 连接中(扫描/连接/认证) ═══ -->
|
||||
<block wx:elif="{{phase === 'scanning' || phase === 'connecting' || phase === 'authenticating'}}">
|
||||
<view class="connect-screen">
|
||||
<view class="connect-anim">
|
||||
<view class="connect-ring connect-ring--active"></view>
|
||||
<view class="connect-center">
|
||||
<text class="connect-bt">BT</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="connect-title">
|
||||
{{phase === 'scanning' ? '正在搜索 M2 手环...' : phase === 'connecting' ? '正在连接...' : '正在认证...'}}
|
||||
</text>
|
||||
<text class="connect-hint">请确保手环已开机且靠近手机</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- ═══ 自动测量中 / 自动测量完成 ═══ -->
|
||||
<block wx:elif="{{phase === 'ready' && (autoMeasuring || autoMeasureDone)}}">
|
||||
<view class="measure-page">
|
||||
<!-- 设备状态栏 -->
|
||||
<view class="device-bar">
|
||||
<view class="device-bar__left">
|
||||
<view class="device-bar__dot"></view>
|
||||
<text class="device-bar__name">{{deviceName}}</text>
|
||||
<text wx:if="{{batteryLevel !== null}}" class="device-bar__battery">{{batteryLevel}}%</text>
|
||||
</view>
|
||||
<view wx:if="{{autoMeasuring}}" class="device-bar__disconnect" bindtap="handleCancelAutoMeasure">取消</view>
|
||||
</view>
|
||||
|
||||
<view class="auto-measure">
|
||||
<!-- 标题 -->
|
||||
<view class="auto-measure__header">
|
||||
<text class="auto-measure__title">{{autoMeasureDone ? '✓ 测量完成!' : '正在自动测量...'}}</text>
|
||||
<text wx:if="{{!autoMeasureDone}}" class="auto-measure__subtitle">请保持手环佩戴,无需任何操作</text>
|
||||
</view>
|
||||
|
||||
<!-- 指标列表 -->
|
||||
<view class="auto-measure__list">
|
||||
<view
|
||||
wx:for="{{measureTypes}}"
|
||||
wx:key="type"
|
||||
class="auto-item {{autoMeasureStatus[item.type] === 'done' ? 'auto-item--done' : autoMeasureStatus[item.type] === 'measuring' ? 'auto-item--active' : autoMeasureStatus[item.type] === 'error' ? 'auto-item--error' : ''}}"
|
||||
>
|
||||
<view class="auto-item__left">
|
||||
<view class="auto-item__icon-wrap" style="background: {{autoMeasureStatus[item.type] === 'done' ? item.color : autoMeasureStatus[item.type] === 'error' ? '#ccc' : item.color}}">
|
||||
<text class="auto-item__icon">{{autoMeasureStatus[item.type] === 'done' ? '✓' : autoMeasureStatus[item.type] === 'error' ? '✕' : item.icon}}</text>
|
||||
</view>
|
||||
<text class="auto-item__label">{{item.label}}</text>
|
||||
</view>
|
||||
<view class="auto-item__right">
|
||||
<block wx:if="{{autoMeasureStatus[item.type] === 'done'}}">
|
||||
<text class="auto-item__value" style="color: {{item.color}}">{{autoMeasureValues[item.type]}}</text>
|
||||
<text wx:if="{{item.unit}}" class="auto-item__unit">{{item.unit}}</text>
|
||||
</block>
|
||||
<text wx:elif="{{autoMeasureStatus[item.type] === 'measuring'}}" class="auto-item__status auto-item__status--active">测量中...</text>
|
||||
<text wx:elif="{{autoMeasureStatus[item.type] === 'error'}}" class="auto-item__status auto-item__status--error">已跳过</text>
|
||||
<text wx:else class="auto-item__status">等待中</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<view class="auto-progress">
|
||||
<view class="auto-progress__bar">
|
||||
<view class="auto-progress__fill" style="width: {{autoMeasureProgress}}%"></view>
|
||||
</view>
|
||||
<text class="auto-progress__text">{{autoMeasureProgress}}%</text>
|
||||
</view>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<view class="disclaimer">
|
||||
<text class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</text>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="actions">
|
||||
<block wx:if="{{autoMeasureDone}}">
|
||||
<view class="btn btn--primary" bindtap="handleBack">查看结果并返回</view>
|
||||
</block>
|
||||
<block wx:elif="{{autoMeasuring}}">
|
||||
<view class="btn btn--text" bindtap="handleCancelAutoMeasure">取消自动测量</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- ═══ 就绪 + 手动测量 ═══ -->
|
||||
<block wx:elif="{{phase === 'ready'}}">
|
||||
<view class="measure-page">
|
||||
<!-- 设备状态栏 -->
|
||||
<view class="device-bar">
|
||||
<view class="device-bar__left">
|
||||
<view class="device-bar__dot"></view>
|
||||
<text class="device-bar__name">{{deviceName}}</text>
|
||||
<text wx:if="{{batteryLevel !== null}}" class="device-bar__battery">{{batteryLevel}}%</text>
|
||||
</view>
|
||||
<view class="device-bar__disconnect" bindtap="handleDisconnect">断开</view>
|
||||
</view>
|
||||
|
||||
<!-- 指标选择器 — 药丸式 -->
|
||||
<scroll-view class="selector" scroll-x enhanced show-scrollbar="{{false}}">
|
||||
<view
|
||||
wx:for="{{measureTypes}}"
|
||||
wx:key="type"
|
||||
class="selector__pill {{selectedType === item.type ? 'selector__pill--active' : ''}} {{results[item.type] ? 'selector__pill--done' : ''}}"
|
||||
data-type="{{item.type}}"
|
||||
bindtap="handleSelectType"
|
||||
>
|
||||
<view class="selector__icon-wrap" style="background: {{item.color}}">
|
||||
<text class="selector__icon">{{item.icon}}</text>
|
||||
</view>
|
||||
<text class="selector__label">{{item.label}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 仪表盘区域 -->
|
||||
<view class="gauge-section">
|
||||
<view class="gauge {{measurePhase === 'measuring' ? 'gauge--measuring' : ''}}">
|
||||
<!-- SVG 圆环 -->
|
||||
<view class="gauge__ring-wrap">
|
||||
<view class="gauge__ring-bg"></view>
|
||||
<view class="gauge__ring-progress" style="background: conic-gradient({{selectedColor}} {{measureProgress * 3.6}}deg, #E8E2DC 0deg);"></view>
|
||||
<view class="gauge__center">
|
||||
<!-- 空闲 -->
|
||||
<block wx:if="{{measurePhase === 'idle'}}">
|
||||
<text class="gauge__icon-lg" style="color: {{selectedColor}}">{{selectedIcon}}</text>
|
||||
<text class="gauge__hint">点击下方按钮开始</text>
|
||||
</block>
|
||||
<!-- 测量中 -->
|
||||
<block wx:elif="{{measurePhase === 'measuring'}}">
|
||||
<text wx:if="{{measureDisplayValue}}" class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
|
||||
<text wx:else class="gauge__loading">测量中...</text>
|
||||
<text wx:if="{{measureDisplayValue}}" class="gauge__unit">{{selectedUnit}}</text>
|
||||
</block>
|
||||
<!-- 成功 -->
|
||||
<block wx:elif="{{measurePhase === 'success'}}">
|
||||
<text class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
|
||||
<text class="gauge__unit">{{selectedUnit}}</text>
|
||||
</block>
|
||||
<!-- 错误 -->
|
||||
<block wx:elif="{{measurePhase === 'error'}}">
|
||||
<text class="gauge__err">!</text>
|
||||
<text class="gauge__err-text">{{measureError}}</text>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<view wx:if="{{measurePhase === 'measuring' && measureProgress > 0}}" class="progress-bar">
|
||||
<view class="progress-bar__fill" style="width: {{measureProgress}}%; background: {{selectedColor}};"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<view class="disclaimer">
|
||||
<text class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</text>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="actions">
|
||||
<block wx:if="{{measurePhase === 'idle'}}">
|
||||
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">
|
||||
开始测量{{selectedLabel}}
|
||||
</view>
|
||||
</block>
|
||||
<block wx:elif="{{measurePhase === 'measuring'}}">
|
||||
<view class="btn btn--secondary" bindtap="handleCancelMeasure">停止测量</view>
|
||||
<view class="btn btn--text" bindtap="handleBack">完成并查看结果</view>
|
||||
</block>
|
||||
<block wx:elif="{{measurePhase === 'success'}}">
|
||||
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleResetResult">重新测量</view>
|
||||
<view class="btn btn--secondary" bindtap="handleBack">完成并查看结果</view>
|
||||
</block>
|
||||
<block wx:elif="{{measurePhase === 'error'}}">
|
||||
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">重新测量</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
619
apps/miniprogram/native/pkg-veepoo/index.wxss
Normal file
619
apps/miniprogram/native/pkg-veepoo/index.wxss
Normal file
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* Veepoo M2 原生页面样式
|
||||
* 设计原型: docs/design/veepoo-measure-prototype.html
|
||||
* 复刻小程序 design token
|
||||
*/
|
||||
|
||||
page {
|
||||
--pri: #C4623A;
|
||||
--pri-l: #F0DDD4;
|
||||
--bg: #F5F0EB;
|
||||
--card: #FFFFFF;
|
||||
--tx: #2D2A26;
|
||||
--tx2: #5A554F;
|
||||
--tx3: #78716C;
|
||||
--bd: #E8E2DC;
|
||||
--acc: #5B7A5E;
|
||||
--acc-l: #E8F0E8;
|
||||
--dan: #B54A4A;
|
||||
--dan-l: #FDEAEA;
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
连接页面(未连接/连接中/错误)
|
||||
═══════════════════════════════════════ */
|
||||
.connect-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.connect-anim {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.connect-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--pri);
|
||||
animation: pulse-ring 2s ease-out infinite;
|
||||
}
|
||||
|
||||
.connect-ring--active {
|
||||
border-color: var(--pri);
|
||||
animation: pulse-ring 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
.connect-center {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--pri);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.connect-bt {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.connect-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--tx);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.connect-hint {
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connect-error {
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connect-error-text {
|
||||
font-size: 14px;
|
||||
color: var(--dan);
|
||||
}
|
||||
|
||||
.connect-btn-wrap {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.connect-back {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(1.4); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
测量页面(就绪态)
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
/* ═══ 自动测量进度 ═══ */
|
||||
.auto-measure {
|
||||
padding: 24px 20px 40px;
|
||||
}
|
||||
|
||||
.auto-measure__header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.auto-measure__title {
|
||||
display: block;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--tx);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auto-measure__subtitle {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.auto-measure__list {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(45,42,38,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auto-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
transition: background 200ms ease;
|
||||
}
|
||||
|
||||
.auto-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.auto-item--active {
|
||||
background: rgba(196,98,58,0.04);
|
||||
}
|
||||
|
||||
.auto-item--done {
|
||||
background: rgba(91,122,94,0.04);
|
||||
}
|
||||
|
||||
.auto-item--error {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.auto-item__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.auto-item__icon-wrap {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auto-item--done .auto-item__icon-wrap {
|
||||
background: var(--acc) !important;
|
||||
}
|
||||
|
||||
.auto-item--error .auto-item__icon-wrap {
|
||||
background: var(--tx3) !important;
|
||||
}
|
||||
|
||||
.auto-item__icon {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auto-item__label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.auto-item__right {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.auto-item__value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auto-item__unit {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.auto-item__status {
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.auto-item__status--active {
|
||||
color: var(--pri);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auto-item__status--error {
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
/* ── 自动测量进度条 ── */
|
||||
.auto-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.auto-progress__bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--bd);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auto-progress__fill {
|
||||
height: 100%;
|
||||
background: var(--pri);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease-out;
|
||||
}
|
||||
|
||||
.auto-progress__text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--pri);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
.measure-page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* ── 设备状态栏 ── */
|
||||
.device-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.device-bar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-bar__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--acc);
|
||||
}
|
||||
|
||||
.device-bar__name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.device-bar__battery {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.device-bar__disconnect {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* ── 指标选择器(药丸式) ── */
|
||||
.selector {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
padding: 16px 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selector__pill {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
min-width: 64px;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.selector__pill--active {
|
||||
background: var(--card);
|
||||
box-shadow: 0 2px 12px rgba(45,42,38,0.10);
|
||||
}
|
||||
|
||||
.selector__pill--done::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: var(--acc);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.selector__icon-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.selector__pill--active .selector__icon-wrap {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.selector__icon {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.selector__label {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.selector__pill--active .selector__label {
|
||||
color: var(--tx);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 仪表盘 ── */
|
||||
.gauge-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 0 24px;
|
||||
}
|
||||
|
||||
.gauge {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gauge--measuring {
|
||||
animation: gauge-breathe 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gauge-breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
}
|
||||
|
||||
.gauge__ring-wrap {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.gauge__ring-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 10px solid var(--bd);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gauge__ring-progress {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gauge__center {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gauge__icon-lg {
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.gauge__hint {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gauge__value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 52px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gauge__unit {
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.gauge__loading {
|
||||
font-size: 16px;
|
||||
color: var(--tx2);
|
||||
}
|
||||
|
||||
.gauge__err {
|
||||
font-size: 36px;
|
||||
color: var(--dan);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gauge__err-text {
|
||||
font-size: 13px;
|
||||
color: var(--tx2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── 进度条 ── */
|
||||
.progress-bar {
|
||||
width: 240px;
|
||||
height: 4px;
|
||||
background: var(--bd);
|
||||
border-radius: 2px;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* ── 免责声明 ── */
|
||||
.disclaimer {
|
||||
text-align: center;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.disclaimer__text {
|
||||
font-size: 11px;
|
||||
color: var(--tx3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 操作按钮 ── */
|
||||
.actions {
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--pri);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(196,98,58,0.3);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--card);
|
||||
color: var(--tx);
|
||||
border: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.btn--text {
|
||||
background: transparent;
|
||||
color: var(--tx3);
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ═══ 旧版兼容样式 ═══ */
|
||||
.btn-primary {
|
||||
background: var(--pri);
|
||||
color: #fff;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 16px rgba(196,98,58,0.3);
|
||||
}
|
||||
.btn-primary:active { opacity: 0.85; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--card);
|
||||
color: var(--tx);
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--bd);
|
||||
}
|
||||
.btn-secondary:active { opacity: 0.85; }
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
color: var(--tx3);
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-large { margin: 0; }
|
||||
|
||||
/* 旧版 header/selector/gauge 兼容 */
|
||||
.header { display: none; }
|
||||
.header-device { display: none; }
|
||||
.header-dot { display: none; }
|
||||
.header-name { display: none; }
|
||||
.header-battery { display: none; }
|
||||
.header-disconnect { display: none; }
|
||||
.selector-item { display: none; }
|
||||
.selector-icon { display: none; }
|
||||
.selector-label { display: none; }
|
||||
.selector-check { display: none; }
|
||||
.gauge-circle { display: none; }
|
||||
.gauge-icon { display: none; }
|
||||
.gauge-hint { display: none; }
|
||||
.gauge-value { display: none; }
|
||||
.gauge-loading { display: none; }
|
||||
.gauge-err { display: none; }
|
||||
.gauge-err-text { display: none; }
|
||||
.gauge-progress-bar { display: none; }
|
||||
.gauge-progress-fill { display: none; }
|
||||
.assessment { display: none; }
|
||||
.assessment-text { display: none; }
|
||||
.disclaimer-text { display: none; }
|
||||
.measure-error { display: none; }
|
||||
.measure-error-text { display: none; }
|
||||
@@ -12,6 +12,8 @@
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx}'",
|
||||
"format:check": "prettier --check 'src/**/*.{ts,tsx}'",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "vitest run --config e2e/vitest.config.ts",
|
||||
"dev:h5": "dotenv -e .env.h5 -- taro build --type h5 --watch",
|
||||
"build:h5": "dotenv -e .env.h5 -- taro build --type h5"
|
||||
@@ -32,6 +34,7 @@
|
||||
"@tarojs/runtime": "4.2.0",
|
||||
"@tarojs/shared": "4.2.0",
|
||||
"@tarojs/taro": "4.2.0",
|
||||
"mp-html": "^2.5.2",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "18.3.1",
|
||||
"zustand": "^5.0.0"
|
||||
|
||||
8
apps/miniprogram/pnpm-lock.yaml
generated
8
apps/miniprogram/pnpm-lock.yaml
generated
@@ -38,6 +38,9 @@ importers:
|
||||
'@tarojs/taro':
|
||||
specifier: 4.2.0
|
||||
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
|
||||
mp-html:
|
||||
specifier: ^2.5.2
|
||||
version: 2.5.2
|
||||
react:
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.1
|
||||
@@ -4435,6 +4438,9 @@ packages:
|
||||
mobile-detect@1.4.5:
|
||||
resolution: {integrity: sha512-yc0LhH6tItlvfLBugVUEtgawwFU2sIe+cSdmRJJCTMZ5GEJyLxNyC/NIOAOGk67Fa8GNpOttO3Xz/1bHpXFD/g==}
|
||||
|
||||
mp-html@2.5.2:
|
||||
resolution: {integrity: sha512-45e8c32Qgux4YU4iC3qCSFsOh3y+RwPwZ+iz/vvLkDgSGWk+1zsL4WUzWWQc9w3AsAfkaD/QR0oIufIDngBmXA==}
|
||||
|
||||
ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
|
||||
@@ -11011,6 +11017,8 @@ snapshots:
|
||||
|
||||
mobile-detect@1.4.5: {}
|
||||
|
||||
mp-html@2.5.2: {}
|
||||
|
||||
ms@2.0.0: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"minifyWXML": true,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"ignoreUploadUnusedFiles": true
|
||||
"ignoreUploadUnusedFiles": true,
|
||||
"skylineRenderEnable": false
|
||||
},
|
||||
"condition": {}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export default defineAppConfig({
|
||||
...(process.env.NODE_ENV === 'production' ? { lazyCodeLoading: 'requiredComponents' as const } : {}),
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/login/index',
|
||||
@@ -76,6 +77,7 @@ export default defineAppConfig({
|
||||
list: [
|
||||
{ pagePath: 'pages/index/index', text: '首页', iconPath: 'assets/tabbar/home.png', selectedIconPath: 'assets/tabbar/home-active.png' },
|
||||
{ pagePath: 'pages/health/index', text: '健康', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' },
|
||||
{ pagePath: 'pages/mall/index', text: '商城', iconPath: 'assets/tabbar/mall.png', selectedIconPath: 'assets/tabbar/mall-active.png' },
|
||||
{ pagePath: 'pages/messages/index', text: '助手', iconPath: 'assets/tabbar/message.png', selectedIconPath: 'assets/tabbar/message-active.png' },
|
||||
{ pagePath: 'pages/profile/index', text: '我的', iconPath: 'assets/tabbar/profile.png', selectedIconPath: 'assets/tabbar/profile-active.png' },
|
||||
],
|
||||
@@ -83,12 +85,16 @@ export default defineAppConfig({
|
||||
preloadRule: {
|
||||
'pages/index/index': {
|
||||
network: 'all',
|
||||
packages: ['pages/pkg-health', 'pages/pkg-doctor-core', 'pages/article'],
|
||||
packages: ['pages/pkg-health', 'pages/article'],
|
||||
},
|
||||
'pages/health/index': {
|
||||
network: 'all',
|
||||
packages: ['pages/pkg-health'],
|
||||
},
|
||||
'pages/mall/index': {
|
||||
network: 'all',
|
||||
packages: ['pages/pkg-mall'],
|
||||
},
|
||||
'pages/consultation/index': {
|
||||
network: 'all',
|
||||
packages: ['pages/pkg-consultation'],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './utils/crypto-polyfill';
|
||||
import './utils/abort-controller-polyfill';
|
||||
import { useEffect, useRef, PropsWithChildren } from 'react';
|
||||
import { useDidShow, useDidHide } from '@tarojs/taro';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
|
||||
BIN
apps/miniprogram/src/assets/tabbar/mall-active.png
Normal file
BIN
apps/miniprogram/src/assets/tabbar/mall-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 B |
BIN
apps/miniprogram/src/assets/tabbar/mall.png
Normal file
BIN
apps/miniprogram/src/assets/tabbar/mall.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 B |
48
apps/miniprogram/src/components/FrozenPage/index.scss
Normal file
48
apps/miniprogram/src/components/FrozenPage/index.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.frozen-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.frozen-page-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.frozen-page-title {
|
||||
font-size: var(--tk-font-h3);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.frozen-page-desc {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.frozen-page-btn {
|
||||
height: 44px;
|
||||
padding: 0 32px;
|
||||
border-radius: $r;
|
||||
background: var(--tk-pri);
|
||||
@include flex-center;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.frozen-page-btn-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
color: $white;
|
||||
}
|
||||
22
apps/miniprogram/src/components/FrozenPage/index.tsx
Normal file
22
apps/miniprogram/src/components/FrozenPage/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
|
||||
export default function FrozenPage() {
|
||||
return (
|
||||
<PageShell scroll={false}>
|
||||
<View className="frozen-page">
|
||||
<Text className="frozen-page-icon">🚧</Text>
|
||||
<Text className="frozen-page-title">功能即将上线</Text>
|
||||
<Text className="frozen-page-desc">我们正在努力准备中,敬请期待</Text>
|
||||
<View
|
||||
className="frozen-page-btn"
|
||||
onClick={() => Taro.navigateBack({ delta: 1 }).catch(() => Taro.switchTab({ url: '/pages/index/index' }))}
|
||||
>
|
||||
<Text className="frozen-page-btn-text">返回</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
54
apps/miniprogram/src/components/RichArticle/index.tsx
Normal file
54
apps/miniprogram/src/components/RichArticle/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { View } from '@tarojs/components';
|
||||
import { sanitizeHtml } from '@/utils/sanitize-html';
|
||||
|
||||
interface RichArticleProps {
|
||||
html: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TAG_STYLE = JSON.stringify({
|
||||
h1: 'font-size:20px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h2: 'font-size:18px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h3: 'font-size:16px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h4: 'font-size:15px;font-weight:600;color:#2D2A26;margin:12px 0 6px',
|
||||
p: 'font-size:16px;color:#2D2A26;line-height:1.85;margin-bottom:12px',
|
||||
ul: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
|
||||
ol: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
|
||||
li: 'margin-bottom:4px',
|
||||
blockquote: 'border-left:3px solid #C4623A;padding:6px 12px;color:#5A554F;margin:12px 0',
|
||||
strong: 'font-weight:700;color:#2D2A26',
|
||||
em: 'font-style:italic',
|
||||
code: 'background:#F5F0EB;padding:2px 6px;border-radius:4px;font-size:14px;color:#C4623A',
|
||||
pre: 'background:#F5F0EB;padding:12px;border-radius:8px;margin:14px 0;overflow-x:auto',
|
||||
table: 'width:100%;border-collapse:collapse;margin:8px 0;font-size:14px',
|
||||
th: 'border:1px solid #E8E2DC;padding:6px 8px;background:#FAF8F5;font-weight:600;text-align:left',
|
||||
td: 'border:1px solid #E8E2DC;padding:6px 8px',
|
||||
hr: 'border:none;border-top:1px dashed #D1D5DB;margin:14px 0',
|
||||
img: 'max-width:100%;border-radius:8px;margin:8px 0;display:block',
|
||||
a: 'color:#C4623A;text-decoration:none',
|
||||
});
|
||||
|
||||
function prepareHtml(raw: string): string {
|
||||
return sanitizeHtml(raw);
|
||||
}
|
||||
|
||||
function RichArticle({ html, className }: RichArticleProps) {
|
||||
const content = useMemo(() => prepareHtml(html), [html]);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<View className={className}>
|
||||
<mp-html
|
||||
content={content}
|
||||
lazy-load
|
||||
selectable
|
||||
container-style="font-size:16px;color:#5A554F;line-height:1.8;word-break:break-word"
|
||||
tag-style={TAG_STYLE}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(RichArticle);
|
||||
@@ -3,12 +3,12 @@ import { View, Text } from '@tarojs/components';
|
||||
import './index.scss';
|
||||
|
||||
interface Tab {
|
||||
key: string;
|
||||
label: string;
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
interface SegmentTabsProps {
|
||||
tabs: Tab[];
|
||||
tabs: readonly Tab[];
|
||||
activeKey: string;
|
||||
onChange: (key: string) => void;
|
||||
variant?: 'underline' | 'pill';
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.checkin-calendar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
padding: $sp-section $sp-lg $sp-md;
|
||||
|
||||
&__day {
|
||||
width: 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $sp-xs;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
@include flex-center;
|
||||
|
||||
&--checked {
|
||||
background: $acc-l;
|
||||
}
|
||||
&--today {
|
||||
background: $pri;
|
||||
border: 2px solid $pri;
|
||||
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.3);
|
||||
}
|
||||
&--empty {
|
||||
background: $surface-alt;
|
||||
}
|
||||
}
|
||||
|
||||
&__check {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: $acc;
|
||||
|
||||
.checkin-calendar__dot--today & {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
color: $tx3;
|
||||
font-weight: 400;
|
||||
|
||||
&--today {
|
||||
color: $tx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__tip {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 14px;
|
||||
background: $acc-l;
|
||||
border-radius: $r-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-xs;
|
||||
}
|
||||
|
||||
&__tip-text {
|
||||
font-size: 12px;
|
||||
color: $acc;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode & {
|
||||
&__dot {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
}
|
||||
&__tip-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
53
apps/miniprogram/src/components/ui/CheckinCalendar/index.tsx
Normal file
53
apps/miniprogram/src/components/ui/CheckinCalendar/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import './index.scss';
|
||||
|
||||
interface CheckinCalendarProps {
|
||||
consecutiveDays: number;
|
||||
earnedPoints?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const DAYS = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
|
||||
const CheckinCalendar: React.FC<CheckinCalendarProps> = ({
|
||||
consecutiveDays,
|
||||
}) => {
|
||||
const daysUntilReward = 7 - consecutiveDays;
|
||||
|
||||
return (
|
||||
<View className='checkin-calendar'>
|
||||
{DAYS.map((d, i) => {
|
||||
const isChecked = i < consecutiveDays;
|
||||
const isToday = i === consecutiveDays - 1;
|
||||
return (
|
||||
<View key={i} className='checkin-calendar__day'>
|
||||
<View
|
||||
className={`checkin-calendar__dot ${
|
||||
isChecked
|
||||
? isToday
|
||||
? 'checkin-calendar__dot--today'
|
||||
: 'checkin-calendar__dot--checked'
|
||||
: 'checkin-calendar__dot--empty'
|
||||
}`}
|
||||
>
|
||||
{isChecked && <Text className='checkin-calendar__check'>✓</Text>}
|
||||
</View>
|
||||
<Text className={`checkin-calendar__label ${isToday ? 'checkin-calendar__label--today' : ''}`}>
|
||||
周{d}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{daysUntilReward > 0 && (
|
||||
<View className='checkin-calendar__tip'>
|
||||
<Text className='checkin-calendar__tip-text'>
|
||||
再坚持 {daysUntilReward} 天,连续 7 天签到额外奖励 50 积分
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(CheckinCalendar);
|
||||
142
apps/miniprogram/src/components/ui/CheckinModal/index.scss
Normal file
142
apps/miniprogram/src/components/ui/CheckinModal/index.scss
Normal file
@@ -0,0 +1,142 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.checkin-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
width: 320px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__header {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
padding: $sp-lg;
|
||||
padding-bottom: 20px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__header-deco {
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
right: -15px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 30px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--tk-font-body);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: $sp-xs;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__points-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: $sp-2xs;
|
||||
}
|
||||
|
||||
&__points-num {
|
||||
@include serif-number;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: $white;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__points-unit {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
&__streak {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
margin-top: $sp-xs;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__calendar {
|
||||
padding: $sp-section $sp-lg 0;
|
||||
}
|
||||
|
||||
&__calendar-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: $sp-sm;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__calendar-body {
|
||||
position: relative;
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 0 $sp-lg $sp-lg;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
width: 100%;
|
||||
padding: 12px 0;
|
||||
border-radius: $r-pill;
|
||||
background: $pri;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
box-shadow: $shadow-btn;
|
||||
@include touch-target;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
&__btn-text {
|
||||
font-size: 15px;
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode & {
|
||||
&__card {
|
||||
width: 340px;
|
||||
}
|
||||
&__points-num {
|
||||
font-size: 42px;
|
||||
}
|
||||
&__title {
|
||||
font-size: 17px;
|
||||
}
|
||||
&__btn {
|
||||
padding: 16px 0;
|
||||
}
|
||||
&__btn-text {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
apps/miniprogram/src/components/ui/CheckinModal/index.tsx
Normal file
63
apps/miniprogram/src/components/ui/CheckinModal/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import CheckinCalendar from '../CheckinCalendar';
|
||||
import './index.scss';
|
||||
|
||||
interface CheckinModalProps {
|
||||
visible: boolean;
|
||||
consecutiveDays: number;
|
||||
earnedPoints: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CheckinModal: React.FC<CheckinModalProps> = ({
|
||||
visible,
|
||||
consecutiveDays,
|
||||
earnedPoints,
|
||||
onClose,
|
||||
}) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View className='checkin-modal'>
|
||||
<View className='checkin-modal__overlay' onClick={onClose} />
|
||||
<View className='checkin-modal__card'>
|
||||
{/* 顶部装饰区 */}
|
||||
<View className='checkin-modal__header'>
|
||||
<View className='checkin-modal__header-deco' />
|
||||
<Text className='checkin-modal__title'>签到成功</Text>
|
||||
<View className='checkin-modal__points-row'>
|
||||
<Text className='checkin-modal__points-num'>+{earnedPoints}</Text>
|
||||
<Text className='checkin-modal__points-unit'>积分</Text>
|
||||
</View>
|
||||
{consecutiveDays > 0 && (
|
||||
<Text className='checkin-modal__streak'>
|
||||
已连续签到 {consecutiveDays} 天
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 7天日历 */}
|
||||
<View className='checkin-modal__calendar'>
|
||||
<Text className='checkin-modal__calendar-title'>本周签到</Text>
|
||||
<View className='checkin-modal__calendar-body'>
|
||||
<CheckinCalendar
|
||||
consecutiveDays={consecutiveDays}
|
||||
earnedPoints={earnedPoints}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<View className='checkin-modal__footer'>
|
||||
<View className='checkin-modal__btn' onClick={onClose}>
|
||||
<Text className='checkin-modal__btn-text'>我知道了</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(CheckinModal);
|
||||
@@ -27,7 +27,9 @@ export default function DoctorTabBar({ active }: DoctorTabBarProps) {
|
||||
|
||||
const handleTab = (tab: TabItem) => {
|
||||
if (tab.key === activeKey) return;
|
||||
Taro.reLaunch({ url: tab.url });
|
||||
Taro.reLaunch({ url: tab.url }).catch(() => {
|
||||
Taro.redirectTo({ url: tab.url }).catch(() => {});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
124
apps/miniprogram/src/components/ui/PointsCard/index.scss
Normal file
124
apps/miniprogram/src/components/ui/PointsCard/index.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.points-card {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
border-radius: $r;
|
||||
padding: $sp-lg;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: $sp-section;
|
||||
box-shadow: 0 8px 24px rgba(196, 98, 58, 0.25);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&__deco {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
|
||||
&--1 {
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
&--2 {
|
||||
bottom: -30px;
|
||||
right: 40px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
&--3 {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: $sp-xs;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
&__balance {
|
||||
@include serif-number;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: $white;
|
||||
line-height: 1;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: $sp-2xs;
|
||||
}
|
||||
|
||||
&__streak {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
&__checkin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-2xs;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
padding: 8px 16px;
|
||||
border-radius: $r-pill;
|
||||
cursor: pointer;
|
||||
|
||||
@include touch-target;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&--done {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__checkin-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__checkin--done &__checkin-text {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode & {
|
||||
&__balance {
|
||||
font-size: 52px;
|
||||
}
|
||||
&__label {
|
||||
font-size: 15px;
|
||||
}
|
||||
&__checkin {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
&__checkin-text {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
apps/miniprogram/src/components/ui/PointsCard/index.tsx
Normal file
50
apps/miniprogram/src/components/ui/PointsCard/index.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import './index.scss';
|
||||
|
||||
interface PointsCardProps {
|
||||
balance: number;
|
||||
consecutiveDays: number;
|
||||
checkedIn: boolean;
|
||||
checkinLoading?: boolean;
|
||||
onCheckin?: () => void;
|
||||
}
|
||||
|
||||
const PointsCard: React.FC<PointsCardProps> = ({
|
||||
balance,
|
||||
consecutiveDays,
|
||||
checkedIn,
|
||||
checkinLoading = false,
|
||||
onCheckin,
|
||||
}) => {
|
||||
return (
|
||||
<View className='points-card'>
|
||||
{/* 装饰圆 */}
|
||||
<View className='points-card__deco points-card__deco--1' />
|
||||
<View className='points-card__deco points-card__deco--2' />
|
||||
<View className='points-card__deco points-card__deco--3' />
|
||||
|
||||
<View className='points-card__body'>
|
||||
<View className='points-card__left'>
|
||||
<Text className='points-card__label'>我的积分</Text>
|
||||
<Text className='points-card__balance'>{balance.toLocaleString()}</Text>
|
||||
{consecutiveDays > 0 && (
|
||||
<Text className='points-card__streak'>
|
||||
已连续签到 {consecutiveDays} 天
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
className={`points-card__checkin ${checkedIn ? 'points-card__checkin--done' : ''}`}
|
||||
onClick={() => !checkedIn && !checkinLoading && onCheckin?.()}
|
||||
>
|
||||
<Text className='points-card__checkin-text'>
|
||||
{checkinLoading ? '...' : checkedIn ? '已签到' : '签到'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PointsCard);
|
||||
116
apps/miniprogram/src/components/ui/ProductCard/index.scss
Normal file
116
apps/miniprogram/src/components/ui/ProductCard/index.scss
Normal file
@@ -0,0 +1,116 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.product-card {
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
@include touch-feedback;
|
||||
|
||||
&__thumb-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
gap: $sp-2xs;
|
||||
|
||||
&--physical { background: $pri-l; }
|
||||
&--service { background: $acc-l; }
|
||||
&--privilege { background: $wrn-l; }
|
||||
}
|
||||
|
||||
&__thumb-type {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__soldout {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
&__soldout-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__info {
|
||||
padding: 10px $sp-sm 14px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
height: 40px;
|
||||
margin-bottom: $sp-xs;
|
||||
}
|
||||
|
||||
&__bottom {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $sp-2xs;
|
||||
}
|
||||
|
||||
&__points-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__points-num {
|
||||
@include serif-number;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&__points-unit {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $pri;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__low-stock {
|
||||
display: inline-block;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $wrn;
|
||||
font-weight: 500;
|
||||
background: $wrn-l;
|
||||
padding: 2px $sp-xs;
|
||||
border-radius: $r-sm;
|
||||
margin-top: $sp-2xs;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode & {
|
||||
&__name {
|
||||
font-size: 16px;
|
||||
height: 46px;
|
||||
}
|
||||
&__points-num {
|
||||
font-size: 22px;
|
||||
}
|
||||
&__points-unit {
|
||||
font-size: 13px;
|
||||
}
|
||||
&__low-stock {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
apps/miniprogram/src/components/ui/ProductCard/index.tsx
Normal file
58
apps/miniprogram/src/components/ui/ProductCard/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import type { PointsProduct } from '@/services/points';
|
||||
import './index.scss';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: PointsProduct;
|
||||
onPress?: (product: PointsProduct) => void;
|
||||
}
|
||||
|
||||
const TYPE_BG_CLASS: Record<string, string> = {
|
||||
physical: 'product-card__thumb--physical',
|
||||
service: 'product-card__thumb--service',
|
||||
privilege: 'product-card__thumb--privilege',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
physical: '实物',
|
||||
service: '服务券',
|
||||
privilege: '权益',
|
||||
};
|
||||
|
||||
const ProductCard: React.FC<ProductCardProps> = ({ product, onPress }) => {
|
||||
const isSoldOut = product.stock <= 0;
|
||||
const isLowStock = product.stock > 0 && product.stock <= 10;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='product-card'
|
||||
onClick={() => onPress?.(product)}
|
||||
>
|
||||
<View className='product-card__thumb-wrap'>
|
||||
<View className={`product-card__thumb ${TYPE_BG_CLASS[product.product_type] || ''}`}>
|
||||
<Text className='product-card__thumb-type'>{TYPE_LABELS[product.product_type] || '商品'}</Text>
|
||||
</View>
|
||||
{isSoldOut && (
|
||||
<View className='product-card__soldout'>
|
||||
<Text className='product-card__soldout-text'>已兑完</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='product-card__info'>
|
||||
<Text className='product-card__name'>{product.name}</Text>
|
||||
<View className='product-card__bottom'>
|
||||
<View className='product-card__points-row'>
|
||||
<Text className='product-card__points-num'>{product.points_cost}</Text>
|
||||
<Text className='product-card__points-unit'>积分</Text>
|
||||
</View>
|
||||
</View>
|
||||
{isLowStock && (
|
||||
<Text className='product-card__low-stock'>仅剩{product.stock}件</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ProductCard);
|
||||
@@ -79,8 +79,14 @@ export function useAlertPolling() {
|
||||
s.lastAlertCount = count;
|
||||
|
||||
failCount = 0;
|
||||
} catch {
|
||||
failCount++;
|
||||
} catch (err) {
|
||||
// 权限不足时立即停止轮询,不再重试(避免反复弹 toast)
|
||||
if (err instanceof Error && err.message === '权限不足') {
|
||||
s.failCount = MAX_FAILURES;
|
||||
return;
|
||||
}
|
||||
// 网络异常时快速累积失败计数(离线抑制下会在 3s 内快速耗尽重试)
|
||||
failCount += 3;
|
||||
}
|
||||
|
||||
if (gen !== s.generation) return;
|
||||
|
||||
8
apps/miniprogram/src/native-components/mp-html/index.js
Normal file
8
apps/miniprogram/src/native-components/mp-html/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
"use strict";function e(t){"@babel/helpers - typeof";return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(t)}function t(e,t,o){return(t=n(t))in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e}function n(t){var n=o(t,"string");return"symbol"==e(n)?n:n+""}function o(t,n){if("object"!=e(t)||!t)return t;var o=t[Symbol.toPrimitive];if(void 0!==o){var i=o.call(t,n||"default");if("object"!=e(i))return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===n?String:Number)(t)}/*!
|
||||
* mp-html v2.5.2
|
||||
* https://github.com/jin-yufeng/mp-html
|
||||
*
|
||||
* Released under the MIT license
|
||||
* Author: Jin Yufeng
|
||||
*/
|
||||
var i=require("./parser"),r=[];Component({data:{nodes:[]},properties:{containerStyle:String,content:{type:String,value:"",observer:function(e){this.setContent(e)}},copyLink:{type:Boolean,value:!0},domain:String,errorImg:String,lazyLoad:Boolean,loadingImg:String,pauseVideo:{type:Boolean,value:!0},previewImg:{type:null,value:!0},scrollTable:Boolean,selectable:null,setTitle:{type:Boolean,value:!0},showImgMenu:{type:Boolean,value:!0},tagStyle:Object,useAnchor:null},created:function(){this.plugins=[];for(var e=r.length;e--;)this.plugins.push(new r[e](this))},detached:function(){this._hook("onDetached")},methods:{in:function(e,t,n){e&&t&&n&&(this._in={page:e,selector:t,scrollTop:n})},navigateTo:function(e,n){var o=this;return new Promise(function(i,r){if(!o.data.useAnchor)return void r(Error("Anchor is disabled"));var a=wx.createSelectorQuery().in(o._in?o._in.page:o).select((o._in?o._in.selector:"._root")+(e?"".concat(">>>","#").concat(e):"")).boundingClientRect();o._in?a.select(o._in.selector).scrollOffset().select(o._in.selector).boundingClientRect():a.selectViewport().scrollOffset(),a.exec(function(e){if(!e[0])return void r(Error("Label not found"));var a=e[1].scrollTop+e[0].top-(e[2]?e[2].top:0)+(n||parseInt(o.data.useAnchor)||0);o._in?o._in.page.setData(t({},o._in.scrollTop,a)):wx.pageScrollTo({scrollTop:a,duration:300}),i()})})},getText:function(e){var t="";return function e(n){for(var o=0;o<n.length;o++){var i=n[o];if("text"===i.type)t+=i.text.replace(/&/g,"&");else if("br"===i.name)t+="\n";else{var r="p"===i.name||"div"===i.name||"tr"===i.name||"li"===i.name||"h"===i.name[0]&&i.name[1]>"0"&&i.name[1]<"7";r&&t&&"\n"!==t[t.length-1]&&(t+="\n"),i.children&&e(i.children),r&&"\n"!==t[t.length-1]?t+="\n":"td"!==i.name&&"th"!==i.name||(t+="\t")}}}(e||this.data.nodes),t},getRect:function(){var e=this;return new Promise(function(t,n){wx.createSelectorQuery().in(e).select("._root").boundingClientRect().exec(function(e){return e[0]?t(e[0]):n(Error("Root label not found"))})})},pauseMedia:function(){for(var e=(this._videos||[]).length;e--;)this._videos[e].pause()},setPlaybackRate:function(e){this.playbackRate=e;for(var t=(this._videos||[]).length;t--;)this._videos[t].playbackRate(e)},setContent:function(e,t){var n=this;this.imgList&&t||(this.imgList=[]),this._videos=[];var o={},r=new i(this).parse(e);if(t)for(var a=this.data.nodes.length,s=r.length;s--;)o["nodes[".concat(a+s,"]")]=r[s];else o.nodes=r;if(this.setData(o,function(){n._hook("onLoad"),n.triggerEvent("load")}),this.data.lazyLoad||this.imgList._unloadimgs<this.imgList.length/2){var l=0,c=function(e){e&&e.height||(e={}),e.height===l?n.triggerEvent("ready",e):(l=e.height,setTimeout(function(){n.getRect().then(c).catch(c)},350))};this.getRect().then(c).catch(c)}else this.imgList._unloadimgs||this.getRect().then(function(e){n.triggerEvent("ready",e)}).catch(function(){n.triggerEvent("ready",{})})},_hook:function(e){for(var t=r.length;t--;)this.plugins[t][e]&&this.plugins[t][e]()},_add:function(e){e.detail.root=this}}});
|
||||
@@ -0,0 +1 @@
|
||||
{"component":true,"usingComponents":{"node":"./node/node"}}
|
||||
@@ -0,0 +1 @@
|
||||
<view class="_root {{selectable?'_select':''}}" style="{{containerStyle}}"><slot wx:if="{{!nodes[0]}}"/><node id="_root" childs="{{nodes}}" opts="{{[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]}}" catchadd="_add"/></view>
|
||||
@@ -0,0 +1 @@
|
||||
._root{padding:1px 0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}._select{-webkit-user-select:text;user-select:text}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"component":true,"usingComponents":{"node":"./node"}}
|
||||
@@ -0,0 +1 @@
|
||||
<wxs module="isInline">var e={abbr:!0,b:!0,big:!0,code:!0,del:!0,em:!0,i:!0,ins:!0,label:!0,q:!0,small:!0,span:!0,strong:!0,sub:!0,sup:!0};module.exports=function(n,i){return e[n]||-1!==(i||"").indexOf("inline")};</wxs><template name="el"><block wx:if="{{n.name==='img'}}"><rich-text wx:if="{{n.t}}" style="display:{{n.t}}" nodes="<img class='_img' style='{{n.attrs.style}}' src='{{n.attrs.src}}'>" data-i="{{i}}" catchtap="imgTap"/><block wx:else><image wx:if="{{(opts[1]&&!ctrl[i])||ctrl[i]<0}}" class="_img" style="{{n.attrs.style}}" src="{{ctrl[i]<0?opts[2]:opts[1]}}" mode="widthFix"/><image id="{{n.attrs.id}}" class="_img {{n.attrs.class}}" style="{{ctrl[i]===-1?'display:none;':''}}width:{{ctrl[i]||1}}px;height:1px;{{n.attrs.style}}" src="{{n.attrs.src}}" mode="{{!n.h?'widthFix':(!n.w?'heightFix':(n.m||'scaleToFill'))}}" lazy-load="{{opts[0]}}" webp="{{n.webp}}" show-menu-by-longpress="{{opts[3]&&!n.attrs.ignore}}" data-i="{{i}}" bindload="imgLoad" binderror="mediaError" catchtap="imgTap" bindlongpress="noop"/></block></block><text wx:elif="{{n.text}}" user-select="{{opts[4]=='force'&&isiOS}}" decode>{{n.text}}</text><text wx:elif="{{n.name==='br'}}">{{'\n'}}</text><view wx:elif="{{n.name==='a'}}" id="{{n.attrs.id}}" class="{{n.attrs.href?'_a ':''}}{{n.attrs.class}}" hover-class="_hover" style="display:inline;{{n.attrs.style}}" data-i="{{i}}" catchtap="linkTap"><node childs="{{n.children}}" opts="{{opts}}" style="display:inherit"/></view><video wx:elif="{{n.name==='video'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" autoplay="{{n.attrs.autoplay}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" muted="{{n.attrs.muted}}" object-fit="{{n.attrs['object-fit']}}" poster="{{n.attrs.poster}}" src="{{n.src[ctrl[i]||0]}}" data-i="{{i}}" bindplay="play" bindpause="mediaEvent" bindfullscreenchange="mediaEvent" binderror="mediaError"/><audio wx:elif="{{n.name==='audio'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" author="{{n.attrs.author}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" name="{{n.attrs.name}}" poster="{{n.attrs.poster}}" src="{{n.src[ctrl[i]||0]}}" data-i="{{i}}" bindplay="play" bindpause="mediaEvent" binderror="mediaError"/><rich-text wx:else id="{{n.attrs.id}}" style="{{n.f}}" user-select="{{opts[4]}}" nodes="{{[n]}}"/></template><block wx:for="{{nodes}}" wx:for-item="n1" wx:for-index="i1" wx:key="i1"><template wx:if="{{!n1.c&&(!n1.children||n1.name==='a'||!isInline(n1.name,n1.attrs.style))}}" is="el" data="{{n:n1,i:''+i1,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n1.attrs.id}}" class="_{{n1.name}} {{n1.attrs.class}}" style="{{n1.attrs.style}}"><block wx:for="{{n1.children}}" wx:for-item="n2" wx:for-index="i2" wx:key="i2"><template wx:if="{{!n2.c&&(!n2.children||n2.name==='a'||!isInline(n2.name,n2.attrs.style))}}" is="el" data="{{n:n2,i:i1+'_'+i2,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n2.attrs.id}}" class="_{{n2.name}} {{n2.attrs.class}}" style="{{n2.attrs.style}}"><block wx:for="{{n2.children}}" wx:for-item="n3" wx:for-index="i3" wx:key="i3"><template wx:if="{{!n3.c&&(!n3.children||n3.name==='a'||!isInline(n3.name,n3.attrs.style))}}" is="el" data="{{n:n3,i:i1+'_'+i2+'_'+i3,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n3.attrs.id}}" class="_{{n3.name}} {{n3.attrs.class}}" style="{{n3.attrs.style}}"><block wx:for="{{n3.children}}" wx:for-item="n4" wx:for-index="i4" wx:key="i4"><template wx:if="{{!n4.c&&(!n4.children||n4.name==='a'||!isInline(n4.name,n4.attrs.style))}}" is="el" data="{{n:n4,i:i1+'_'+i2+'_'+i3+'_'+i4,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n4.attrs.id}}" class="_{{n4.name}} {{n4.attrs.class}}" style="{{n4.attrs.style}}"><block wx:for="{{n4.children}}" wx:for-item="n5" wx:for-index="i5" wx:key="i5"><template wx:if="{{!n5.c&&(!n5.children||n5.name==='a'||!isInline(n5.name,n5.attrs.style))}}" is="el" data="{{n:n5,i:i1+'_'+i2+'_'+i3+'_'+i4+'_'+i5,opts:opts,ctrl:ctrl}}"/><node wx:else id="{{n5.attrs.id}}" class="_{{n5.name}} {{n5.attrs.class}}" style="{{n5.attrs.style}}" childs="{{n5.children}}" opts="{{opts}}"/></block></view></block></view></block></view></block></view></block>
|
||||
@@ -0,0 +1 @@
|
||||
._a{padding:1.5px 0 1.5px 0;color:#366092;word-break:break-all}._hover{text-decoration:underline;opacity:.7}._img{max-width:100%;-webkit-touch-callout:none}._b,._strong{font-weight:700}._code{font-family:monospace}._del{text-decoration:line-through}._em,._i{font-style:italic}._h1{font-size:2em}._h2{font-size:1.5em}._h3{font-size:1.17em}._h5{font-size:.83em}._h6{font-size:.67em}._h1,._h2,._h3,._h4,._h5,._h6{display:block;font-weight:700}._ins{text-decoration:underline}._li{display:list-item}._ol{list-style-type:decimal}._ol,._ul{display:block;padding-left:40px;margin:1em 0}._q::before{content:'"'}._q::after{content:'"'}._sub{font-size:smaller;vertical-align:sub}._sup{font-size:smaller;vertical-align:super}._tbody,._tfoot,._thead{display:table-row-group}._tr{display:table-row}._td,._th{display:table-cell;vertical-align:middle}._th{font-weight:700;text-align:center}._ul{list-style-type:disc}._ul ._ul{margin:0;list-style-type:circle}._ul ._ul ._ul{list-style-type:square}._abbr,._b,._code,._del,._em,._i,._ins,._label,._q,._span,._strong,._sub,._sup{display:inline}._blockquote,._div,._p{display:block}
|
||||
1
apps/miniprogram/src/native-components/mp-html/parser.js
Normal file
1
apps/miniprogram/src/native-components/mp-html/parser.js
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '文章详情',
|
||||
usingComponents: {
|
||||
'mp-html': '../../../native-components/mp-html/index',
|
||||
},
|
||||
});
|
||||
@@ -1,19 +1,21 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
// 文章详情页 — 对齐原型 docs/design/mp-04-article-report.html → ArticleDetail
|
||||
// 文章详情页 — 阅读优化排版
|
||||
|
||||
.article-detail-page {
|
||||
padding-bottom: 80px;
|
||||
padding-bottom: 100px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h2);
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
line-height: 1.35;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
@@ -21,36 +23,38 @@
|
||||
gap: var(--tk-gap-md);
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
margin-bottom: var(--tk-gap-lg);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.article-divider {
|
||||
height: 1px;
|
||||
background: $bd-l;
|
||||
background: linear-gradient(90deg, $bd-l, $bd, $bd-l);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.article-body {
|
||||
font-size: 15px;
|
||||
// RichText 内部样式由 formatArticleHtml 内联注入
|
||||
// 这里只控制容器间距
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
line-height: 1.8;
|
||||
|
||||
// RichText 内部样式
|
||||
h1, h2, h3 {
|
||||
font-weight: bold;
|
||||
// 兜底:万一内联样式未生效的标签
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: $tx;
|
||||
margin: var(--tk-gap-lg) 0 var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: $r-sm;
|
||||
margin: var(--tk-gap-sm) 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,31 +63,40 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
height: 64px;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd-l;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
gap: 48px;
|
||||
padding: 0 var(--tk-page-padding);
|
||||
z-index: 10;
|
||||
// 安全区适配
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
box-shadow: 0 -2px 12px rgba(45, 42, 38, 0.06);
|
||||
}
|
||||
|
||||
.article-action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
min-width: $touch-min;
|
||||
min-height: $touch-min;
|
||||
justify-content: center;
|
||||
border-radius: $r-sm;
|
||||
transition: background var(--tk-duration-fast);
|
||||
|
||||
&:active {
|
||||
background: $surface-alt;
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.article-action-icon {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -105,3 +118,29 @@
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ─── 关怀模式覆盖 ───
|
||||
.elder-mode {
|
||||
.article-title {
|
||||
font-size: var(--tk-font-h1);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.article-body {
|
||||
font-size: var(--tk-font-body);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.article-bottom-bar {
|
||||
height: 72px;
|
||||
gap: 56px;
|
||||
}
|
||||
|
||||
.article-action-icon {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.article-action-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, RichText } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getArticleDetail, getPublicArticleDetail, Article } from '../../../services/article';
|
||||
import { trackEvent } from '@/services/analytics';
|
||||
import { sanitizeHtml } from '@/utils/sanitize-html';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import RichArticle from '@/components/RichArticle';
|
||||
import './index.scss';
|
||||
|
||||
export default function ArticleDetail() {
|
||||
@@ -77,14 +77,24 @@ export default function ArticleDetail() {
|
||||
<Text className='article-title'>{article.title}</Text>
|
||||
|
||||
<View className='article-meta'>
|
||||
{article.author && <Text>{article.author}</Text>}
|
||||
{article.published_at && <Text>{article.published_at.slice(0, 10)}</Text>}
|
||||
{article.author && (
|
||||
<View className='meta-item'>
|
||||
<Text className='meta-icon'>✍</Text>
|
||||
<Text>{article.author}</Text>
|
||||
</View>
|
||||
)}
|
||||
{article.published_at && (
|
||||
<View className='meta-item'>
|
||||
<Text className='meta-icon'>📅</Text>
|
||||
<Text>{article.published_at.slice(0, 10)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='article-divider' />
|
||||
|
||||
<View className='article-body'>
|
||||
<RichText nodes={sanitizeHtml(article.content || '')} />
|
||||
<RichArticle html={article.content || ''} />
|
||||
</View>
|
||||
|
||||
<View className='article-bottom-bar'>
|
||||
|
||||
@@ -3,7 +3,13 @@ import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listArticles, listCategories } from '../../services/article';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
listArticles,
|
||||
listCategories,
|
||||
listPublicArticles,
|
||||
listPublicCategories,
|
||||
} from '../../services/article';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
@@ -33,6 +39,7 @@ interface ArticleCategory {
|
||||
|
||||
export default function ArticleList() {
|
||||
const modeClass = useElderClass();
|
||||
const isLoggedIn = !!useAuthStore((s) => s.user);
|
||||
const [articles, setArticles] = useState<ArticleItem[]>([]);
|
||||
const [, setPage] = useState(1);
|
||||
const [, setTotal] = useState(0);
|
||||
@@ -46,10 +53,9 @@ export default function ArticleList() {
|
||||
setError(false);
|
||||
try {
|
||||
const cid = categoryId !== undefined ? categoryId : activeCategory;
|
||||
const res = await listArticles({
|
||||
page: p,
|
||||
category_id: cid || undefined,
|
||||
});
|
||||
const res = isLoggedIn
|
||||
? await listArticles({ page: p, category_id: cid || undefined })
|
||||
: await listPublicArticles({ page: p, category_id: cid || undefined });
|
||||
const list = res.data || [];
|
||||
setArticles(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
@@ -61,19 +67,21 @@ export default function ArticleList() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeCategory]);
|
||||
}, [activeCategory, isLoggedIn]);
|
||||
|
||||
usePageData(
|
||||
useCallback(async () => {
|
||||
try {
|
||||
const cats = await listCategories();
|
||||
const cats = isLoggedIn
|
||||
? await listCategories()
|
||||
: await listPublicCategories();
|
||||
setCategories(cats || []);
|
||||
} catch (err) {
|
||||
console.warn('[article] 加载分类失败:', err);
|
||||
setCategories([]);
|
||||
}
|
||||
await fetchData(1);
|
||||
}, [fetchData]),
|
||||
}, [fetchData, isLoggedIn]),
|
||||
{ throttleMs: 10000, enablePullDown: true },
|
||||
);
|
||||
|
||||
|
||||
@@ -2,410 +2,131 @@
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.health-page {
|
||||
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ─── 页头 ─── */
|
||||
.health-header {
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.health-title {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
/* ─── 今日体征摘要 ─── */
|
||||
.vitals-grid {
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.vitals-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.vital-cell {
|
||||
text-align: center;
|
||||
padding: var(--tk-gap-sm);
|
||||
border-radius: $r-sm;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.vital-cell.vital-warn {
|
||||
background: $wrn-l;
|
||||
|
||||
.vital-value {
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.vital-cell.vital-ok {
|
||||
.vital-value {
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 快捷入口 ─── */
|
||||
.quick-entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--tk-gap-sm);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.quick-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ─── 分类标签 ─── */
|
||||
.health-categories {
|
||||
white-space: nowrap;
|
||||
padding: var(--tk-gap-xs) var(--tk-page-padding);
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.health-cat-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-xs);
|
||||
min-height: var(--tk-touch-min);
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r;
|
||||
background: var(--tk-pri-l);
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.quick-icon-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: var(--tk-pri);
|
||||
}
|
||||
|
||||
.quick-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 告警提示 ─── */
|
||||
.alert-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: $dan;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
flex: 1;
|
||||
padding: 8px 18px;
|
||||
margin-right: 8px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 500;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
.alert-arrow {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── 趋势图 ─── */
|
||||
.trend-section {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.trend-empty {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trend-empty-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 400;
|
||||
color: $tx2;
|
||||
background: $surface-alt;
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--active {
|
||||
background: var(--tk-pri);
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
padding: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 120px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: var(--tk-gap-sm) var(--tk-gap-xs);
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trend-threshold-line {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
border-top: 1.5px dashed $wrn;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trend-threshold-label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -16px;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $wrn;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.trend-bar-col {
|
||||
/* ─── 可滚动内容区 ─── */
|
||||
.health-scroll {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
/* 微信小程序 ScrollView scrollY 需要显式高度 */
|
||||
height: 0; /* flex:1 + height:0 让 flex 布局正确分配剩余高度 */
|
||||
}
|
||||
|
||||
/* ─── 文章列表 ─── */
|
||||
.health-article-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
width: 28px;
|
||||
border-radius: $r-xs $r-xs 0 0;
|
||||
min-height: 8px;
|
||||
opacity: 0.8;
|
||||
|
||||
&.trend-bar-normal {
|
||||
background: var(--tk-pri);
|
||||
}
|
||||
|
||||
&.trend-bar-warn {
|
||||
background: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-bar-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-top: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
/* ─── BLE 设备卡片 ─── */
|
||||
.device-section {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
.content-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r-sm;
|
||||
background: var(--tk-pri-l);
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-icon-text {
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
|
||||
.device-info {
|
||||
.health-article-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $acc;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-arrow {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── 健康资讯入口 ─── */
|
||||
.article-entry {
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.article-entry-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── AI 建议卡片 ─── */
|
||||
.ai-suggestion-card {
|
||||
background: $acc-l;
|
||||
border-radius: $r;
|
||||
padding: var(--tk-gap-md);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
box-shadow: none;
|
||||
border-left: 4px solid $acc;
|
||||
}
|
||||
|
||||
.ai-card-header {
|
||||
.health-article-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-card-title {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 600;
|
||||
color: $acc;
|
||||
.health-article-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.ai-card-count {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $acc;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ai-suggestion-item {
|
||||
padding: var(--tk-gap-xs) 0;
|
||||
border-bottom: 1px solid rgba($acc, 0.15);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-suggestion-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-xs);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-risk-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.ai-risk-high {
|
||||
background: $dan;
|
||||
}
|
||||
|
||||
&.ai-risk-medium {
|
||||
background: $wrn;
|
||||
}
|
||||
|
||||
&.ai-risk-low {
|
||||
background: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-suggestion-text {
|
||||
.health-article-summary {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-2xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── AI 建议反馈按钮 ─── */
|
||||
.ai-feedback-row {
|
||||
.health-article-meta {
|
||||
display: flex;
|
||||
gap: var(--tk-gap-xs);
|
||||
margin-top: var(--tk-gap-2xs);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.ai-feedback-btn {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
border-radius: $r-xs;
|
||||
@include flex-center;
|
||||
padding: 0 var(--tk-gap-sm);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&.ai-feedback-adopt {
|
||||
background: rgba($acc, 0.15);
|
||||
}
|
||||
|
||||
&.ai-feedback-ignore {
|
||||
background: $surface-alt;
|
||||
}
|
||||
|
||||
&.ai-feedback-consult {
|
||||
background: var(--tk-pri-l);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-feedback-btn-text {
|
||||
gap: var(--tk-gap-sm);
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 500;
|
||||
color: $tx2;
|
||||
color: $tx3;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ai-feedback-adopt .ai-feedback-btn-text {
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
.ai-feedback-consult .ai-feedback-btn-text {
|
||||
.health-article-tag {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-pri);
|
||||
background: var(--tk-pri-l);
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
.health-article-date {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode .health-page {
|
||||
.health-cat-tab {
|
||||
padding: 10px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.health-article-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
.health-article-summary {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,226 +1,152 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import GuestGuard from '../../components/GuestGuard';
|
||||
import Loading from '../../components/Loading';
|
||||
import {
|
||||
listArticles,
|
||||
listCategories,
|
||||
listPublicArticles,
|
||||
listPublicCategories,
|
||||
type Article,
|
||||
type ArticleCategory,
|
||||
} from '../../services/article';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import SegmentTabs from '../../components/SegmentTabs';
|
||||
import { useHealthOverview, VITAL_TABS, type VitalType } from './useHealthOverview';
|
||||
import { submitSuggestionFeedback } from '../../services/ai-analysis';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
import Loading from '../../components/Loading';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const QUICK_ENTRIES = [
|
||||
{ label: '录入体征', icon: '笔', path: '/pages/pkg-health/input/index' },
|
||||
{ label: '健康趋势', icon: '线', path: '/pages/pkg-health/trend/index' },
|
||||
{ label: '我的报告', icon: '报', path: '/pages/pkg-profile/reports/index' },
|
||||
{ label: '用药记录', icon: '药', path: '/pages/pkg-profile/medication/index' },
|
||||
] as const;
|
||||
|
||||
function statusClass(status?: string): string {
|
||||
if (!status) return '';
|
||||
if (status === 'high' || status === 'abnormal') return 'vital-warn';
|
||||
if (status === 'low') return 'vital-warn';
|
||||
return 'vital-ok';
|
||||
}
|
||||
|
||||
export default function Health() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const modeClass = useElderClass();
|
||||
const {
|
||||
todaySummary, loading, error, activeTab, trendData, trendLoading,
|
||||
aiSuggestions, thresholds, alertCount, handleTabChange, fetchData,
|
||||
} = useHealthOverview();
|
||||
const isLoggedIn = !!useAuthStore((s) => s.user);
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [categories, setCategories] = useState<ArticleCategory[]>([]);
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
|
||||
if (!user) {
|
||||
return <GuestGuard title='请先登录' desc='登录后即可查看健康数据' />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageShell padding="md" safeBottom={false} scroll={false} className={`health-page ${modeClass}`}>
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康总览</Text>
|
||||
</View>
|
||||
<Loading />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const maxTrendValue = trendData.reduce((max, d) => Math.max(max, d.value), 1);
|
||||
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
const summary = todaySummary || {};
|
||||
const vitals = [
|
||||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status },
|
||||
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status },
|
||||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status },
|
||||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status },
|
||||
];
|
||||
|
||||
const getThresholdValue = (type: VitalType): number | null => {
|
||||
if (!thresholds.length) return null;
|
||||
const th = thresholds;
|
||||
if (type === 'blood_pressure') {
|
||||
const v = th.find((t) => t.indicator === 'systolic_bp' && t.level === 'high');
|
||||
return v?.threshold_value ?? 140;
|
||||
const fetchData = useCallback(async (p: number, append = false, categoryId?: string | null) => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const cid = categoryId !== undefined ? categoryId : activeCategory;
|
||||
const res = isLoggedIn
|
||||
? await listArticles({ page: p, category_id: cid || undefined })
|
||||
: await listPublicArticles({ page: p, category_id: cid || undefined });
|
||||
const list = res.data || [];
|
||||
setArticles(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[health] 加载文章列表失败:', err);
|
||||
setError(true);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
if (type === 'heart_rate') {
|
||||
const v = th.find((t) => t.indicator === 'heart_rate' && t.level === 'high');
|
||||
return v?.threshold_value ?? 100;
|
||||
}, [activeCategory, isLoggedIn]);
|
||||
|
||||
usePageData(
|
||||
useCallback(async () => {
|
||||
try {
|
||||
const cats = isLoggedIn
|
||||
? await listCategories()
|
||||
: await listPublicCategories();
|
||||
setCategories(cats || []);
|
||||
} catch (err) {
|
||||
console.warn('[health] 加载分类失败:', err);
|
||||
setCategories([]);
|
||||
}
|
||||
await fetchData(1);
|
||||
}, [fetchData, isLoggedIn]),
|
||||
{ throttleMs: 10000, enablePullDown: true },
|
||||
);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!loading && articles.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
}
|
||||
if (type === 'blood_sugar') {
|
||||
const v = th.find((t) => t.indicator === 'blood_sugar_fasting' && t.level === 'high');
|
||||
return v?.threshold_value ?? 6.1;
|
||||
}
|
||||
return null;
|
||||
}, [loading, articles.length, total, page, fetchData]);
|
||||
|
||||
const handleCategoryChange = (categoryId: string | null) => {
|
||||
setActiveCategory(categoryId);
|
||||
fetchData(1, false, categoryId);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const month = d.getMonth() + 1;
|
||||
const day = d.getDate();
|
||||
return `${month}月${day}日`;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell padding="md" safeBottom={false} scroll className={`health-page ${modeClass}`}>
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康总览</Text>
|
||||
</View>
|
||||
<PageShell safeBottom={false} padding="none" scroll={false} className={`health-page ${modeClass}`}>
|
||||
{/* 分类标签 */}
|
||||
{categories.length > 0 && (
|
||||
<ScrollView scrollX className='health-categories'>
|
||||
<View
|
||||
className={`health-cat-tab ${!activeCategory ? 'health-cat-tab--active' : ''}`}
|
||||
onClick={() => handleCategoryChange(null)}
|
||||
>
|
||||
<Text>推荐</Text>
|
||||
</View>
|
||||
{categories.map((cat) => (
|
||||
<View
|
||||
key={cat.id}
|
||||
className={`health-cat-tab ${activeCategory === cat.id ? 'health-cat-tab--active' : ''}`}
|
||||
onClick={() => handleCategoryChange(cat.id)}
|
||||
>
|
||||
<Text>{cat.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* 今日体征摘要 */}
|
||||
<ContentCard variant="elevated" className='vitals-grid'>
|
||||
{loading ? <Loading /> : (
|
||||
<View className='vitals-row'>
|
||||
{vitals.map((v) => (
|
||||
<View className={`vital-cell ${statusClass(v.status)}`} key={v.label}>
|
||||
<Text className='vital-value'>{v.value}</Text>
|
||||
<Text className='vital-unit'>{v.unit}</Text>
|
||||
<Text className='vital-label'>{v.label}</Text>
|
||||
</View>
|
||||
{/* 文章列表 */}
|
||||
<ScrollView scrollY className='health-scroll' onScrollToLower={loadMore} lowerThreshold={200}>
|
||||
{error ? (
|
||||
<ErrorState onRetry={() => fetchData(1, false, null)} />
|
||||
) : articles.length === 0 && !loading ? (
|
||||
<EmptyState text='暂无健康资讯' />
|
||||
) : (
|
||||
<View className='health-article-list'>
|
||||
{articles.map((a) => (
|
||||
<ContentCard
|
||||
key={a.id}
|
||||
padding='sm'
|
||||
margin='none'
|
||||
onPress={() => safeNavigateTo(`/pages/article/detail/index?id=${a.id}`)}
|
||||
>
|
||||
<View className='health-article-body'>
|
||||
<View className='health-article-content'>
|
||||
<Text className='health-article-title'>{a.title}</Text>
|
||||
{a.summary && (
|
||||
<Text className='health-article-summary'>{a.summary}</Text>
|
||||
)}
|
||||
<View className='health-article-meta'>
|
||||
{(a.category_name || a.category) && (
|
||||
<Text className='health-article-tag'>{a.category_name || a.category}</Text>
|
||||
)}
|
||||
{a.published_at && (
|
||||
<Text className='health-article-date'>{formatDate(a.published_at)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* 快捷入口 */}
|
||||
<View className='quick-entries'>
|
||||
{QUICK_ENTRIES.map((e) => (
|
||||
<View
|
||||
key={e.label}
|
||||
className='quick-entry'
|
||||
onClick={() => safeNavigateTo(e.path)}
|
||||
>
|
||||
<View className='quick-icon'>
|
||||
<Text className='quick-icon-text'>{e.icon}</Text>
|
||||
</View>
|
||||
<Text className='quick-label'>{e.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 告警提示 */}
|
||||
{alertCount > 0 && (
|
||||
<ContentCard
|
||||
variant="elevated"
|
||||
className='alert-hint'
|
||||
onPress={() => safeNavigateTo('/pages/pkg-health/alerts/index')}
|
||||
>
|
||||
<View className='alert-dot' />
|
||||
<Text className='alert-text'>{alertCount} 条待处理告警</Text>
|
||||
<Text className='alert-arrow'>›</Text>
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{/* AI 建议 */}
|
||||
{aiSuggestions.length > 0 && (
|
||||
<View className='ai-suggestion-card'>
|
||||
<View className='ai-card-header'>
|
||||
<Text className='ai-card-title'>AI 健康建议</Text>
|
||||
<Text className='ai-card-count'>{aiSuggestions.length} 条</Text>
|
||||
</View>
|
||||
{aiSuggestions.map((s) => {
|
||||
const riskCls = s.risk_level === 'high' ? 'ai-risk-high' : s.risk_level === 'medium' ? 'ai-risk-medium' : 'ai-risk-low';
|
||||
const params = s.params as Record<string, unknown> | null;
|
||||
const reason = (params?.reason as string) || (params?.message as string) || '健康建议';
|
||||
return (
|
||||
<View key={s.id} className='ai-suggestion-item'>
|
||||
<View className='ai-suggestion-main' onClick={() => {
|
||||
if (s.suggestion_type === 'appointment') safeNavigateTo('/pages/appointment/create/index');
|
||||
else if (s.suggestion_type === 'followup') safeNavigateTo('/pages/pkg-profile/followups/index');
|
||||
}}>
|
||||
<View className={`ai-risk-dot ${riskCls}`} />
|
||||
<Text className='ai-suggestion-text'>{reason.slice(0, 50)}</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-row'>
|
||||
<View className='ai-feedback-btn ai-feedback-adopt' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'adopt'); Taro.showToast({ title: '已采纳', icon: 'success' }); fetchData(); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>采纳</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-btn ai-feedback-ignore' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'ignore'); Taro.showToast({ title: '已忽略', icon: 'success' }); fetchData(); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>忽略</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-btn ai-feedback-consult' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'consult'); safeNavigateTo('/pages/consultation/index'); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>咨询医生</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 7天趋势 */}
|
||||
<View className='trend-section'>
|
||||
<Text className='section-title'>近 7 天趋势</Text>
|
||||
<SegmentTabs tabs={VITAL_TABS} activeKey={activeTab} onChange={handleTabChange} variant="pill" />
|
||||
{trendLoading ? <Loading /> : trendData.length === 0 ? (
|
||||
<ContentCard padding="md">
|
||||
<Text className='trend-empty-text'>暂无趋势数据</Text>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<ContentCard padding="md">
|
||||
<View className='trend-bars'>
|
||||
{(() => {
|
||||
const tv = getThresholdValue(activeTab);
|
||||
if (tv) {
|
||||
const pct = Math.min(95, (tv / maxTrendValue) * 100);
|
||||
return (
|
||||
<View className='trend-threshold-line' style={`bottom:${((12 + pct * 1.08) / 120 * 100).toFixed(1)}%;`}>
|
||||
<Text className='trend-threshold-label'>{tv}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{trendData.map((point, i) => {
|
||||
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
|
||||
const tv = getThresholdValue(activeTab);
|
||||
const isAbnormal = tv ? point.value >= tv : false;
|
||||
const dayOfWeek = new Date(point.date).getDay();
|
||||
return (
|
||||
<View className='trend-bar-col' key={i}>
|
||||
<View
|
||||
className={`trend-bar ${isAbnormal ? 'trend-bar-warn' : 'trend-bar-normal'}`}
|
||||
style={`height:${heightPct}%;`}
|
||||
/>
|
||||
<Text className='trend-bar-label'>{dayLabels[dayOfWeek]}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ContentCard>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 健康资讯入口 */}
|
||||
<ContentCard onPress={() => safeNavigateTo('/pages/article/index')}>
|
||||
<Text className='article-entry-text'>最新健康资讯 ›</Text>
|
||||
</ContentCard>
|
||||
{loading && <Loading />}
|
||||
</ScrollView>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,8 +105,8 @@ function GuestHome({ modeClass }: { modeClass: string }) {
|
||||
indicatorActiveColor='#FFFFFF'
|
||||
autoplay={swiperAutoplay}
|
||||
circular
|
||||
interval={4000}
|
||||
duration={500}
|
||||
interval={5000}
|
||||
duration={300}
|
||||
>
|
||||
{slides.map((slide, idx) => (
|
||||
<SwiperItem key={slide.id || idx}>
|
||||
@@ -370,7 +370,8 @@ export default function Index() {
|
||||
url: target,
|
||||
fail: () => {
|
||||
redirectingRef.current = false;
|
||||
console.warn('跳转医生端失败,停留患者首页');
|
||||
console.warn('跳转医生端失败,降级为 redirectTo');
|
||||
Taro.redirectTo({ url: target }).catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,78 +50,6 @@
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
/* ─── 输入框 ─── */
|
||||
.login-field {
|
||||
height: 56px;
|
||||
background: $card;
|
||||
border: 1.5px solid $bd;
|
||||
border-radius: $r;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.login-placeholder {
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
|
||||
.login-eye {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: var(--tk-pri);
|
||||
font-weight: 500;
|
||||
padding: 6px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── 登录按钮 ─── */
|
||||
.login-submit {
|
||||
height: 54px;
|
||||
border-radius: $r;
|
||||
background: var(--tk-pri);
|
||||
@include flex-center;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 4px 16px rgba($pri, 0.3);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.login-submit-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
/* ─── 分隔线 ─── */
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
.login-divider-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
/* ─── 微信登录 ─── */
|
||||
.login-wechat-btn {
|
||||
height: 54px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, Input, Button } from '@tarojs/components';
|
||||
import { View, Text, Button } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
@@ -9,11 +9,9 @@ declare const __wxConfig: Record<string, unknown> | undefined;
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== 'production';
|
||||
const IS_SIMULATOR = typeof __wxConfig !== 'undefined' && (__wxConfig as Record<string, unknown>)?.envVersion === 'develop';
|
||||
const SHOW_DEV_LOGIN = (IS_DEV || IS_SIMULATOR) && !!(process.env.TARO_APP_DEV_USER && process.env.TARO_APP_DEV_PASS);
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [needBind, setNeedBind] = useState(false);
|
||||
|
||||
@@ -25,12 +23,9 @@ export default function Login() {
|
||||
|
||||
const navigateAfterLogin = () => {
|
||||
if (isMedicalStaff()) {
|
||||
// 使用 redirectTo 替代 reLaunch 避免分包加载超时
|
||||
// redirectTo 只替换当前页面,不销毁整个页栈,分包预加载不会被中断
|
||||
Taro.redirectTo({
|
||||
url: '/pages/pkg-doctor-core/index',
|
||||
fail: () => {
|
||||
// fallback: 先跳首页再 redirectTo
|
||||
Taro.switchTab({
|
||||
url: '/pages/index/index',
|
||||
success: () => {
|
||||
@@ -46,37 +41,16 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCredentialLogin = async () => {
|
||||
if (!username.trim()) {
|
||||
Taro.showToast({ title: '请输入账号', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!password.trim()) {
|
||||
Taro.showToast({ title: '请输入密码', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const requireAgreement = () => {
|
||||
if (!agreed) {
|
||||
Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const success = await credentialLogin(username.trim(), password);
|
||||
if (success) {
|
||||
navigateAfterLogin();
|
||||
} else {
|
||||
Taro.showToast({ title: '账号或密码错误', icon: 'none' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[login] 登录失败:', err);
|
||||
Taro.showToast({ title: '登录失败,请重试', icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleWechatLogin = async () => {
|
||||
if (!agreed) {
|
||||
Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!requireAgreement()) return;
|
||||
try {
|
||||
const { code } = await Taro.login();
|
||||
const result = await login(code);
|
||||
@@ -92,23 +66,6 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDevQuickLogin = async () => {
|
||||
const devUser = process.env.TARO_APP_DEV_USER || '';
|
||||
const devPass = process.env.TARO_APP_DEV_PASS || '';
|
||||
if (!devUser || !devPass) {
|
||||
Taro.showToast({ title: '未配置开发账号', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const success = await credentialLogin(devUser, devPass);
|
||||
if (success) {
|
||||
navigateAfterLogin();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
Taro.showToast({ title: err instanceof Error ? err.message : '登录失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetPhone = async (e: { detail: { errMsg: string; encryptedData: string; iv: string } }) => {
|
||||
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
||||
Taro.showToast({ title: '需要授权手机号', icon: 'none' });
|
||||
@@ -131,6 +88,40 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
// DevTools 中 getPhoneNumber 不可用,直接传 mock 数据绕过微信 SDK
|
||||
// 仅在后端 wechat_dev_mode=true 时有效,后端会生成 mock 手机号
|
||||
const handleDevBindPhone = async () => {
|
||||
try {
|
||||
const success = await bindPhone('dev_mock', 'dev_mock');
|
||||
if (success) {
|
||||
navigateAfterLogin();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
Taro.showModal({
|
||||
title: '绑定失败',
|
||||
content: err instanceof Error ? err.message : '绑定失败',
|
||||
confirmText: '重新登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => { if (res.confirm) setNeedBind(false); },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDevQuickLogin = async () => {
|
||||
if (!requireAgreement()) return;
|
||||
const devUser = process.env.TARO_APP_DEV_USER || '';
|
||||
const devPass = process.env.TARO_APP_DEV_PASS || '';
|
||||
if (!devUser || !devPass) return;
|
||||
try {
|
||||
const success = await credentialLogin(devUser, devPass);
|
||||
if (success) {
|
||||
navigateAfterLogin();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
Taro.showToast({ title: err instanceof Error ? err.message : '登录失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="login-page">
|
||||
{/* 品牌区 */}
|
||||
@@ -144,88 +135,75 @@ export default function Login() {
|
||||
|
||||
{!needBind ? (
|
||||
<>
|
||||
{/* 账号输入 */}
|
||||
<View className="login-field">
|
||||
<Input
|
||||
className="login-input"
|
||||
type="text"
|
||||
placeholder="请输入账号"
|
||||
placeholderClass="login-placeholder"
|
||||
value={username}
|
||||
onInput={(e) => setUsername(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 密码输入 */}
|
||||
<View className="login-field">
|
||||
<Input
|
||||
className="login-input"
|
||||
type="safe-password"
|
||||
password={!showPassword}
|
||||
placeholder="请输入密码"
|
||||
placeholderClass="login-placeholder"
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.detail.value)}
|
||||
/>
|
||||
<Text
|
||||
className="login-eye"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? '隐藏' : '显示'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<View className="login-submit" onClick={handleCredentialLogin}>
|
||||
<Text className="login-submit-text">{loading ? '登录中...' : '登录'}</Text>
|
||||
</View>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<View className="login-divider">
|
||||
<View className="login-divider-line" />
|
||||
<Text className="login-divider-text">或</Text>
|
||||
<View className="login-divider-line" />
|
||||
</View>
|
||||
|
||||
{/* 微信一键登录 */}
|
||||
{/* 微信一键登录(主按钮) */}
|
||||
<View className="login-wechat-btn" onClick={handleWechatLogin}>
|
||||
<Text className="login-wechat-icon">微</Text>
|
||||
<Text className="login-wechat-text">微信一键登录</Text>
|
||||
</View>
|
||||
|
||||
{/* 协议 */}
|
||||
<View className="agreement-row">
|
||||
<View
|
||||
className={`agreement-check ${agreed ? 'checked' : ''}`}
|
||||
onClick={() => setAgreed(!agreed)}
|
||||
>
|
||||
{agreed && <Text className="agreement-check-mark">✓</Text>}
|
||||
</View>
|
||||
<Text className="agreement-text">
|
||||
登录即同意
|
||||
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/user-agreement')}>《用户协议》</Text>
|
||||
和
|
||||
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/privacy-policy')}>《隐私政策》</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View className="login-bind-section">
|
||||
<Button
|
||||
className="login-btn-bind"
|
||||
openType="getPhoneNumber"
|
||||
onGetPhoneNumber={handleGetPhone}
|
||||
loading={loading}
|
||||
>
|
||||
授权手机号完成绑定
|
||||
</Button>
|
||||
{/* 真机:微信手机号授权 */}
|
||||
{!SHOW_DEV_LOGIN && (
|
||||
<Button
|
||||
className="login-btn-bind"
|
||||
openType="getPhoneNumber"
|
||||
onGetPhoneNumber={handleGetPhone}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
授权手机号完成绑定
|
||||
</Button>
|
||||
)}
|
||||
{/* DevTools:跳过微信 SDK 直接调后端(后端 wechat_dev_mode 会用 mock 手机号) */}
|
||||
{SHOW_DEV_LOGIN && (
|
||||
<Button
|
||||
className="login-btn-bind"
|
||||
onClick={handleDevBindPhone}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
授权手机号完成绑定(开发模式)
|
||||
</Button>
|
||||
)}
|
||||
{/* 协议 */}
|
||||
<View className="agreement-row" style={{ marginTop: '16px' }}>
|
||||
<View
|
||||
className={`agreement-check ${agreed ? 'checked' : ''}`}
|
||||
onClick={() => setAgreed(!agreed)}
|
||||
>
|
||||
{agreed && <Text className="agreement-check-mark">✓</Text>}
|
||||
</View>
|
||||
<Text className="agreement-text">
|
||||
登录即同意
|
||||
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/user-agreement')}>《用户协议》</Text>
|
||||
和
|
||||
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/privacy-policy')}>《隐私政策》</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 协议 */}
|
||||
<View className="agreement-row">
|
||||
<View
|
||||
className={`agreement-check ${agreed ? 'checked' : ''}`}
|
||||
onClick={() => setAgreed(!agreed)}
|
||||
>
|
||||
{agreed && <Text className="agreement-check-mark">✓</Text>}
|
||||
</View>
|
||||
<Text className="agreement-text">
|
||||
登录即同意
|
||||
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/user-agreement')}>《用户协议》</Text>
|
||||
和
|
||||
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/privacy-policy')}>《隐私政策》</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1 }} />
|
||||
|
||||
{/* 开发模式 */}
|
||||
{(IS_DEV || IS_SIMULATOR) && (
|
||||
{/* 开发模式快速登录 — 仅 dev 构建 + DevTools 中显示 */}
|
||||
{SHOW_DEV_LOGIN && (
|
||||
<View className="login-dev-btn" onClick={handleDevQuickLogin}>
|
||||
<Text className="login-dev-btn-text">开发模式快速登录 ›</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,323 +1,89 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
// 积分商城 — 对齐原型 docs/design/mp-05-mall.html
|
||||
// 积分商城 V2 — 对齐原型 docs/design/mp-05-mall-v2.html
|
||||
|
||||
.mall-page {
|
||||
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
/* ─── 积分卡片(渐变背景) ─── */
|
||||
/* ─── 积分卡片区 ─── */
|
||||
.mall-header {
|
||||
background: linear-gradient(135deg, var(--tk-pri) 0%, var(--tk-pri-d) 100%);
|
||||
padding: var(--tk-gap-xl) var(--tk-page-padding) var(--tk-gap-xl);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// 装饰圆
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
right: 40px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 40px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
padding: 0 var(--tk-page-padding);
|
||||
padding-top: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.points-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.points-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.checkin-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
padding: 6px 14px;
|
||||
border-radius: $r-pill;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&.checked {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.checkin-btn-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.checkin-btn.checked .checkin-btn-text {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.points-balance {
|
||||
@include serif-number;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: $white;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
letter-spacing: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.points-streak {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
display: block;
|
||||
/* ─── 可滚动内容区 ─── */
|
||||
.mall-content {
|
||||
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
|
||||
}
|
||||
|
||||
/* ─── 快捷操作 ─── */
|
||||
.mall-actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: var(--tk-section-gap) var(--tk-page-padding);
|
||||
padding: var(--tk-section-gap) 0;
|
||||
}
|
||||
|
||||
.mall-action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: $sp-xs;
|
||||
@include touch-feedback;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
&-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 26px;
|
||||
@include flex-center;
|
||||
|
||||
.mall-action-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--checkin {
|
||||
background: $acc;
|
||||
box-shadow: 0 4px 12px rgba(91, 122, 94, 0.3);
|
||||
}
|
||||
&--task {
|
||||
background: $pri;
|
||||
box-shadow: 0 4px 12px rgba(196, 98, 58, 0.3);
|
||||
}
|
||||
&--history {
|
||||
background: $wrn;
|
||||
box-shadow: 0 4px 12px rgba(196, 135, 58, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.mall-action-icon-text {
|
||||
font-size: 22px;
|
||||
color: $white;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mall-action-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 分类标签(Pill) ─── */
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 0 var(--tk-page-padding) var(--tk-section-gap);
|
||||
overflow-x: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
padding: 7px 18px;
|
||||
border-radius: $r-pill;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 400;
|
||||
background: $surface-alt;
|
||||
color: $tx2;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
&--checkin {
|
||||
background: $acc;
|
||||
box-shadow: 0 4px 12px rgba(91, 122, 94, 0.3);
|
||||
}
|
||||
&--history {
|
||||
background: $wrn;
|
||||
box-shadow: 0 4px 12px rgba(196, 135, 58, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--tk-pri);
|
||||
&-icon-text {
|
||||
font-size: 22px;
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--tk-shadow-tab);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tab-text {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
/* ─── 分割线 ─── */
|
||||
.mall-divider {
|
||||
height: 1px;
|
||||
background: $bd;
|
||||
margin-bottom: $sp-md;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: inherit;
|
||||
}
|
||||
/* ─── 分类标签 ─── */
|
||||
.mall-tabs {
|
||||
margin-bottom: $sp-section;
|
||||
}
|
||||
|
||||
/* ─── 商品网格 ─── */
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--tk-gap-sm);
|
||||
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
|
||||
gap: $sp-sm;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
@include flex-center;
|
||||
position: relative;
|
||||
|
||||
&.type-physical { background: $pri-l; }
|
||||
&.type-service { background: $acc-l; }
|
||||
&.type-privilege { background: $wrn-l; }
|
||||
}
|
||||
|
||||
.product-image-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
line-height: 1;
|
||||
|
||||
.type-service & { color: $acc; }
|
||||
.type-privilege & { color: $wrn; }
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 10px var(--tk-gap-sm) 14px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
height: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-bottom {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-points {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.product-points-char {
|
||||
@include serif-number;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.product-points-value {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $pri;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 2px 6px;
|
||||
border-radius: $r-xs;
|
||||
|
||||
&.out {
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&.low {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
.product-tag {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
color: $white;
|
||||
|
||||
&--hot {
|
||||
background: $dan;
|
||||
}
|
||||
&--new {
|
||||
background: $acc;
|
||||
// 长者模式
|
||||
.elder-mode .mall-page {
|
||||
.mall-action-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,25 +7,24 @@ import { listProducts } from '../../services/points';
|
||||
import type { PointsProduct } from '../../services/points';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { usePointsStore } from '../../stores/points';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import PointsCard from '@/components/ui/PointsCard';
|
||||
import ProductCard from '@/components/ui/ProductCard';
|
||||
import TabFilter from '@/components/ui/TabFilter';
|
||||
import CheckinModal from '@/components/ui/CheckinModal';
|
||||
import Loading from '../../components/Loading';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
|
||||
const PRODUCT_TYPE_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'physical', label: '实物' },
|
||||
{ key: 'service', label: '服务券' },
|
||||
{ key: 'privilege', label: '权益' },
|
||||
];
|
||||
const PRODUCT_TABS = ['全部', '实物', '服务券', '权益'];
|
||||
const TAB_TYPE_MAP = ['', 'physical', 'service', 'privilege'];
|
||||
|
||||
const TYPE_BG: Record<string, string> = {
|
||||
physical: 'type-physical',
|
||||
service: 'type-service',
|
||||
privilege: 'type-privilege',
|
||||
};
|
||||
const QUICK_ACTIONS = [
|
||||
{ icon: '✓', label: '签到打卡', cls: 'mall-action-icon--checkin' },
|
||||
{ icon: '◷', label: '兑换记录', cls: 'mall-action-icon--history' },
|
||||
] as const;
|
||||
|
||||
export default function Mall() {
|
||||
const currentPatient = useAuthStore((s) => s.currentPatient);
|
||||
@@ -35,35 +34,28 @@ export default function Mall() {
|
||||
const refreshPoints = usePointsStore((s) => s.refresh);
|
||||
const doCheckin = usePointsStore((s) => s.doCheckin);
|
||||
const [products, setProducts] = useState<PointsProduct[]>([]);
|
||||
const [productType, setProductType] = useState('');
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkinLoading, setCheckinLoading] = useState(false);
|
||||
const [showCheckin, setShowCheckin] = useState(false);
|
||||
const [noProfile, setNoProfile] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const modeClass = useElderClass();
|
||||
|
||||
const fetchProducts = useCallback(
|
||||
async (pageNum: number, type: string, isRefresh = false) => {
|
||||
async (pageNum: number, typeIdx: number, isRefresh = false) => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const res = await listProducts({
|
||||
page: pageNum,
|
||||
page_size: 10,
|
||||
product_type: type || undefined,
|
||||
});
|
||||
const type = TAB_TYPE_MAP[typeIdx] || undefined;
|
||||
const res = await listProducts({ page: pageNum, page_size: 10, product_type: type });
|
||||
const list = res.data || [];
|
||||
if (isRefresh) {
|
||||
setProducts(list);
|
||||
} else {
|
||||
setProducts((prev) => [...prev, ...list]);
|
||||
}
|
||||
setProducts((prev) => (isRefresh ? list : [...prev, ...list]));
|
||||
setTotal(res.total);
|
||||
setPage(pageNum);
|
||||
} catch (err) {
|
||||
console.warn('[mall] 加载商品列表失败:', err);
|
||||
} catch {
|
||||
setError(true);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
@@ -74,12 +66,11 @@ export default function Mall() {
|
||||
);
|
||||
|
||||
const loadAll = useCallback(
|
||||
async (type?: string) => {
|
||||
const t = type !== undefined ? type : productType;
|
||||
async (tabIdx?: number) => {
|
||||
const t = tabIdx !== undefined ? tabIdx : activeTab;
|
||||
if (!currentPatient) {
|
||||
await loadPatients();
|
||||
const updated = useAuthStore.getState().currentPatient;
|
||||
if (!updated) {
|
||||
if (!useAuthStore.getState().currentPatient) {
|
||||
setNoProfile(true);
|
||||
return;
|
||||
}
|
||||
@@ -87,7 +78,7 @@ export default function Mall() {
|
||||
setNoProfile(false);
|
||||
await Promise.all([refreshPoints(), fetchProducts(1, t, true)]);
|
||||
},
|
||||
[currentPatient, loadPatients, refreshPoints, fetchProducts, productType],
|
||||
[currentPatient, loadPatients, refreshPoints, fetchProducts, activeTab],
|
||||
);
|
||||
|
||||
usePageData(
|
||||
@@ -100,7 +91,7 @@ export default function Mall() {
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && products.length < total) {
|
||||
fetchProducts(page + 1, productType);
|
||||
fetchProducts(page + 1, activeTab);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -110,7 +101,7 @@ export default function Mall() {
|
||||
try {
|
||||
const ok = await doCheckin();
|
||||
if (ok) {
|
||||
Taro.showToast({ title: '签到成功', icon: 'success', duration: 2000 });
|
||||
setShowCheckin(true);
|
||||
}
|
||||
} catch (err) {
|
||||
Taro.showToast({
|
||||
@@ -122,9 +113,9 @@ export default function Mall() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setProductType(key);
|
||||
fetchProducts(1, key, true);
|
||||
const handleTabChange = (idx: number) => {
|
||||
setActiveTab(idx);
|
||||
fetchProducts(1, idx, true);
|
||||
};
|
||||
|
||||
const handleProductClick = (item: PointsProduct) => {
|
||||
@@ -135,7 +126,17 @@ export default function Mall() {
|
||||
safeNavigateTo(`/pages/pkg-mall/product/index?product_id=${item.id}`);
|
||||
};
|
||||
|
||||
const handleAction = (label: string) => {
|
||||
if (label === '签到打卡') {
|
||||
handleCheckin();
|
||||
} else if (label === '兑换记录') {
|
||||
safeNavigateTo('/pages/pkg-mall/orders/index');
|
||||
}
|
||||
};
|
||||
|
||||
const balance = account?.balance ?? 0;
|
||||
const consecutiveDays = checkinStatus?.consecutive_days ?? 0;
|
||||
const checkedIn = checkinStatus?.checked_in_today ?? false;
|
||||
|
||||
if (noProfile) {
|
||||
return (
|
||||
@@ -153,101 +154,77 @@ export default function Mall() {
|
||||
|
||||
return (
|
||||
<PageShell padding="none" safeBottom={false} scroll={false} className={`mall-page ${modeClass}`}>
|
||||
{/* 积分余额卡片 */}
|
||||
{/* 积分卡片 */}
|
||||
<View className='mall-header'>
|
||||
<View className='points-card'>
|
||||
<View className='points-top'>
|
||||
<Text className='points-label'>我的积分</Text>
|
||||
<PointsCard
|
||||
balance={balance}
|
||||
consecutiveDays={consecutiveDays}
|
||||
checkedIn={checkedIn}
|
||||
checkinLoading={checkinLoading}
|
||||
onCheckin={handleCheckin}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 可滚动内容区 */}
|
||||
<View className='mall-content'>
|
||||
{/* 快捷操作 */}
|
||||
<View className='mall-actions'>
|
||||
{QUICK_ACTIONS.map((action) => (
|
||||
<View
|
||||
className={`checkin-btn ${checkinStatus?.checked_in_today ? 'checked' : ''}`}
|
||||
onClick={handleCheckin}
|
||||
key={action.label}
|
||||
className='mall-action'
|
||||
onClick={() => handleAction(action.label)}
|
||||
>
|
||||
<Text className='checkin-btn-text'>
|
||||
{checkinLoading ? '...' : checkinStatus?.checked_in_today ? '已签到' : '签到'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='points-balance'>{balance.toLocaleString()}</Text>
|
||||
{checkinStatus && checkinStatus.consecutive_days > 0 && (
|
||||
<Text className='points-streak'>
|
||||
已连续签到 {checkinStatus.consecutive_days} 天
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<View className='mall-actions'>
|
||||
<View className='mall-action' onClick={handleCheckin}>
|
||||
<View className='mall-action-icon mall-action-icon--checkin'>
|
||||
<Text className='mall-action-icon-text'>✓</Text>
|
||||
</View>
|
||||
<Text className='mall-action-label'>签到打卡</Text>
|
||||
</View>
|
||||
{/* TODO: 积分任务功能待实现后恢复 */}
|
||||
<View className='mall-action' onClick={() => safeNavigateTo('/pages/pkg-mall/orders/index')}>
|
||||
<View className='mall-action-icon mall-action-icon--history'>
|
||||
<Text className='mall-action-icon-text'>◷</Text>
|
||||
</View>
|
||||
<Text className='mall-action-label'>兑换记录</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品类型切换 */}
|
||||
<View className='type-tabs'>
|
||||
{PRODUCT_TYPE_TABS.map((tab) => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`type-tab ${productType === tab.key ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
<Text className={`type-tab-text ${productType === tab.key ? 'active' : ''}`}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 商品列表 */}
|
||||
{error ? (
|
||||
<ErrorState onRetry={() => loadAll()} />
|
||||
) : products.length === 0 && !loading ? (
|
||||
<EmptyState icon='礼' text='暂无商品' hint='更多好物即将上架' />
|
||||
) : (
|
||||
<View className='product-grid'>
|
||||
{products.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='product-card'
|
||||
onClick={() => handleProductClick(item)}
|
||||
>
|
||||
<View className={`product-image ${TYPE_BG[item.product_type] || ''}`}>
|
||||
<Text className='product-image-char'>
|
||||
{item.product_type === 'physical' ? '物' : item.product_type === 'service' ? '券' : '权'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='product-info'>
|
||||
<Text className='product-name'>{item.name}</Text>
|
||||
<View className='product-bottom'>
|
||||
<View className='product-points'>
|
||||
<Text className='product-points-char'>{item.points_cost}</Text>
|
||||
<Text className='product-points-value'>积分</Text>
|
||||
</View>
|
||||
{item.stock <= 0 ? (
|
||||
<Text className='product-stock out'>已兑完</Text>
|
||||
) : item.stock <= 10 ? (
|
||||
<Text className='product-stock low'>仅剩{item.stock}件</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View className={`mall-action-icon ${action.cls}`}>
|
||||
<Text className='mall-action-icon-text'>{action.icon}</Text>
|
||||
</View>
|
||||
<Text className='mall-action-label'>{action.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
{loading && <Loading />}
|
||||
{!loading && products.length >= total && total > 0 && (
|
||||
<Loading text='没有更多了' />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 分割线 */}
|
||||
<View className='mall-divider' />
|
||||
|
||||
{/* 分类标签 */}
|
||||
<View className='mall-tabs'>
|
||||
<TabFilter
|
||||
tabs={PRODUCT_TABS}
|
||||
activeIndex={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant='pill'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 商品网格 */}
|
||||
{error ? (
|
||||
<ErrorState onRetry={() => loadAll()} />
|
||||
) : products.length === 0 && !loading ? (
|
||||
<EmptyState icon='礼' text='暂无商品' hint='更多好物即将上架' />
|
||||
) : (
|
||||
<View className='product-grid'>
|
||||
{products.map((item) => (
|
||||
<ProductCard
|
||||
key={item.id}
|
||||
product={item}
|
||||
onPress={handleProductClick}
|
||||
/>
|
||||
))}
|
||||
{loading && <Loading />}
|
||||
{!loading && products.length >= total && total > 0 && (
|
||||
<Loading text='没有更多了' />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 签到弹窗 */}
|
||||
<CheckinModal
|
||||
visible={showCheckin}
|
||||
consecutiveDays={consecutiveDays + (checkedIn ? 0 : 0)}
|
||||
earnedPoints={10}
|
||||
onClose={() => setShowCheckin(false)}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: env(safe-area-inset-bottom, 10px);
|
||||
background: $card;
|
||||
border-top: 1px solid $bd-l;
|
||||
flex-shrink: 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,20 +6,66 @@ import { BLEManager } from '@/services/ble/BLEManager';
|
||||
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
|
||||
import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter';
|
||||
import { GlucoseMeterAdapter } from '@/services/ble/adapters/GlucoseMeterAdapter';
|
||||
import { CustomBandAdapter } from '@/services/ble/adapters/GenericBleAdapter';
|
||||
import { CustomBandAdapter, HuaweiBandAdapter, FallbackAdapter } from '@/services/ble/adapters/GenericBleAdapter';
|
||||
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
|
||||
import type { BLEDevice, NormalizedReading, BLEDiscoveredService } from '@/services/ble/types';
|
||||
import { useElderClass } from '@/hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import PrimaryButton from '@/components/ui/PrimaryButton';
|
||||
import './index.scss';
|
||||
|
||||
/** liveReadings 最大保留条数,防止内存无限增长 */
|
||||
const MAX_LIVE_READINGS = 200;
|
||||
|
||||
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
|
||||
type PageState = 'idle' | 'scanning' | 'found' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
|
||||
|
||||
const DEVICE_TYPE_MAP: Record<string, { label: string; unit: string }> = {
|
||||
heart_rate: { label: '心率', unit: 'bpm' },
|
||||
blood_pressure: { label: '血压', unit: 'mmHg' },
|
||||
blood_glucose: { label: '血糖', unit: 'mmol/L' },
|
||||
blood_oxygen: { label: '血氧', unit: '%' },
|
||||
temperature: { label: '体温', unit: '°C' },
|
||||
steps: { label: '步数', unit: '步' },
|
||||
sleep: { label: '睡眠', unit: 'h' },
|
||||
stress: { label: '压力', unit: '' },
|
||||
};
|
||||
|
||||
function formatReadingValue(r: NormalizedReading): string {
|
||||
if (r.device_type === 'heart_rate' && typeof r.values.heart_rate === 'number') {
|
||||
return String(r.values.heart_rate);
|
||||
}
|
||||
if (r.device_type === 'blood_pressure') {
|
||||
if (typeof r.values.systolic === 'number' && typeof r.values.diastolic === 'number') {
|
||||
return `${r.values.systolic}/${r.values.diastolic}`;
|
||||
}
|
||||
if (r.metric === 'systolic' && typeof r.values.value === 'number') return String(r.values.value);
|
||||
if (r.metric === 'diastolic' && typeof r.values.value === 'number') return String(r.values.value);
|
||||
}
|
||||
if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') {
|
||||
return String(r.values.blood_glucose);
|
||||
}
|
||||
if (typeof r.values.value === 'number') return String(r.values.value);
|
||||
return '--';
|
||||
}
|
||||
|
||||
function getReadingUnit(r: NormalizedReading): string {
|
||||
const mapped = DEVICE_TYPE_MAP[r.device_type];
|
||||
if (mapped) return mapped.unit;
|
||||
return typeof r.values.unit === 'string' ? r.values.unit : '';
|
||||
}
|
||||
|
||||
function getReadingLabel(r: NormalizedReading): string {
|
||||
const mapped = DEVICE_TYPE_MAP[r.device_type];
|
||||
if (!mapped) return r.device_type;
|
||||
if (r.device_type === 'blood_pressure') {
|
||||
if (r.metric === 'systolic') return '收缩压';
|
||||
if (r.metric === 'diastolic') return '舒张压';
|
||||
return '血压';
|
||||
}
|
||||
return mapped.label;
|
||||
}
|
||||
|
||||
export default function DeviceSync() {
|
||||
const modeClass = useElderClass();
|
||||
@@ -34,16 +80,16 @@ export default function DeviceSync() {
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [discoveredServices, setDiscoveredServices] = useState<BLEDiscoveredService[]>([]);
|
||||
|
||||
const scheduler = useMemo(() => new DataSyncScheduler({
|
||||
intervalMs: 60 * 60 * 1000,
|
||||
}), []);
|
||||
const scheduler = useMemo(() => new DataSyncScheduler({ intervalMs: 60 * 60 * 1000 }), []);
|
||||
|
||||
const bleManagerRef = useRef<BLEManager | null>(null);
|
||||
const getBleManager = useCallback(() => {
|
||||
if (!bleManagerRef.current) {
|
||||
const mgr = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
|
||||
mgr.registerAdapter(XiaomiBandAdapter);
|
||||
mgr.registerAdapter(HuaweiBandAdapter);
|
||||
mgr.registerAdapter(BloodPressureAdapter);
|
||||
mgr.registerAdapter(GlucoseMeterAdapter);
|
||||
mgr.registerAdapter(CustomBandAdapter);
|
||||
@@ -53,7 +99,12 @@ export default function DeviceSync() {
|
||||
}, []);
|
||||
|
||||
useThrottledDidShow(() => {
|
||||
const bleManager = getBleManager();
|
||||
let bleManager: BLEManager;
|
||||
try {
|
||||
bleManager = getBleManager();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
bleManager.setOnConnectionChange(() => {});
|
||||
bleManager.setOnReadings((readings) => {
|
||||
setLiveReadings((prev) => {
|
||||
@@ -61,17 +112,8 @@ export default function DeviceSync() {
|
||||
return merged.length > MAX_LIVE_READINGS ? merged.slice(-MAX_LIVE_READINGS) : merged;
|
||||
});
|
||||
});
|
||||
|
||||
// 显示上次同步时间
|
||||
setLastSyncAt(scheduler.getLastSyncAt());
|
||||
|
||||
// 检查是否有未上传的缓冲数据
|
||||
const buffer = (bleManager as unknown as { dataBuffer?: Map<string, number> }).dataBuffer;
|
||||
if (buffer) {
|
||||
setPendingCount(buffer.size);
|
||||
}
|
||||
|
||||
// 自动同步:超过间隔时尝试上传缓冲数据
|
||||
if (currentPatient && scheduler.needsSync()) {
|
||||
scheduler.tryAutoSync(async () => {
|
||||
const count = await bleManager.flushPendingReadings(async (readings) => {
|
||||
@@ -95,18 +137,23 @@ export default function DeviceSync() {
|
||||
}, [scheduler]);
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
console.log('[device-sync] 用户点击扫描按钮');
|
||||
setPageState('scanning');
|
||||
setDevices([]);
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const found = await getBleManager().scanDevices();
|
||||
setDevices(found);
|
||||
if (found.length === 0) {
|
||||
setErrorMsg('未发现支持的设备,请确认设备已开启蓝牙并靠近手机');
|
||||
}
|
||||
setPageState('idle');
|
||||
console.log('[device-sync] 扫描返回设备数:', found.length);
|
||||
// 未匹配适配器的设备分配 FallbackAdapter(尝试标准健康协议)
|
||||
const withFallback = found.map((d) =>
|
||||
d.adapter ? d : { ...d, adapter: FallbackAdapter },
|
||||
);
|
||||
setDevices(withFallback);
|
||||
setPageState('found');
|
||||
} catch (e: unknown) {
|
||||
setErrorMsg(e instanceof Error ? e.message : '扫描失败');
|
||||
const msg = e instanceof Error ? e.message : '扫描失败';
|
||||
console.error('[device-sync] 扫描异常:', msg);
|
||||
setErrorMsg(msg);
|
||||
setPageState('error');
|
||||
}
|
||||
}, []);
|
||||
@@ -117,6 +164,8 @@ export default function DeviceSync() {
|
||||
setErrorMsg('');
|
||||
try {
|
||||
await getBleManager().connect(device);
|
||||
const conn = getBleManager().getConnection();
|
||||
setDiscoveredServices(conn?.discoveredServices ?? []);
|
||||
setPageState('connected');
|
||||
} catch (e: unknown) {
|
||||
setErrorMsg(e instanceof Error ? e.message : '连接失败');
|
||||
@@ -126,33 +175,22 @@ export default function DeviceSync() {
|
||||
|
||||
const handleSync = useCallback(async () => {
|
||||
if (!currentPatient || !selectedDevice) return;
|
||||
|
||||
setPageState('syncing');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const result = await getBleManager().syncToServer(async (readings) => {
|
||||
return uploadReadings(
|
||||
currentPatient.id,
|
||||
selectedDevice.deviceId,
|
||||
selectedDevice.name,
|
||||
readings,
|
||||
);
|
||||
return uploadReadings(currentPatient.id, selectedDevice.deviceId, selectedDevice.name, readings);
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setSyncCount(result.uploadedCount);
|
||||
setLastSyncAt(Date.now());
|
||||
setPageState('done');
|
||||
|
||||
// 如果从体征录入页跳转而来,将最新读数写入 storage 供回填
|
||||
if (returnTo === 'input' && liveReadings.length > 0) {
|
||||
const mapped: Record<string, number> = {};
|
||||
for (const r of liveReadings) {
|
||||
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;
|
||||
// 兼容 values 中直接包含 systolic/diastolic 的格式
|
||||
if (typeof r.values.systolic === 'number') mapped.systolic = r.values.systolic as number;
|
||||
if (typeof r.values.diastolic === 'number') mapped.diastolic = r.values.diastolic as number;
|
||||
} else if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') {
|
||||
@@ -182,143 +220,377 @@ export default function DeviceSync() {
|
||||
setLiveReadings([]);
|
||||
setSyncCount(0);
|
||||
setErrorMsg('');
|
||||
setDiscoveredServices([]);
|
||||
}, []);
|
||||
|
||||
const renderIdle = () => (
|
||||
<View className="sync-section">
|
||||
<View className="sync-hero">
|
||||
<Text className="sync-hero-icon">D</Text>
|
||||
<Text className="sync-hero-title">设备同步</Text>
|
||||
<Text className="sync-hero-desc">连接智能手环、血压计、血糖仪,自动采集健康数据</Text>
|
||||
const latestReading = liveReadings.length > 0 ? liveReadings[liveReadings.length - 1] : null;
|
||||
|
||||
// ── 渲染子区域 ──
|
||||
|
||||
const renderHero = () => (
|
||||
<View className="ds-hero">
|
||||
<View className="ds-hero__icon">
|
||||
<Text className="ds-hero__bt">BT</Text>
|
||||
</View>
|
||||
|
||||
{(lastSyncAt || pendingCount > 0) && (
|
||||
<View className="sync-status-info">
|
||||
{lastSyncAt && (
|
||||
<Text className="sync-status-time">
|
||||
上次同步: {new Date(lastSyncAt).toLocaleTimeString()}
|
||||
</Text>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<Text className="sync-status-pending">
|
||||
{pendingCount} 条数据待上传
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="sync-action" onClick={handleScan}>
|
||||
<Text className="sync-action-text">扫描设备</Text>
|
||||
</View>
|
||||
|
||||
{devices.length > 0 && (
|
||||
<View className="sync-device-list">
|
||||
<Text className="sync-section-title">发现的设备</Text>
|
||||
{devices.map((d) => (
|
||||
<View
|
||||
key={d.deviceId}
|
||||
className="sync-device-item"
|
||||
onClick={() => handleConnect(d)}
|
||||
>
|
||||
<View className="sync-device-info">
|
||||
<Text className="sync-device-name">{d.name}</Text>
|
||||
<Text className="sync-device-adapter">{d.adapter?.name}</Text>
|
||||
</View>
|
||||
<Text className="sync-device-rssi">信号 {d.RSSI > -60 ? '强' : d.RSSI > -80 ? '中' : '弱'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<Text className="ds-hero__title">智能设备同步</Text>
|
||||
<Text className="ds-hero__desc">连接蓝牙设备,自动采集健康数据</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderConnected = () => (
|
||||
<View className="sync-section">
|
||||
<ContentCard className="sync-status-card">
|
||||
<Text className="sync-status-dot sync-status-dot--connected" />
|
||||
<Text className="sync-status-text">已连接: {selectedDevice?.name}</Text>
|
||||
</ContentCard>
|
||||
const renderDeviceTypes = () => (
|
||||
<View className="ds-types">
|
||||
<Text className="ds-types__label">支持的设备</Text>
|
||||
<View className="ds-types__row">
|
||||
<View className="ds-type-tag"><Text className="ds-type-tag__dot ds-type-tag__dot--heart" /><Text className="ds-type-tag__text">心率手环</Text></View>
|
||||
<View className="ds-type-tag"><Text className="ds-type-tag__dot ds-type-tag__dot--bp" /><Text className="ds-type-tag__text">血压计</Text></View>
|
||||
<View className="ds-type-tag"><Text className="ds-type-tag__dot ds-type-tag__dot--glu" /><Text className="ds-type-tag__text">血糖仪</Text></View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
{liveReadings.length > 0 && (
|
||||
<View className="sync-readings-panel">
|
||||
<Text className="sync-section-title">实时数据</Text>
|
||||
{liveReadings.slice(-5).reverse().map((r, i) => (
|
||||
<View key={i} className="sync-reading-item">
|
||||
<Text className="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 className="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>
|
||||
const renderLastSync = () => {
|
||||
if (!lastSyncAt && pendingCount === 0) return null;
|
||||
return (
|
||||
<ContentCard variant="outlined" padding="md" margin="none" className="ds-sync-info">
|
||||
<View className="ds-sync-info__inner">
|
||||
<View className="ds-sync-info__icon-wrap">
|
||||
<Text className="ds-sync-info__check">✓</Text>
|
||||
</View>
|
||||
<View className="ds-sync-info__text">
|
||||
<Text className="ds-sync-info__title">上次同步</Text>
|
||||
{lastSyncAt && <Text className="ds-sync-info__time">{new Date(lastSyncAt).toLocaleTimeString()}</Text>}
|
||||
</View>
|
||||
{pendingCount > 0 && (
|
||||
<View className="ds-sync-info__badge">{pendingCount} 条待上传</View>
|
||||
)}
|
||||
</View>
|
||||
</ContentCard>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPendingWarning = () => {
|
||||
if (pendingCount <= 0) return null;
|
||||
return (
|
||||
<View className="ds-warning">
|
||||
<Text className="ds-warning__icon">!</Text>
|
||||
<Text className="ds-warning__text">{pendingCount} 条数据待上传</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderScanButton = () => (
|
||||
<View className="ds-scan-btn-wrap">
|
||||
<PrimaryButton size="large" onClick={handleScan} loading={pageState === 'scanning'}>
|
||||
扫描附近设备
|
||||
</PrimaryButton>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderDeviceList = () => {
|
||||
if (pageState !== 'found') return null;
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<View className="ds-devices ds-devices--empty">
|
||||
<View className="ds-devices__empty-box">
|
||||
<Text className="ds-devices__empty-box-icon">!</Text>
|
||||
<Text className="ds-devices__empty-box-title">未发现设备</Text>
|
||||
<Text className="ds-devices__empty-box-desc">请确认设备已开机且蓝牙已开启,并靠近手机后重试</Text>
|
||||
</View>
|
||||
<View className="ds-devices__rescan-wrap">
|
||||
<PrimaryButton size="large" onClick={handleScan}>重新扫描</PrimaryButton>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View className="ds-devices">
|
||||
<View className="ds-devices__header">
|
||||
<Text className="ds-devices__count">发现 {devices.length} 台设备</Text>
|
||||
<Text className="ds-devices__rescan" onClick={handleScan}>重新扫描</Text>
|
||||
</View>
|
||||
{devices.map((d) => {
|
||||
const isFallback = d.adapter?.name === '通用设备';
|
||||
return (
|
||||
<View key={d.deviceId} className={`ds-device-card ${isFallback ? 'ds-device-card--generic' : ''}`} onClick={() => handleConnect(d)}>
|
||||
<View className="ds-device-card__icon">
|
||||
<Text className="ds-device-card__bt">BT</Text>
|
||||
</View>
|
||||
<View className="ds-device-card__info">
|
||||
<Text className="ds-device-card__name">{d.name}</Text>
|
||||
<Text className="ds-device-card__adapter">{d.adapter?.name}{isFallback ? ' · 尝试标准协议' : ''}</Text>
|
||||
</View>
|
||||
<View className="ds-device-card__signal">
|
||||
{[4, 7, 10, 13].map((h, i) => (
|
||||
<View
|
||||
key={i}
|
||||
className={`ds-signal-bar ${i < (d.RSSI > -60 ? 4 : d.RSSI > -80 ? 3 : d.RSSI > -90 ? 2 : 1) ? 'ds-signal-bar--active' : ''}`}
|
||||
style={{ height: `${h}px` }}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text className="ds-device-card__arrow">›</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View className="ds-devices__empty-hint">
|
||||
<Text className="ds-devices__empty-icon">?</Text>
|
||||
<View className="ds-devices__empty-text">
|
||||
<Text className="ds-devices__empty-title">没有找到你的设备?</Text>
|
||||
<Text className="ds-devices__empty-desc">确保设备已开机且蓝牙已开启</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLoading = (text: string) => (
|
||||
<View className="ds-loading">
|
||||
<View className="ds-loading__spinner" />
|
||||
<Text className="ds-loading__text">{text}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderConnectedStatus = () => (
|
||||
<View className="ds-connected-status">
|
||||
<View className="ds-connected-status__icon">
|
||||
<Text className="ds-connected-status__bt">BT</Text>
|
||||
</View>
|
||||
<View className="ds-connected-status__info">
|
||||
<Text className="ds-connected-status__name">{selectedDevice?.name}</Text>
|
||||
<Text className="ds-connected-status__sub">已连接 · 正在采集数据</Text>
|
||||
</View>
|
||||
<View className="ds-connected-status__badge">实时</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
/** 渲染 BLE 服务发现信息 */
|
||||
const renderServiceDiscovery = () => {
|
||||
if (discoveredServices.length === 0) return null;
|
||||
|
||||
// 检查各类健康数据是否在已发现的 UUID 中可用
|
||||
const hasCharShort = (short: string) =>
|
||||
discoveredServices.some((s) =>
|
||||
s.characteristics.some((c) =>
|
||||
c.uuid.toUpperCase().replace(/-/g, '').slice(-4) === short,
|
||||
),
|
||||
);
|
||||
|
||||
const capabilities = [
|
||||
{ key: '2A37', label: '心率', available: hasCharShort('2A37') },
|
||||
{ key: '2A5F', label: '血氧(实时)', available: hasCharShort('2A5F') },
|
||||
{ key: '2A5E', label: '血氧(单次)', available: hasCharShort('2A5E') },
|
||||
{ key: '2A1C', label: '体温', available: hasCharShort('2A1C') },
|
||||
{ key: '2A35', label: '血压', available: hasCharShort('2A35') },
|
||||
];
|
||||
|
||||
const availableCount = capabilities.filter((c) => c.available).length;
|
||||
|
||||
return (
|
||||
<ContentCard variant="outlined" padding="md" margin="none" className="ds-services-info">
|
||||
<Text className="ds-services-info__title">
|
||||
设备服务 ({discoveredServices.length} 个服务, {availableCount} 种可用数据)
|
||||
</Text>
|
||||
<View className="ds-services-info__caps">
|
||||
{capabilities.map((cap) => (
|
||||
<View key={cap.key} className={`ds-cap-tag ${cap.available ? 'ds-cap-tag--on' : 'ds-cap-tag--off'}`}>
|
||||
<Text className="ds-cap-tag__dot">{cap.available ? '●' : '○'}</Text>
|
||||
<Text className="ds-cap-tag__text">{cap.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
<Text className="sync-readings-count">
|
||||
已采集 {liveReadings.length} 条数据
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{availableCount <= 1 && (
|
||||
<View className="ds-services-info__hint">
|
||||
<Text className="ds-services-info__hint-text">
|
||||
{selectedDevice?.name?.includes('HUAWEI') || selectedDevice?.name?.includes('HW')
|
||||
? '华为手环的睡眠/步数/压力数据使用私有协议,需要华为运动健康 App 同步'
|
||||
: '此设备仅暴露少量标准健康服务,更多数据请使用设备官方 App 同步'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ContentCard>
|
||||
);
|
||||
};
|
||||
|
||||
<View className="sync-actions-row">
|
||||
<View className="sync-action sync-action--primary" onClick={handleSync}>
|
||||
<Text className="sync-action-text">上传数据</Text>
|
||||
const renderLatestReading = () => {
|
||||
if (!latestReading) return null;
|
||||
return (
|
||||
<ContentCard variant="elevated" padding="lg" margin="none" className="ds-latest-reading">
|
||||
<View className="ds-latest-reading__icon-wrap ds-latest-reading__icon-wrap--heart">
|
||||
<Text className="ds-latest-reading__heart">♥</Text>
|
||||
</View>
|
||||
<View className="sync-action sync-action--danger" onClick={handleDisconnect}>
|
||||
<Text className="sync-action-text">断开连接</Text>
|
||||
<View className="ds-latest-reading__body">
|
||||
<Text className="ds-latest-reading__label">{getReadingLabel(latestReading)} · 刚刚</Text>
|
||||
<View className="ds-latest-reading__values">
|
||||
<Text className="ds-latest-reading__num">{formatReadingValue(latestReading)}</Text>
|
||||
<Text className="ds-latest-reading__unit">{getReadingUnit(latestReading)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ContentCard>
|
||||
);
|
||||
};
|
||||
|
||||
const renderReadingsHistory = () => {
|
||||
if (liveReadings.length <= 1) return null;
|
||||
const history = liveReadings.slice(0, -1).reverse().slice(0, 5);
|
||||
return (
|
||||
<View className="ds-history">
|
||||
<Text className="ds-history__title">历史读数</Text>
|
||||
<View className="ds-history__list">
|
||||
{history.map((r, i) => (
|
||||
<View key={i} className={`ds-history__row ${i === history.length - 1 ? 'ds-history__row--last' : ''}`}>
|
||||
<Text className="ds-history__type">{getReadingLabel(r)}</Text>
|
||||
<View className="ds-history__val-wrap">
|
||||
<Text className="ds-history__val">{formatReadingValue(r)}</Text>
|
||||
<Text className="ds-history__unit">{getReadingUnit(r)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<Text className="ds-history__count">已采集 {liveReadings.length} 条数据</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderConnectedActions = () => (
|
||||
<View className="ds-actions">
|
||||
<View className="ds-actions__upload" onClick={handleSync}>
|
||||
<Text className="ds-actions__upload-text">上传数据</Text>
|
||||
</View>
|
||||
<View className="ds-actions__disconnect" onClick={handleDisconnect}>
|
||||
<Text className="ds-actions__disconnect-icon">✕</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderDone = () => (
|
||||
<View className="sync-section">
|
||||
<ContentCard className="sync-result-card">
|
||||
<Text className="sync-result-icon">V</Text>
|
||||
<Text className="sync-result-title">同步完成</Text>
|
||||
<Text className="sync-result-count">成功上传 {syncCount} 条数据</Text>
|
||||
</ContentCard>
|
||||
<View className="sync-action" onClick={() => {
|
||||
handleDisconnect();
|
||||
if (returnTo === 'input') {
|
||||
Taro.navigateBack();
|
||||
}
|
||||
}}>
|
||||
<Text className="sync-action-text">{returnTo === 'input' ? '返回录入' : '完成'}</Text>
|
||||
<View className="ds-done">
|
||||
<View className="ds-done__icon">
|
||||
<Text className="ds-done__check">✓</Text>
|
||||
</View>
|
||||
<Text className="ds-done__title">同步完成</Text>
|
||||
<Text className="ds-done__desc">数据已安全上传至健康管理平台</Text>
|
||||
<View className="ds-done__stats">
|
||||
<View className="ds-done__stat">
|
||||
<Text className="ds-done__stat-num ds-done__stat-num--pri">{syncCount}</Text>
|
||||
<Text className="ds-done__stat-label">上传条数</Text>
|
||||
</View>
|
||||
<View className="ds-done__stat">
|
||||
<Text className="ds-done__stat-num">{new Set(liveReadings.map((r) => r.device_type)).size}</Text>
|
||||
<Text className="ds-done__stat-label">数据类型</Text>
|
||||
</View>
|
||||
<View className="ds-done__stat">
|
||||
<Text className="ds-done__stat-num ds-done__stat-num--acc">100%</Text>
|
||||
<Text className="ds-done__stat-label">成功率</Text>
|
||||
</View>
|
||||
</View>
|
||||
<PrimaryButton size="large" onClick={() => { handleDisconnect(); if (returnTo === 'input') Taro.navigateBack(); }}>
|
||||
{returnTo === 'input' ? '返回录入' : '完成'}
|
||||
</PrimaryButton>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderError = () => (
|
||||
<View className="ds-error-page">
|
||||
<View className="ds-error-page__icon">
|
||||
<Text className="ds-error-page__x">✕</Text>
|
||||
</View>
|
||||
<Text className="ds-error-page__title">连接失败</Text>
|
||||
<Text className="ds-error-page__desc">{errorMsg || '无法连接到设备,请检查设备是否在范围内并重试'}</Text>
|
||||
<View className="ds-error-page__detail">
|
||||
<Text className="ds-error-page__detail-title">错误详情</Text>
|
||||
<View className="ds-error-page__detail-row"><Text className="ds-error-page__detail-label">设备</Text><Text className="ds-error-page__detail-value">{selectedDevice?.name || '--'}</Text></View>
|
||||
<View className="ds-error-page__detail-row ds-error-page__detail-row--last"><Text className="ds-error-page__detail-label">时间</Text><Text className="ds-error-page__detail-value">{new Date().toLocaleTimeString()}</Text></View>
|
||||
</View>
|
||||
<PrimaryButton size="large" onClick={handleScan}>重新扫描</PrimaryButton>
|
||||
<View className="ds-error-page__back" onClick={() => Taro.navigateBack()}>
|
||||
<Text className="ds-error-page__back-text">返回</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// ── 主渲染 ──
|
||||
return (
|
||||
<PageShell padding="none" className={modeClass}>
|
||||
<View className="sync-header">
|
||||
<Text className="sync-header-title">设备同步</Text>
|
||||
</View>
|
||||
|
||||
{errorMsg && (
|
||||
<View className="sync-error">
|
||||
<Text className="sync-error-text">{errorMsg}</Text>
|
||||
<PageShell padding="none" className={`ds-page ${modeClass}`}>
|
||||
{/* 空闲态 */}
|
||||
{pageState === 'idle' && (
|
||||
<View className="ds-body">
|
||||
{renderHero()}
|
||||
{renderDeviceTypes()}
|
||||
{renderLastSync()}
|
||||
{renderPendingWarning()}
|
||||
{renderScanButton()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(pageState === 'scanning' || pageState === 'connecting' || pageState === 'syncing') && (
|
||||
<View className="sync-loading">
|
||||
<Text className="sync-loading-text">
|
||||
{pageState === 'scanning' && '正在扫描设备...'}
|
||||
{pageState === 'connecting' && '正在连接设备...'}
|
||||
{pageState === 'syncing' && '正在上传数据...'}
|
||||
</Text>
|
||||
{/* 扫描结果(设备列表或空结果提示) */}
|
||||
{pageState === 'found' && (
|
||||
<View className="ds-body">
|
||||
{renderHero()}
|
||||
{renderDeviceTypes()}
|
||||
{renderDeviceList()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(pageState === 'idle' || pageState === 'error') && renderIdle()}
|
||||
{pageState === 'connected' && renderConnected()}
|
||||
{pageState === 'done' && renderDone()}
|
||||
{/* 扫描中 */}
|
||||
{pageState === 'scanning' && (
|
||||
<View className="ds-body ds-body--center">
|
||||
<View className="ds-pulse">
|
||||
<View className="ds-pulse__ring ds-pulse__ring--1" />
|
||||
<View className="ds-pulse__ring ds-pulse__ring--2" />
|
||||
<View className="ds-pulse__center">
|
||||
<Text className="ds-pulse__bt">BT</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="ds-pulse__title">正在搜索设备...</Text>
|
||||
<Text className="ds-pulse__hint">请确保设备已开启蓝牙并靠近手机</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 连接中 */}
|
||||
{pageState === 'connecting' && (
|
||||
<View className="ds-body ds-body--center">
|
||||
<View className="ds-connect-anim">
|
||||
<View className="ds-connect-anim__ring" />
|
||||
<View className="ds-connect-anim__center">
|
||||
<Text className="ds-connect-anim__bt">BT</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="ds-connect-anim__title">正在连接 {selectedDevice?.name}</Text>
|
||||
<Text className="ds-connect-anim__sub">正在进行蓝牙配对...</Text>
|
||||
<View className="ds-steps">
|
||||
<View className="ds-steps__dot ds-steps__dot--done" />
|
||||
<Text className="ds-steps__label ds-steps__label--done">发现设备</Text>
|
||||
<View className="ds-steps__line ds-steps__line--active" />
|
||||
<View className="ds-steps__dot ds-steps__dot--active" />
|
||||
<Text className="ds-steps__label ds-steps__label--active">连接中</Text>
|
||||
<View className="ds-steps__line" />
|
||||
<View className="ds-steps__dot" />
|
||||
<Text className="ds-steps__label">同步数据</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 同步中 */}
|
||||
{pageState === 'syncing' && renderLoading('正在上传数据...')}
|
||||
|
||||
{/* 已连接 */}
|
||||
{pageState === 'connected' && (
|
||||
<View className="ds-body">
|
||||
{renderConnectedStatus()}
|
||||
{renderServiceDiscovery()}
|
||||
{renderLatestReading()}
|
||||
{renderReadingsHistory()}
|
||||
{renderConnectedActions()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 同步完成 */}
|
||||
{pageState === 'done' && <View className="ds-body ds-body--center">{renderDone()}</View>}
|
||||
|
||||
{/* 错误态 */}
|
||||
{pageState === 'error' && errorMsg && (
|
||||
<View className="ds-body ds-body--center">{renderError()}</View>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '健康测量',
|
||||
});
|
||||
256
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss
Normal file
256
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss
Normal file
@@ -0,0 +1,256 @@
|
||||
// Veepoo 测量结果 + 上传页样式
|
||||
// 设计原型: docs/design/veepoo-measure-prototype.html
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
.vm-page {
|
||||
min-height: 100vh;
|
||||
background: var(--tk-bg-primary, $bg);
|
||||
}
|
||||
|
||||
// ── 连接中(等待跳转态) ──
|
||||
.vm-connect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 0 32px;
|
||||
|
||||
&__anim {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&__ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid $pri;
|
||||
animation: vm-pulse-ring 2s ease-out infinite;
|
||||
}
|
||||
|
||||
&__center {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
border-radius: 50%;
|
||||
background: $pri;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__bt {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h2, 22px);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vm-pulse-ring {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(1.4); opacity: 0; }
|
||||
}
|
||||
|
||||
// ── 上传页面 ──
|
||||
.vm-upload {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 40px;
|
||||
|
||||
&__header {
|
||||
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-md, 16px);
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h2, 22px);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 结果卡片网格 ──
|
||||
.vm-results-grid {
|
||||
padding: 0 var(--tk-page-padding, 20px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--tk-gap-sm, 12px);
|
||||
}
|
||||
|
||||
.vm-result-card {
|
||||
background: $card;
|
||||
border-radius: var(--tk-card-radius, 16px);
|
||||
padding: var(--tk-gap-md, 16px);
|
||||
box-shadow: $shadow-sm;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
&--empty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap, 13px);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num-lg, 34px);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__unit {
|
||||
font-size: var(--tk-font-cap, 13px);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-top: 8px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--tk-font-micro, 11px);
|
||||
font-weight: 500;
|
||||
|
||||
&--normal {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
padding-left: 8px;
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&--sleep {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 睡眠数据行 ──
|
||||
.vm-sleep-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 8px;
|
||||
margin-left: 8px;
|
||||
|
||||
&__day {
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx2;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body, 16px);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__quality {
|
||||
font-size: 12px;
|
||||
color: $wrn;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 底部上传播区 ──
|
||||
.vm-upload-footer {
|
||||
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-xl, 32px);
|
||||
|
||||
&__hint {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap, 13px);
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
margin-bottom: var(--tk-gap-sm, 12px);
|
||||
}
|
||||
|
||||
&__btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: block;
|
||||
font-size: var(--tk-font-micro, 11px);
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
margin-top: var(--tk-gap-sm, 12px);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 长者模式 ──
|
||||
.elder-mode {
|
||||
.vm-results-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vm-result-card__value {
|
||||
font-size: var(--tk-font-num-lg, 40px);
|
||||
}
|
||||
}
|
||||
306
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx
Normal file
306
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
import type { NormalizedReading } from '@/services/ble/types';
|
||||
import { useElderClass } from '@/hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import PrimaryButton from '@/components/ui/PrimaryButton';
|
||||
import './index.scss';
|
||||
|
||||
/** 原生页面返回的测量结果格式 */
|
||||
interface NativeMeasureResult {
|
||||
type: string;
|
||||
values: Record<string, number>;
|
||||
measuredAt: number;
|
||||
}
|
||||
|
||||
/** 原生页面返回的睡眠数据格式 */
|
||||
interface NativeSleepResult {
|
||||
day: number;
|
||||
deepSleepMinutes: number;
|
||||
lightSleepMinutes: number;
|
||||
totalSleepMinutes: number;
|
||||
qualityScore: number;
|
||||
fallAsleepTime: string;
|
||||
exitSleepTime: string;
|
||||
}
|
||||
|
||||
/** 指标配置 */
|
||||
const METRIC_CONFIG = [
|
||||
{ type: 'heart_rate', label: '心率', unit: 'bpm', color: '#EF4444', icon: '♥' },
|
||||
{ type: 'blood_oxygen', label: '血氧', unit: '%', color: '#3B82F6', icon: 'O₂' },
|
||||
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', color: '#8B5CF6', icon: '↕' },
|
||||
{ type: 'temperature', label: '体温', unit: '°C', color: '#F59E0B', icon: 'T' },
|
||||
{ type: 'pressure', label: '压力', unit: '', color: '#6366F1', icon: '~' },
|
||||
] as const;
|
||||
|
||||
/** 健康评估 */
|
||||
function assessHealth(type: string, values: Record<string, number>): { level: 'normal' | 'warning' | 'danger'; text: string } {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
const v = values.heart_rate ?? 0;
|
||||
if (v >= 60 && v <= 100) return { level: 'normal', text: '心率正常' };
|
||||
if (v < 50 || v > 120) return { level: 'danger', text: '心率异常' };
|
||||
return { level: 'warning', text: '心率偏离正常范围' };
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
const v = values.blood_oxygen ?? 0;
|
||||
if (v >= 95) return { level: 'normal', text: '血氧正常' };
|
||||
if (v >= 90) return { level: 'warning', text: '血氧偏低' };
|
||||
return { level: 'danger', text: '血氧过低' };
|
||||
}
|
||||
case 'blood_pressure': {
|
||||
const sys = values.systolic ?? 0;
|
||||
const dia = values.diastolic ?? 0;
|
||||
if (sys >= 90 && sys <= 140 && dia >= 60 && dia <= 90) return { level: 'normal', text: '血压正常' };
|
||||
if (sys > 160 || dia > 100) return { level: 'danger', text: '血压过高' };
|
||||
return { level: 'warning', text: '血压偏高' };
|
||||
}
|
||||
case 'temperature': {
|
||||
const v = values.temperature ?? 0;
|
||||
if (v >= 36.0 && v <= 37.3) return { level: 'normal', text: '体温正常' };
|
||||
if (v > 38.0) return { level: 'danger', text: '发热' };
|
||||
return { level: 'warning', text: '体温偏离正常' };
|
||||
}
|
||||
case 'pressure': {
|
||||
const v = values.pressure ?? 0;
|
||||
if (v >= 1 && v <= 40) return { level: 'normal', text: '压力正常' };
|
||||
if (v > 60) return { level: 'danger', text: '压力过高' };
|
||||
return { level: 'warning', text: '压力偏高' };
|
||||
}
|
||||
default:
|
||||
return { level: 'normal', text: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化显示值 */
|
||||
function formatValue(type: string, values: Record<string, number>): string {
|
||||
if (type === 'blood_pressure') {
|
||||
return `${values.systolic ?? '--'}/${values.diastolic ?? '--'}`;
|
||||
}
|
||||
const v = Object.values(values)[0];
|
||||
return v !== undefined ? String(v) : '--';
|
||||
}
|
||||
|
||||
export default function VeepooMeasure() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const patient = useAuthStore((s) => s.currentPatient);
|
||||
const navigatedRef = useRef(false);
|
||||
const [results, setResults] = React.useState<Record<string, NativeMeasureResult>>({});
|
||||
const [sleepData, setSleepData] = React.useState<NativeSleepResult[]>([]);
|
||||
const [uploadStatus, setUploadStatus] = React.useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
|
||||
|
||||
// 从 URL 或 store 获取 patientId
|
||||
const patientId = patient?.id || router.params.patientId || '';
|
||||
|
||||
// C3 修复:用 ref 防重入,避免 React Strict Mode 双触发
|
||||
if (!navigatedRef.current) {
|
||||
navigatedRef.current = true;
|
||||
// 延迟到下一个微任务,确保页面渲染完成后再跳转
|
||||
setTimeout(() => {
|
||||
Taro.navigateTo({
|
||||
url: `/pkg-veepoo/index?patientId=${patientId}`,
|
||||
events: {
|
||||
measureResult: (data: NativeMeasureResult) => {
|
||||
setResults((prev) => ({ ...prev, [data.type]: data }));
|
||||
},
|
||||
measureComplete: (data: { results: Record<string, NativeMeasureResult>; count: number }) => {
|
||||
if (data.results) setResults(data.results);
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// 页面恢复时读取原生页面返回的测量结果 + 睡眠数据
|
||||
useDidShow(() => {
|
||||
try {
|
||||
const raw = Taro.getStorageSync('hms:veepoo_measure_results');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Record<string, NativeMeasureResult>;
|
||||
setResults(parsed);
|
||||
Taro.removeStorageSync('hms:veepoo_measure_results');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
const rawSleep = Taro.getStorageSync('hms:veepoo_sleep_results');
|
||||
if (rawSleep) {
|
||||
const parsedSleep = JSON.parse(rawSleep) as NativeSleepResult[];
|
||||
if (parsedSleep.length > 0) {
|
||||
setSleepData(parsedSleep);
|
||||
}
|
||||
Taro.removeStorageSync('hms:veepoo_sleep_results');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
// 修复:添加明确的错误提示,不再静默退出
|
||||
if (!patientId) {
|
||||
console.warn('[veepoo-measure] 上传失败:未获取到患者 ID');
|
||||
Taro.showToast({ title: '请先绑定患者档案', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const allResults = Object.values(results);
|
||||
const hasMeasureData = allResults.length > 0;
|
||||
const hasSleep = sleepData.length > 0;
|
||||
|
||||
if (!hasMeasureData && !hasSleep) {
|
||||
console.warn('[veepoo-measure] 上传失败:无数据');
|
||||
Taro.showToast({ title: '暂无测量数据', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadStatus('uploading');
|
||||
try {
|
||||
const allReadings: NormalizedReading[] = [];
|
||||
|
||||
// 测量结果
|
||||
if (hasMeasureData) {
|
||||
console.log('[veepoo-measure] 上传测量数据', allResults.length, '项');
|
||||
allReadings.push(...allResults.map((r) => ({
|
||||
device_type: r.type as NormalizedReading['device_type'],
|
||||
values: r.values,
|
||||
measured_at: new Date(r.measuredAt).toISOString(),
|
||||
})));
|
||||
}
|
||||
|
||||
// 睡眠数据
|
||||
if (hasSleep) {
|
||||
const now = new Date();
|
||||
console.log('[veepoo-measure] 上传睡眠数据', sleepData.length, '天');
|
||||
allReadings.push(...sleepData.map((s) => {
|
||||
const baseDate = new Date(now.getTime() - s.day * 86400000);
|
||||
return {
|
||||
device_type: 'sleep' as const,
|
||||
values: {
|
||||
deep_sleep_minutes: s.deepSleepMinutes,
|
||||
light_sleep_minutes: s.lightSleepMinutes,
|
||||
total_sleep_minutes: s.totalSleepMinutes,
|
||||
quality_score: s.qualityScore,
|
||||
},
|
||||
measured_at: baseDate.toISOString(),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
await uploadReadings(patientId, 'veepoo_m2', 'Veepoo M2', allReadings);
|
||||
setUploadStatus('success');
|
||||
Taro.showToast({ title: '数据已上传', icon: 'success' });
|
||||
} catch (err) {
|
||||
console.error('[veepoo-measure] 上传失败:', err);
|
||||
setUploadStatus('error');
|
||||
Taro.showToast({ title: '上传失败,请重试', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const hasResults = Object.keys(results).length > 0;
|
||||
const measuredCount = Object.keys(results).length;
|
||||
const measuredAt = hasResults
|
||||
? new Date(Object.values(results)[0].measuredAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
|
||||
return (
|
||||
<PageShell padding="none" className={`vm-page ${modeClass}`}>
|
||||
{hasResults ? (
|
||||
<View className="vm-upload">
|
||||
{/* 页面标题 */}
|
||||
<View className="vm-upload__header">
|
||||
<Text className="vm-upload__title">测量结果</Text>
|
||||
<Text className="vm-upload__subtitle">Veepoo M2 · 刚刚完成测量</Text>
|
||||
</View>
|
||||
|
||||
{/* 结果卡片网格 */}
|
||||
<View className="vm-results-grid">
|
||||
{METRIC_CONFIG.map((metric) => {
|
||||
const result = results[metric.type];
|
||||
if (result) {
|
||||
const assessment = assessHealth(metric.type, result.values);
|
||||
return (
|
||||
<View
|
||||
key={metric.type}
|
||||
className={`vm-result-card ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
|
||||
>
|
||||
<View className="vm-result-card__badge" style={{ background: metric.color }} />
|
||||
<Text className="vm-result-card__label">{metric.label}</Text>
|
||||
<View className="vm-result-card__row">
|
||||
<Text className="vm-result-card__value">{formatValue(metric.type, result.values)}</Text>
|
||||
<Text className="vm-result-card__unit">{metric.unit}</Text>
|
||||
</View>
|
||||
<View className={`vm-result-card__tag vm-result-card__tag--${assessment.level}`}>
|
||||
<Text>● {assessment.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// 未测量占位
|
||||
return (
|
||||
<View
|
||||
key={metric.type}
|
||||
className={`vm-result-card vm-result-card--empty ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
|
||||
>
|
||||
<View className="vm-result-card__badge" style={{ background: metric.color, opacity: 0.3 }} />
|
||||
<Text className="vm-result-card__label">{metric.label}</Text>
|
||||
<Text className="vm-result-card__placeholder">未测量</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 睡眠数据卡片 */}
|
||||
{sleepData.length > 0 && (
|
||||
<View className="vm-result-card vm-result-card--full vm-result-card--sleep">
|
||||
<View className="vm-result-card__badge" style={{ background: '#5B7A5E' }} />
|
||||
<Text className="vm-result-card__label">睡眠数据({sleepData.length} 天)</Text>
|
||||
{sleepData.map((sleep, idx) => {
|
||||
const hours = Math.floor(sleep.totalSleepMinutes / 60);
|
||||
const mins = sleep.totalSleepMinutes % 60;
|
||||
const dayLabel = sleep.day === 0 ? '昨晚' : sleep.day === 1 ? '前晚' : '大前晚';
|
||||
return (
|
||||
<View key={idx} className="vm-sleep-row">
|
||||
<Text className="vm-sleep-row__day">{dayLabel}</Text>
|
||||
<Text className="vm-sleep-row__time">{hours}h{mins > 0 ? ` ${mins}min` : ''}</Text>
|
||||
<View className="vm-sleep-row__quality">
|
||||
{'★'.repeat(Math.min(sleep.qualityScore, 5))}{'☆'.repeat(Math.max(5 - sleep.qualityScore, 0))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View className="vm-result-card__tag vm-result-card__tag--normal">
|
||||
<Text>● 自动同步</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 底部上传播区 */}
|
||||
<View className="vm-upload-footer">
|
||||
<Text className="vm-upload-footer__hint">测量数据将上传至您的健康档案</Text>
|
||||
<View className="vm-upload-footer__btn">
|
||||
<PrimaryButton onClick={handleUpload} disabled={uploadStatus === 'uploading'}>
|
||||
{uploadStatus === 'uploading'
|
||||
? '上传中...'
|
||||
: uploadStatus === 'success'
|
||||
? '✓ 已上传'
|
||||
: `上传数据(${measuredCount} 项测量${sleepData.length > 0 ? ' + ' + sleepData.length + ' 天睡眠' : ''})`}
|
||||
</PrimaryButton>
|
||||
</View>
|
||||
{measuredAt && <Text className="vm-upload-footer__time">测量时间:{measuredAt}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className="vm-connect">
|
||||
<View className="vm-connect__anim">
|
||||
<View className="vm-connect__ring" />
|
||||
<View className="vm-connect__center"><Text className="vm-connect__bt">BT</Text></View>
|
||||
</View>
|
||||
<Text className="vm-connect__title">M2 手环健康测量</Text>
|
||||
<Text className="vm-connect__hint">即将跳转到设备测量页面...</Text>
|
||||
</View>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +1,256 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// 商品详情 — 对齐原型 docs/design/mp-10-mall-pkg.html → 屏幕1
|
||||
// 商品详情 V2 — 对齐原型 docs/design/mp-05-mall-v2.html
|
||||
|
||||
.product-detail-page {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.product-detail-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 加载/空状态
|
||||
.product-detail-loading,
|
||||
.product-detail-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--tk-gap-2xl) 0;
|
||||
}
|
||||
|
||||
.product-detail-loading-text,
|
||||
.product-detail-empty-text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// 商品图区域
|
||||
.product-detail-image {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&--physical { background: $pri-l; }
|
||||
&--service { background: $acc-l; }
|
||||
&--privilege { background: $wrn-l; }
|
||||
}
|
||||
|
||||
.product-detail-image-char {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
line-height: 1;
|
||||
opacity: 0.3;
|
||||
|
||||
.product-detail-image--service & { color: $acc; }
|
||||
.product-detail-image--privilege & { color: $wrn; }
|
||||
}
|
||||
|
||||
.product-detail-image-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// 商品信息卡片
|
||||
.product-detail-info {
|
||||
background: $card;
|
||||
border-radius: 20px 20px 0 0;
|
||||
margin-top: -16px;
|
||||
position: relative;
|
||||
padding: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.product-detail-name {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.product-detail-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--tk-gap-sm);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.product-detail-points {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.product-detail-type-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 600;
|
||||
color: $acc;
|
||||
background: $acc-l;
|
||||
}
|
||||
|
||||
.product-detail-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.8;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
// 规格信息
|
||||
.product-detail-specs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px var(--tk-gap-md);
|
||||
.product-detail {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.product-detail-spec-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--tk-font-cap);
|
||||
}
|
||||
|
||||
.product-detail-spec-label {
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.product-detail-spec-value {
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 温馨提示
|
||||
.product-detail-notice {
|
||||
padding: var(--tk-gap-sm);
|
||||
background: $wrn-l;
|
||||
border-radius: $r-sm;
|
||||
border-left: 3px solid $wrn;
|
||||
}
|
||||
|
||||
.product-detail-notice-title {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $wrn;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.product-detail-notice-text {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 底部操作栏
|
||||
.product-detail-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd-l;
|
||||
padding: var(--tk-gap-sm) var(--tk-page-padding);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.product-detail-fav {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.product-detail-fav-icon {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.product-detail-exchange-btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
background: var(--tk-pri);
|
||||
border-radius: $r;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--tk-shadow-btn);
|
||||
|
||||
&.disabled {
|
||||
background: $bd;
|
||||
box-shadow: none;
|
||||
&__loading, &__empty {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
gap: $sp-md;
|
||||
padding: $sp-2xl 0;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
&__retry-btn {
|
||||
padding: 10px 28px;
|
||||
border-radius: $r-pill;
|
||||
background: $pri;
|
||||
@include touch-target;
|
||||
}
|
||||
|
||||
&__retry-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&__scroll {
|
||||
padding-bottom: calc(70px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
// 商品大图
|
||||
&__hero {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
gap: $sp-2xs;
|
||||
|
||||
&--physical { background: $pri-l; }
|
||||
&--service { background: $acc-l; }
|
||||
&--privilege { background: $wrn-l; }
|
||||
}
|
||||
|
||||
&__hero-type {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 商品信息卡
|
||||
&__info-card {
|
||||
background: $card;
|
||||
margin: 0 $sp-section;
|
||||
margin-top: -$sp-md;
|
||||
border-radius: $r;
|
||||
padding: $sp-section;
|
||||
margin-bottom: $sp-md;
|
||||
box-shadow: $shadow-sm;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-xs;
|
||||
margin-bottom: $sp-xs;
|
||||
}
|
||||
|
||||
&__type-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $acc;
|
||||
background: $acc-l;
|
||||
padding: 2px $sp-xs;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1.4;
|
||||
margin-bottom: $sp-sm;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $sp-2xs;
|
||||
margin-bottom: $sp-sm;
|
||||
}
|
||||
|
||||
&__points-num {
|
||||
@include serif-number;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__points-unit {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $pri;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 库存/余额卡
|
||||
&__status-card {
|
||||
background: $card;
|
||||
margin: 0 $sp-section;
|
||||
border-radius: $r;
|
||||
padding: $sp-md $sp-section;
|
||||
margin-bottom: $sp-md;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
&__status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
& + & {
|
||||
margin-top: $sp-sm;
|
||||
}
|
||||
}
|
||||
|
||||
&__status-label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__status-value {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $acc;
|
||||
|
||||
&--warn { color: $wrn; }
|
||||
&--danger { color: $dan; }
|
||||
&--ok { color: $acc; }
|
||||
}
|
||||
|
||||
// 温馨提示
|
||||
&__notice {
|
||||
margin: 0 $sp-section;
|
||||
padding: $sp-sm $sp-md;
|
||||
background: $wrn-l;
|
||||
border-radius: $r-sm;
|
||||
margin-bottom: $sp-section;
|
||||
}
|
||||
|
||||
&__notice-text {
|
||||
font-size: 12px;
|
||||
color: $wrn;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// 底部操作栏 — fixed 固定在视口底部
|
||||
&__footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd;
|
||||
padding: $sp-sm $sp-section;
|
||||
padding-bottom: calc(#{$sp-sm} + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-sm;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__footer-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__footer-hint {
|
||||
font-size: 12px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__footer-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__footer-num {
|
||||
@include serif-number;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&__footer-unit {
|
||||
font-size: 12px;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&__exchange-btn {
|
||||
padding: 14px 32px;
|
||||
border-radius: $r-pill;
|
||||
background: $pri;
|
||||
color: $white;
|
||||
box-shadow: $shadow-btn;
|
||||
@include touch-target;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
background: $bd;
|
||||
color: $tx3;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__exchange-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode & {
|
||||
&__name {
|
||||
font-size: 24px;
|
||||
}
|
||||
&__points-num {
|
||||
font-size: 34px;
|
||||
}
|
||||
&__footer-num {
|
||||
font-size: 28px;
|
||||
}
|
||||
&__exchange-btn {
|
||||
padding: 16px 36px;
|
||||
}
|
||||
&__exchange-text {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.product-detail-exchange-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,53 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getProduct } from '../../../services/points';
|
||||
import type { PointsProduct } from '../../../services/points';
|
||||
import { usePointsStore } from '../../../stores/points';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_CHAR: Record<string, string> = {
|
||||
physical: '物',
|
||||
service: '券',
|
||||
privilege: '权',
|
||||
const TYPE_BG: Record<string, string> = {
|
||||
physical: 'product-detail__hero--physical',
|
||||
service: 'product-detail__hero--service',
|
||||
privilege: 'product-detail__hero--privilege',
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
physical: '实物商品',
|
||||
service: '服务体验',
|
||||
privilege: '权益卡',
|
||||
physical: '实物',
|
||||
service: '服务券',
|
||||
privilege: '权益',
|
||||
};
|
||||
|
||||
const LOAD_TIMEOUT = 8000;
|
||||
|
||||
export default function ProductDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const productId = router.params.product_id || '';
|
||||
const account = usePointsStore((s) => s.account);
|
||||
const refreshPoints = usePointsStore((s) => s.refresh);
|
||||
const [product, setProduct] = useState<PointsProduct | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const fetchProduct = useCallback(async () => {
|
||||
if (!productId) return;
|
||||
if (!productId) {
|
||||
setLoading(false);
|
||||
setLoadError(true);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
const data = await getProduct(productId);
|
||||
setProduct(data);
|
||||
} catch (err) {
|
||||
console.warn('[product] 单商品接口失败,降级列表查找:', err);
|
||||
try {
|
||||
const { listProducts } = await import('../../../services/points');
|
||||
const res = await listProducts({ page: 1, page_size: 100 });
|
||||
const found = res.data.find((p) => p.id === productId);
|
||||
if (found) setProduct(found);
|
||||
} catch (fallbackErr) {
|
||||
console.warn('[product] 降级列表查找也失败:', fallbackErr);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
}
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -51,108 +55,128 @@ export default function ProductDetail() {
|
||||
|
||||
usePageData(fetchProduct, { throttleMs: 30000 });
|
||||
|
||||
useEffect(() => {
|
||||
refreshPoints();
|
||||
}, [refreshPoints]);
|
||||
|
||||
// 加载超时保护
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setLoading(false);
|
||||
setLoadError(true);
|
||||
}, LOAD_TIMEOUT);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<View className='product-detail-loading'>
|
||||
<Text className='product-detail-loading-text'>加载中...</Text>
|
||||
<View className='product-detail__loading'>
|
||||
<Text>加载中...</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
if (loadError || !product) {
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<View className='product-detail-empty'>
|
||||
<Text className='product-detail-empty-text'>商品不存在</Text>
|
||||
<View className='product-detail__empty'>
|
||||
<Text>{!productId ? '参数错误' : '商品不存在或加载失败'}</Text>
|
||||
<View className='product-detail__retry-btn' onClick={fetchProduct}>
|
||||
<Text className='product-detail__retry-text'>重试</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const productType = product.product_type || 'physical';
|
||||
const typeChar = TYPE_CHAR[productType] || '礼';
|
||||
const typeLabel = TYPE_LABEL[productType] || '商品';
|
||||
const isService = productType === 'service';
|
||||
const balance = account?.balance ?? 0;
|
||||
const canAfford = balance >= product.points_cost;
|
||||
const type = product.product_type || 'physical';
|
||||
const isService = type === 'service';
|
||||
|
||||
const handleExchange = () => {
|
||||
if (product.stock <= 0) {
|
||||
Taro.showToast({ title: '已兑完', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!canAfford) {
|
||||
Taro.showToast({ title: '积分不足', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
Taro.navigateTo({
|
||||
url: `/pages/pkg-mall/exchange/index?product_id=${product.id}`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell padding="none" safeBottom={false} scroll={false} className={`product-detail-page ${modeClass}`}>
|
||||
<View className='product-detail-scroll'>
|
||||
{/* 商品图区域 */}
|
||||
<View className={`product-detail-image product-detail-image--${productType}`}>
|
||||
<Text className='product-detail-image-char'>{typeChar}</Text>
|
||||
<Text className='product-detail-image-label'>{product.name}</Text>
|
||||
<PageShell padding="none" safeBottom={false} scroll={false} className={`product-detail ${modeClass}`}>
|
||||
<View className='product-detail__scroll'>
|
||||
<View className={`product-detail__hero ${TYPE_BG[type] || ''}`}>
|
||||
<Text className='product-detail__hero-type'>{TYPE_LABEL[type]}</Text>
|
||||
</View>
|
||||
|
||||
{/* 商品信息卡片 */}
|
||||
<View className='product-detail-info'>
|
||||
<Text className='product-detail-name'>{product.name}</Text>
|
||||
<View className='product-detail-price-row'>
|
||||
<Text className='product-detail-points'>
|
||||
{product.points_cost.toLocaleString()} 积分
|
||||
</Text>
|
||||
<Text className='product-detail-type-tag'>{typeLabel}</Text>
|
||||
<View className='product-detail__info-card'>
|
||||
<View className='product-detail__tags'>
|
||||
<Text className='product-detail__type-badge'>{TYPE_LABEL[type]}</Text>
|
||||
</View>
|
||||
<Text className='product-detail__name'>{product.name}</Text>
|
||||
<View className='product-detail__price-row'>
|
||||
<Text className='product-detail__points-num'>{product.points_cost}</Text>
|
||||
<Text className='product-detail__points-unit'>积分</Text>
|
||||
</View>
|
||||
|
||||
{product.description && (
|
||||
<Text className='product-detail-desc'>{product.description}</Text>
|
||||
<Text className='product-detail__desc'>{product.description}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 规格 */}
|
||||
<View className='product-detail-specs'>
|
||||
<View className='product-detail-spec-row'>
|
||||
<Text className='product-detail-spec-label'>类型</Text>
|
||||
<Text className='product-detail-spec-value'>{typeLabel}</Text>
|
||||
</View>
|
||||
<View className='product-detail-spec-row'>
|
||||
<Text className='product-detail-spec-label'>库存</Text>
|
||||
<Text className='product-detail-spec-value'>
|
||||
{product.stock > 0 ? `${product.stock} 件` : '已兑完'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='product-detail-spec-row'>
|
||||
<Text className='product-detail-spec-label'>兑换方式</Text>
|
||||
<Text className='product-detail-spec-value'>
|
||||
{isService ? '到院核销' : '后台审核发货'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 温馨提示 */}
|
||||
<View className='product-detail-notice'>
|
||||
<Text className='product-detail-notice-title'>温馨提示</Text>
|
||||
<Text className='product-detail-notice-text'>
|
||||
{isService
|
||||
? '兑换成功后将生成核销码,请凭核销码到前台核销体验服务。'
|
||||
: '兑换后需工作人员审核确认,审核通过后将在 7 个工作日内寄出。'}
|
||||
<View className='product-detail__status-card'>
|
||||
<View className='product-detail__status-row'>
|
||||
<Text className='product-detail__status-label'>库存状态</Text>
|
||||
<Text className={`product-detail__status-value ${
|
||||
product.stock > 10 ? 'product-detail__status-value--ok' :
|
||||
product.stock > 0 ? 'product-detail__status-value--warn' :
|
||||
'product-detail__status-value--danger'
|
||||
}`}>
|
||||
{product.stock > 10 ? '充足' : product.stock > 0 ? `仅剩 ${product.stock} 件` : '已兑完'}
|
||||
</Text>
|
||||
<Text className='product-detail-notice-text'>积分一经兑换不可退回。</Text>
|
||||
</View>
|
||||
<View className='product-detail__status-row'>
|
||||
<Text className='product-detail__status-label'>您的积分</Text>
|
||||
<Text className={`product-detail__status-value ${canAfford ? 'product-detail__status-value--ok' : 'product-detail__status-value--danger'}`}>
|
||||
{balance.toLocaleString()} ({canAfford ? '充足' : '不足'})
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='product-detail__notice'>
|
||||
<Text className='product-detail__notice-text'>
|
||||
{isService
|
||||
? '兑换成功后将生成核销码,请凭核销码到前台核销。过期未核销订单将自动取消并退还积分。'
|
||||
: '兑换后需工作人员审核确认,审核通过后 7 个工作日内寄出。积分一经兑换不可退回。'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<View className='product-detail-footer'>
|
||||
<View className='product-detail-fav'>
|
||||
<Text className='product-detail-fav-icon'>♡</Text>
|
||||
<View className='product-detail__footer'>
|
||||
<View className='product-detail__footer-left'>
|
||||
<Text className='product-detail__footer-hint'>需要</Text>
|
||||
<View className='product-detail__footer-price'>
|
||||
<Text className='product-detail__footer-num'>{product.points_cost}</Text>
|
||||
<Text className='product-detail__footer-unit'>积分</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
className={`product-detail-exchange-btn ${product.stock <= 0 ? 'disabled' : ''}`}
|
||||
onClick={product.stock <= 0 ? undefined : handleExchange}
|
||||
className={`product-detail__exchange-btn ${!canAfford || product.stock <= 0 ? 'product-detail__exchange-btn--disabled' : ''}`}
|
||||
onClick={handleExchange}
|
||||
>
|
||||
<Text className='product-detail-exchange-text'>
|
||||
{product.stock <= 0 ? '已兑完' : '立即兑换'}
|
||||
<Text className='product-detail__exchange-text'>
|
||||
{product.stock <= 0 ? '已兑完' : !canAfford ? '积分不足' : '立即兑换'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,131 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useReachBottom } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getCachedPatientId } from '@/services/request';
|
||||
import { listConsents, revokeConsent } from '@/services/consent';
|
||||
import type { Consent } from '@/services/consent';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
import FrozenPage from '@/components/FrozenPage';
|
||||
|
||||
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' },
|
||||
};
|
||||
|
||||
export default function ConsentList() {
|
||||
const modeClass = useElderClass();
|
||||
const [consents, setConsents] = useState<Consent[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [revoking, setRevoking] = useState<string | null>(null);
|
||||
const [hasPatient, setHasPatient] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async (p: number, append = false) => {
|
||||
const patientId = getCachedPatientId();
|
||||
if (!patientId) {
|
||||
setConsents([]);
|
||||
setHasPatient(false);
|
||||
return;
|
||||
}
|
||||
setHasPatient(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listConsents(patientId, { page: p, page_size: 20 });
|
||||
const list = res.data || [];
|
||||
setConsents(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[consent] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && consents.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
}
|
||||
});
|
||||
|
||||
const handleRevoke = async (consent: Consent) => {
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认撤回',
|
||||
content: `确定要撤回「${CONSENT_TYPE_MAP[consent.consent_type] || consent.consent_type}」的同意吗?`,
|
||||
});
|
||||
if (!confirm) return;
|
||||
setRevoking(consent.id);
|
||||
try {
|
||||
const updated = await revokeConsent(consent.id, consent.version);
|
||||
setConsents((prev) => prev.map((c) => c.id === updated.id ? updated : c));
|
||||
Taro.showToast({ title: '已撤回', icon: 'success' });
|
||||
} catch (err) {
|
||||
console.warn('[consent] 撤回失败:', err);
|
||||
Taro.showToast({ title: '撤回失败', icon: 'none' });
|
||||
} finally {
|
||||
setRevoking(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>知情同意</Text>
|
||||
|
||||
<View className='consent-list'>
|
||||
{consents.map((c) => {
|
||||
const si = STATUS_MAP[c.status] || { label: c.status, cls: '' };
|
||||
const typeName = CONSENT_TYPE_MAP[c.consent_type] || c.consent_type;
|
||||
return (
|
||||
<View className='consent-card' key={c.id}>
|
||||
<View className='consent-card__header'>
|
||||
<Text className='consent-card__type'>{typeName}</Text>
|
||||
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
|
||||
</View>
|
||||
<Text className='consent-card__scope'>范围: {c.consent_scope}</Text>
|
||||
{c.granted_at && (
|
||||
<Text className='consent-card__date'>签署时间: {c.granted_at}</Text>
|
||||
)}
|
||||
{c.revoked_at && (
|
||||
<Text className='consent-card__date'>撤回时间: {c.revoked_at}</Text>
|
||||
)}
|
||||
{c.expiry_date && (
|
||||
<Text className='consent-card__expiry'>有效期至: {c.expiry_date}</Text>
|
||||
)}
|
||||
{c.status === 'granted' && (
|
||||
<View
|
||||
className={`revoke-btn ${revoking === c.id ? 'revoke-btn--disabled' : ''}`}
|
||||
onClick={() => handleRevoke(c)}
|
||||
>
|
||||
<Text className='revoke-btn__text'>{revoking === c.id ? '处理中...' : '撤回同意'}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{consents.length === 0 && !loading && (
|
||||
<EmptyState text={hasPatient ? '暂无知情同意记录' : '请先在就诊人管理中选择就诊人'} />
|
||||
)}
|
||||
|
||||
{loading && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
export default function ConsentsPage() {
|
||||
return <FrozenPage />;
|
||||
}
|
||||
|
||||
@@ -1,102 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useReachBottom } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getCachedPatientId } from '@/services/request';
|
||||
import { listDiagnoses, Diagnosis } from '../../../services/health-record';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
import FrozenPage from '@/components/FrozenPage';
|
||||
|
||||
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' },
|
||||
};
|
||||
|
||||
export default function Diagnoses() {
|
||||
const modeClass = useElderClass();
|
||||
const [records, setRecords] = useState<Diagnosis[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasPatient, setHasPatient] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async (p: number, append = false) => {
|
||||
const patientId = getCachedPatientId();
|
||||
if (!patientId) {
|
||||
setRecords([]);
|
||||
setHasPatient(false);
|
||||
return;
|
||||
}
|
||||
setHasPatient(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listDiagnoses(patientId, { page: p, page_size: 20 });
|
||||
const list = res.data || [];
|
||||
setRecords(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[diagnosis] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && records.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>诊断记录</Text>
|
||||
|
||||
<View className='diagnosis-list'>
|
||||
{records.map((d) => {
|
||||
const typeInfo = TYPE_MAP[d.diagnosis_type] || { label: d.diagnosis_type, cls: '' };
|
||||
const statusInfo = STATUS_MAP[d.status] || { label: d.status, cls: '' };
|
||||
return (
|
||||
<View className='diagnosis-card' key={d.id}>
|
||||
<View className='diagnosis-card__header'>
|
||||
<Text className='diagnosis-card__name'>{d.diagnosis_name}</Text>
|
||||
<Text className={`diagnosis-card__status ${statusInfo.cls}`}>
|
||||
{statusInfo.label}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='diagnosis-card__meta'>
|
||||
<Text className={`diagnosis-card__type ${typeInfo.cls}`}>
|
||||
{typeInfo.label}
|
||||
</Text>
|
||||
<Text className='diagnosis-card__code'>{d.icd_code}</Text>
|
||||
</View>
|
||||
<Text className='diagnosis-card__date'>诊断日期:{d.diagnosed_date}</Text>
|
||||
{d.notes && (
|
||||
<Text className='diagnosis-card__notes'>{d.notes}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{records.length === 0 && !loading && (
|
||||
<EmptyState text={hasPatient ? '暂无诊断记录' : '请先在就诊人管理中选择就诊人'} />
|
||||
)}
|
||||
|
||||
{loading && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
export default function DiagnosesPage() {
|
||||
return <FrozenPage />;
|
||||
}
|
||||
|
||||
@@ -1,123 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getDialysisPrescription } from '@/services/dialysis';
|
||||
import type { DialysisPrescription } from '@/services/dialysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
import FrozenPage from '@/components/FrozenPage';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '生效中', cls: 'active' },
|
||||
inactive: { label: '已停用', cls: 'inactive' },
|
||||
expired: { label: '已过期', cls: 'expired' },
|
||||
};
|
||||
|
||||
export default function DialysisPrescriptionDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
const [rx, setRx] = useState<DialysisPrescription | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getDialysisPrescription(id);
|
||||
setRx(data);
|
||||
} catch (err) {
|
||||
console.warn('[prescription] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
usePageData(fetchDetail, { throttleMs: 60000 });
|
||||
|
||||
if (loading) return <PageShell className={modeClass}><Loading /></PageShell>;
|
||||
if (!rx) return <PageShell className={modeClass}><View className='empty-state'><Text className='empty-text'>处方不存在</Text></View></PageShell>;
|
||||
|
||||
const si = STATUS_MAP[rx.status] || { label: rx.status, cls: '' };
|
||||
|
||||
const Row = ({ label, value }: { label: string; value?: string | number | null }) => {
|
||||
if (value == null) return null;
|
||||
return (
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>{label}</Text>
|
||||
<Text className='detail-value'>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
{/* 状态头部 */}
|
||||
<ContentCard>
|
||||
<View className='header-row'>
|
||||
<Text className='detail-title'>{rx.dialyzer_model || '透析处方'}</Text>
|
||||
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
|
||||
</View>
|
||||
{(rx.effective_from || rx.effective_to) && (
|
||||
<Text className='header-sub'>{rx.effective_from || '...'} ~ {rx.effective_to || '...'}</Text>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* 基本参数 */}
|
||||
<ContentCard>
|
||||
<Text className='section-title'>基本参数</Text>
|
||||
<Row label='透析器型号' value={rx.dialyzer_model} />
|
||||
<Row label='膜面积' value={rx.membrane_area != null ? `${rx.membrane_area} m²` : null} />
|
||||
<Row label='血流速' value={rx.blood_flow_rate != null ? `${rx.blood_flow_rate} ml/min` : null} />
|
||||
<Row label='透析液流量' value={rx.dialysate_flow_rate != null ? `${rx.dialysate_flow_rate} ml/min` : null} />
|
||||
<Row label='频率' value={rx.frequency_per_week != null ? `${rx.frequency_per_week} 次/周` : null} />
|
||||
<Row label='每次时长' value={rx.duration_minutes != null ? `${rx.duration_minutes} 分钟` : null} />
|
||||
</ContentCard>
|
||||
|
||||
{/* 透析液配比 */}
|
||||
<ContentCard>
|
||||
<Text className='section-title'>透析液配比</Text>
|
||||
<Row label='钾浓度' value={rx.dialysate_potassium != null ? `${rx.dialysate_potassium} mmol/L` : null} />
|
||||
<Row label='钙浓度' value={rx.dialysate_calcium != null ? `${rx.dialysate_calcium} mmol/L` : null} />
|
||||
<Row label='碳酸氢盐浓度' value={rx.dialysate_bicarbonate != null ? `${rx.dialysate_bicarbonate} mmol/L` : null} />
|
||||
</ContentCard>
|
||||
|
||||
{/* 抗凝方案 */}
|
||||
<ContentCard>
|
||||
<Text className='section-title'>抗凝方案</Text>
|
||||
<Row label='抗凝类型' value={rx.anticoagulation_type} />
|
||||
<Row label='抗凝剂量' value={rx.anticoagulation_dose} />
|
||||
</ContentCard>
|
||||
|
||||
{/* 血管通路 */}
|
||||
{(rx.vascular_access_type || rx.vascular_access_location) && (
|
||||
<ContentCard>
|
||||
<Text className='section-title'>血管通路</Text>
|
||||
<Row label='通路类型' value={rx.vascular_access_type} />
|
||||
<Row label='通路位置' value={rx.vascular_access_location} />
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{/* 超滤与干体重 */}
|
||||
{(rx.target_ultrafiltration_ml != null || rx.target_dry_weight != null) && (
|
||||
<ContentCard>
|
||||
<Text className='section-title'>超滤目标</Text>
|
||||
<Row label='目标超滤量' value={rx.target_ultrafiltration_ml != null ? `${rx.target_ultrafiltration_ml} ml` : null} />
|
||||
<Row label='目标干体重' value={rx.target_dry_weight != null ? `${rx.target_dry_weight} kg` : null} />
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{/* 备注 */}
|
||||
{rx.notes && (
|
||||
<ContentCard>
|
||||
<Text className='section-title'>备注</Text>
|
||||
<Text className='notes-text'>{rx.notes}</Text>
|
||||
</ContentCard>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
export default function DialysisPrescriptionDetailPage() {
|
||||
return <FrozenPage />;
|
||||
}
|
||||
|
||||
@@ -1,104 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useReachBottom } from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getCachedPatientId } from '@/services/request';
|
||||
import { listDialysisPrescriptions } from '@/services/dialysis';
|
||||
import type { DialysisPrescription } from '@/services/dialysis';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
import FrozenPage from '@/components/FrozenPage';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '生效中', cls: 'active' },
|
||||
inactive: { label: '已停用', cls: 'inactive' },
|
||||
expired: { label: '已过期', cls: 'expired' },
|
||||
};
|
||||
|
||||
export default function DialysisPrescriptionList() {
|
||||
const modeClass = useElderClass();
|
||||
const [prescriptions, setPrescriptions] = useState<DialysisPrescription[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasPatient, setHasPatient] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async (p: number, append = false) => {
|
||||
const patientId = getCachedPatientId();
|
||||
if (!patientId) {
|
||||
setPrescriptions([]);
|
||||
setHasPatient(false);
|
||||
return;
|
||||
}
|
||||
setHasPatient(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listDialysisPrescriptions({ patient_id: patientId, page: p, page_size: 20 });
|
||||
const list = res.data || [];
|
||||
setPrescriptions(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[prescription] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && prescriptions.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
}
|
||||
});
|
||||
|
||||
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' };
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>透析处方</Text>
|
||||
|
||||
<View className='prescription-list'>
|
||||
{prescriptions.map((p) => {
|
||||
const si = statusInfo(p.status);
|
||||
return (
|
||||
<View
|
||||
className='prescription-card'
|
||||
key={p.id}
|
||||
onClick={() => safeNavigateTo(`/pages/pkg-profile/dialysis-prescriptions/detail/index?id=${p.id}`)}
|
||||
>
|
||||
<View className='prescription-card-top'>
|
||||
<Text className='prescription-model'>{p.dialyzer_model || '未指定型号'}</Text>
|
||||
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
|
||||
</View>
|
||||
<View className='prescription-meta'>
|
||||
{p.frequency_per_week != null && (
|
||||
<Text className='meta-item'>{p.frequency_per_week}次/周</Text>
|
||||
)}
|
||||
{p.duration_minutes != null && (
|
||||
<Text className='meta-item'>每次{p.duration_minutes}分钟</Text>
|
||||
)}
|
||||
</View>
|
||||
{(p.effective_from || p.effective_to) && (
|
||||
<Text className='prescription-date'>
|
||||
{p.effective_from || '...'} ~ {p.effective_to || '...'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{prescriptions.length === 0 && !loading && (
|
||||
<EmptyState text={hasPatient ? '暂无透析处方' : '请先在就诊人管理中选择就诊人'} />
|
||||
)}
|
||||
|
||||
{loading && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
export default function DialysisPrescriptionsPage() {
|
||||
return <FrozenPage />;
|
||||
}
|
||||
|
||||
@@ -1,125 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getDialysisRecord } from '@/services/dialysis';
|
||||
import type { DialysisRecord } from '@/services/dialysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
import FrozenPage from '@/components/FrozenPage';
|
||||
|
||||
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: '血液滤过',
|
||||
};
|
||||
|
||||
export default function DialysisRecordDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
const [record, setRecord] = useState<DialysisRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getDialysisRecord(id);
|
||||
setRecord(data);
|
||||
} catch (err) {
|
||||
console.warn('[dialysis] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
usePageData(fetchDetail, { throttleMs: 60000 });
|
||||
|
||||
if (loading) return <PageShell className={modeClass}><Loading /></PageShell>;
|
||||
if (!record) return <PageShell className={modeClass}><View className='empty-state'><Text className='empty-text'>记录不存在</Text></View></PageShell>;
|
||||
|
||||
const si = STATUS_MAP[record.status] || { label: record.status, cls: '' };
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
{/* 状态头部 */}
|
||||
<ContentCard>
|
||||
<View className='header-row'>
|
||||
<Text className='detail-title'>{record.dialysis_date}</Text>
|
||||
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
|
||||
</View>
|
||||
<Text className='header-sub'>{TYPE_MAP[record.dialysis_type] || record.dialysis_type}</Text>
|
||||
{record.reviewed_at && <Text className='review-info'>审核时间 {record.reviewed_at}</Text>}
|
||||
</ContentCard>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<ContentCard>
|
||||
<Text className='section-title'>基本信息</Text>
|
||||
{record.start_time && (
|
||||
<View className='detail-row'><Text className='detail-label'>开始时间</Text><Text className='detail-value'>{record.start_time}</Text></View>
|
||||
)}
|
||||
{record.end_time && (
|
||||
<View className='detail-row'><Text className='detail-label'>结束时间</Text><Text className='detail-value'>{record.end_time}</Text></View>
|
||||
)}
|
||||
{record.dialysis_duration != null && (
|
||||
<View className='detail-row'><Text className='detail-label'>透析时长</Text><Text className='detail-value'>{record.dialysis_duration} 分钟</Text></View>
|
||||
)}
|
||||
{record.blood_flow_rate != null && (
|
||||
<View className='detail-row'><Text className='detail-label'>血流速</Text><Text className='detail-value'>{record.blood_flow_rate} ml/min</Text></View>
|
||||
)}
|
||||
{record.ultrafiltration_volume != null && (
|
||||
<View className='detail-row'><Text className='detail-label'>超滤量</Text><Text className='detail-value'>{record.ultrafiltration_volume} ml</Text></View>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* 体重与血压 */}
|
||||
<ContentCard>
|
||||
<Text className='section-title'>体重与血压</Text>
|
||||
{record.dry_weight != null && (
|
||||
<View className='detail-row'><Text className='detail-label'>干体重</Text><Text className='detail-value'>{record.dry_weight} kg</Text></View>
|
||||
)}
|
||||
{record.pre_weight != null && (
|
||||
<View className='detail-row'><Text className='detail-label'>透前体重</Text><Text className='detail-value'>{record.pre_weight} kg</Text></View>
|
||||
)}
|
||||
{record.post_weight != null && (
|
||||
<View className='detail-row'><Text className='detail-label'>透后体重</Text><Text className='detail-value'>{record.post_weight} kg</Text></View>
|
||||
)}
|
||||
{record.pre_bp_systolic != null && record.pre_bp_diastolic != null && (
|
||||
<View className='detail-row'><Text className='detail-label'>透前血压</Text><Text className='detail-value'>{record.pre_bp_systolic}/{record.pre_bp_diastolic} mmHg</Text></View>
|
||||
)}
|
||||
{record.post_bp_systolic != null && record.post_bp_diastolic != null && (
|
||||
<View className='detail-row'><Text className='detail-label'>透后血压</Text><Text className='detail-value'>{record.post_bp_systolic}/{record.post_bp_diastolic} mmHg</Text></View>
|
||||
)}
|
||||
{record.pre_heart_rate != null && (
|
||||
<View className='detail-row'><Text className='detail-label'>透前心率</Text><Text className='detail-value'>{record.pre_heart_rate} bpm</Text></View>
|
||||
)}
|
||||
{record.post_heart_rate != null && (
|
||||
<View className='detail-row'><Text className='detail-label'>透后心率</Text><Text className='detail-value'>{record.post_heart_rate} bpm</Text></View>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* 症状与并发症 */}
|
||||
{(record.symptoms || record.complication_notes) && (
|
||||
<ContentCard>
|
||||
<Text className='section-title'>症状与并发症</Text>
|
||||
{record.symptoms && Object.keys(record.symptoms).length > 0 && (
|
||||
<View className='detail-row'><Text className='detail-label'>症状</Text><Text className='detail-value'>{JSON.stringify(record.symptoms)}</Text></View>
|
||||
)}
|
||||
{record.complication_notes && (
|
||||
<View className='detail-row'><Text className='detail-label'>并发症备注</Text><Text className='detail-value'>{record.complication_notes}</Text></View>
|
||||
)}
|
||||
</ContentCard>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
export default function DialysisRecordDetailPage() {
|
||||
return <FrozenPage />;
|
||||
}
|
||||
|
||||
@@ -1,109 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useReachBottom } from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getCachedPatientId } from '@/services/request';
|
||||
import { listDialysisRecords } from '@/services/dialysis';
|
||||
import type { DialysisRecord } from '@/services/dialysis';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
import FrozenPage from '@/components/FrozenPage';
|
||||
|
||||
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' },
|
||||
};
|
||||
|
||||
export default function DialysisRecordList() {
|
||||
const modeClass = useElderClass();
|
||||
const [records, setRecords] = useState<DialysisRecord[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasPatient, setHasPatient] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async (p: number, append = false) => {
|
||||
const patientId = getCachedPatientId();
|
||||
if (!patientId) {
|
||||
setRecords([]);
|
||||
setHasPatient(false);
|
||||
return;
|
||||
}
|
||||
setHasPatient(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listDialysisRecords(patientId, { page: p, page_size: 20 });
|
||||
const list = res.data || [];
|
||||
setRecords(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[dialysis] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && records.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
}
|
||||
});
|
||||
|
||||
const typeInfo = (t: string) => TYPE_MAP[t] || { label: t, cls: '' };
|
||||
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' };
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>透析记录</Text>
|
||||
|
||||
<View className='record-list'>
|
||||
{records.map((r) => {
|
||||
const ti = typeInfo(r.dialysis_type);
|
||||
const si = statusInfo(r.status);
|
||||
return (
|
||||
<View
|
||||
className='record-card'
|
||||
key={r.id}
|
||||
onClick={() => safeNavigateTo(`/pages/pkg-profile/dialysis-records/detail/index?id=${r.id}`)}
|
||||
>
|
||||
<View className='record-card-top'>
|
||||
<Text className={`type-tag ${ti.cls}`}>{ti.label}</Text>
|
||||
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
|
||||
</View>
|
||||
<Text className='record-date'>{r.dialysis_date}</Text>
|
||||
{(r.pre_weight || r.post_weight) && (
|
||||
<View className='weight-row'>
|
||||
{r.pre_weight && <Text className='weight-item'>透前 {r.pre_weight}kg</Text>}
|
||||
{r.post_weight && <Text className='weight-item'>透后 {r.post_weight}kg</Text>}
|
||||
</View>
|
||||
)}
|
||||
{r.dialysis_duration && (
|
||||
<Text className='record-meta'>时长 {r.dialysis_duration}分钟</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{records.length === 0 && !loading && (
|
||||
<EmptyState text={hasPatient ? '暂无透析记录' : '请先在就诊人管理中选择就诊人'} />
|
||||
)}
|
||||
|
||||
{loading && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
export default function DialysisRecordsPage() {
|
||||
return <FrozenPage />;
|
||||
}
|
||||
|
||||
@@ -1,118 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import * as pointsApi from '@/services/points';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '@/hooks/useElderClass';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
|
||||
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' },
|
||||
};
|
||||
import FrozenPage from '@/components/FrozenPage';
|
||||
|
||||
export default function EventsPage() {
|
||||
const modeClass = useElderClass();
|
||||
const [events, setEvents] = useState<pointsApi.OfflineEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [registering, setRegistering] = useState<string | null>(null);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await pointsApi.listOfflineEvents({ page: 1, page_size: 50, status: 'published' });
|
||||
setEvents(res.data || []);
|
||||
} catch (err) {
|
||||
console.warn('[event] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePageData(loadEvents, { throttleMs: 10000, enablePullDown: true });
|
||||
|
||||
const handleRegister = async (event: pointsApi.OfflineEvent) => {
|
||||
setRegistering(event.id);
|
||||
try {
|
||||
await pointsApi.registerEvent(event.id);
|
||||
Taro.showToast({ title: '报名成功', icon: 'success' });
|
||||
loadEvents();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '报名失败';
|
||||
Taro.showToast({ title: msg.substring(0, 20), icon: 'none' });
|
||||
} finally {
|
||||
setRegistering(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
return new Date(d).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<View className='events-header'>
|
||||
<Text className='events-header__title'>线下活动</Text>
|
||||
<Text className='events-header__subtitle'>参加活动赢取积分</Text>
|
||||
</View>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<EmptyState text='暂无可报名的活动' />
|
||||
) : (
|
||||
<View className='event-list'>
|
||||
{events.map((event) => {
|
||||
const st = STATUS_MAP[event.status] || { label: event.status, className: '' };
|
||||
const isFull = event.max_participants != null && event.current_participants >= event.max_participants;
|
||||
const isRegistering = registering === event.id;
|
||||
|
||||
return (
|
||||
<View key={event.id} className='event-card'>
|
||||
<View className='event-card__header'>
|
||||
<View className={`event-card__status ${st.className}`}>
|
||||
<Text>{st.label}</Text>
|
||||
</View>
|
||||
<Text className='event-card__points'>+{event.points_reward} 积分</Text>
|
||||
</View>
|
||||
<Text className='event-card__title'>{event.title}</Text>
|
||||
{event.description && (
|
||||
<Text className='event-card__desc'>{event.description}</Text>
|
||||
)}
|
||||
<View className='event-card__info'>
|
||||
<Text className='event-card__date'>{formatDate(event.event_date)}</Text>
|
||||
{event.location && (
|
||||
<Text className='event-card__location'>{event.location}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className='event-card__footer'>
|
||||
<Text className='event-card__participants'>
|
||||
{event.current_participants}{event.max_participants ? `/${event.max_participants}` : ''} 人已报名
|
||||
</Text>
|
||||
<View
|
||||
className={`event-card__btn ${isFull ? 'event-card__btn--disabled' : ''}`}
|
||||
onClick={() => !isFull && !isRegistering && handleRegister(event)}
|
||||
>
|
||||
<Text className='event-card__btn-text'>
|
||||
{isRegistering ? '报名中...' : isFull ? '已满' : '立即报名'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
return <FrozenPage />;
|
||||
}
|
||||
|
||||
@@ -1,179 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, Input, Picker } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { createPatient, updatePatient, Patient } from '../../../services/patient';
|
||||
import { secureGet, secureRemove } from '@/utils/secure-storage';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
import FrozenPage from '@/components/FrozenPage';
|
||||
|
||||
const RELATION_OPTIONS = ['本人', '配偶', '父母', '子女', '其他'];
|
||||
const GENDER_OPTIONS = ['男', '女'];
|
||||
|
||||
export default function FamilyAdd() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const editId = router.params.id || '';
|
||||
const rawEdit = secureGet('edit_patient');
|
||||
const editData: Patient | null = rawEdit ? JSON.parse(rawEdit) : null;
|
||||
|
||||
const [name, setName] = useState(editData?.name || '');
|
||||
const [relationIdx, setRelationIdx] = useState(
|
||||
editData?.relation ? RELATION_OPTIONS.indexOf(editData.relation) : 0
|
||||
);
|
||||
const [genderIdx, setGenderIdx] = useState(
|
||||
editData?.gender === 'female' ? 1 : 0
|
||||
);
|
||||
const [birthDate, setBirthDate] = useState(editData?.birth_date || '');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { safeSetTimeout } = useSafeTimeout();
|
||||
|
||||
useEffect(() => {
|
||||
return () => { secureRemove('edit_patient'); };
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
Taro.showToast({ title: '请输入姓名', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
Taro.showLoading({ title: '提交中...' });
|
||||
try {
|
||||
if (editId && editData) {
|
||||
await updatePatient(editId, {
|
||||
name: name.trim(),
|
||||
gender: GENDER_OPTIONS[genderIdx] === '男' ? 'male' : 'female',
|
||||
birth_date: birthDate || undefined,
|
||||
relation: RELATION_OPTIONS[relationIdx],
|
||||
}, editData.version);
|
||||
Taro.hideLoading();
|
||||
Taro.showToast({ title: '修改成功', icon: 'success' });
|
||||
} else {
|
||||
await createPatient({
|
||||
name: name.trim(),
|
||||
gender: GENDER_OPTIONS[genderIdx] === '男' ? 'male' : 'female',
|
||||
birth_date: birthDate || undefined,
|
||||
});
|
||||
Taro.hideLoading();
|
||||
Taro.showToast({ title: '添加成功', icon: 'success' });
|
||||
}
|
||||
safeSetTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch (err) {
|
||||
console.warn('[family] 操作失败:', err);
|
||||
Taro.hideLoading();
|
||||
Taro.showToast({ title: editId ? '修改失败' : '添加失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell padding="md" safeBottom={false} scroll={false} className={`family-add-page ${modeClass}`}>
|
||||
<Text className='family-add-title'>{editId ? '编辑就诊人' : '添加就诊人'}</Text>
|
||||
|
||||
{/* 提示卡片 */}
|
||||
<View className='family-add-tip'>
|
||||
<Text className='family-add-tip-title'>完善个人信息</Text>
|
||||
<Text className='family-add-tip-desc'>
|
||||
完善信息后即可使用积分商城、签到等功能。请填写真实信息。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 表单 */}
|
||||
<View className='form-card'>
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>姓名<Text className='form-required'>*</Text></Text>
|
||||
<View className='form-input-wrap'>
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='请输入真实姓名'
|
||||
placeholderClass='form-placeholder'
|
||||
value={name}
|
||||
onInput={(e) => setName(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>关系<Text className='form-required'>*</Text></Text>
|
||||
<Picker
|
||||
mode='selector'
|
||||
range={RELATION_OPTIONS}
|
||||
value={relationIdx}
|
||||
onChange={(e) => setRelationIdx(Number(e.detail.value))}
|
||||
>
|
||||
<View className='form-picker-wrap'>
|
||||
<Text className='form-picker-text'>{RELATION_OPTIONS[relationIdx]}</Text>
|
||||
<Text className='form-picker-arrow'>›</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>性别<Text className='form-required'>*</Text></Text>
|
||||
<Picker
|
||||
mode='selector'
|
||||
range={GENDER_OPTIONS}
|
||||
value={genderIdx}
|
||||
onChange={(e) => setGenderIdx(Number(e.detail.value))}
|
||||
>
|
||||
<View className='form-picker-wrap'>
|
||||
<Text className='form-picker-text'>{GENDER_OPTIONS[genderIdx]}</Text>
|
||||
<Text className='form-picker-arrow'>›</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>出生日期<Text className='form-required'>*</Text></Text>
|
||||
<Picker
|
||||
mode='date'
|
||||
value={birthDate || '2000-01-01'}
|
||||
onChange={(e) => setBirthDate(e.detail.value)}
|
||||
>
|
||||
<View className='form-picker-wrap'>
|
||||
<Text className={`form-picker-text ${!birthDate ? 'placeholder' : ''}`}>
|
||||
{birthDate || '请选择'}
|
||||
</Text>
|
||||
<Text className='form-picker-arrow'>›</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>手机号</Text>
|
||||
<View className='form-input-wrap'>
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='选填,用于接收通知'
|
||||
placeholderClass='form-placeholder'
|
||||
type='number'
|
||||
maxlength={11}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>身份证号</Text>
|
||||
<View className='form-input-wrap'>
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='选填,用于医保对接'
|
||||
placeholderClass='form-placeholder'
|
||||
maxlength={18}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`submit-btn ${submitting ? 'disabled' : ''}`}
|
||||
onClick={submitting ? undefined : handleSubmit}
|
||||
>
|
||||
<Text className='submit-text'>{submitting ? '提交中...' : editId ? '保存修改' : '确认添加'}</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
export default function FamilyAddPage() {
|
||||
return <FrozenPage />;
|
||||
}
|
||||
|
||||
@@ -1,131 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { secureSet } from '@/utils/secure-storage';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listPatients, Patient } from '../../../services/patient';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
import FrozenPage from '@/components/FrozenPage';
|
||||
|
||||
const RELATION_CLASS: Record<string, string> = {
|
||||
'本人': 'self',
|
||||
'配偶': 'spouse',
|
||||
'父母': 'parent',
|
||||
'子女': 'child',
|
||||
'其他': 'other',
|
||||
};
|
||||
|
||||
function getRelationClass(relation: string): string {
|
||||
return RELATION_CLASS[relation] || 'other';
|
||||
}
|
||||
|
||||
export default function FamilyList() {
|
||||
const modeClass = useElderClass();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const currentPatient = useAuthStore((s) => s.currentPatient);
|
||||
const setCurrentPatient = useAuthStore((s) => s.setCurrentPatient);
|
||||
|
||||
const fetchPatients = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listPatients();
|
||||
setPatients(res.data || []);
|
||||
} catch (err) {
|
||||
console.warn('[family] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePageData(fetchPatients, { throttleMs: 10000 });
|
||||
|
||||
const handleSelect = (patient: Patient) => {
|
||||
setCurrentPatient({
|
||||
id: patient.id,
|
||||
name: patient.name,
|
||||
gender: patient.gender,
|
||||
birth_date: patient.birth_date,
|
||||
relation: patient.relation || '本人',
|
||||
});
|
||||
Taro.showToast({ title: `已切换为 ${patient.name}`, icon: 'success' });
|
||||
};
|
||||
|
||||
const goToAdd = () => {
|
||||
safeNavigateTo('/pages/pkg-profile/family-add/index');
|
||||
};
|
||||
|
||||
const goToEdit = (patient: Patient) => {
|
||||
secureSet('edit_patient', JSON.stringify(patient));
|
||||
safeNavigateTo(`/pages/pkg-profile/family-add/index?id=${patient.id}`);
|
||||
};
|
||||
|
||||
const genderText = (g?: string) => {
|
||||
if (g === 'male') return '男';
|
||||
if (g === 'female') return '女';
|
||||
return '未知';
|
||||
};
|
||||
|
||||
const birthYear = (d?: string) => {
|
||||
if (!d) return '';
|
||||
return d.slice(0, 4) + '年';
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell padding="md" safeBottom={false} scroll={false} className={`family-page ${modeClass}`}>
|
||||
<Text className='family-page-title'>就诊人管理</Text>
|
||||
<Text className='family-hint'>完善信息后即可使用积分商城、签到等功能。可添加多位家庭成员。</Text>
|
||||
|
||||
<View className='family-list'>
|
||||
{patients.map((p) => {
|
||||
const isActive = currentPatient?.id === p.id;
|
||||
const relClass = getRelationClass(p.relation || '本人');
|
||||
return (
|
||||
<View
|
||||
className={`family-item ${isActive ? 'active' : ''}`}
|
||||
key={p.id}
|
||||
onClick={() => handleSelect(p)}
|
||||
>
|
||||
<View className={`family-avatar family-avatar--${relClass}`}>
|
||||
<Text className='family-avatar-text'>{p.name.charAt(0)}</Text>
|
||||
</View>
|
||||
<View className='family-info'>
|
||||
<View className='family-name-row'>
|
||||
<Text className='family-name'>{p.name}</Text>
|
||||
{isActive && <Text className='family-current-tag'>当前</Text>}
|
||||
</View>
|
||||
<View className='family-meta'>
|
||||
<Text className={`family-relation-tag family-relation-tag--${relClass}`}>
|
||||
{p.relation || '本人'}
|
||||
</Text>
|
||||
<Text>{genderText(p.gender)}</Text>
|
||||
{birthYear(p.birth_date) && <Text>{birthYear(p.birth_date)}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
className='family-edit'
|
||||
onClick={(e) => { e.stopPropagation(); goToEdit(p); }}
|
||||
>
|
||||
<Text className='family-edit-text'>编辑</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{patients.length === 0 && !loading && (
|
||||
<EmptyState text='暂无就诊人' />
|
||||
)}
|
||||
|
||||
<View className='family-add-btn' onClick={goToAdd}>
|
||||
<Text className='family-add-icon'>+</Text>
|
||||
<Text className='family-add-text'>添加就诊人</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
export default function FamilyPage() {
|
||||
return <FrozenPage />;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
|
||||
.page-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
display: block;
|
||||
@include section-title;
|
||||
padding-left: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
/* ─── 健康记录卡片 ─── */
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -65,3 +59,79 @@
|
||||
display: block;
|
||||
margin-top: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
/* ─── 诊断记录卡片 ─── */
|
||||
.diagnosis-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.diagnosis-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: var(--tk-card-padding-lg);
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.diagnosis-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
.diagnosis-card__name {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
margin-right: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.diagnosis-card__status {
|
||||
font-size: var(--tk-font-cap);
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-pill;
|
||||
font-weight: 600;
|
||||
|
||||
&.active { background: $acc-l; color: $acc; }
|
||||
&.resolved { background: $bd-l; color: $tx2; }
|
||||
&.chronic { background: $wrn-l; color: $wrn; }
|
||||
}
|
||||
|
||||
.diagnosis-card__meta {
|
||||
display: flex;
|
||||
gap: var(--tk-gap-sm);
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
.diagnosis-card__type {
|
||||
font-size: var(--tk-font-cap);
|
||||
padding: 1px 6px;
|
||||
border-radius: $r-pill;
|
||||
|
||||
&.primary { background: var(--tk-pri-l); color: var(--tk-pri); }
|
||||
&.secondary { background: $bd-l; color: $tx2; }
|
||||
&.comorbid { background: $wrn-l; color: $wrn; }
|
||||
}
|
||||
|
||||
.diagnosis-card__code {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.diagnosis-card__date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.diagnosis-card__notes {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-top: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
@@ -3,89 +3,197 @@ import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useReachBottom } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getCachedPatientId } from '@/services/request';
|
||||
import { listHealthRecords, HealthRecord } from '../../../services/health-record';
|
||||
import {
|
||||
listHealthRecords,
|
||||
HealthRecord,
|
||||
listDiagnoses,
|
||||
Diagnosis,
|
||||
} from '../../../services/health-record';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import SegmentTabs from '../../../components/SegmentTabs';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
const TABS = [
|
||||
{ key: 'records', label: '体检记录' },
|
||||
{ key: 'diagnoses', label: '诊断记录' },
|
||||
] as const;
|
||||
|
||||
const RECORD_TYPE_MAP: Record<string, string> = {
|
||||
checkup: '体检',
|
||||
follow_up: '复查',
|
||||
referral: '转诊',
|
||||
};
|
||||
|
||||
export default function HealthRecords() {
|
||||
const modeClass = useElderClass();
|
||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasPatient, setHasPatient] = useState(true);
|
||||
const DIAG_TYPE_MAP: Record<string, { label: string; cls: string }> = {
|
||||
primary: { label: '主要', cls: 'primary' },
|
||||
secondary: { label: '次要', cls: 'secondary' },
|
||||
comorbid: { label: '合并症', cls: 'comorbid' },
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async (p: number, append = false) => {
|
||||
const DIAG_STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '活动', cls: 'active' },
|
||||
resolved: { label: '已解决', cls: 'resolved' },
|
||||
chronic: { label: '慢性', cls: 'chronic' },
|
||||
};
|
||||
|
||||
type TabKey = 'records' | 'diagnoses';
|
||||
|
||||
export default function HealthArchive() {
|
||||
const modeClass = useElderClass();
|
||||
const [tab, setTab] = useState<TabKey>('records');
|
||||
|
||||
// --- 健康记录 ---
|
||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
||||
const [recordsPage, setRecordsPage] = useState(1);
|
||||
const [recordsTotal, setRecordsTotal] = useState(0);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
|
||||
const fetchRecords = useCallback(async (p: number, append = false) => {
|
||||
const patientId = getCachedPatientId();
|
||||
if (!patientId) {
|
||||
setRecords([]);
|
||||
setHasPatient(false);
|
||||
return;
|
||||
}
|
||||
setHasPatient(true);
|
||||
setLoading(true);
|
||||
if (!patientId) return;
|
||||
setRecordsLoading(true);
|
||||
try {
|
||||
const res = await listHealthRecords(patientId, { page: p, page_size: 20 });
|
||||
const list = res.data || [];
|
||||
setRecords(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
setRecordsTotal(res.total);
|
||||
setRecordsPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[health-records] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRecordsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
|
||||
// --- 诊断记录 ---
|
||||
const [diagnoses, setDiagnoses] = useState<Diagnosis[]>([]);
|
||||
const [diagPage, setDiagPage] = useState(1);
|
||||
const [diagTotal, setDiagTotal] = useState(0);
|
||||
const [diagLoading, setDiagLoading] = useState(false);
|
||||
const [diagnosesLoaded, setDiagnosesLoaded] = useState(false);
|
||||
|
||||
const fetchDiagnoses = useCallback(async (p: number, append = false) => {
|
||||
const patientId = getCachedPatientId();
|
||||
if (!patientId) return;
|
||||
setDiagLoading(true);
|
||||
try {
|
||||
const res = await listDiagnoses(patientId, { page: p, page_size: 20 });
|
||||
const list = res.data || [];
|
||||
setDiagnoses(append ? (prev) => [...prev, ...list] : list);
|
||||
setDiagTotal(res.total);
|
||||
setDiagPage(p);
|
||||
setDiagnosesLoaded(true);
|
||||
} catch (err) {
|
||||
console.warn('[diagnoses] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setDiagLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (tab === 'records') {
|
||||
await fetchRecords(1);
|
||||
} else {
|
||||
await fetchDiagnoses(1);
|
||||
}
|
||||
}, [tab, fetchRecords, fetchDiagnoses]);
|
||||
|
||||
const handleTabSwitch = (key: TabKey) => {
|
||||
if (key === tab) return;
|
||||
setTab(key);
|
||||
if (key === 'diagnoses' && !diagnosesLoaded) {
|
||||
fetchDiagnoses(1);
|
||||
}
|
||||
};
|
||||
|
||||
usePageData(handleRefresh, { throttleMs: 10000, enablePullDown: true });
|
||||
|
||||
const currentLoading = tab === 'records' ? recordsLoading : diagLoading;
|
||||
const currentItems = tab === 'records' ? records.length : diagnoses.length;
|
||||
const currentTotal = tab === 'records' ? recordsTotal : diagTotal;
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && records.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
if (currentLoading || currentItems >= currentTotal) return;
|
||||
if (tab === 'records') {
|
||||
fetchRecords(recordsPage + 1, true);
|
||||
} else {
|
||||
fetchDiagnoses(diagPage + 1, true);
|
||||
}
|
||||
});
|
||||
|
||||
const hasPatient = !!getCachedPatientId();
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>健康记录</Text>
|
||||
<Text className='page-title'>健康档案</Text>
|
||||
|
||||
<View className='record-list'>
|
||||
{records.map((r) => (
|
||||
<View className='record-card' key={r.id}>
|
||||
<View className='record-card__header'>
|
||||
<Text className='record-card__type'>
|
||||
{TYPE_MAP[r.record_type] || r.record_type}
|
||||
</Text>
|
||||
<Text className='record-card__date'>{r.record_date}</Text>
|
||||
<SegmentTabs tabs={TABS} activeKey={tab} onChange={(k) => handleTabSwitch(k as TabKey)} variant="pill" />
|
||||
|
||||
{tab === 'records' && (
|
||||
<View className='record-list'>
|
||||
{records.map((r) => (
|
||||
<View className='record-card' key={r.id}>
|
||||
<View className='record-card__header'>
|
||||
<Text className='record-card__type'>
|
||||
{RECORD_TYPE_MAP[r.record_type] || r.record_type}
|
||||
</Text>
|
||||
<Text className='record-card__date'>{r.record_date}</Text>
|
||||
</View>
|
||||
{r.overall_assessment && (
|
||||
<Text className='record-card__assessment'>{r.overall_assessment}</Text>
|
||||
)}
|
||||
{r.source && (
|
||||
<Text className='record-card__source'>来源:{r.source}</Text>
|
||||
)}
|
||||
{r.notes && (
|
||||
<Text className='record-card__notes'>{r.notes}</Text>
|
||||
)}
|
||||
</View>
|
||||
{r.overall_assessment && (
|
||||
<Text className='record-card__assessment'>{r.overall_assessment}</Text>
|
||||
)}
|
||||
{r.source && (
|
||||
<Text className='record-card__source'>来源:{r.source}</Text>
|
||||
)}
|
||||
{r.notes && (
|
||||
<Text className='record-card__notes'>{r.notes}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{records.length === 0 && !loading && (
|
||||
<EmptyState text={hasPatient ? '暂无健康记录' : '请先在就诊人管理中选择就诊人'} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && <Loading />}
|
||||
{tab === 'diagnoses' && (
|
||||
<View className='diagnosis-list'>
|
||||
{diagnoses.map((d) => {
|
||||
const typeInfo = DIAG_TYPE_MAP[d.diagnosis_type] || { label: d.diagnosis_type, cls: '' };
|
||||
const statusInfo = DIAG_STATUS_MAP[d.status] || { label: d.status, cls: '' };
|
||||
return (
|
||||
<View className='diagnosis-card' key={d.id}>
|
||||
<View className='diagnosis-card__header'>
|
||||
<Text className='diagnosis-card__name'>{d.diagnosis_name}</Text>
|
||||
<Text className={`diagnosis-card__status ${statusInfo.cls}`}>
|
||||
{statusInfo.label}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='diagnosis-card__meta'>
|
||||
<Text className={`diagnosis-card__type ${typeInfo.cls}`}>
|
||||
{typeInfo.label}
|
||||
</Text>
|
||||
<Text className='diagnosis-card__code'>{d.icd_code}</Text>
|
||||
</View>
|
||||
<Text className='diagnosis-card__date'>诊断日期:{d.diagnosed_date}</Text>
|
||||
{d.notes && (
|
||||
<Text className='diagnosis-card__notes'>{d.notes}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{currentItems === 0 && !currentLoading && (
|
||||
<EmptyState text={hasPatient
|
||||
? (tab === 'records' ? '暂无健康记录' : '暂无诊断记录')
|
||||
: '请先在就诊人管理中选择就诊人'} />
|
||||
)}
|
||||
|
||||
{currentLoading && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,217 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, Input, Picker } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getCachedPatientId } from '@/services/request';
|
||||
import { requestSubscribe } from '@/services/wechat-templates';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import {
|
||||
listReminders,
|
||||
createReminder,
|
||||
updateReminder,
|
||||
deleteReminder,
|
||||
type MedicationReminder,
|
||||
} from '../../../services/medication-reminder';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
import FrozenPage from '@/components/FrozenPage';
|
||||
|
||||
export default function MedicationReminder() {
|
||||
const modeClass = useElderClass();
|
||||
const [reminders, setReminders] = useState<MedicationReminder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formDosage, setFormDosage] = useState('');
|
||||
const [formTime, setFormTime] = useState('08:00');
|
||||
|
||||
const fetchReminders = useCallback(async () => {
|
||||
try {
|
||||
const res = await listReminders();
|
||||
setReminders(res.data ?? []);
|
||||
} catch (err) {
|
||||
console.warn('[medication] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePageData(
|
||||
async () => {
|
||||
await fetchReminders();
|
||||
// 请求用药提醒推送订阅
|
||||
requestSubscribe('MEDICATION_REMINDER');
|
||||
},
|
||||
{ throttleMs: 5000, enablePullDown: true },
|
||||
);
|
||||
|
||||
const handleToggle = async (r: MedicationReminder) => {
|
||||
try {
|
||||
await updateReminder(r.id, {
|
||||
is_active: !r.is_active,
|
||||
version: r.version,
|
||||
});
|
||||
fetchReminders();
|
||||
} catch (err) {
|
||||
console.warn('[medication] 操作失败:', err);
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (r: MedicationReminder) => {
|
||||
Taro.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个提醒吗?',
|
||||
}).then(async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await deleteReminder(r.id, r.version);
|
||||
Taro.showToast({ title: '已删除', icon: 'success' });
|
||||
fetchReminders();
|
||||
} catch (err) {
|
||||
console.warn('[medication] 删除失败:', err);
|
||||
Taro.showToast({ title: '删除失败', icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!formName.trim()) {
|
||||
Taro.showToast({ title: '请输入药品名称', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const patientId = getCachedPatientId();
|
||||
if (!patientId) {
|
||||
Taro.showToast({ title: '请先绑定患者档案', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createReminder({
|
||||
patient_id: patientId,
|
||||
medication_name: formName.trim(),
|
||||
dosage: formDosage.trim() || undefined,
|
||||
reminder_times: [formTime],
|
||||
is_active: true,
|
||||
});
|
||||
setFormName('');
|
||||
setFormDosage('');
|
||||
setFormTime('08:00');
|
||||
setShowForm(false);
|
||||
Taro.showToast({ title: '添加成功', icon: 'success' });
|
||||
fetchReminders();
|
||||
} catch (err) {
|
||||
console.warn('[medication] 添加失败:', err);
|
||||
Taro.showToast({ title: '添加失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const nameInitial = (name: string) => {
|
||||
return name ? name.charAt(0) : '药';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>用药提醒</Text>
|
||||
<View className='medication-loading'>
|
||||
<Text className='medication-loading-text'>加载中...</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>用药提醒</Text>
|
||||
|
||||
<View className='reminder-list'>
|
||||
{reminders.map((r) => (
|
||||
<View className={`reminder-card ${!r.is_active ? 'disabled' : ''}`} key={r.id}>
|
||||
<View className='reminder-avatar'>
|
||||
<Text className='reminder-avatar-text'>{nameInitial(r.medication_name)}</Text>
|
||||
</View>
|
||||
<View className='reminder-info'>
|
||||
<Text className='reminder-name'>{r.medication_name}</Text>
|
||||
<Text className='reminder-dosage'>
|
||||
{r.dosage || '-'} | {r.reminder_times?.join(', ') || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='reminder-actions'>
|
||||
<View
|
||||
className={`toggle ${r.is_active ? 'on' : 'off'}`}
|
||||
onClick={() => handleToggle(r)}
|
||||
>
|
||||
<View className='toggle-dot' />
|
||||
</View>
|
||||
<Text
|
||||
className='delete-btn'
|
||||
onClick={() => handleDelete(r)}
|
||||
>
|
||||
删除
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{reminders.length === 0 && (
|
||||
<EmptyState text='暂无用药提醒' />
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<View className='form-card'>
|
||||
<Text className='form-card-title'>添加提醒</Text>
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>药品名称</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='请输入药品名称'
|
||||
placeholderClass='form-placeholder'
|
||||
value={formName}
|
||||
onInput={(e) => setFormName(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>剂量</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='如: 1片、10ml'
|
||||
placeholderClass='form-placeholder'
|
||||
value={formDosage}
|
||||
onInput={(e) => setFormDosage(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>提醒时间</Text>
|
||||
<Picker
|
||||
mode='time'
|
||||
value={formTime}
|
||||
onChange={(e) => setFormTime(e.detail.value)}
|
||||
>
|
||||
<View className='time-picker-wrap'>
|
||||
<Text className='time-value'>{formTime}</Text>
|
||||
<Text className='time-modify'>修改</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-actions'>
|
||||
<View className='form-cancel' onClick={() => setShowForm(false)}>
|
||||
<Text className='form-cancel-text'>取消</Text>
|
||||
</View>
|
||||
<View className='form-confirm' onClick={handleAdd}>
|
||||
<Text className='form-confirm-text'>确认</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!showForm && (
|
||||
<View className='add-btn' onClick={() => setShowForm(true)}>
|
||||
<Text className='add-text'>添加提醒</Text>
|
||||
</View>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
export default function MedicationPage() {
|
||||
return <FrozenPage />;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
|
||||
.page-title {
|
||||
@include section-title;
|
||||
padding-left: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
/* ─── 检查报告卡片 ─── */
|
||||
.report-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -80,3 +79,63 @@
|
||||
display: block;
|
||||
padding-left: 72px;
|
||||
}
|
||||
|
||||
/* ─── AI 分析卡片 ─── */
|
||||
.ai-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.ai-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: var(--tk-card-padding-lg);
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: var(--tk-font-cap);
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-pill;
|
||||
font-weight: 600;
|
||||
|
||||
&.completed { background: $acc-l; color: $acc; }
|
||||
&.streaming { background: var(--tk-pri-l); color: var(--tk-pri); }
|
||||
&.failed { background: $dan-l; color: $dan; }
|
||||
&.pending { background: $bd-l; color: $tx2; }
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__model {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,104 +5,187 @@ import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getCachedPatientId } from '@/services/request';
|
||||
import { listReports, LabReport } from '../../../services/report';
|
||||
import { listAiAnalysis, type AiAnalysisItem } from '../../../services/ai-analysis';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import SegmentTabs from '../../../components/SegmentTabs';
|
||||
import './index.scss';
|
||||
|
||||
type TabKey = 'reports' | 'ai';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'reports', label: '检查报告' },
|
||||
{ key: 'ai', label: 'AI 解读' },
|
||||
] as const;
|
||||
|
||||
const AI_TYPE_LABELS: Record<string, string> = {
|
||||
lab_report_interpretation: '化验单解读',
|
||||
health_trend_analysis: '趋势分析',
|
||||
personalized_checkup_plan: '体检方案',
|
||||
report_summary_generation: '报告摘要',
|
||||
};
|
||||
|
||||
const AI_STATUS_MAP: Record<string, { text: string; cls: string }> = {
|
||||
completed: { text: '已完成', cls: 'completed' },
|
||||
streaming: { text: '分析中', cls: 'streaming' },
|
||||
failed: { text: '失败', cls: 'failed' },
|
||||
pending: { text: '等待中', cls: 'pending' },
|
||||
};
|
||||
|
||||
export default function MyReports() {
|
||||
const modeClass = useElderClass();
|
||||
const [reports, setReports] = useState<LabReport[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasPatient, setHasPatient] = useState(true);
|
||||
const [tab, setTab] = useState<TabKey>('reports');
|
||||
|
||||
const fetchData = useCallback(async (p: number, append = false) => {
|
||||
// --- 检查报告 ---
|
||||
const [reports, setReports] = useState<LabReport[]>([]);
|
||||
const [reportsPage, setReportsPage] = useState(1);
|
||||
const [reportsTotal, setReportsTotal] = useState(0);
|
||||
const [reportsLoading, setReportsLoading] = useState(false);
|
||||
|
||||
const fetchReports = useCallback(async (p: number, append = false) => {
|
||||
const patientId = getCachedPatientId();
|
||||
if (!patientId) {
|
||||
setReports([]);
|
||||
setHasPatient(false);
|
||||
return;
|
||||
}
|
||||
setHasPatient(true);
|
||||
setLoading(true);
|
||||
if (!patientId) return;
|
||||
setReportsLoading(true);
|
||||
try {
|
||||
const res = await listReports(patientId, p);
|
||||
const list = res.data || [];
|
||||
setReports(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
setReportsTotal(res.total);
|
||||
setReportsPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[reports] 加载报告列表失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setReportsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
|
||||
// --- AI 分析 ---
|
||||
const [aiList, setAiList] = useState<AiAnalysisItem[]>([]);
|
||||
const [aiPage, setAiPage] = useState(1);
|
||||
const [aiHasMore, setAiHasMore] = useState(true);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiLoaded, setAiLoaded] = useState(false);
|
||||
|
||||
const fetchAiList = useCallback(async (p: number, append = false) => {
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const res = await listAiAnalysis(p, 20);
|
||||
const items = res.data || [];
|
||||
setAiList(append ? (prev) => [...prev, ...items] : items);
|
||||
setAiPage(p);
|
||||
setAiHasMore(items.length >= 20);
|
||||
setAiLoaded(true);
|
||||
} catch (err) {
|
||||
console.warn('[ai-report] 加载分析列表失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (tab === 'reports') {
|
||||
await fetchReports(1);
|
||||
} else {
|
||||
await fetchAiList(1);
|
||||
}
|
||||
}, [tab, fetchReports, fetchAiList]);
|
||||
|
||||
const handleTabSwitch = (key: TabKey) => {
|
||||
if (key === tab) return;
|
||||
setTab(key);
|
||||
if (key === 'ai' && !aiLoaded) {
|
||||
fetchAiList(1);
|
||||
}
|
||||
};
|
||||
|
||||
usePageData(handleRefresh, { throttleMs: 10000, enablePullDown: true });
|
||||
|
||||
const currentLoading = tab === 'reports' ? reportsLoading : aiLoading;
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && reports.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
if (currentLoading) return;
|
||||
if (tab === 'reports') {
|
||||
if (reports.length < reportsTotal) fetchReports(reportsPage + 1, true);
|
||||
} else {
|
||||
if (aiHasMore) fetchAiList(aiPage + 1, true);
|
||||
}
|
||||
});
|
||||
|
||||
const goToDetail = (id: string) => {
|
||||
safeNavigateTo(`/pages/pkg-profile/reports/detail/index?id=${id}`);
|
||||
};
|
||||
const hasPatient = !!getCachedPatientId();
|
||||
|
||||
const formatStatus = (report: LabReport) => {
|
||||
const formatReportStatus = (report: LabReport) => {
|
||||
const items = report.items;
|
||||
if (!items || !Array.isArray(items)) return 'unknown';
|
||||
const vals = items as Array<{ is_abnormal?: boolean }>;
|
||||
const hasAbnormal = vals.some((v) => v.is_abnormal);
|
||||
return hasAbnormal ? 'abnormal' : 'normal';
|
||||
};
|
||||
|
||||
const typeInitial = (type: string) => {
|
||||
return type ? type.charAt(0) : '报';
|
||||
return vals.some((v) => v.is_abnormal) ? 'abnormal' : 'normal';
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>检查报告</Text>
|
||||
<Text className='page-title'>我的报告</Text>
|
||||
|
||||
<View className='report-list'>
|
||||
{reports.map((r) => {
|
||||
const status = formatStatus(r);
|
||||
return (
|
||||
<View
|
||||
className='report-card'
|
||||
key={r.id}
|
||||
onClick={() => goToDetail(r.id)}
|
||||
>
|
||||
<View className='report-card-top'>
|
||||
<View className='report-type-row'>
|
||||
<View className='report-avatar'>
|
||||
<Text className='report-avatar-text'>{typeInitial(r.report_type)}</Text>
|
||||
<SegmentTabs tabs={TABS} activeKey={tab} onChange={(k) => handleTabSwitch(k as TabKey)} variant="pill" />
|
||||
|
||||
{tab === 'reports' && (
|
||||
<View className='report-list'>
|
||||
{reports.map((r) => {
|
||||
const status = formatReportStatus(r);
|
||||
return (
|
||||
<View className='report-card' key={r.id} onClick={() => safeNavigateTo(`/pages/pkg-profile/reports/detail/index?id=${r.id}`)}>
|
||||
<View className='report-card-top'>
|
||||
<View className='report-type-row'>
|
||||
<View className='report-avatar'>
|
||||
<Text className='report-avatar-text'>{r.report_type ? r.report_type.charAt(0) : '报'}</Text>
|
||||
</View>
|
||||
<Text className='report-type'>{r.report_type}</Text>
|
||||
</View>
|
||||
<Text className='report-type'>{r.report_type}</Text>
|
||||
<Text className={`report-status ${status}`}>
|
||||
{status === 'normal' ? '正常' : status === 'abnormal' ? '异常' : '未知'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className={`report-status ${status}`}>
|
||||
{status === 'normal' ? '正常' : status === 'abnormal' ? '异常' : '未知'}
|
||||
</Text>
|
||||
<Text className='report-date'>{r.report_date}</Text>
|
||||
</View>
|
||||
<Text className='report-date'>{r.report_date}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{reports.length === 0 && !loading && (
|
||||
{tab === 'ai' && (
|
||||
<View className='ai-list'>
|
||||
{aiList.map((item) => {
|
||||
const si = AI_STATUS_MAP[item.status] || { text: item.status, cls: '' };
|
||||
return (
|
||||
<View
|
||||
key={item.id}
|
||||
className='ai-card'
|
||||
onClick={() => item.status === 'completed' && safeNavigateTo(`/pages/ai-report/detail/index?id=${item.id}`)}
|
||||
>
|
||||
<View className='ai-card__header'>
|
||||
<Text className='ai-card__type'>{AI_TYPE_LABELS[item.analysis_type] || item.analysis_type}</Text>
|
||||
<Text className={`ai-card__status ${si.cls}`}>{si.text}</Text>
|
||||
</View>
|
||||
<View className='ai-card__footer'>
|
||||
<Text className='ai-card__time'>{new Date(item.created_at).toLocaleString('zh-CN')}</Text>
|
||||
<Text className='ai-card__model'>{item.model_used}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{tab === 'reports' && reports.length === 0 && !reportsLoading && (
|
||||
<EmptyState text={hasPatient ? '暂无报告记录' : '请先在就诊人管理中选择就诊人'} />
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<Loading />
|
||||
{tab === 'ai' && aiList.length === 0 && !aiLoading && (
|
||||
<EmptyState text='暂无 AI 分析报告' />
|
||||
)}
|
||||
|
||||
{currentLoading && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,3 +65,14 @@
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
margin-right: var(--tk-gap-xs);
|
||||
|
||||
&.settings-toggle--active {
|
||||
color: var(--tk-pri);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
import { useUIStore } from '../../../stores/ui';
|
||||
import { invalidateHeadersCache, clearRequestCache } from '@/services/request';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
@@ -12,6 +13,8 @@ export default function Settings() {
|
||||
const modeClass = useElderClass();
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const isMedicalStaff = useAuthStore((s) => s.isMedicalStaff);
|
||||
const mode = useUIStore((s) => s.mode);
|
||||
const toggleMode = useUIStore((s) => s.toggle);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
const { confirm } = await Taro.showModal({
|
||||
@@ -72,6 +75,16 @@ export default function Settings() {
|
||||
<Text className='page-title'>设置</Text>
|
||||
|
||||
<View className='settings-group'>
|
||||
<View className='settings-item' onClick={toggleMode}>
|
||||
<View className='settings-icon'>
|
||||
<Text className='settings-icon-text'>老</Text>
|
||||
</View>
|
||||
<Text className='settings-label'>长辈模式</Text>
|
||||
<Text className={`settings-toggle ${mode === 'elder' ? 'settings-toggle--active' : ''}`}>
|
||||
{mode === 'elder' ? '已开启' : '未开启'}
|
||||
</Text>
|
||||
<Text className='settings-arrow'>{'>'}</Text>
|
||||
</View>
|
||||
<View className='settings-item' onClick={handleClearCache}>
|
||||
<View className='settings-icon'>
|
||||
<Text className='settings-icon-text'>缓</Text>
|
||||
|
||||
@@ -19,7 +19,6 @@ interface MenuItem {
|
||||
bg: string;
|
||||
color: string;
|
||||
path: string;
|
||||
isSwitchTab?: boolean;
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
@@ -29,43 +28,15 @@ interface MenuGroup {
|
||||
|
||||
const LOGGED_IN_GROUPS: MenuGroup[] = [
|
||||
{
|
||||
title: '健康管理',
|
||||
title: '健康档案',
|
||||
items: [
|
||||
{ label: '健康记录', icon: '健', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/health-records/index' },
|
||||
{ label: '健康档案', icon: '健', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/health-records/index' },
|
||||
{ label: '我的报告', icon: '报', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/reports/index' },
|
||||
{ label: 'AI 分析', icon: '智', bg: 'pri-l', color: 'pri', path: '/pages/ai-report/list/index' },
|
||||
{ label: '诊断记录', icon: '诊', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/diagnoses/index' },
|
||||
{ label: '用药记录', icon: '药', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/medication/index' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '就诊服务',
|
||||
items: [
|
||||
{ label: '我的预约', icon: '约', bg: 'pri-l', color: 'pri', path: '/pages/appointment/index' },
|
||||
{ label: '我的随访', icon: '随', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/followups/index' },
|
||||
{ label: '在线咨询', icon: '问', bg: 'pri-l', color: 'pri', path: '/pages/consultation/index' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '透析管理',
|
||||
items: [
|
||||
{ label: '透析记录', icon: '透', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/dialysis-records/index' },
|
||||
{ label: '透析处方', icon: '方', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/dialysis-prescriptions/index' },
|
||||
{ label: '知情同意', icon: '知', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/consents/index' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '生活服务',
|
||||
items: [
|
||||
{ label: '积分商城', icon: '礼', bg: 'pri-l', color: 'pri', path: '/pages/mall/index' },
|
||||
{ label: '线下活动', icon: '活', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/events/index' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
items: [
|
||||
{ label: '就诊人管理', icon: '家', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/family/index' },
|
||||
{ label: '长辈模式', icon: '老', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/elder-mode/index' },
|
||||
{ label: '设备同步', icon: '设', bg: 'surface-alt', color: 'tx3', path: '/pages/pkg-health/device-sync/index' },
|
||||
{ label: '设置', icon: '齿', bg: 'surface-alt', color: 'tx3', path: '/pages/pkg-profile/settings/index' },
|
||||
],
|
||||
@@ -76,7 +47,6 @@ const GUEST_GROUPS: MenuGroup[] = [
|
||||
{
|
||||
title: '设置',
|
||||
items: [
|
||||
{ label: '长辈模式', icon: '老', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/elder-mode/index' },
|
||||
{ label: '设置', icon: '齿', bg: 'surface-alt', color: 'tx3', path: '/pages/pkg-profile/settings/index' },
|
||||
],
|
||||
},
|
||||
@@ -101,9 +71,9 @@ export default function Profile() {
|
||||
await refreshPoints();
|
||||
setPointsLoading(false);
|
||||
try {
|
||||
const res = await notificationService.list<{ total?: number; data?: { read?: boolean }[] }>({ page: 1, page_size: 50 });
|
||||
const items = (res as { data?: { read?: boolean }[] })?.data || [];
|
||||
setUnreadCount(items.filter((n) => !n.read).length);
|
||||
const res = await notificationService.getUnreadCount();
|
||||
const count = (res as { data?: { count?: number } })?.data?.count ?? 0;
|
||||
setUnreadCount(count);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}, [isGuest, refreshPoints]);
|
||||
@@ -111,11 +81,7 @@ export default function Profile() {
|
||||
usePageData(fetchPoints, { throttleMs: 5000 });
|
||||
|
||||
const handleMenuClick = (item: MenuItem) => {
|
||||
if (item.isSwitchTab) {
|
||||
Taro.switchTab({ url: item.path });
|
||||
} else {
|
||||
safeNavigateTo(item.path);
|
||||
}
|
||||
safeNavigateTo(item.path);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
|
||||
@@ -22,5 +22,5 @@ export async function listPatientAlerts(patientId: string, params?: { status?: s
|
||||
page: params?.page ?? 1,
|
||||
page_size: params?.page_size ?? 20,
|
||||
status: params?.status,
|
||||
});
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ function persistQueue(): void {
|
||||
}
|
||||
|
||||
export function trackEvent(event: EventName | string, properties?: Record<string, unknown>): void {
|
||||
if (flushDisabled) return;
|
||||
loadQueue();
|
||||
const evt: AnalyticsEvent = {
|
||||
event,
|
||||
@@ -89,7 +90,10 @@ export function trackPageView(pageName: string, properties?: Record<string, unkn
|
||||
trackEvent('page_view', { page: pageName, ...properties });
|
||||
}
|
||||
|
||||
let flushDisabled = false;
|
||||
|
||||
export async function flushEvents(): Promise<void> {
|
||||
if (flushDisabled) return;
|
||||
loadQueue();
|
||||
if (memoryQueue.length === 0) return;
|
||||
|
||||
@@ -99,9 +103,16 @@ export async function flushEvents(): Promise<void> {
|
||||
|
||||
try {
|
||||
await api.post('/analytics/batch', { events: batch });
|
||||
} catch (e) {
|
||||
// 静默失败,不打印错误避免控制台洪泛
|
||||
void e;
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg === '权限不足' || msg === '登录已过期') {
|
||||
// 权限不足或未认证,停止后续 flush 并丢弃队列
|
||||
flushDisabled = true;
|
||||
memoryQueue = [];
|
||||
persistQueue();
|
||||
return;
|
||||
}
|
||||
// 其他错误(网络等)保留队列重试
|
||||
memoryQueue = [...batch.slice(-MAX_QUEUE_SIZE), ...memoryQueue].slice(-MAX_QUEUE_SIZE);
|
||||
persistQueue();
|
||||
}
|
||||
@@ -111,3 +122,8 @@ export function getQueueSize(): number {
|
||||
loadQueue();
|
||||
return memoryQueue.length;
|
||||
}
|
||||
|
||||
/** 登录/切换用户时调用,重新启用 flush */
|
||||
export function resetAnalyticsDisabled(): void {
|
||||
flushDisabled = false;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { api } from './request';
|
||||
|
||||
export interface Article {
|
||||
@@ -41,6 +42,46 @@ export function buildCategoryTree(flat: ArticleCategory[]): ArticleCategory[] {
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** 获取默认 tenant_id(用于公开 API 调用) */
|
||||
function getDefaultTenantId(): string {
|
||||
return Taro.getStorageSync('tenant_id') || process.env.TARO_APP_DEFAULT_TENANT_ID || '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 公开端点(无需认证,游客可访问)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listPublicArticles(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category_id?: string;
|
||||
tag_id?: string;
|
||||
keyword?: string;
|
||||
}) {
|
||||
const tenantId = getDefaultTenantId();
|
||||
return api.get<{ data: Article[]; total: number }>('/public/articles', {
|
||||
tenant_id: tenantId,
|
||||
page: params?.page ?? 1,
|
||||
page_size: params?.page_size ?? 20,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPublicCategories() {
|
||||
const tenantId = getDefaultTenantId();
|
||||
return api.get<ArticleCategory[]>('/public/article-categories', {
|
||||
tenant_id: tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPublicArticleDetail(id: string) {
|
||||
return api.get<Article>(`/public/articles/${id}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 认证端点(需要登录)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listArticles(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
@@ -60,11 +101,6 @@ export async function getArticleDetail(id: string) {
|
||||
return api.get<Article>(`/health/articles/${id}`);
|
||||
}
|
||||
|
||||
/** 公开文章详情(无需认证) */
|
||||
export async function getPublicArticleDetail(id: string) {
|
||||
return api.get<Article>(`/public/articles/${id}`);
|
||||
}
|
||||
|
||||
export async function listCategories() {
|
||||
return api.get<ArticleCategory[]>('/health/article-categories');
|
||||
}
|
||||
|
||||
@@ -68,7 +68,9 @@ export interface PatientSummary {
|
||||
}
|
||||
|
||||
/** 获取患者摘要列表(字段最小化,替代 getPatients) */
|
||||
export async function getPatientSummaries() {
|
||||
const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary');
|
||||
export async function getPatientSummaries(userId?: string) {
|
||||
const params: Record<string, string> = {};
|
||||
if (userId) params.user_id = userId;
|
||||
const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary', { params });
|
||||
return Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
BLEConnectionChangeResult,
|
||||
BLECharacteristicChangeResult,
|
||||
BLEServiceItem,
|
||||
BLEDiscoveredService,
|
||||
BLEDiscoveredCharacteristic,
|
||||
} from './types';
|
||||
|
||||
/** BLE 连接管理 — 封装连接/断开/服务发现/通知订阅/数据监听 */
|
||||
@@ -121,10 +123,24 @@ export class BLEConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/** 发现服务并启用通知 */
|
||||
/** 已知的健康相关 Characteristic UUID(用于自动发现和订阅) */
|
||||
private static readonly HEALTH_CHAR_UUIDS: Record<string, string> = {
|
||||
'2A37': 'heart_rate', // Heart Rate Measurement
|
||||
'2A38': 'heart_rate_loc', // Body Sensor Location
|
||||
'2A1C': 'temperature', // Temperature Measurement
|
||||
'2A35': 'blood_pressure', // Blood Pressure Measurement
|
||||
'2A5F': 'blood_oxygen', // PLX Continuous Measurement
|
||||
'2A5E': 'blood_oxygen_spot',// PLX Spot-Check Measurement
|
||||
};
|
||||
|
||||
/** 发现服务并启用通知 — 先订阅适配器指定的,再扫描全部服务尝试自动发现 */
|
||||
private async discoverServices(device: BLEDevice): Promise<void> {
|
||||
const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId });
|
||||
const services = servicesRes.services || [];
|
||||
const discoveredServices: BLEDiscoveredService[] = [];
|
||||
|
||||
// ── 第一轮:订阅适配器预定义的 Characteristic(保持向后兼容) ──
|
||||
const subscribedCharUUIDs = new Set<string>();
|
||||
|
||||
for (const { service: svcUUID, characteristic: charUUID } of device.adapter!.notifyCharacteristics) {
|
||||
const svc = services.find((s: BLEServiceItem) =>
|
||||
@@ -137,13 +153,90 @@ export class BLEConnection {
|
||||
serviceId: svc.uuid,
|
||||
});
|
||||
|
||||
await Taro.notifyBLECharacteristicValueChange({
|
||||
deviceId: device.deviceId,
|
||||
serviceId: svc.uuid,
|
||||
characteristicId: charUUID,
|
||||
state: true,
|
||||
try {
|
||||
await Taro.notifyBLECharacteristicValueChange({
|
||||
deviceId: device.deviceId,
|
||||
serviceId: svc.uuid,
|
||||
characteristicId: charUUID,
|
||||
state: true,
|
||||
});
|
||||
subscribedCharUUIDs.add(charUUID.toUpperCase().replace(/-/g, '').slice(-4));
|
||||
console.log(`[ble] 已订阅适配器预定义: ${svcUUID} / ${charUUID}`);
|
||||
} catch (err) {
|
||||
console.warn(`[ble] 订阅失败 (预定义): ${charUUID}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 第二轮:扫描全部服务,发现并订阅健康相关 Characteristic ──
|
||||
for (const svc of services) {
|
||||
const svcUUID = svc.uuid.toUpperCase();
|
||||
const discoveredChars: BLEDiscoveredCharacteristic[] = [];
|
||||
|
||||
let charsRes: Taro.getBLEDeviceCharacteristics.SuccessCallbackResult;
|
||||
try {
|
||||
charsRes = await Taro.getBLEDeviceCharacteristics({
|
||||
deviceId: device.deviceId,
|
||||
serviceId: svc.uuid,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[ble] 读取特征列表失败: ${svcUUID}`, err);
|
||||
continue;
|
||||
}
|
||||
|
||||
const characteristics = charsRes.characteristics || [];
|
||||
|
||||
for (const char of characteristics) {
|
||||
const charUUIDShort = char.uuid.toUpperCase().replace(/-/g, '').slice(-4);
|
||||
const props = char.properties || {};
|
||||
|
||||
const discoveredChar: BLEDiscoveredCharacteristic = {
|
||||
uuid: char.uuid,
|
||||
properties: {
|
||||
read: !!props.read,
|
||||
write: !!props.write,
|
||||
notify: !!props.notify,
|
||||
indicate: !!props.indicate,
|
||||
},
|
||||
};
|
||||
discoveredChars.push(discoveredChar);
|
||||
|
||||
// 如果是已知的健康 UUID 且尚未订阅,尝试订阅
|
||||
if (
|
||||
BLEConnection.HEALTH_CHAR_UUIDS[charUUIDShort] &&
|
||||
!subscribedCharUUIDs.has(charUUIDShort) &&
|
||||
(props.notify || props.indicate)
|
||||
) {
|
||||
try {
|
||||
await Taro.notifyBLECharacteristicValueChange({
|
||||
deviceId: device.deviceId,
|
||||
serviceId: svc.uuid,
|
||||
characteristicId: char.uuid,
|
||||
state: true,
|
||||
});
|
||||
subscribedCharUUIDs.add(charUUIDShort);
|
||||
console.log(`[ble] 自动发现并订阅: ${BLEConnection.HEALTH_CHAR_UUIDS[charUUIDShort]} (${svcUUID} / ${char.uuid})`);
|
||||
} catch (err) {
|
||||
console.warn(`[ble] 自动订阅失败: ${char.uuid}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
discoveredServices.push({
|
||||
uuid: svc.uuid,
|
||||
isPrimary: !!svc.isPrimary,
|
||||
characteristics: discoveredChars,
|
||||
});
|
||||
}
|
||||
|
||||
// 存储发现结果到连接信息
|
||||
if (this.conn) {
|
||||
this.conn = { ...this.conn, discoveredServices };
|
||||
}
|
||||
|
||||
console.log(`[ble] 服务发现完成: ${discoveredServices.length} 个服务, 已订阅 ${subscribedCharUUIDs.size} 个特征`);
|
||||
console.log(`[ble] 已订阅的健康特征:`, [...subscribedCharUUIDs].map(
|
||||
(s) => `${s}(${BLEConnection.HEALTH_CHAR_UUIDS[s] ?? '未知'})`
|
||||
).join(', '));
|
||||
}
|
||||
|
||||
/** 手动读取特征值 */
|
||||
|
||||
@@ -70,10 +70,13 @@ export class BLEManager {
|
||||
|
||||
/** 初始化蓝牙适配器 */
|
||||
async initialize(): Promise<void> {
|
||||
console.log('[ble] 步骤1: 开始初始化蓝牙适配器...');
|
||||
try {
|
||||
await Taro.openBluetoothAdapter();
|
||||
console.log('[ble] 步骤1: 蓝牙适配器初始化成功');
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg || '蓝牙初始化失败,请检查蓝牙是否开启';
|
||||
console.error('[ble] 步骤1: 蓝牙初始化失败:', errMsg);
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
}
|
||||
@@ -83,38 +86,61 @@ export class BLEManager {
|
||||
await this.initialize();
|
||||
|
||||
const discovered = new Map<string, BLEDevice>();
|
||||
const allModelKeywords = this.adapters.flatMap((a) => a.supportedModels);
|
||||
|
||||
console.log('[ble] 步骤2: 注册的适配器:', this.adapters.map((a) => a.name));
|
||||
console.log('[ble] 步骤2: 匹配关键词:', allModelKeywords);
|
||||
|
||||
let scanDeviceCount = 0;
|
||||
|
||||
const onFound = (res: BLEScanResult) => {
|
||||
for (const device of res.devices || []) {
|
||||
const devices = res.devices || [];
|
||||
scanDeviceCount += devices.length;
|
||||
|
||||
for (const device of devices) {
|
||||
const name = device.name || device.localName || '';
|
||||
if (!name) continue;
|
||||
const adapter = this.matchAdapter(name);
|
||||
if (adapter) {
|
||||
discovered.set(device.deviceId, {
|
||||
deviceId: device.deviceId,
|
||||
name,
|
||||
RSSI: device.RSSI ?? 0,
|
||||
localName: device.localName,
|
||||
advertisData: device.advertisData,
|
||||
adapter,
|
||||
});
|
||||
|
||||
// 每个新发现的设备都打印(最多前 30 个避免日志爆炸)
|
||||
if (discovered.size < 30 && !discovered.has(device.deviceId)) {
|
||||
console.log(`[ble] 发现设备: "${name}" (RSSI:${device.RSSI ?? '?'}, 匹配:${adapter?.name ?? '无'})`);
|
||||
}
|
||||
|
||||
discovered.set(device.deviceId, {
|
||||
deviceId: device.deviceId,
|
||||
name,
|
||||
RSSI: device.RSSI ?? 0,
|
||||
localName: device.localName,
|
||||
advertisData: device.advertisData,
|
||||
adapter: adapter ?? undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Taro.onBluetoothDeviceFound(onFound);
|
||||
|
||||
const allServiceUUIDs = this.adapters.flatMap((a) => a.serviceUUIDs);
|
||||
console.log('[ble] 步骤3: 开始扫描 (超时', this.config.scanTimeout, 'ms)...');
|
||||
// 不传 services 参数 — 扫描所有 BLE 设备,避免设备使用私有 UUID 被过滤掉
|
||||
await Taro.startBluetoothDevicesDiscovery({
|
||||
allowDuplicatesKey: false,
|
||||
services: allServiceUUIDs.length > 0 ? allServiceUUIDs : undefined,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.scanTimer = setTimeout(async () => {
|
||||
await this.stopScan();
|
||||
Taro.offBluetoothDeviceFound(onFound);
|
||||
resolve(Array.from(discovered.values()));
|
||||
|
||||
const results = Array.from(discovered.values());
|
||||
console.log('[ble] 步骤4: 扫描结束');
|
||||
console.log('[ble] 回调触发设备总数:', scanDeviceCount);
|
||||
console.log('[ble] 有名称的设备数:', discovered.size);
|
||||
console.log('[ble] 最终返回设备数:', results.length);
|
||||
if (results.length > 0) {
|
||||
console.log('[ble] 设备列表:', results.map((d) => `${d.name} (${d.adapter?.name ?? '无适配器'})`));
|
||||
}
|
||||
|
||||
resolve(results);
|
||||
}, this.config.scanTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
305
apps/miniprogram/src/services/ble/VeepooBridge.ts
Normal file
305
apps/miniprogram/src/services/ble/VeepooBridge.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Veepoo SDK 桥接模块
|
||||
*
|
||||
* 调用顺序(基于 SDK Demo 验证):
|
||||
* 1. startScan() — 初始化蓝牙 + 扫描
|
||||
* 2. stopScan() — 找到设备后停止扫描
|
||||
* 3. connectDevice(deviceObj) — 传入完整设备对象(非 deviceId 字符串)
|
||||
* 4. registerDataListener() — 连接成功后注册数据监听
|
||||
* 5. authenticate() — 延迟 500ms 后调用秘钥认证
|
||||
* 6. 认证结果通过数据监听回调 type=1 返回
|
||||
*/
|
||||
|
||||
// @ts-ignore — SDK 类型声明为 any
|
||||
import { veepooBle, veepooFeature, veepooLogger } from './veepoo-sdk';
|
||||
|
||||
// ── SDK 事件类型常量 ──
|
||||
|
||||
/** 秘钥认证结果 */
|
||||
export const SDK_EVENT_AUTH = 1;
|
||||
/** 日常数据 */
|
||||
export const SDK_EVENT_DAILY = 5;
|
||||
/** 体温检测 */
|
||||
export const SDK_EVENT_TEMPERATURE = 6;
|
||||
/** 血压 */
|
||||
export const SDK_EVENT_BLOOD_PRESSURE = 18;
|
||||
/** 血氧手动测量 */
|
||||
export const SDK_EVENT_BLOOD_OXYGEN = 31;
|
||||
/** 心率测量 */
|
||||
export const SDK_EVENT_HEART_RATE = 51;
|
||||
/** 压力测量 */
|
||||
export const SDK_EVENT_PRESSURE = 58;
|
||||
|
||||
/** 设备正忙状态枚举(SDK state 字段) */
|
||||
export const DEVICE_STATE = {
|
||||
IDLE: 0,
|
||||
MEASURING_BP: 1,
|
||||
MEASURING_HR: 2,
|
||||
AUTO_TEST: 3,
|
||||
MEASURING_SPO2: 4,
|
||||
MEASURING_FATIGUE: 5,
|
||||
NOT_WORN: 6,
|
||||
CHARGING: 7,
|
||||
LOW_BATTERY: 8,
|
||||
BUSY: 9,
|
||||
} as const;
|
||||
|
||||
/** 连接回调中 connection 字段为 true 表示连接成功 */
|
||||
export interface VeepooConnectionResult {
|
||||
connection?: boolean;
|
||||
errno?: number;
|
||||
errCode?: number;
|
||||
errMsg?: string;
|
||||
}
|
||||
|
||||
/** SDK 事件回调数据(统一格式) */
|
||||
export interface SdkEventData {
|
||||
name: string;
|
||||
type: number;
|
||||
content: Record<string, unknown>;
|
||||
Progress?: number;
|
||||
state?: number;
|
||||
control?: number;
|
||||
ack?: number;
|
||||
}
|
||||
|
||||
// ── 蓝牙模块 ──
|
||||
|
||||
/** 初始化蓝牙 + 开始扫描 */
|
||||
export function startScan(onDeviceFound: (device: unknown) => void): void {
|
||||
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(
|
||||
(res: unknown) => {
|
||||
const device = Array.isArray(res) ? res[0] : res;
|
||||
if (device) onDeviceFound(device);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 停止扫描 */
|
||||
export function stopScan(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
veepooBle.veepooWeiXinSDKStopSearchBleManager(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
/** 连接设备 — 传入完整设备对象 */
|
||||
export function connectDevice(device: unknown): Promise<VeepooConnectionResult> {
|
||||
return new Promise((resolve) => {
|
||||
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(
|
||||
device,
|
||||
(res: VeepooConnectionResult) => resolve(res),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册数据监听(必须在连接成功后调用) */
|
||||
export function registerDataListener(callback: (data: SdkEventData) => void): void {
|
||||
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(callback);
|
||||
}
|
||||
|
||||
/** 监听蓝牙连接状态变化 */
|
||||
export function registerConnectionListener(callback: (res: { deviceId: string; connected: boolean }) => void): void {
|
||||
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(callback);
|
||||
}
|
||||
|
||||
/** 断开连接 */
|
||||
export function disconnect(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// ── 功能模块:认证 ──
|
||||
|
||||
/** 秘钥认证(无参数无回调,结果通过数据监听 type=1 返回) */
|
||||
export function authenticate(): void {
|
||||
veepooFeature.veepooBlePasswordCheckManager();
|
||||
}
|
||||
|
||||
// ── 功能模块:测量指令 ──
|
||||
|
||||
/** 心率测量开关(true=开启,false=关闭) */
|
||||
export function setHeartRateMeasure(on: boolean): void {
|
||||
veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: on });
|
||||
}
|
||||
|
||||
/** 血氧测量开关('start'=开启,'stop'=关闭) */
|
||||
export function setBloodOxygenMeasure(action: 'start' | 'stop'): void {
|
||||
veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: action });
|
||||
}
|
||||
|
||||
/** 血压测量开关('start'=开启,'stop'=关闭) */
|
||||
export function setBloodPressureMeasure(action: 'start' | 'stop'): void {
|
||||
veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: action });
|
||||
}
|
||||
|
||||
/** 体温测量(单次触发) */
|
||||
export function startTemperatureMeasure(): void {
|
||||
veepooFeature.veepooSendTemperatureMeasurementSwitchManager();
|
||||
}
|
||||
|
||||
/** 压力测量开关(true=开启,false=关闭) */
|
||||
export function setPressureMeasure(on: boolean): void {
|
||||
veepooFeature.veepooSendPressureTestManager({ switch: on });
|
||||
}
|
||||
|
||||
// ── 功能模块:日常数据 ──
|
||||
|
||||
/** 读取日常数据(day: 0=今天, 1=昨天, 2=前天;package: 开始包序号,默认1) */
|
||||
export function readDailyData(day: number, pkg: number = 1): void {
|
||||
veepooFeature.veepooSendReadDailyDataManager({ day, package: pkg });
|
||||
}
|
||||
|
||||
// ── 功能模块:精准睡眠数据 ──
|
||||
|
||||
/** 精准睡眠事件类型 */
|
||||
export const SDK_EVENT_SLEEP = 4;
|
||||
|
||||
/** 精准睡眠数据(SDK 回调 type=4) */
|
||||
export interface SleepData {
|
||||
/** 入睡时间(时间戳字符串) */
|
||||
fallAsleepTime: string;
|
||||
/** 退出睡眠时间(时间戳字符串) */
|
||||
exitSleepTime: string;
|
||||
/** 起夜得分 */
|
||||
nightScore: number;
|
||||
/** 深睡得分 */
|
||||
deepSleepScore: number;
|
||||
/** 睡眠效率得分 */
|
||||
sleepEfficiencyScore: number;
|
||||
/** 入睡效率得分 */
|
||||
fallAsleepEfficiencyScore: number;
|
||||
/** 睡眠时长得分 */
|
||||
sleepTimeScore: number;
|
||||
/** 睡眠质量(1-5 星) */
|
||||
sleepQuality: number;
|
||||
/** 深睡时长(分钟) */
|
||||
deepSleepTime: number;
|
||||
/** 浅睡时长(分钟) */
|
||||
lightSleepTime: number;
|
||||
/** 其他睡眠时长(分钟) */
|
||||
otherSleepTime: number;
|
||||
/** 睡眠总时长(分钟) */
|
||||
sleepTotalTime: number;
|
||||
/** 首次深睡眠时长(分钟) */
|
||||
firstDeepSleepTime: number;
|
||||
/** 起夜总时长(分钟) */
|
||||
nightTotalTime: number;
|
||||
/** 起夜到深睡均值 */
|
||||
nightDeepSleepMeanValue: number;
|
||||
/** 失眠得分 */
|
||||
insomniaScore: number;
|
||||
/** 失眠次数 */
|
||||
insomniaCount: number;
|
||||
/** 睡眠曲线字符串(0=深睡, 1=浅睡, 2=REM, 3=失眠, 4=苏醒) */
|
||||
sleepCurve: string;
|
||||
}
|
||||
|
||||
/** 读取精准睡眠数据(day: 0=今天, 1=昨天, 2=前天) */
|
||||
export function readPreciseSleepData(day: number): void {
|
||||
veepooFeature.veepooSendReadPreciseSleepManager({ day });
|
||||
}
|
||||
|
||||
// ── 功能模块:自动测量(B3) ──
|
||||
|
||||
/** 自动测量事件类型 */
|
||||
export const SDK_EVENT_AUTO_TEST = 54;
|
||||
|
||||
/** B3 自动测量功能类型枚举 */
|
||||
export const AUTO_TEST_FUN_TYPES = {
|
||||
PULSE_RATE: 0, // 脉率
|
||||
BLOOD_PRESSURE: 1, // 血压
|
||||
BLOOD_GLUCOSE: 2, // 血糖
|
||||
PRESSURE: 3, // 压力
|
||||
BLOOD_OXYGEN: 4, // 血氧
|
||||
TEMPERATURE: 5, // 体温
|
||||
LORENTZ_SCATTER: 6, // 洛伦兹散点图
|
||||
HRV: 7, // HRV
|
||||
BLOOD_COMPONENT: 8, // 血液成分
|
||||
} as const;
|
||||
|
||||
export type AutoTestFunType = (typeof AUTO_TEST_FUN_TYPES)[keyof typeof AUTO_TEST_FUN_TYPES];
|
||||
|
||||
/** B3 自动测量配置项 */
|
||||
export interface AutoTestConfig {
|
||||
/** 协议类型(不可修改) */
|
||||
protocolType: number;
|
||||
/** 功能类型 0-8(可修改) */
|
||||
funTypeContent: AutoTestFunType;
|
||||
/** 开关:0=关闭, 1=开启 */
|
||||
funSwitch: number;
|
||||
/** 最小步进(分钟) */
|
||||
stepUnit: number;
|
||||
/** 是否支持时间段修改 */
|
||||
timeSlotModify: number;
|
||||
/** 是否支持时间间隔修改 */
|
||||
timeIntervalModify: number;
|
||||
/** 支持的测试时间段 */
|
||||
supportTimeSlot: { startTime: string; stopTime: string };
|
||||
/** 测量间隔(分钟,按 stepUnit 递增) */
|
||||
measInterval: number;
|
||||
/** 当前测试时间段 */
|
||||
currentTimeSlot: { startTime: string; stopTime: string };
|
||||
}
|
||||
|
||||
/** 读取自动测量功能配置 */
|
||||
export function readAutoTestConfig(): void {
|
||||
veepooFeature.veepooSendReadB3AutoTestFeatureDataManager();
|
||||
}
|
||||
|
||||
/** 设置自动测量功能 */
|
||||
export function setAutoTestConfig(config: AutoTestConfig): void {
|
||||
veepooFeature.veepooSendSetupB3AutoTestFeatureDataManager({
|
||||
p_protocol_type: config.protocolType,
|
||||
p_fun_type_content: config.funTypeContent,
|
||||
p_fun_switch: config.funSwitch,
|
||||
p_step_unit: config.stepUnit,
|
||||
p_time_slot_modify: config.timeSlotModify,
|
||||
p_time_interval_modify: config.timeIntervalModify,
|
||||
p_support_time_slot: config.supportTimeSlot,
|
||||
p_meas_inv: config.measInterval,
|
||||
p_cur_time_slot: config.currentTimeSlot,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 功能模块:开关设置 ──
|
||||
|
||||
/** 自动心率监测开关 */
|
||||
export function setAutoHeartRate(enabled: boolean): void {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticHRTest: enabled ? 'open' : 'close',
|
||||
});
|
||||
}
|
||||
|
||||
/** 自动血压监测开关 */
|
||||
export function setAutoBloodPressure(enabled: boolean): void {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticBPTest: enabled ? 'open' : 'close',
|
||||
});
|
||||
}
|
||||
|
||||
/** 体温自动监测 */
|
||||
export function setAutoTemperature(enabled: boolean): void {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticTemperatureTest: enabled ? 'open' : 'close',
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取体温自动监测数据 */
|
||||
export function readAutoTemperatureData(): void {
|
||||
veepooFeature.veepooReadAutoTemperatureMeasurementDataManager({ day: 0 });
|
||||
}
|
||||
|
||||
// ── 功能模块:设备信息 ──
|
||||
|
||||
/** 读取设备电量 */
|
||||
export function readBatteryLevel(): void {
|
||||
veepooFeature.veepooReadElectricQuantityManager();
|
||||
}
|
||||
|
||||
// ── 日志模块 ──
|
||||
|
||||
/** 设置日志级别(0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=NONE) */
|
||||
export function setLogLevel(level: number): void {
|
||||
veepooLogger.setLevel(level);
|
||||
}
|
||||
@@ -23,8 +23,44 @@ const SERVICES: Record<string, { uuid: string; chars: { notify: string; read: st
|
||||
read: '00002A35-0000-1000-8000-00805F9B34FB',
|
||||
},
|
||||
},
|
||||
pulse_oximeter: {
|
||||
uuid: '00001822-0000-1000-8000-00805F9B34FB',
|
||||
chars: {
|
||||
// PLX Continuous Measurement — 实时血氧+脉率
|
||||
notify: '00002A5F-0000-1000-8000-00805F9B34FB',
|
||||
// PLX Spot-Check Measurement — 单次测量
|
||||
read: '00002A5E-0000-1000-8000-00805F9B34FB',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── IEEE 11073 SFLOAT 解析(Bluetooth SIG 医疗 Profile 通用格式) ──
|
||||
|
||||
/** 特殊 SFLOAT 值 */
|
||||
const SFLOAT_NAN = 0x07FF;
|
||||
const SFLOAT_NRES = 0x0800;
|
||||
const SFLOAT_POS_INF = 0x07FE;
|
||||
const SFLOAT_NEG_INF = 0x0802;
|
||||
|
||||
function parseSFLOAT(view: DataView, offset: number): number | null {
|
||||
if (offset + 2 > view.byteLength) return null;
|
||||
const raw = view.getUint16(offset, true);
|
||||
|
||||
if (raw === SFLOAT_NAN || raw === SFLOAT_NRES) return null;
|
||||
if (raw === SFLOAT_POS_INF) return Infinity;
|
||||
if (raw === SFLOAT_NEG_INF) return -Infinity;
|
||||
|
||||
const signM = (raw >> 15) & 0x01;
|
||||
const exp = (raw >> 12) & 0x07;
|
||||
const mantissa = raw & 0x0FFF;
|
||||
|
||||
// 指数用 3 位补码表示(0-3 正,4-7 负)
|
||||
const exponent = exp >= 4 ? exp - 8 : exp;
|
||||
const signedMantissa = signM ? -(mantissa ^ 0x0FFF) - 1 : mantissa;
|
||||
|
||||
return signedMantissa * Math.pow(10, exponent);
|
||||
}
|
||||
|
||||
// ── 解析器 ──
|
||||
|
||||
function parseHeartRate(data: ArrayBuffer): NormalizedReading | null {
|
||||
@@ -66,6 +102,39 @@ function parseTemperature(data: ArrayBuffer): NormalizedReading | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Pulse Oximeter Service 数据
|
||||
* PLX Continuous Measurement (0x2A5F) 和 Spot-Check (0x2A5E) 共用
|
||||
* 格式: Flags(1B) + SpO2(SFLOAT 2B) + PulseRate(SFLOAT 2B) + optional...
|
||||
*/
|
||||
function parsePulseOximeter(data: ArrayBuffer): NormalizedReading[] {
|
||||
const view = new DataView(data);
|
||||
if (view.byteLength < 5) return [];
|
||||
|
||||
const spO2 = parseSFLOAT(view, 1);
|
||||
const pulseRate = parseSFLOAT(view, 3);
|
||||
const now = new Date().toISOString();
|
||||
const results: NormalizedReading[] = [];
|
||||
|
||||
if (spO2 !== null && spO2 >= 0 && spO2 <= 100) {
|
||||
results.push({
|
||||
device_type: 'blood_oxygen',
|
||||
values: { blood_oxygen: Math.round(spO2), unit: '%' },
|
||||
measured_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
if (pulseRate !== null && pulseRate > 0 && pulseRate <= 300) {
|
||||
results.push({
|
||||
device_type: 'heart_rate',
|
||||
values: { heart_rate: Math.round(pulseRate) },
|
||||
measured_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── 工厂函数 ──
|
||||
|
||||
export interface GenericAdapterConfig {
|
||||
@@ -100,20 +169,23 @@ export function createGenericBleAdapter(config: GenericAdapterConfig): DeviceAda
|
||||
): NormalizedReading[] {
|
||||
const upper = charUUID.toUpperCase();
|
||||
|
||||
// Heart Rate Measurement
|
||||
const hrsChar = SERVICES.heart_rate.chars.notify.toUpperCase();
|
||||
if (upper === hrsChar || upper.includes('2A37')) {
|
||||
// Heart Rate Measurement (0x2A37)
|
||||
if (upper.includes('2A37')) {
|
||||
const result = parseHeartRate(data);
|
||||
return result ? [result] : [];
|
||||
}
|
||||
|
||||
// Temperature Measurement
|
||||
const htChar = SERVICES.health_thermometer.chars.notify.toUpperCase();
|
||||
if (upper === htChar || upper.includes('2A1C')) {
|
||||
// Temperature Measurement (0x2A1C)
|
||||
if (upper.includes('2A1C')) {
|
||||
const result = parseTemperature(data);
|
||||
return result ? [result] : [];
|
||||
}
|
||||
|
||||
// Pulse Oximeter Continuous (0x2A5F) / Spot-Check (0x2A5E)
|
||||
if (upper.includes('2A5F') || upper.includes('2A5E')) {
|
||||
return parsePulseOximeter(data);
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
@@ -142,4 +214,30 @@ export const CustomBandAdapter = createGenericBleAdapter({
|
||||
profiles: ['heart_rate', 'health_thermometer'],
|
||||
});
|
||||
|
||||
/** 华为手环/手表 BLE 适配器 */
|
||||
export const HuaweiBandAdapter = createGenericBleAdapter({
|
||||
name: 'Huawei Band',
|
||||
supportedModels: [
|
||||
'HUAWEI Band',
|
||||
'HUAWEI Watch',
|
||||
'Huawei Band',
|
||||
'Huawei Watch',
|
||||
'HW-B',
|
||||
'HUAW',
|
||||
'华为手环',
|
||||
'华为手表',
|
||||
],
|
||||
profiles: ['heart_rate', 'health_thermometer', 'pulse_oximeter'],
|
||||
});
|
||||
|
||||
/**
|
||||
* 万能 fallback 适配器 — 匹配所有有名称的设备
|
||||
* 尝试标准 BLE 健康协议(心率/体温/血压),设备不支持的服务会被安全跳过
|
||||
*/
|
||||
export const FallbackAdapter = createGenericBleAdapter({
|
||||
name: '通用设备',
|
||||
supportedModels: [], // 不参与 matchAdapter,仅作为 fallback
|
||||
profiles: ['heart_rate', 'health_thermometer', 'blood_pressure'],
|
||||
});
|
||||
|
||||
export default CustomBandAdapter;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { XiaomiBandAdapter } from './XiaomiBandAdapter';
|
||||
export { BloodPressureAdapter } from './BloodPressureAdapter';
|
||||
export { GlucoseMeterAdapter } from './GlucoseMeterAdapter';
|
||||
export { CustomBandAdapter, createGenericBleAdapter } from './GenericBleAdapter';
|
||||
export { CustomBandAdapter, HuaweiBandAdapter, FallbackAdapter, createGenericBleAdapter } from './GenericBleAdapter';
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface BLEConnection {
|
||||
adapter: DeviceAdapter;
|
||||
connectedAt?: number;
|
||||
error?: string;
|
||||
/** 连接后扫描到的全部服务(用于调试和展示) */
|
||||
discoveredServices?: BLEDiscoveredService[];
|
||||
}
|
||||
|
||||
/** 同步操作结果 */
|
||||
@@ -96,7 +98,20 @@ export interface BLEManagerConfig {
|
||||
export type GenericBLEProfile =
|
||||
| 'heart_rate' // Heart Rate Service (0x180D)
|
||||
| 'health_thermometer' // Health Thermometer Service (0x1809)
|
||||
| 'blood_pressure'; // Blood Pressure Service (0x1810)
|
||||
| 'blood_pressure' // Blood Pressure Service (0x1810)
|
||||
| 'pulse_oximeter'; // Pulse Oximeter Service (0x1822)
|
||||
|
||||
/** BLE 服务发现结果(连接后扫描到的全部服务/特征) */
|
||||
export interface BLEDiscoveredCharacteristic {
|
||||
uuid: string;
|
||||
properties: { read: boolean; write: boolean; notify: boolean; indicate: boolean };
|
||||
}
|
||||
|
||||
export interface BLEDiscoveredService {
|
||||
uuid: string;
|
||||
isPrimary: boolean;
|
||||
characteristics: BLEDiscoveredCharacteristic[];
|
||||
}
|
||||
|
||||
/** 微信 BLE 扫描回调结果 */
|
||||
export interface BLEScanResult {
|
||||
|
||||
245
apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts
Normal file
245
apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Veepoo 历史数据读取器 — 3天日常数据分批读取 + 上传
|
||||
*
|
||||
* SDK 日常数据格式(type=5):
|
||||
* - 包含计步、心率、血压、血氧、睡眠、压力、体温等
|
||||
* - Progress 字段 1-100% 表示读取进度
|
||||
* - 每次回调可能包含一包数据
|
||||
*/
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
import { readDailyData } from '../VeepooBridge';
|
||||
import type { SdkEventData } from '../VeepooBridge';
|
||||
import type { NormalizedReading } from '../types';
|
||||
import type { SleepReading } from './types';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
|
||||
const CHECKPOINT_KEY = 'veepoo_history_checkpoint';
|
||||
const UPLOAD_BATCH_SIZE = 20;
|
||||
|
||||
interface Checkpoint {
|
||||
lastProgress: number;
|
||||
packagesRead: number;
|
||||
deviceId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type HistoryReadPhase = 'idle' | 'reading' | 'uploading' | 'done' | 'error';
|
||||
|
||||
export class VeepooHistoryReader {
|
||||
private phase: HistoryReadPhase = 'idle';
|
||||
private progress = 0;
|
||||
private packagesRead = 0;
|
||||
private buffer: NormalizedReading[] = [];
|
||||
private day = 0;
|
||||
private patientId = '';
|
||||
private deviceId = '';
|
||||
private onProgress?: (progress: number, phase: HistoryReadPhase) => void;
|
||||
private uploadedCount = 0;
|
||||
|
||||
setCallbacks(cbs: { onProgress?: (progress: number, phase: HistoryReadPhase) => void }): void {
|
||||
this.onProgress = cbs.onProgress;
|
||||
}
|
||||
|
||||
/** 开始读取3天数据 */
|
||||
async startRead(patientId: string, deviceId: string): Promise<number> {
|
||||
this.patientId = patientId;
|
||||
this.deviceId = deviceId;
|
||||
this.buffer = [];
|
||||
this.uploadedCount = 0;
|
||||
this.phase = 'reading';
|
||||
|
||||
// 依次读取 3 天数据
|
||||
for (let day = 0; day < 3; day++) {
|
||||
this.day = day;
|
||||
this.progress = 0;
|
||||
this.onProgress?.(0, 'reading');
|
||||
|
||||
await this.readDay(day);
|
||||
|
||||
// 刷新剩余 buffer
|
||||
if (this.buffer.length > 0) {
|
||||
await this.flushBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
this.phase = 'done';
|
||||
this.onProgress?.(100, 'done');
|
||||
this.clearCheckpoint();
|
||||
|
||||
return this.uploadedCount;
|
||||
}
|
||||
|
||||
/** 读取单天数据 */
|
||||
private readDay(day: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// 发送读取指令
|
||||
readDailyData(day, 1);
|
||||
|
||||
// 进度通过 handleDailyEvent 更新
|
||||
// Progress=100 时 resolve
|
||||
this.dayResolve = resolve;
|
||||
|
||||
// 超时保护:30s
|
||||
this.dayTimeout = setTimeout(() => {
|
||||
this.dayResolve = null;
|
||||
resolve();
|
||||
}, 30_000);
|
||||
});
|
||||
}
|
||||
|
||||
private dayResolve: (() => void) | null = null;
|
||||
private dayTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** 处理 SDK 日常数据回调 */
|
||||
handleDailyEvent(data: SdkEventData): void {
|
||||
if (this.phase !== 'reading') return;
|
||||
|
||||
const progress = (data.Progress ?? 0) as number;
|
||||
this.progress = progress;
|
||||
this.onProgress?.(progress, 'reading');
|
||||
|
||||
// 解析数据
|
||||
const readings = this.parseDailyData(data);
|
||||
if (readings.length > 0) {
|
||||
this.buffer.push(...readings);
|
||||
this.packagesRead++;
|
||||
}
|
||||
|
||||
// 达到批量大小就上传
|
||||
if (this.buffer.length >= UPLOAD_BATCH_SIZE) {
|
||||
this.flushBuffer();
|
||||
}
|
||||
|
||||
// 进度 100% 表示当天数据读取完成
|
||||
if (progress >= 100) {
|
||||
if (this.dayTimeout) clearTimeout(this.dayTimeout);
|
||||
this.dayTimeout = null;
|
||||
const resolve = this.dayResolve;
|
||||
this.dayResolve = null;
|
||||
resolve?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 SDK 日常数据为 NormalizedReading */
|
||||
private parseDailyData(data: SdkEventData): NormalizedReading[] {
|
||||
const content = data.content ?? {};
|
||||
const readings: NormalizedReading[] = [];
|
||||
const now = new Date();
|
||||
// 偏移到对应天
|
||||
const baseDate = new Date(now.getTime() - this.day * 86400000);
|
||||
const timestamp = baseDate.toISOString();
|
||||
|
||||
// 心率
|
||||
const hr = content.heartReat ?? content.heartRate;
|
||||
if (typeof hr === 'number' && hr >= 30 && hr <= 250) {
|
||||
readings.push({ device_type: 'heart_rate', values: { heart_rate: hr }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 血氧
|
||||
const bo = content.bloodOxygen;
|
||||
if (typeof bo === 'number' && bo >= 70 && bo <= 100) {
|
||||
readings.push({ device_type: 'blood_oxygen', values: { blood_oxygen: bo }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 血压
|
||||
const bph = content.bloodPressureHigh;
|
||||
const bpl = content.bloodPressureLow;
|
||||
if (typeof bph === 'number' && typeof bpl === 'number' && bph > 0 && bpl > 0) {
|
||||
readings.push({ device_type: 'blood_pressure', values: { systolic: bph, diastolic: bpl }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 体温
|
||||
const temp = content.bodyTemperature;
|
||||
if (typeof temp === 'number' && temp > 30 && temp < 45) {
|
||||
readings.push({ device_type: 'temperature', values: { temperature: temp }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 压力
|
||||
const pressure = content.pressure;
|
||||
if (typeof pressure === 'number' && pressure >= 0 && pressure <= 100) {
|
||||
readings.push({ device_type: 'stress', values: { value: pressure }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 步数
|
||||
const steps = content.stepCount ?? content.steps;
|
||||
if (typeof steps === 'number' && steps >= 0) {
|
||||
readings.push({ device_type: 'steps', values: { value: steps }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
return readings;
|
||||
}
|
||||
|
||||
/** 上传 buffer 中的数据 */
|
||||
private async flushBuffer(): Promise<void> {
|
||||
if (this.buffer.length === 0) return;
|
||||
|
||||
const batch = this.buffer.splice(0, this.buffer.length);
|
||||
this.phase = 'uploading';
|
||||
this.onProgress?.(this.progress, 'uploading');
|
||||
|
||||
try {
|
||||
await uploadReadings(this.patientId, this.deviceId, 'Veepoo M2', batch);
|
||||
this.uploadedCount += batch.length;
|
||||
this.saveCheckpoint();
|
||||
} catch {
|
||||
// 上传失败,放回 buffer
|
||||
this.buffer.unshift(...batch);
|
||||
}
|
||||
|
||||
this.phase = 'reading';
|
||||
}
|
||||
|
||||
private saveCheckpoint(): void {
|
||||
try {
|
||||
const checkpoint: Checkpoint = {
|
||||
lastProgress: this.progress,
|
||||
packagesRead: this.packagesRead,
|
||||
deviceId: this.deviceId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
Taro.setStorageSync(CHECKPOINT_KEY, JSON.stringify(checkpoint));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private clearCheckpoint(): void {
|
||||
try { Taro.removeStorageSync(CHECKPOINT_KEY); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
getPhase(): HistoryReadPhase { return this.phase; }
|
||||
getProgress(): number { return this.progress; }
|
||||
getUploadedCount(): number { return this.uploadedCount; }
|
||||
|
||||
// ── 睡眠数据上传 ──
|
||||
|
||||
/** 将睡眠数据转换为 NormalizedReading 并上传 */
|
||||
async uploadSleepReadings(patientId: string, deviceId: string, sleepData: SleepReading[]): Promise<number> {
|
||||
if (sleepData.length === 0) return 0;
|
||||
|
||||
const now = new Date();
|
||||
const readings: NormalizedReading[] = sleepData.map((sleep) => {
|
||||
// 根据天数偏移计算日期
|
||||
const baseDate = new Date(now.getTime() - sleep.day * 86400000);
|
||||
return {
|
||||
device_type: 'sleep',
|
||||
values: {
|
||||
deep_sleep_minutes: sleep.deepSleepMinutes,
|
||||
light_sleep_minutes: sleep.lightSleepMinutes,
|
||||
total_sleep_minutes: sleep.totalSleepMinutes,
|
||||
quality_score: sleep.qualityScore,
|
||||
},
|
||||
measured_at: baseDate.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadReadings(patientId, deviceId, 'Veepoo M2', readings);
|
||||
this.uploadedCount += readings.length;
|
||||
console.log('[veepoo-history] 睡眠数据上传成功:', readings.length, '条');
|
||||
return readings.length;
|
||||
} catch (err) {
|
||||
console.error('[veepoo-history] 睡眠数据上传失败:', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
588
apps/miniprogram/src/services/ble/veepoo/VeepooPipeline.ts
Normal file
588
apps/miniprogram/src/services/ble/veepoo/VeepooPipeline.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* Veepoo 管线 — SDK 事件路由 + 连接编排 + 测量 Promise 封装
|
||||
*
|
||||
* 职责:
|
||||
* 1. 连接流程编排:扫描 → 连接 → 注册监听 → 认证 → 就绪
|
||||
* 2. SDK 事件路由:registerDataListener 按 type 分发
|
||||
* 3. 测量 Promise 化:startMeasure(type) → Promise<MeasureResult>
|
||||
*/
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
import {
|
||||
startScan,
|
||||
stopScan,
|
||||
connectDevice,
|
||||
registerDataListener,
|
||||
registerConnectionListener,
|
||||
authenticate,
|
||||
disconnect as veepooDisconnect,
|
||||
setHeartRateMeasure,
|
||||
setBloodOxygenMeasure,
|
||||
setBloodPressureMeasure,
|
||||
startTemperatureMeasure,
|
||||
setPressureMeasure,
|
||||
readBatteryLevel,
|
||||
readPreciseSleepData,
|
||||
readAutoTestConfig,
|
||||
setAutoHeartRate,
|
||||
setAutoBloodPressure,
|
||||
setAutoTemperature,
|
||||
setLogLevel,
|
||||
SDK_EVENT_AUTH,
|
||||
SDK_EVENT_HEART_RATE,
|
||||
SDK_EVENT_BLOOD_OXYGEN,
|
||||
SDK_EVENT_BLOOD_PRESSURE,
|
||||
SDK_EVENT_TEMPERATURE,
|
||||
SDK_EVENT_PRESSURE,
|
||||
SDK_EVENT_DAILY,
|
||||
SDK_EVENT_SLEEP,
|
||||
SDK_EVENT_AUTO_TEST,
|
||||
DEVICE_STATE,
|
||||
} from '../VeepooBridge';
|
||||
import type { SdkEventData } from '../VeepooBridge';
|
||||
import type { MeasureType, MeasureResult, SleepReading } from './types';
|
||||
|
||||
const AUTH_TIMEOUT = 8_000;
|
||||
const AUTH_POLL_INTERVAL = 500;
|
||||
const MEASURE_SETTLE_DELAY = 1_500;
|
||||
|
||||
/** pending 测量的 resolve/reject 句柄 */
|
||||
interface PendingMeasure {
|
||||
type: MeasureType;
|
||||
resolve: (result: MeasureResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
lastValue: number | null;
|
||||
lastValues: Record<string, number>;
|
||||
settleTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
/** SDK type 到 MeasureType 的映射 */
|
||||
const SDK_TYPE_TO_MEASURE: Record<number, MeasureType> = {
|
||||
[SDK_EVENT_HEART_RATE]: 'heart_rate',
|
||||
[SDK_EVENT_BLOOD_OXYGEN]: 'blood_oxygen',
|
||||
[SDK_EVENT_BLOOD_PRESSURE]: 'blood_pressure',
|
||||
[SDK_EVENT_TEMPERATURE]: 'temperature',
|
||||
[SDK_EVENT_PRESSURE]: 'pressure',
|
||||
};
|
||||
|
||||
export type ConnectionChangeCallback = (connected: boolean, deviceId: string) => void;
|
||||
export type AuthResultCallback = (success: boolean) => void;
|
||||
export type MeasureEventCallback = (type: MeasureType, data: Record<string, unknown>) => void;
|
||||
export type DailyDataCallback = (data: SdkEventData) => void;
|
||||
export type SleepDataCallback = (day: number, sleep: SleepReading) => void;
|
||||
|
||||
export class VeepooPipeline {
|
||||
private pending: PendingMeasure | null = null;
|
||||
private isConnected = false;
|
||||
private deviceId = '';
|
||||
|
||||
/** 睡眠数据读取 Promise resolve 队列 */
|
||||
private sleepResolvers: Map<number, (sleep: SleepReading | null) => void> = new Map();
|
||||
private sleepTimeouts: Map<number, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
private onConnectionChange?: ConnectionChangeCallback;
|
||||
private onAuthResult?: AuthResultCallback;
|
||||
private onMeasureEvent?: MeasureEventCallback;
|
||||
private onDailyData?: DailyDataCallback;
|
||||
private onSleepData?: SleepDataCallback;
|
||||
|
||||
/** 注册回调 */
|
||||
setCallbacks(cbs: {
|
||||
onConnectionChange?: ConnectionChangeCallback;
|
||||
onAuthResult?: AuthResultCallback;
|
||||
onMeasureEvent?: MeasureEventCallback;
|
||||
onDailyData?: DailyDataCallback;
|
||||
onSleepData?: SleepDataCallback;
|
||||
}): void {
|
||||
this.onConnectionChange = cbs.onConnectionChange;
|
||||
this.onAuthResult = cbs.onAuthResult;
|
||||
this.onMeasureEvent = cbs.onMeasureEvent;
|
||||
this.onDailyData = cbs.onDailyData;
|
||||
this.onSleepData = cbs.onSleepData;
|
||||
}
|
||||
|
||||
/** 全流程:扫描 → 连接 → 注册监听 → 认证 */
|
||||
async connect(targetName: string, debug = false): Promise<string> {
|
||||
console.log('[veepoo-pipeline] connect() 开始, target:', targetName);
|
||||
if (debug) setLogLevel(0);
|
||||
|
||||
// 1. 扫描
|
||||
console.log('[veepoo-pipeline] Step 1: 扫描...');
|
||||
const device = await this.scanFor(targetName);
|
||||
if (!device) {
|
||||
console.error('[veepoo-pipeline] 扫描未找到设备');
|
||||
throw new Error(`未找到设备 ${targetName}`);
|
||||
}
|
||||
console.log('[veepoo-pipeline] 找到设备:', (device as Record<string, unknown>)?.deviceId);
|
||||
|
||||
// 2. 连接
|
||||
console.log('[veepoo-pipeline] Step 2: 连接...');
|
||||
const connRes = await connectDevice(device);
|
||||
console.log('[veepoo-pipeline] 连接结果:', JSON.stringify(connRes));
|
||||
// SDK 连接成功返回 errno=0 或 connection=true,两种都要兼容
|
||||
const ok = connRes?.connection === true || connRes?.errno === 0 || connRes?.errCode === 0;
|
||||
if (!ok) throw new Error('连接失败');
|
||||
|
||||
const id = (device as Record<string, unknown>).deviceId as string;
|
||||
this.deviceId = id;
|
||||
this.isConnected = true;
|
||||
|
||||
// 3. 注册数据监听(连接成功后)
|
||||
registerDataListener((data) => this.routeEvent(data));
|
||||
registerConnectionListener((res) => {
|
||||
this.isConnected = res.connected;
|
||||
this.onConnectionChange?.(res.connected, res.deviceId);
|
||||
});
|
||||
|
||||
// 4. 认证(延迟 500ms)
|
||||
await delay(500);
|
||||
authenticate();
|
||||
|
||||
// 5. 等待认证结果
|
||||
const authOk = await this.waitForAuth();
|
||||
if (!authOk) throw new Error('设备认证失败,请重新连接');
|
||||
|
||||
// 6. 读取电量
|
||||
readBatteryLevel();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 扫描指定名称的设备 */
|
||||
private scanFor(targetName: string): Promise<unknown | null> {
|
||||
return new Promise((resolve) => {
|
||||
let found: unknown = null;
|
||||
const upper = targetName.toUpperCase();
|
||||
|
||||
startScan((device) => {
|
||||
const d = device as Record<string, unknown>;
|
||||
const name = String(d.localName ?? d.name ?? '').toUpperCase();
|
||||
if (name.includes(upper) && !found) {
|
||||
found = device;
|
||||
stopScan().then(() => resolve(found));
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!found) {
|
||||
stopScan().then(() => resolve(null));
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** 等待认证结果(轮询 deviceChipStatus) */
|
||||
private waitForAuth(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
|
||||
const poll = () => {
|
||||
try {
|
||||
const status = Taro.getStorageSync('deviceChipStatus');
|
||||
if (status === 'successfulVerification' || status === 'passTheVerification') {
|
||||
this.onAuthResult?.(true);
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (Date.now() - start >= AUTH_TIMEOUT) {
|
||||
this.onAuthResult?.(false);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(poll, AUTH_POLL_INTERVAL);
|
||||
};
|
||||
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
/** SDK 事件路由 */
|
||||
private routeEvent(data: SdkEventData): void {
|
||||
const eventType = data.type;
|
||||
|
||||
// 认证回调
|
||||
if (eventType === SDK_EVENT_AUTH) {
|
||||
const content = data.content ?? {};
|
||||
const password = content.VPDevicepassword;
|
||||
if (password === 'passTheVerification' || password === 'successfulVerification') {
|
||||
this.onAuthResult?.(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 日常数据
|
||||
if (eventType === SDK_EVENT_DAILY) {
|
||||
this.onDailyData?.(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 精准睡眠数据
|
||||
if (eventType === SDK_EVENT_SLEEP) {
|
||||
this.handleSleepEvent(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动测量功能回调
|
||||
if (eventType === SDK_EVENT_AUTO_TEST) {
|
||||
console.log('[veepoo-pipeline] 自动测量配置回调:', JSON.stringify(data).substring(0, 300));
|
||||
return;
|
||||
}
|
||||
|
||||
// 测量数据
|
||||
const measureType = SDK_TYPE_TO_MEASURE[eventType];
|
||||
if (!measureType) return;
|
||||
|
||||
this.handleMeasureEvent(measureType, data);
|
||||
this.onMeasureEvent?.(measureType, data.content ?? {});
|
||||
}
|
||||
|
||||
/** 处理测量事件 */
|
||||
private handleMeasureEvent(type: MeasureType, data: SdkEventData): void {
|
||||
if (!this.pending || this.pending.type !== type) return;
|
||||
|
||||
const content = data.content ?? {};
|
||||
|
||||
// 检查设备状态错误
|
||||
const deviceBusy = content.deviceBusy === true;
|
||||
const notWear = content.notWear === true;
|
||||
const state = data.state;
|
||||
const ack = data.ack;
|
||||
|
||||
if (deviceBusy) {
|
||||
this.rejectPending(new Error('设备正忙,请稍后重试'));
|
||||
return;
|
||||
}
|
||||
if (notWear || state === DEVICE_STATE.NOT_WORN) {
|
||||
this.rejectPending(new Error('请将手环佩戴到手腕上'));
|
||||
return;
|
||||
}
|
||||
if (state === DEVICE_STATE.CHARGING) {
|
||||
this.rejectPending(new Error('设备正在充电,请取出后重试'));
|
||||
return;
|
||||
}
|
||||
if (state === DEVICE_STATE.LOW_BATTERY) {
|
||||
this.rejectPending(new Error('设备电量不足,请充电后重试'));
|
||||
return;
|
||||
}
|
||||
if (type === 'pressure' && ack === 2) {
|
||||
this.rejectPending(new Error('设备电量不足'));
|
||||
return;
|
||||
}
|
||||
if (type === 'pressure' && ack === 3) {
|
||||
this.rejectPending(new Error('设备正在测量其他数据'));
|
||||
return;
|
||||
}
|
||||
if (type === 'pressure' && ack === 4) {
|
||||
this.rejectPending(new Error('佩戴检测未通过'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取数值
|
||||
const values = this.extractValues(type, content);
|
||||
if (!values) return;
|
||||
|
||||
// 更新 pending 最新值
|
||||
this.pending.lastValues = values;
|
||||
|
||||
// 对于进度型指标,检查是否完成
|
||||
const progress = data.Progress;
|
||||
if (progress !== undefined && progress >= 100) {
|
||||
this.resolvePending(values);
|
||||
return;
|
||||
}
|
||||
|
||||
// 对于持续测量型/单次型,收到第一个有效值后延迟 settle
|
||||
if (this.pending.settleTimer === null) {
|
||||
this.pending.settleTimer = setTimeout(() => {
|
||||
if (this.pending && this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) {
|
||||
this.resolvePending(this.pending.lastValues);
|
||||
}
|
||||
}, MEASURE_SETTLE_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 SDK content 提取标准化数值 */
|
||||
private extractValues(type: MeasureType, content: Record<string, unknown>): Record<string, number> | null {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
const hr = Number(content.heartRate);
|
||||
if (hr >= 30 && hr <= 250) return { heart_rate: hr };
|
||||
return null;
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
const bo = Number(content.bloodOxygen);
|
||||
if (bo >= 70 && bo <= 100) return { blood_oxygen: bo };
|
||||
return null;
|
||||
}
|
||||
case 'blood_pressure': {
|
||||
const high = Number(content.bloodPressureHigh);
|
||||
const low = Number(content.bloodPressureLow);
|
||||
if (high > 0 && low > 0) return { systolic: high, diastolic: low };
|
||||
return null;
|
||||
}
|
||||
case 'temperature': {
|
||||
const temp = Number(content.bodyTemperature);
|
||||
if (temp > 30 && temp < 45) return { temperature: temp };
|
||||
return null;
|
||||
}
|
||||
case 'pressure': {
|
||||
const p = Number(content.pressure);
|
||||
if (p >= 0 && p <= 100) return { pressure: p };
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 发起测量 */
|
||||
startMeasure(type: MeasureType): Promise<MeasureResult> {
|
||||
if (this.pending) {
|
||||
throw new Error(`正在测量 ${this.pending.type},请等待完成`);
|
||||
}
|
||||
if (!this.isConnected) {
|
||||
throw new Error('设备未连接');
|
||||
}
|
||||
|
||||
return new Promise<MeasureResult>((resolve, reject) => {
|
||||
const timeout = getMeasureTimeout(type);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.rejectPending(new Error('测量超时,请重试'));
|
||||
}, timeout);
|
||||
|
||||
this.pending = {
|
||||
type,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
lastValue: null,
|
||||
lastValues: {},
|
||||
settleTimer: null,
|
||||
};
|
||||
|
||||
// 发送 SDK 测量指令
|
||||
this.sendMeasureCommand(type);
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消当前测量 */
|
||||
cancelMeasure(): void {
|
||||
if (!this.pending) return;
|
||||
this.stopMeasureCommand(this.pending.type);
|
||||
if (this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) {
|
||||
this.resolvePending(this.pending.lastValues);
|
||||
} else {
|
||||
this.rejectPending(new Error('测量已取消'));
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送 SDK 测量指令 */
|
||||
private sendMeasureCommand(type: MeasureType): void {
|
||||
switch (type) {
|
||||
case 'heart_rate':
|
||||
setHeartRateMeasure(true);
|
||||
break;
|
||||
case 'blood_oxygen':
|
||||
setBloodOxygenMeasure('start');
|
||||
break;
|
||||
case 'blood_pressure':
|
||||
setBloodPressureMeasure('start');
|
||||
break;
|
||||
case 'temperature':
|
||||
startTemperatureMeasure();
|
||||
break;
|
||||
case 'pressure':
|
||||
setPressureMeasure(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送 SDK 停止测量指令 */
|
||||
private stopMeasureCommand(type: MeasureType): void {
|
||||
switch (type) {
|
||||
case 'heart_rate':
|
||||
setHeartRateMeasure(false);
|
||||
break;
|
||||
case 'blood_oxygen':
|
||||
setBloodOxygenMeasure('stop');
|
||||
break;
|
||||
case 'blood_pressure':
|
||||
setBloodPressureMeasure('stop');
|
||||
break;
|
||||
case 'temperature':
|
||||
break; // 体温是单次触发,无法停止
|
||||
case 'pressure':
|
||||
setPressureMeasure(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** 成功 resolve pending 测量 */
|
||||
private resolvePending(values: Record<string, number>): void {
|
||||
if (!this.pending) return;
|
||||
const p = this.pending;
|
||||
this.pending = null;
|
||||
|
||||
clearTimeout(p.timer);
|
||||
if (p.settleTimer) clearTimeout(p.settleTimer);
|
||||
|
||||
// 停止持续测量型指标的 SDK 指令
|
||||
this.stopMeasureCommand(p.type);
|
||||
|
||||
p.resolve({
|
||||
type: p.type,
|
||||
values,
|
||||
measuredAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/** 失败 reject pending 测量 */
|
||||
private rejectPending(error: Error): void {
|
||||
if (!this.pending) return;
|
||||
const p = this.pending;
|
||||
this.pending = null;
|
||||
|
||||
clearTimeout(p.timer);
|
||||
if (p.settleTimer) clearTimeout(p.settleTimer);
|
||||
|
||||
// 停止 SDK 指令
|
||||
this.stopMeasureCommand(p.type);
|
||||
|
||||
p.reject(error);
|
||||
}
|
||||
|
||||
// ── 睡眠数据 ──
|
||||
|
||||
/** 读取单天精准睡眠数据,返回 Promise */
|
||||
readSleepData(day: number): Promise<SleepReading | null> {
|
||||
if (!this.isConnected) {
|
||||
return Promise.reject(new Error('设备未连接'));
|
||||
}
|
||||
|
||||
return new Promise<SleepReading | null>((resolve) => {
|
||||
this.sleepResolvers.set(day, resolve);
|
||||
|
||||
// 超时保护 30s
|
||||
const timer = setTimeout(() => {
|
||||
this.sleepResolvers.delete(day);
|
||||
this.sleepTimeouts.delete(day);
|
||||
resolve(null);
|
||||
}, 30_000);
|
||||
this.sleepTimeouts.set(day, timer);
|
||||
|
||||
// 发送 SDK 读取指令
|
||||
readPreciseSleepData(day);
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取 3 天睡眠数据 */
|
||||
async readAllSleepData(): Promise<SleepReading[]> {
|
||||
const results: SleepReading[] = [];
|
||||
for (let day = 0; day < 3; day++) {
|
||||
const sleep = await this.readSleepData(day);
|
||||
if (sleep) {
|
||||
results.push(sleep);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** 处理 SDK 睡眠数据回调(type=4) */
|
||||
private handleSleepEvent(data: SdkEventData): void {
|
||||
const progress = data.Progress ?? 0;
|
||||
const readDay = (data as { readDay?: number }).readDay ?? 0;
|
||||
|
||||
// 进度未达 100% 忽略
|
||||
if (progress < 100) return;
|
||||
|
||||
const content = data.content ?? {};
|
||||
const sleep = this.parseSleepData(readDay, content as Record<string, unknown>);
|
||||
|
||||
// 通知回调
|
||||
if (sleep) {
|
||||
this.onSleepData?.(readDay, sleep);
|
||||
}
|
||||
|
||||
// resolve 等待中的 Promise
|
||||
const resolve = this.sleepResolvers.get(readDay);
|
||||
if (resolve) {
|
||||
const timer = this.sleepTimeouts.get(readDay);
|
||||
if (timer) clearTimeout(timer);
|
||||
this.sleepResolvers.delete(readDay);
|
||||
this.sleepTimeouts.delete(readDay);
|
||||
resolve(sleep);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 SDK content 解析精准睡眠数据 */
|
||||
private parseSleepData(day: number, content: Record<string, unknown>): SleepReading | null {
|
||||
const total = Number(content.sleepTotalTime ?? 0);
|
||||
if (total <= 0) return null;
|
||||
|
||||
return {
|
||||
day,
|
||||
deepSleepMinutes: Number(content.deepSleepTime ?? 0),
|
||||
lightSleepMinutes: Number(content.lightSleepTime ?? 0),
|
||||
otherSleepMinutes: Number(content.otherSleepTime ?? 0),
|
||||
totalSleepMinutes: total,
|
||||
qualityScore: Number(content.sleepQuality ?? 0),
|
||||
fallAsleepTime: String(content.fallAsleepTime ?? ''),
|
||||
exitSleepTime: String(content.exitSleepTime ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 自动测量 ──
|
||||
|
||||
/** 开启自动测量(心率 + 血压 + 血氧 + 体温) */
|
||||
enableAutoMeasurement(): void {
|
||||
if (!this.isConnected) return;
|
||||
|
||||
console.log('[veepoo-pipeline] 开启自动测量功能');
|
||||
setAutoHeartRate(true);
|
||||
setAutoBloodPressure(true);
|
||||
setAutoTemperature(true);
|
||||
|
||||
// 读取当前自动测量配置
|
||||
readAutoTestConfig();
|
||||
}
|
||||
|
||||
/** 断开连接 */
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.pending) {
|
||||
this.rejectPending(new Error('设备已断开'));
|
||||
}
|
||||
this.isConnected = false;
|
||||
this.deviceId = '';
|
||||
await veepooDisconnect();
|
||||
}
|
||||
|
||||
/** 获取连接状态 */
|
||||
getConnected(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
/** 获取设备 ID */
|
||||
getDeviceId(): string {
|
||||
return this.deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function getMeasureTimeout(type: MeasureType): number {
|
||||
const timeouts: Record<MeasureType, number> = {
|
||||
heart_rate: 60_000,
|
||||
blood_oxygen: 60_000,
|
||||
blood_pressure: 120_000,
|
||||
temperature: 60_000,
|
||||
pressure: 90_000,
|
||||
};
|
||||
return timeouts[type];
|
||||
}
|
||||
21
apps/miniprogram/src/services/ble/veepoo/index.ts
Normal file
21
apps/miniprogram/src/services/ble/veepoo/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { VeepooPipeline } from './VeepooPipeline';
|
||||
export { VeepooHistoryReader } from './VeepooHistoryReader';
|
||||
export type {
|
||||
ConnectionChangeCallback,
|
||||
AuthResultCallback,
|
||||
MeasureEventCallback,
|
||||
DailyDataCallback,
|
||||
} from './VeepooPipeline';
|
||||
export type {
|
||||
MeasureType,
|
||||
MeasurePhase,
|
||||
MeasureStatus,
|
||||
MeasureResult,
|
||||
MeasureConfig,
|
||||
ConnectionPhase,
|
||||
VeepooDeviceInfo,
|
||||
HistorySyncState,
|
||||
SleepReading,
|
||||
AutoTestSyncState,
|
||||
} from './types';
|
||||
export { MEASURE_TYPES, MEASURE_CONFIG } from './types';
|
||||
152
apps/miniprogram/src/services/ble/veepoo/types.ts
Normal file
152
apps/miniprogram/src/services/ble/veepoo/types.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/** Veepoo 管线专用类型定义 */
|
||||
|
||||
/** 测量指标类型 */
|
||||
export type MeasureType =
|
||||
| 'heart_rate'
|
||||
| 'blood_oxygen'
|
||||
| 'blood_pressure'
|
||||
| 'temperature'
|
||||
| 'pressure';
|
||||
|
||||
/** 所有支持的测量指标 */
|
||||
export const MEASURE_TYPES: readonly MeasureType[] = [
|
||||
'heart_rate',
|
||||
'blood_oxygen',
|
||||
'blood_pressure',
|
||||
'temperature',
|
||||
'pressure',
|
||||
] as const;
|
||||
|
||||
/** 测量指标配置 */
|
||||
export interface MeasureConfig {
|
||||
label: string;
|
||||
unit: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
/** 正常范围 [min, max] */
|
||||
normalRange: [number, number];
|
||||
/** 测量超时(毫秒) */
|
||||
timeout: number;
|
||||
/** 测量模式 */
|
||||
mode: 'continuous' | 'progress' | 'single';
|
||||
}
|
||||
|
||||
/** 各指标配置表 */
|
||||
export const MEASURE_CONFIG: Record<MeasureType, MeasureConfig> = {
|
||||
heart_rate: {
|
||||
label: '心率',
|
||||
unit: 'bpm',
|
||||
icon: '♥',
|
||||
color: '#EF4444',
|
||||
normalRange: [60, 100],
|
||||
timeout: 60_000,
|
||||
mode: 'continuous',
|
||||
},
|
||||
blood_oxygen: {
|
||||
label: '血氧',
|
||||
unit: '%',
|
||||
icon: 'O₂',
|
||||
color: '#3B82F6',
|
||||
normalRange: [95, 100],
|
||||
timeout: 60_000,
|
||||
mode: 'continuous',
|
||||
},
|
||||
blood_pressure: {
|
||||
label: '血压',
|
||||
unit: 'mmHg',
|
||||
icon: '↕',
|
||||
color: '#8B5CF6',
|
||||
normalRange: [90, 140],
|
||||
timeout: 120_000,
|
||||
mode: 'progress',
|
||||
},
|
||||
temperature: {
|
||||
label: '体温',
|
||||
unit: '°C',
|
||||
icon: 'T',
|
||||
color: '#F59E0B',
|
||||
normalRange: [36.0, 37.3],
|
||||
timeout: 60_000,
|
||||
mode: 'single',
|
||||
},
|
||||
pressure: {
|
||||
label: '压力',
|
||||
unit: '',
|
||||
icon: '~',
|
||||
color: '#6366F1',
|
||||
normalRange: [1, 40],
|
||||
timeout: 90_000,
|
||||
mode: 'progress',
|
||||
},
|
||||
};
|
||||
|
||||
/** 连接阶段 */
|
||||
export type ConnectionPhase =
|
||||
| 'idle'
|
||||
| 'scanning'
|
||||
| 'connecting'
|
||||
| 'authenticating'
|
||||
| 'ready'
|
||||
| 'disconnected'
|
||||
| 'error';
|
||||
|
||||
/** 测量阶段 */
|
||||
export type MeasurePhase = 'idle' | 'measuring' | 'success' | 'error';
|
||||
|
||||
/** 单个指标的测量状态 */
|
||||
export interface MeasureStatus {
|
||||
phase: MeasurePhase;
|
||||
progress: number;
|
||||
currentValue: number | null;
|
||||
result: MeasureResult | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** 测量结果 */
|
||||
export interface MeasureResult {
|
||||
type: MeasureType;
|
||||
values: Record<string, number>;
|
||||
measuredAt: number;
|
||||
}
|
||||
|
||||
/** 设备信息 */
|
||||
export interface VeepooDeviceInfo {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
batteryLevel: number | null;
|
||||
}
|
||||
|
||||
/** 历史数据同步状态 */
|
||||
export interface HistorySyncState {
|
||||
phase: 'idle' | 'reading' | 'uploading' | 'done';
|
||||
progress: number;
|
||||
packagesRead: number;
|
||||
lastCheckpoint: number;
|
||||
}
|
||||
|
||||
/** 睡眠数据(从 SDK 精准睡眠解析) */
|
||||
export interface SleepReading {
|
||||
/** 读取天数(0=今天, 1=昨天, 2=前天) */
|
||||
day: number;
|
||||
/** 深睡时长(分钟) */
|
||||
deepSleepMinutes: number;
|
||||
/** 浅睡时长(分钟) */
|
||||
lightSleepMinutes: number;
|
||||
/** 其他睡眠时长(分钟) */
|
||||
otherSleepMinutes: number;
|
||||
/** 睡眠总时长(分钟) */
|
||||
totalSleepMinutes: number;
|
||||
/** 睡眠质量评分(1-5 星) */
|
||||
qualityScore: number;
|
||||
/** 入睡时间(时间戳字符串) */
|
||||
fallAsleepTime: string;
|
||||
/** 退出睡眠时间(时间戳字符串) */
|
||||
exitSleepTime: string;
|
||||
}
|
||||
|
||||
/** 自动测量同步状态 */
|
||||
export interface AutoTestSyncState {
|
||||
phase: 'idle' | 'reading_config' | 'configuring' | 'configured';
|
||||
enabledTypes: string[];
|
||||
intervalMinutes: number;
|
||||
}
|
||||
@@ -8,5 +8,5 @@ export const notificationService = {
|
||||
markAllRead: () =>
|
||||
api.put('/messages/read-all'),
|
||||
getUnreadCount: () =>
|
||||
api.get('/messages/unread-count'),
|
||||
api.get('/messages/unread-count', undefined, 10_000),
|
||||
};
|
||||
|
||||
@@ -22,6 +22,42 @@ const ERROR_CODE_MAP: Record<string, string> = {
|
||||
CONCURRENCY_CONFLICT: '数据已被其他人修改,请刷新后重试',
|
||||
};
|
||||
|
||||
// --- 网络异常状态感知 ---
|
||||
// 检测到网络故障后,短时间内抑制后续请求,避免并发请求全部超时产生大量 toast
|
||||
// 连续失败时指数退避(3s → 6s → 12s → 30s),避免后端不可达时请求洪泛
|
||||
const OFFLINE_SUPPRESS_MS = 3000;
|
||||
const OFFLINE_MAX_MS = 30_000;
|
||||
let offlineDetectedAt = 0;
|
||||
let offlineSuppressMs = OFFLINE_SUPPRESS_MS;
|
||||
let networkToastShown = false;
|
||||
let networkToastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let consecutiveNetErrors = 0;
|
||||
|
||||
function isOffline(): boolean {
|
||||
return offlineDetectedAt > 0 && Date.now() - offlineDetectedAt < offlineSuppressMs;
|
||||
}
|
||||
|
||||
function markOffline(): void {
|
||||
offlineDetectedAt = Date.now();
|
||||
consecutiveNetErrors++;
|
||||
// 指数退避:连续失败越多,抑制时间越长(3s → 6s → 12s → 30s cap)
|
||||
offlineSuppressMs = Math.min(OFFLINE_MAX_MS, OFFLINE_SUPPRESS_MS * Math.pow(2, consecutiveNetErrors - 1));
|
||||
if (!networkToastShown) {
|
||||
networkToastShown = true;
|
||||
Taro.showToast({ title: '网络异常,请检查连接', icon: 'none', duration: 2000 });
|
||||
if (networkToastTimer) clearTimeout(networkToastTimer);
|
||||
networkToastTimer = setTimeout(() => { networkToastShown = false; networkToastTimer = null; }, offlineSuppressMs);
|
||||
}
|
||||
}
|
||||
|
||||
function clearOffline(): void {
|
||||
offlineDetectedAt = 0;
|
||||
offlineSuppressMs = OFFLINE_SUPPRESS_MS;
|
||||
consecutiveNetErrors = 0;
|
||||
if (networkToastTimer) { clearTimeout(networkToastTimer); networkToastTimer = null; }
|
||||
networkToastShown = false;
|
||||
}
|
||||
|
||||
function safeGet(key: string): string {
|
||||
return secureGet(key);
|
||||
}
|
||||
@@ -125,9 +161,15 @@ async function doRefresh(): Promise<boolean> {
|
||||
let reLaunchPromise: Promise<void> | null = null;
|
||||
|
||||
function safeReLaunch(url: string): void {
|
||||
// 已在目标页,跳过(防止 DevTools reLaunch bug)
|
||||
const pages = Taro.getCurrentPages();
|
||||
const currentPath = pages[pages.length - 1]?.path || '';
|
||||
if (currentPath.includes('pages/login')) return;
|
||||
if (reLaunchPromise) return;
|
||||
reLaunchPromise = Taro.reLaunch({ url }).then(() => {}, (err) => {
|
||||
console.warn('[request] reLaunch failed:', err);
|
||||
// reLaunch 失败时降级为 redirectTo
|
||||
Taro.redirectTo({ url }).catch(() => {});
|
||||
}).then(() => {
|
||||
setTimeout(() => { reLaunchPromise = null; }, 2000);
|
||||
});
|
||||
@@ -139,6 +181,12 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
let retryCount401 = 0;
|
||||
for (;;) {
|
||||
if (signal?.aborted) throw new Error('请求已取消');
|
||||
|
||||
// 离线抑制:刚检测到网络故障时,直接跳过请求,避免 9+ 并发请求全部超时
|
||||
if (isOffline()) {
|
||||
throw new Error('网络异常');
|
||||
}
|
||||
|
||||
if (!bypassLimiter) await limiter.acquire();
|
||||
try {
|
||||
const headers = await getHeaders();
|
||||
@@ -153,10 +201,13 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
Taro.showToast({ title: '网络超时,请重试', icon: 'none' });
|
||||
throw new Error('网络超时');
|
||||
}
|
||||
Taro.showToast({ title: '网络异常,请检查连接', icon: 'none' });
|
||||
// 网络异常:标记离线 + toast 去重(3 秒内只弹一次)
|
||||
markOffline();
|
||||
throw new Error('网络异常');
|
||||
}
|
||||
|
||||
// 请求成功,清除离线标记
|
||||
clearOffline();
|
||||
if (signal?.aborted) throw new Error('请求已取消');
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
@@ -181,7 +232,6 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
}
|
||||
|
||||
if (res.statusCode === 403) {
|
||||
Taro.showToast({ title: '权限不足', icon: 'none' });
|
||||
throw new Error('权限不足');
|
||||
}
|
||||
|
||||
@@ -275,4 +325,6 @@ export function resetForTesting(): void {
|
||||
headersCacheTs = 0;
|
||||
refreshPromise = null;
|
||||
isLoggingOut = false;
|
||||
offlineDetectedAt = 0;
|
||||
networkToastShown = false;
|
||||
}
|
||||
|
||||
@@ -28,9 +28,17 @@ export class ResponseCache {
|
||||
}
|
||||
|
||||
get<T>(url: string): T | null {
|
||||
const entry = this.cache.get(this.cacheKey(url));
|
||||
if (entry && Date.now() < entry.expiry) return entry.data as T;
|
||||
return null;
|
||||
const key = this.cacheKey(url);
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() >= entry.expiry) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
// LRU: 命中时更新插入顺序,使该条目移到末尾
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, entry);
|
||||
return entry.data as T;
|
||||
}
|
||||
|
||||
getInflight<T>(url: string): Promise<T> | null {
|
||||
|
||||
@@ -2,13 +2,14 @@ import { create } from 'zustand';
|
||||
import Taro from '@tarojs/taro';
|
||||
import * as authApi from '@/services/auth';
|
||||
import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage';
|
||||
import { clearRequestCache, markLoggingOut, clearLoggingOut, setCachedPatientId } from '@/services/request';
|
||||
import { clearRequestCache, invalidateHeadersCache, markLoggingOut, clearLoggingOut, setCachedPatientId } from '@/services/request';
|
||||
|
||||
// secureGet 已内置明文键 fallback,无需再手动 fallback
|
||||
function storageGet(key: string): string {
|
||||
return secureGet(key);
|
||||
}
|
||||
import { resetAllStores } from './index';
|
||||
import { resetAnalyticsDisabled } from '@/services/analytics';
|
||||
|
||||
// --- 内存缓存,避免每次 Tab 切换重复 Storage IPC + JSON.parse ---
|
||||
let cachedUserJson = '';
|
||||
@@ -109,9 +110,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
setCachedPatientId(currentPatient.id);
|
||||
}
|
||||
|
||||
// 状态有变化时清理请求缓存,避免返回过期数据
|
||||
clearRequestCache();
|
||||
|
||||
// 跳过未变更的 set()
|
||||
const cur = get();
|
||||
const userChanged = cur.user?.id !== user?.id;
|
||||
@@ -119,6 +117,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
const patientChanged = cur.currentPatient?.id !== currentPatient?.id;
|
||||
if (!userChanged && !rolesChanged && !patientChanged) return;
|
||||
|
||||
// 状态有变化时清理请求缓存,避免返回过期数据
|
||||
clearRequestCache();
|
||||
set({ user, roles, currentPatient });
|
||||
},
|
||||
|
||||
@@ -143,16 +143,24 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
secureSet('tenant_id', user.tenant_id || '');
|
||||
set({ user, roles, loading: false });
|
||||
clearLoggingOut();
|
||||
invalidateHeadersCache();
|
||||
resetAnalyticsDisabled();
|
||||
get().loadPatients();
|
||||
return true;
|
||||
}
|
||||
// 未绑定:存储 openid 供后续绑定流程使用
|
||||
if (!resp.openid) {
|
||||
set({ loading: false });
|
||||
throw new Error('登录失败:服务器未返回用户标识');
|
||||
}
|
||||
secureSet('wechat_openid', resp.openid);
|
||||
set({ loading: false });
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.warn('[auth] 微信登录失败:', err);
|
||||
set({ loading: false });
|
||||
return false;
|
||||
// 不吞掉错误 — 让调用方区分"未绑定"和"真正的错误"
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -176,7 +184,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
secureSet('tenant_id', resp.user?.tenant_id || tenantId);
|
||||
set({ user: resp.user, roles, loading: false });
|
||||
clearLoggingOut();
|
||||
// 登录成功后自动加载患者档案(如果有的话)
|
||||
invalidateHeadersCache();
|
||||
resetAnalyticsDisabled();
|
||||
get().loadPatients();
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -212,6 +221,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
secureRemove('wechat_openid');
|
||||
set({ user: tokenData.user, roles, loading: false });
|
||||
clearLoggingOut();
|
||||
invalidateHeadersCache();
|
||||
resetAnalyticsDisabled();
|
||||
get().loadPatients();
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
@@ -238,7 +249,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
loadPatients: async () => {
|
||||
try {
|
||||
const summaries = await authApi.getPatientSummaries();
|
||||
const userId = get().user?.id;
|
||||
const summaries = await authApi.getPatientSummaries(userId);
|
||||
const patients: authApi.PatientInfo[] = summaries.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -288,6 +300,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
});
|
||||
resetAllStores();
|
||||
set({ user: null, roles: [], currentPatient: null, patients: [] });
|
||||
Taro.reLaunch({ url: '/pages/index/index' });
|
||||
Taro.reLaunch({ url: '/pages/index/index' }).catch((err) => {
|
||||
console.warn('[auth] reLaunch after logout failed:', err);
|
||||
Taro.redirectTo({ url: '/pages/index/index' }).catch(() => {});
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
335
apps/miniprogram/src/stores/veepoo.ts
Normal file
335
apps/miniprogram/src/stores/veepoo.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { create } from 'zustand';
|
||||
import { VeepooPipeline } from '@/services/ble/veepoo/VeepooPipeline';
|
||||
import { VeepooHistoryReader } from '@/services/ble/veepoo/VeepooHistoryReader';
|
||||
import type {
|
||||
MeasureType,
|
||||
MeasureStatus,
|
||||
MeasureResult,
|
||||
ConnectionPhase,
|
||||
VeepooDeviceInfo,
|
||||
HistorySyncState,
|
||||
SleepReading,
|
||||
} from '@/services/ble/veepoo/types';
|
||||
import { MEASURE_TYPES } from '@/services/ble/veepoo/types';
|
||||
import { useAuthStore } from './auth';
|
||||
|
||||
/** 初始化每个指标的默认状态 */
|
||||
function initialMeasureStates(): Record<MeasureType, MeasureStatus> {
|
||||
const states = {} as Record<MeasureType, MeasureStatus>;
|
||||
for (const t of MEASURE_TYPES) {
|
||||
states[t] = { phase: 'idle', progress: 0, currentValue: null, result: null, error: null };
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
interface VeepooState {
|
||||
// 连接
|
||||
connectionPhase: ConnectionPhase;
|
||||
device: VeepooDeviceInfo | null;
|
||||
error: string | null;
|
||||
|
||||
// 测量
|
||||
activeMeasure: MeasureType | null;
|
||||
measureStates: Record<MeasureType, MeasureStatus>;
|
||||
|
||||
// 历史
|
||||
historySync: HistorySyncState;
|
||||
|
||||
// 睡眠
|
||||
sleepData: SleepReading[];
|
||||
sleepLoading: boolean;
|
||||
|
||||
// Actions
|
||||
connect: (targetName?: string) => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
startMeasure: (type: MeasureType) => Promise<MeasureResult>;
|
||||
cancelMeasure: () => void;
|
||||
syncHistory: (patientId: string) => Promise<void>;
|
||||
readSleepData: () => Promise<SleepReading[]>;
|
||||
enableAutoMeasurement: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
let pipelineInstance: VeepooPipeline | null = null;
|
||||
let historyReaderInstance: VeepooHistoryReader | null = null;
|
||||
|
||||
function getPipeline(): VeepooPipeline {
|
||||
if (!pipelineInstance) {
|
||||
pipelineInstance = new VeepooPipeline();
|
||||
}
|
||||
return pipelineInstance;
|
||||
}
|
||||
|
||||
function getHistoryReader(): VeepooHistoryReader {
|
||||
if (!historyReaderInstance) {
|
||||
historyReaderInstance = new VeepooHistoryReader();
|
||||
}
|
||||
return historyReaderInstance;
|
||||
}
|
||||
|
||||
export const useVeepooStore = create<VeepooState>((set, get) => ({
|
||||
connectionPhase: 'idle',
|
||||
device: null,
|
||||
error: null,
|
||||
activeMeasure: null,
|
||||
measureStates: initialMeasureStates(),
|
||||
historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 },
|
||||
sleepData: [],
|
||||
sleepLoading: false,
|
||||
|
||||
connect: async (targetName = 'M2') => {
|
||||
console.log('[veepoo-store] connect() 开始, target:', targetName);
|
||||
set({ connectionPhase: 'scanning', error: null });
|
||||
const pipeline = getPipeline();
|
||||
const historyReader = getHistoryReader();
|
||||
|
||||
// 注册全部回调(包含新增的 onSleepData)
|
||||
pipeline.setCallbacks({
|
||||
onConnectionChange: (connected) => {
|
||||
if (!connected) {
|
||||
set({ connectionPhase: 'disconnected', device: null });
|
||||
}
|
||||
},
|
||||
onAuthResult: (success) => {
|
||||
if (success) {
|
||||
set({ connectionPhase: 'ready' });
|
||||
}
|
||||
},
|
||||
onMeasureEvent: (type, data) => {
|
||||
const state = get();
|
||||
if (state.activeMeasure !== type) return;
|
||||
|
||||
const value = extractDisplayValue(type, data);
|
||||
set({
|
||||
measureStates: {
|
||||
...state.measureStates,
|
||||
[type]: {
|
||||
...state.measureStates[type],
|
||||
phase: 'measuring',
|
||||
progress: (data.Progress ?? data.progress ?? 0) as number,
|
||||
currentValue: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
onDailyData: (data) => {
|
||||
// 转发给 HistoryReader 处理
|
||||
historyReader.handleDailyEvent(data);
|
||||
|
||||
const progress = data.Progress ?? 0;
|
||||
set((s) => ({
|
||||
historySync: { ...s.historySync, progress: progress as number },
|
||||
}));
|
||||
},
|
||||
onSleepData: (_day, sleep) => {
|
||||
// 收集睡眠数据到 store
|
||||
set((s) => ({
|
||||
sleepData: [...s.sleepData, sleep],
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// 注册 HistoryReader 进度回调
|
||||
historyReader.setCallbacks({
|
||||
onProgress: (progress, phase) => {
|
||||
set((s) => ({
|
||||
historySync: {
|
||||
...s.historySync,
|
||||
phase: phase === 'uploading' ? 'uploading' : 'reading',
|
||||
progress,
|
||||
},
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
set({ connectionPhase: 'connecting' });
|
||||
const deviceId = await pipeline.connect(targetName);
|
||||
set({
|
||||
connectionPhase: 'authenticating',
|
||||
device: { deviceId, name: targetName, batteryLevel: null },
|
||||
});
|
||||
|
||||
// 认证结果由 onAuthResult 回调设置
|
||||
// 等待 ready 状态(最多 10s)
|
||||
await waitForState(() => get().connectionPhase === 'ready', 10_000);
|
||||
|
||||
// 认证通过后:自动同步历史 + 读取睡眠 + 开启自动测量
|
||||
const patient = useAuthStore.getState().currentPatient;
|
||||
const readyState = get().connectionPhase === 'ready';
|
||||
if (patient && readyState) {
|
||||
const deviceIdForReader = get().device?.deviceId ?? 'veepoo_m2';
|
||||
|
||||
// 并行执行三件事:
|
||||
// 1. 同步日常历史数据(后台执行,进度通过回调更新)
|
||||
get().syncHistory(patient.id);
|
||||
|
||||
// 2. 读取睡眠数据 → 完成后自动上传
|
||||
get().readSleepData().then((sleepResults) => {
|
||||
if (sleepResults.length > 0) {
|
||||
historyReader.uploadSleepReadings(patient.id, deviceIdForReader, sleepResults);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 开启自动测量(心率+血压+体温)
|
||||
pipeline.enableAutoMeasurement();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[veepoo-store] connect 失败:', err);
|
||||
set({
|
||||
connectionPhase: 'error',
|
||||
error: err instanceof Error ? err.message : '连接失败',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
disconnect: async () => {
|
||||
const pipeline = getPipeline();
|
||||
await pipeline.disconnect();
|
||||
set({
|
||||
connectionPhase: 'idle',
|
||||
device: null,
|
||||
error: null,
|
||||
activeMeasure: null,
|
||||
measureStates: initialMeasureStates(),
|
||||
sleepData: [],
|
||||
sleepLoading: false,
|
||||
});
|
||||
},
|
||||
|
||||
startMeasure: async (type: MeasureType) => {
|
||||
const state = get();
|
||||
if (state.activeMeasure) {
|
||||
throw new Error(`正在测量 ${state.activeMeasure},请等待完成`);
|
||||
}
|
||||
if (state.connectionPhase !== 'ready') {
|
||||
throw new Error('设备未就绪');
|
||||
}
|
||||
|
||||
set({
|
||||
activeMeasure: type,
|
||||
measureStates: {
|
||||
...state.measureStates,
|
||||
[type]: { phase: 'measuring', progress: 0, currentValue: null, result: null, error: null },
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = getPipeline();
|
||||
try {
|
||||
const result = await pipeline.startMeasure(type);
|
||||
set((s) => ({
|
||||
activeMeasure: null,
|
||||
measureStates: {
|
||||
...s.measureStates,
|
||||
[type]: { phase: 'success', progress: 100, currentValue: null, result, error: null },
|
||||
},
|
||||
}));
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '测量失败';
|
||||
set((s) => ({
|
||||
activeMeasure: null,
|
||||
measureStates: {
|
||||
...s.measureStates,
|
||||
[type]: { phase: 'error', progress: 0, currentValue: null, result: null, error: msg },
|
||||
},
|
||||
}));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
cancelMeasure: () => {
|
||||
const pipeline = getPipeline();
|
||||
pipeline.cancelMeasure();
|
||||
},
|
||||
|
||||
syncHistory: async (patientId: string) => {
|
||||
const deviceId = get().device?.deviceId ?? 'veepoo_m2';
|
||||
set((s) => ({ historySync: { ...s.historySync, phase: 'reading', progress: 0 } }));
|
||||
|
||||
try {
|
||||
const historyReader = getHistoryReader();
|
||||
const count = await historyReader.startRead(patientId, deviceId);
|
||||
set((s) => ({
|
||||
historySync: { ...s.historySync, phase: 'done', progress: 100, packagesRead: count },
|
||||
}));
|
||||
console.log('[veepoo-store] 历史数据同步完成, 上传:', count, '条');
|
||||
} catch (err) {
|
||||
console.error('[veepoo-store] 历史数据同步失败:', err);
|
||||
set((s) => ({ historySync: { ...s.historySync, phase: 'done', progress: 100 } }));
|
||||
}
|
||||
},
|
||||
|
||||
readSleepData: async () => {
|
||||
const pipeline = getPipeline();
|
||||
if (!pipeline.getConnected()) {
|
||||
console.warn('[veepoo-store] 设备未连接,跳过睡眠数据读取');
|
||||
return [];
|
||||
}
|
||||
|
||||
set({ sleepLoading: true, sleepData: [] });
|
||||
try {
|
||||
const sleepResults = await pipeline.readAllSleepData();
|
||||
set({ sleepData: sleepResults, sleepLoading: false });
|
||||
console.log('[veepoo-store] 睡眠数据读取完成:', sleepResults.length, '天');
|
||||
return sleepResults;
|
||||
} catch (err) {
|
||||
console.error('[veepoo-store] 睡眠数据读取失败:', err);
|
||||
set({ sleepLoading: false });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
enableAutoMeasurement: () => {
|
||||
const pipeline = getPipeline();
|
||||
pipeline.enableAutoMeasurement();
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
connectionPhase: 'idle',
|
||||
device: null,
|
||||
error: null,
|
||||
activeMeasure: null,
|
||||
measureStates: initialMeasureStates(),
|
||||
historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 },
|
||||
sleepData: [],
|
||||
sleepLoading: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
/** 从 SDK 事件 content 提取显示值 */
|
||||
function extractDisplayValue(type: MeasureType, content: Record<string, unknown>): number | null {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
const v = Number(content.heartRate);
|
||||
return v >= 30 && v <= 250 ? v : null;
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
const v = Number(content.bloodOxygen);
|
||||
return v >= 70 && v <= 100 ? v : null;
|
||||
}
|
||||
case 'blood_pressure':
|
||||
return Number(content.bloodPressureHigh) || null;
|
||||
case 'temperature':
|
||||
return Number(content.bodyTemperature) || null;
|
||||
case 'pressure':
|
||||
return Number(content.pressure) || null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询等待状态满足条件 */
|
||||
function waitForState(check: () => boolean, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const poll = () => {
|
||||
if (check()) { resolve(); return; }
|
||||
if (Date.now() - start >= timeoutMs) { reject(new Error('等待超时')); return; }
|
||||
setTimeout(poll, 200);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
15
apps/miniprogram/src/types/mp-html.d.ts
vendored
Normal file
15
apps/miniprogram/src/types/mp-html.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
declare namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'mp-html': {
|
||||
content?: string;
|
||||
'lazy-load'?: boolean;
|
||||
selectable?: boolean;
|
||||
'show-img-menu'?: boolean;
|
||||
domain?: string;
|
||||
'tag-style'?: string;
|
||||
'link-style'?: string;
|
||||
'container-style'?: string;
|
||||
onReady?: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
57
apps/miniprogram/src/utils/abort-controller-polyfill.ts
Normal file
57
apps/miniprogram/src/utils/abort-controller-polyfill.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* AbortController / AbortSignal polyfill — 微信小程序 JS 运行时
|
||||
*
|
||||
* 微信小程序 JSCore/V8 不提供 AbortController / AbortSignal Web API。
|
||||
* usePageData hook 在每个页面挂载时 new AbortController(),
|
||||
* 缺少 polyfill 会导致 ReferenceError 崩溃,影响全部 ~40 个数据页面。
|
||||
*
|
||||
* 在 app.tsx 首行导入(crypto-polyfill 之后),确保在任何页面代码之前执行。
|
||||
*
|
||||
* 实现了 usePageData / request.ts 所需的完整规范子集:
|
||||
* - signal.aborted (getter)
|
||||
* - controller.abort()
|
||||
* - signal.addEventListener('abort', cb) / removeEventListener
|
||||
*/
|
||||
|
||||
if (typeof globalThis.AbortController === 'undefined') {
|
||||
class _AbortSignal {
|
||||
aborted = false;
|
||||
private _listeners: Array<() => void> = [];
|
||||
|
||||
addEventListener(type: string, cb: () => void): void {
|
||||
if (type === 'abort') this._listeners.push(cb);
|
||||
}
|
||||
|
||||
removeEventListener(_type: string, cb: () => void): void {
|
||||
this._listeners = this._listeners.filter((fn) => fn !== cb);
|
||||
}
|
||||
|
||||
/** @internal 触发 abort 事件 */
|
||||
_doAbort(): void {
|
||||
if (this.aborted) return;
|
||||
this.aborted = true;
|
||||
const listeners = this._listeners.slice();
|
||||
this._listeners = [];
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
/* best-effort dispatch */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _AbortController {
|
||||
readonly signal = new _AbortSignal();
|
||||
|
||||
abort(): void {
|
||||
this.signal._doAbort();
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error — polyfill: globalThis 上原本没有 AbortController
|
||||
globalThis.AbortController = _AbortController;
|
||||
// @ts-expect-error — polyfill: globalThis 上原本没有 AbortSignal
|
||||
globalThis.AbortSignal = _AbortSignal;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function showToast(options: {
|
||||
const duration = options.duration ?? (mode === 'elder' ? 3000 : 1500);
|
||||
|
||||
if (mode === 'elder') {
|
||||
try { Taro.vibrateShort({ type: 'light' }); } catch { /* 不支持时静默 */ }
|
||||
Taro.vibrateShort({ type: 'light' }).catch(() => {});
|
||||
}
|
||||
|
||||
Taro.showToast({ ...options, duration, icon: options.icon ?? 'none' });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user