Compare commits
352 Commits
main
...
feat/media
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
22e33114b1 | ||
|
|
0dfbe3130c | ||
|
|
d24aefe750 | ||
|
|
490ae075b7 | ||
|
|
437f5d1ae9 | ||
|
|
c2c9657b4d | ||
|
|
a5efab2a13 | ||
|
|
be8ae84d45 | ||
|
|
148cd875dc | ||
|
|
4fcbf705ca | ||
|
|
c9fe654d44 | ||
|
|
bdc2d07c1c | ||
|
|
8d2c377b68 | ||
|
|
b44ed6dfd2 | ||
|
|
2aa393dd65 | ||
|
|
ca9d065d31 | ||
|
|
96a6196373 | ||
|
|
898e22c715 | ||
|
|
02a96682f6 | ||
|
|
21f8040994 | ||
|
|
29543ef0e7 | ||
|
|
408527375f | ||
|
|
9c61156ab3 | ||
|
|
6c21f9eb2a | ||
|
|
685cf53673 | ||
|
|
89fa322d7a | ||
|
|
093b9fe9a3 | ||
|
|
a7b5548b35 | ||
|
|
d70b027f20 | ||
|
|
4b40d47b71 | ||
|
|
21481dbd88 | ||
|
|
fd994edf3e | ||
|
|
ee7dd0d6e1 | ||
|
|
7571ad74cb | ||
|
|
05e679b5ef | ||
|
|
f59e40e6fe | ||
|
|
bc571c7749 | ||
|
|
8e616f2210 | ||
|
|
58afc59676 | ||
|
|
d213afc649 | ||
|
|
b0f96258ee | ||
|
|
7ad5ddb898 | ||
|
|
4e9eb7b397 | ||
|
|
6338cd7428 | ||
|
|
23f7bcb8ce | ||
|
|
43795b2fb7 | ||
|
|
ce0561001f | ||
|
|
6e33c106d7 | ||
|
|
fde510f8a3 | ||
|
|
345e46002a | ||
|
|
d576b8ba8f | ||
|
|
652cccf66c | ||
|
|
e769a5785a | ||
|
|
831d2ba598 | ||
|
|
8c9d177642 | ||
|
|
c1458b1e4b | ||
|
|
b8c84ed9af | ||
|
|
2644926fb6 | ||
|
|
4d8658ae98 | ||
|
|
a3c84fc12a | ||
|
|
c5caed73b3 | ||
|
|
41a865cf68 | ||
|
|
9033ec8ca2 | ||
|
|
ec7f76127d | ||
|
|
5877342a4d | ||
|
|
4728794604 | ||
|
|
e3318e8266 | ||
|
|
80ee83861c | ||
|
|
853a0ca2b4 | ||
|
|
65cf96f119 | ||
|
|
fa1dc764a3 | ||
|
|
5f34e5715a | ||
|
|
17114d492e | ||
|
|
e83101dd23 | ||
|
|
3c94f5d585 | ||
|
|
03c50f6712 | ||
|
|
6e8239daf0 | ||
|
|
f3bf8b3b1d | ||
|
|
d74c7a61de | ||
|
|
c6d4e76b62 | ||
|
|
8fbe1543cb | ||
|
|
975928233f | ||
|
|
8e5bc97f93 | ||
|
|
a48a3d9906 | ||
|
|
de342f9195 | ||
|
|
b03ea47fed | ||
|
|
bcff978ea0 | ||
|
|
8064db3475 | ||
|
|
8b59f2d7d9 | ||
|
|
6f088347ce | ||
|
|
7edf1ed1d3 | ||
|
|
8b88cb4a50 | ||
|
|
c0570dfbfc | ||
|
|
7658bc3cdf | ||
|
|
9576e80175 | ||
|
|
2660f1afff | ||
|
|
205f6fb5a2 | ||
|
|
1e2ad6170a | ||
|
|
b2053d5bcc | ||
|
|
89581b070f | ||
|
|
5ba28ea349 | ||
|
|
7e3d27ecf3 | ||
|
|
281c71ebfc | ||
|
|
bf37acc681 | ||
|
|
d623f8b2ff | ||
|
|
38b0d91407 | ||
|
|
20714661d2 | ||
|
|
edea8f49d1 | ||
|
|
882b27ab7a | ||
|
|
e47fe547c8 | ||
|
|
aab4dfea79 | ||
|
|
f42669f934 | ||
|
|
2d62605812 | ||
|
|
877e9831f6 | ||
|
|
f668f0995a | ||
|
|
46b30504a5 | ||
|
|
f42e3ba611 | ||
|
|
64456d0172 | ||
|
|
cad48a97d5 | ||
|
|
01c75dbf5d | ||
|
|
4e12298ff3 | ||
|
|
e149a61ce6 | ||
|
|
3aa71a94d2 | ||
|
|
ded37830fe | ||
|
|
e555496528 | ||
|
|
2698c98888 | ||
|
|
31771168dd | ||
|
|
b0892706c8 | ||
|
|
530262590e | ||
|
|
6759723731 | ||
|
|
1c8319fb4d | ||
|
|
6151fde7c4 | ||
|
|
c26ca9088b | ||
|
|
63a9dac9d3 | ||
|
|
a4732cd2d4 | ||
|
|
35bd60af5b | ||
|
|
b8dce8a42a | ||
|
|
d26ea64ab2 | ||
|
|
676a6c0e13 | ||
|
|
fcce2f5c51 | ||
|
|
66aef532fa | ||
|
|
3c98aaedbd | ||
|
|
59dd5ef38e | ||
|
|
1576709342 | ||
|
|
9d50ef7847 | ||
|
|
b84becfbea | ||
|
|
d5ec250184 | ||
|
|
b8ce19f5dc | ||
|
|
29d77e8c3d | ||
|
|
6c42d541fc | ||
|
|
c631d364b3 | ||
|
|
7b2c03309c | ||
|
|
e8bbc36364 | ||
|
|
c2c7f2d967 | ||
|
|
227d81ddd6 | ||
|
|
551d19d921 | ||
|
|
6841c45846 | ||
|
|
8d3c5915c9 | ||
|
|
c38967a36e | ||
|
|
aa27c5174c | ||
|
|
710b2e2423 | ||
|
|
4be28de3ce | ||
|
|
95e219ad5a | ||
|
|
1786f0d707 | ||
|
|
f8d0b41d61 | ||
|
|
184bd0ea03 | ||
|
|
c6bffd4019 | ||
|
|
466b6567d1 | ||
|
|
37327a4da4 | ||
|
|
4dd5a1b4d9 | ||
|
|
900c9babc3 | ||
|
|
61f1061092 | ||
|
|
85701ddeb2 | ||
|
|
5e230ba1b5 | ||
|
|
8d41d5a167 | ||
|
|
40b88c566d | ||
|
|
483342a1d8 | ||
|
|
ae23baeece | ||
|
|
3e88dcaba5 | ||
|
|
9415807a40 | ||
|
|
1579f35ff5 | ||
|
|
9728afbc1b | ||
|
|
80794c9547 | ||
|
|
d758563a13 | ||
|
|
3fb5a77ac0 | ||
|
|
c06e986090 | ||
|
|
ced1c0ad0c | ||
|
|
bf8bcdbd5d | ||
|
|
50e3b16381 | ||
|
|
33febd2fbd | ||
|
|
d44c6167b1 | ||
|
|
41515e5bec | ||
|
|
2c48bb0f56 | ||
|
|
8763e10d6e | ||
|
|
9319203e09 | ||
|
|
4ca9027cd6 | ||
|
|
057d9b5896 | ||
|
|
2c567bd772 | ||
|
|
18fa6ce6d4 | ||
|
|
dc983945ff | ||
|
|
9bd2d4c2e6 | ||
|
|
4c38fcd89d | ||
|
|
5baa518516 | ||
|
|
6d151bbfb1 | ||
|
|
1fd2c7a533 | ||
|
|
0f58af245d | ||
|
|
fed1759985 | ||
|
|
74bffb4878 | ||
|
|
5ea991c5df | ||
|
|
8f353946e1 | ||
|
|
447126b6c5 | ||
|
|
a8d7183d7c | ||
|
|
9e0f421c14 | ||
|
|
9faccac9eb | ||
|
|
0f6f7a2851 | ||
|
|
9c7ce939c7 | ||
|
|
431c42289d | ||
|
|
675d5a3405 | ||
|
|
df1d85bfde | ||
|
|
212c08b7ae | ||
|
|
e4e5ef04d4 | ||
|
|
616e0a1539 | ||
|
|
93c77c5857 | ||
|
|
02082ccc61 | ||
|
|
20d606d21c | ||
|
|
e9458a6bdf | ||
|
|
c681049c82 | ||
|
|
935ca70dfa | ||
|
|
b7efa51d5f | ||
|
|
9a4a65a241 | ||
|
|
5905742080 | ||
|
|
d6676abecf | ||
|
|
6d97328ff6 | ||
|
|
a48ad6ed33 | ||
|
|
a87425e551 | ||
|
|
78c052ecc9 | ||
|
|
22ef9b32d6 | ||
|
|
cba8c8306d | ||
|
|
ba0a4f4d2e | ||
|
|
a999ee0036 | ||
|
|
44dcfbd5cb | ||
|
|
95db4fe9ff | ||
|
|
57f33dd726 | ||
|
|
fe983ba4ae | ||
|
|
7e2a20727e | ||
|
|
af3eb0c7a1 | ||
|
|
0a8ff4bbe7 | ||
|
|
ac8d300dc0 | ||
|
|
d0cb45f457 | ||
|
|
fc30702846 | ||
|
|
533a2b6a8e | ||
|
|
0f67f1c21f | ||
|
|
8c347a5de9 | ||
|
|
129a7b175c | ||
|
|
103c8aa059 | ||
|
|
9487ccb62e | ||
|
|
6269815046 | ||
|
|
00301d2528 | ||
|
|
e00ee69d28 | ||
|
|
c716cc0f7b | ||
|
|
f4b09858c4 | ||
|
|
4788e19a1d | ||
|
|
a6ec8129c9 | ||
|
|
270818c3ad | ||
|
|
4ea54ff27c | ||
|
|
fca0b5a78f | ||
|
|
edb4b6557d | ||
|
|
09725acad7 | ||
|
|
7fcabd2e6b | ||
|
|
d2b79e4a1c | ||
|
|
b2c6d9c8c8 | ||
|
|
6bf8cc53f8 | ||
|
|
2c7d4a3d63 | ||
|
|
85bff6f267 | ||
|
|
1a459de4ad | ||
|
|
3a672636c0 | ||
|
|
a9bd850ce2 | ||
|
|
601d977438 | ||
|
|
603a986281 |
244
.claude/skills/design-handoff/SKILL.md
Normal file
244
.claude/skills/design-handoff/SKILL.md
Normal file
@@ -0,0 +1,244 @@
|
||||
---
|
||||
name: design-handoff
|
||||
description: 设计交付编排器 — 调用 huashu-design 设计原型、解析生成 SPEC,再由本 skill 根据 SPEC 实施代码,覆盖从需求到实现的完整流程
|
||||
---
|
||||
|
||||
# 设计交付编排器 (design-handoff)
|
||||
|
||||
编排从需求到代码实现的完整流程:
|
||||
1. 调用 huashu-design 设计 HTML 原型
|
||||
2. 解析原型生成 SPEC.md + 截图 + Token 映射
|
||||
3. 根据 SPEC 直接实施代码(不调用 huashu-design)
|
||||
|
||||
## 触发词
|
||||
|
||||
- `design-handoff`
|
||||
- `设计交付`
|
||||
- `handoff`
|
||||
- `设计并实现`
|
||||
- `从设计到代码`
|
||||
|
||||
---
|
||||
|
||||
## 输入模式
|
||||
|
||||
### 模式 A:从需求开始(完整流程)
|
||||
|
||||
用户提供功能需求描述(无 HTML 文件),skill 执行 7 步完整流程。
|
||||
|
||||
**输入示例:**
|
||||
```
|
||||
/design-handoff 患者端家庭档案管理页面,包含家庭成员列表、添加/编辑/删除操作
|
||||
```
|
||||
|
||||
### 模式 B:从原型开始(仅解析+实施)
|
||||
|
||||
用户提供已有 HTML 原型文件路径,跳过 Step 0,从 Step 1 开始。
|
||||
|
||||
**输入示例:**
|
||||
```
|
||||
/design-handoff docs/design/mp-13-family-profile.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程(7 步)
|
||||
|
||||
### Step 0: 设计原型(仅模式 A)
|
||||
|
||||
调用 huashu-design 生成 HTML 原型:
|
||||
|
||||
```
|
||||
Skill("huashu-design", "设计 {用户需求描述}。要求:1) 使用 HMS 设计系统 Token(const T = {...})2) 包含 IosFrame 设备框 3) 每个页面用 .screen-wrap 包裹 4) 输出到 docs/design/ 目录")
|
||||
```
|
||||
|
||||
**关键要求传递给 huashu-design:**
|
||||
- 使用项目 Token 系统(`const T = { pri: ..., bg: ..., r: ... }`)
|
||||
- 每个页面/状态用 `.screen-wrap` 包裹并配中文 label
|
||||
- 使用 `IosFrame` 组件呈现
|
||||
- 输出为单文件 HTML,保存到 `docs/design/mp-{编号}-{名称}.html`
|
||||
|
||||
**完成后获得:** HTML 原型文件路径,进入 Step 1。
|
||||
|
||||
### Step 1: 解析 HTML 原型
|
||||
|
||||
调用 `scripts/parse-prototype.mjs` 解析 HTML 文件:
|
||||
|
||||
```bash
|
||||
node .claude/skills/design-handoff/scripts/parse-prototype.mjs <html文件路径>
|
||||
```
|
||||
|
||||
**输出(stdout JSON):**
|
||||
- `tokens`: T 对象所有键值对(如 `{"T.pri": "#C4623A", ...}`)
|
||||
- `inlineStyles`: 内联样式值收集(按 CSS 属性分组)
|
||||
- `screens`: 每个屏幕的标签和组件名
|
||||
- `components`: 所有组件函数定义列表
|
||||
- `variant`: `"patient"` 或 `"doctor"`(根据 T.pri 值自动检测)
|
||||
|
||||
**若脚本不存在:** LLM 自行解析 HTML,提取 T 对象和 DOM 结构。
|
||||
|
||||
### Step 2: 截图
|
||||
|
||||
调用 `scripts/extract-screenshots.mjs` 生成截图:
|
||||
|
||||
```bash
|
||||
node .claude/skills/design-handoff/scripts/extract-screenshots.mjs <html文件路径> <输出目录>
|
||||
```
|
||||
|
||||
**输出:** PNG 文件到输出目录,文件名由中文标签自动翻译为英文。
|
||||
|
||||
**若脚本不存在:** 指导用户手动截图,保存到 `docs/design/{原型名}/screenshots/`。
|
||||
|
||||
### Step 3: Token 匹配
|
||||
|
||||
调用 `scripts/match-tokens.mjs` 进行三层 Token 匹配:
|
||||
|
||||
```bash
|
||||
node .claude/skills/design-handoff/scripts/parse-prototype.mjs <html> > /tmp/parse-result.json
|
||||
node .claude/skills/design-handoff/scripts/match-tokens.mjs /tmp/parse-result.json .design/tokens.yml
|
||||
```
|
||||
|
||||
**三层匹配算法:**
|
||||
1. **别名直查** — 在 `aliases.prototype_keys` 中查找已确认映射(支持值条件匹配,区分 patient/doctor variant)
|
||||
2. **值精确匹配** — 按 CSS 属性上下文消歧(borderRadius→radius, fontSize→typography)
|
||||
3. **色彩模糊匹配** — RGB 欧几里得距离 < 30 视为近似
|
||||
|
||||
**若脚本不存在:** LLM 自行读取 tokens.yml 执行匹配。
|
||||
|
||||
### Step 4: 组件映射推断
|
||||
|
||||
读取 `defaults/components.yml`,按 DOM 特征推断组件映射。
|
||||
|
||||
### Step 5: 交互推断
|
||||
|
||||
调用 `scripts/infer-interactions.mjs` 推断交互行为:
|
||||
|
||||
```bash
|
||||
node .claude/skills/design-handoff/scripts/infer-interactions.mjs <html文件路径> .claude/skills/design-handoff/rules/interaction-rules.yml
|
||||
```
|
||||
|
||||
### Step 6: 组装 SPEC.md
|
||||
|
||||
将前 5 步数据组装为 SPEC.md,输出到 `docs/design/{原型名}/SPEC.md`。
|
||||
|
||||
### Step 7: 实施代码
|
||||
|
||||
根据 Step 1-6 产出的 SPEC.md、截图和 Token 映射,**在当前会话中直接实施代码**。
|
||||
|
||||
**实施流程:**
|
||||
|
||||
1. **读取 SPEC.md** — 获取 Token 映射、组件清单、交互行为、未匹配项
|
||||
2. **读取截图** — 对照截图确认视觉还原度
|
||||
3. **创建/修改文件** — 在 `apps/miniprogram/src/pages/` 下实现 Taro 页面
|
||||
4. **验证** — `pnpm build` 编译通过 + 长者模式适配
|
||||
|
||||
**实施规则:**
|
||||
|
||||
- 样式使用 `var(--tk-*)` CSS 变量,不硬编码数值
|
||||
- 使用组件库已有组件(ContentCard, PrimaryButton, SectionTitle 等),参见 `defaults/components.yml`
|
||||
- 交互行为按 SPEC.md 第 4 节实现
|
||||
- 遵循 CLAUDE.md 中的小程序代码规范
|
||||
- 长者模式:字号 ≥ 22px,间距按 elder 值
|
||||
- 未匹配的 Token 需与用户确认后再补充到 `.design/tokens.yml`
|
||||
- 新建组件时遵循 `feedback-component-consistency` 原则:不适配则新建,不外部覆盖
|
||||
|
||||
---
|
||||
|
||||
## 前置检查
|
||||
|
||||
执行任何步骤前,必须完成以下检查:
|
||||
|
||||
### 1. 输入验证
|
||||
|
||||
**模式 A(从需求开始):** 无需输入文件验证,直接进入 Step 0。
|
||||
|
||||
**模式 B(从原型开始):** 确认输入文件为 huashu-design 产物:
|
||||
- 文件为 `.html` 格式
|
||||
- 包含 React + Babel 的 script 标签
|
||||
- 包含 Token 定义块(`const T = {`)
|
||||
|
||||
### 2. Token 配置文件初始化
|
||||
|
||||
检查 `.design/tokens.yml` 是否存在:
|
||||
- **存在** → 直接使用
|
||||
- **不存在** → 从 `defaults/tokens.yml` 复制
|
||||
|
||||
---
|
||||
|
||||
## 输出目录结构
|
||||
|
||||
```
|
||||
docs/design/
|
||||
mp-00-visitor.html # 原始原型(Step 0 产物或已有文件)
|
||||
mp-00-visitor/ # 交付包目录
|
||||
├── SPEC.md # 设计规格文档(Step 6 产物)
|
||||
├── screenshots/ # 截图目录(Step 2 产物)
|
||||
│ ├── home.png
|
||||
│ └── profile.png
|
||||
├── tokens.json # Token 匹配结果(Step 3 产物)
|
||||
└── META.yml # 元数据
|
||||
```
|
||||
|
||||
### META.yml 格式
|
||||
|
||||
```yaml
|
||||
prototype: {原型文件名}
|
||||
source: {HTML文件路径}
|
||||
variant: patient | doctor
|
||||
generated_at: {ISO 8601}
|
||||
tokens:
|
||||
matched: {数}
|
||||
unmatched: {数}
|
||||
components:
|
||||
total: {数}
|
||||
mapped: {数}
|
||||
new: {数}
|
||||
interactions: {数}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 终端输出
|
||||
|
||||
完成全部步骤后:
|
||||
|
||||
```
|
||||
============================================================
|
||||
设计交付完成: {原型名}
|
||||
输出目录: docs/design/{原型名}/
|
||||
模式: {完整流程 | 仅解析}
|
||||
Variant: {patient | doctor}
|
||||
Token 匹配: {matched}/{total} ({unmatched} 未匹配)
|
||||
组件: {total} ({new} 需新建)
|
||||
交互: {数} 项
|
||||
下一步: 根据 SPEC 实施代码(Step 7)
|
||||
============================================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 待确认项交互
|
||||
|
||||
完成 Step 1-6 后,若有 `pending` 或 `unmatched` Token,需与用户交互确认后再进入 Step 7。
|
||||
|
||||
---
|
||||
|
||||
## 脚本说明
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| `scripts/parse-prototype.mjs` | 解析 HTML 原型为结构化 JSON |
|
||||
| `scripts/extract-screenshots.mjs` | Playwright 截图(裁掉设备框) |
|
||||
| `scripts/match-tokens.mjs` | 三层 Token 匹配 |
|
||||
| `scripts/infer-interactions.mjs` | 交互行为推断 |
|
||||
|
||||
**降级策略:** 每个脚本缺失时,LLM 自行执行对应步骤。
|
||||
|
||||
## 模板与规则
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `templates/spec-template.md` | SPEC.md 模板 |
|
||||
| `defaults/tokens.yml` | Token 注册表 + 别名映射 |
|
||||
| `defaults/components.yml` | 组件映射规则 |
|
||||
| `rules/interaction-rules.yml` | 交互推断规则 |
|
||||
139
.claude/skills/design-handoff/defaults/components.yml
Normal file
139
.claude/skills/design-handoff/defaults/components.yml
Normal file
@@ -0,0 +1,139 @@
|
||||
version: 1
|
||||
updated: "2026-05-17"
|
||||
|
||||
# ============================================================================
|
||||
# 小程序 UI 组件映射注册表
|
||||
# 数据源: apps/miniprogram/src/components/ui/
|
||||
# 每个组件记录 import 路径和实际 props(从源码接口定义提取)
|
||||
# ============================================================================
|
||||
|
||||
components:
|
||||
# --- 数据展示 ---
|
||||
ContentCard:
|
||||
miniprogram:
|
||||
import: "@components/ui/ContentCard"
|
||||
props: "variant('default'|'outlined'|'elevated'), padding('none'|'sm'|'md'|'lg'), margin('none'|'md'), activeFeedback('bg'|'opacity'|'scale'|'none'), onPress, className, style, children"
|
||||
notes: "通用卡片容器,padding 映射到 --tk-card-padding-* token"
|
||||
|
||||
AlertCard:
|
||||
miniprogram:
|
||||
import: "@components/ui/AlertCard"
|
||||
props: "variant('gradient'|'left-border'|'bordered'), title, subtitle, children, className"
|
||||
notes: "告警/提示卡片,默认 left-border 变体"
|
||||
|
||||
VitalCard:
|
||||
miniprogram:
|
||||
import: "@components/ui/VitalCard"
|
||||
props: "label, value, unit, status, onPress, className"
|
||||
notes: "体征数据卡片,内嵌 StatusTag 显示状态"
|
||||
|
||||
ListItem:
|
||||
miniprogram:
|
||||
import: "@components/ui/ListItem"
|
||||
props: "title, subtitle, extra(ReactNode), leftIcon(ReactNode), onPress, showArrow, unread, className"
|
||||
notes: "列表行组件,支持图标/箭头/未读标记"
|
||||
|
||||
InfoRow:
|
||||
miniprogram:
|
||||
import: "@components/ui/InfoRow"
|
||||
props: "label, value, valueNode(ReactNode), last, className"
|
||||
notes: "键值对信息行,last 控制底部分隔线"
|
||||
|
||||
ChatBubble:
|
||||
miniprogram:
|
||||
import: "@components/ui/ChatBubble"
|
||||
props: "content, isMine, time, className"
|
||||
notes: "聊天气泡,isMine 控制左右侧和配色"
|
||||
|
||||
# --- 导航与标题 ---
|
||||
SectionTitle:
|
||||
miniprogram:
|
||||
import: "@components/ui/SectionTitle"
|
||||
props: "title, subtitle, action({text, onPress}), icon(ReactNode)"
|
||||
notes: "区块标题,左侧竖线装饰 + 可选右侧操作链接"
|
||||
|
||||
TabFilter:
|
||||
miniprogram:
|
||||
import: "@components/ui/TabFilter"
|
||||
props: "tabs(string[]), activeIndex, onChange(index), variant('fill'|'pill'|'segment'), className"
|
||||
notes: "标签页筛选器,三种视觉变体"
|
||||
|
||||
GradientHeader:
|
||||
miniprogram:
|
||||
import: "@components/ui/GradientHeader"
|
||||
props: "children, className"
|
||||
notes: "渐变头部容器,承载页面顶部标题区域"
|
||||
|
||||
# --- 输入控件 ---
|
||||
FormInput:
|
||||
miniprogram:
|
||||
import: "@components/ui/FormInput"
|
||||
props: "label, placeholder, value, onInput(value), type('text'|'number'|'idcard'|'digit'), maxLength, disabled, error, className"
|
||||
notes: "表单输入框,支持错误提示和多种输入类型"
|
||||
|
||||
# --- 按钮 ---
|
||||
PrimaryButton:
|
||||
miniprogram:
|
||||
import: "@components/ui/PrimaryButton"
|
||||
props: "children, onClick, disabled, loading, size('default'|'large'), className"
|
||||
notes: "主色按钮,高度映射 --tk-btn-primary-h"
|
||||
|
||||
SecondaryButton:
|
||||
miniprogram:
|
||||
import: "@components/ui/SecondaryButton"
|
||||
props: "children, onClick, disabled, className"
|
||||
notes: "次要/描边按钮,与 PrimaryButton 配套使用"
|
||||
|
||||
# --- 状态指示 ---
|
||||
StatusTag:
|
||||
miniprogram:
|
||||
import: "@components/ui/StatusTag"
|
||||
props: "status, colorMap(Record<string, TagColor>), size('sm'|'md'), className, children"
|
||||
notes: "状态标签,内置 status→color 映射(success/warning/error/info/default)"
|
||||
|
||||
ProgressRing:
|
||||
miniprogram:
|
||||
import: "@components/ui/ProgressRing"
|
||||
props: "progress(0-1), size('sm'|'lg'), label, className"
|
||||
notes: "环形进度条,使用 conic-gradient + --tk-pri 色"
|
||||
|
||||
LoadingCard:
|
||||
miniprogram:
|
||||
import: "@components/ui/LoadingCard"
|
||||
props: "count, layout('card'|'list'|'detail')"
|
||||
notes: "骨架屏加载占位,三种布局形态"
|
||||
|
||||
PageShell:
|
||||
miniprogram:
|
||||
import: "@components/ui/PageShell"
|
||||
props: "padding('none'|'sm'|'md'|'lg'), safeBottom, scroll, className, children"
|
||||
notes: "页面外壳,统一页面内边距和底部安全区,scroll 控制是否 ScrollView 包裹"
|
||||
|
||||
# ============================================================================
|
||||
# 框架/平台组件(非自研,Taro/微信原生)
|
||||
# ============================================================================
|
||||
framework_components:
|
||||
Swiper:
|
||||
import: "@tarojs/components"
|
||||
props: "autoplay, interval, duration, circular, indicatorDots, indicatorColor, indicatorActiveColor, onChange, children"
|
||||
notes: "Taro Swiper 组件,用于轮播图场景"
|
||||
|
||||
TabBar:
|
||||
import: "framework-config"
|
||||
props: "N/A(app.config.ts 中声明 tabBar 配置)"
|
||||
notes: "微信小程序原生 TabBar,在 app.config.ts 的 tabBar 字段配置"
|
||||
|
||||
ScrollView:
|
||||
import: "@tarojs/components"
|
||||
props: "scrollY, scrollX, scrollTop, onScroll, onScrollToLower, onScrollToUpper, children"
|
||||
notes: "Taro ScrollView,PageShell 内部使用"
|
||||
|
||||
Input:
|
||||
import: "@tarojs/components"
|
||||
props: "value, placeholder, type, maxlength, disabled, onInput, onFocus, onBlur"
|
||||
notes: "Taro Input,FormInput 内部使用"
|
||||
|
||||
Picker:
|
||||
import: "@tarojs/components"
|
||||
props: "mode('date'|'time'|'selector'), value, range, onChange, children"
|
||||
notes: "Taro Picker,用于日期/时间/选项选择"
|
||||
423
.claude/skills/design-handoff/defaults/tokens.yml
Normal file
423
.claude/skills/design-handoff/defaults/tokens.yml
Normal file
@@ -0,0 +1,423 @@
|
||||
version: 1
|
||||
updated: "2026-05-17"
|
||||
|
||||
# ============================================================================
|
||||
# Design Token 注册表
|
||||
# 数据源: apps/miniprogram/src/styles/tokens.scss + variables.scss
|
||||
# ============================================================================
|
||||
|
||||
colors:
|
||||
# --- 主色系(赤土橙) ---
|
||||
- token: --tk-pri
|
||||
value: "#C4623A"
|
||||
scss_var: "$pri"
|
||||
role: 主色/赤土橙 accent
|
||||
|
||||
- token: --tk-pri-l
|
||||
value: "#F0DDD4"
|
||||
scss_var: "$pri-l"
|
||||
role: 主色浅/赤土浅
|
||||
|
||||
- token: --tk-pri-d
|
||||
value: "#8B3E1F"
|
||||
scss_var: "$pri-d"
|
||||
role: 主色深/赤土深
|
||||
|
||||
# --- 阴影色(含透明度) ---
|
||||
- token: --tk-shadow-btn
|
||||
value: "0 4px 16px rgba(196, 98, 58, 0.3)"
|
||||
scss_var: "$shadow-btn"
|
||||
role: 主按钮阴影
|
||||
|
||||
- token: --tk-shadow-tab
|
||||
value: "0 2px 8px rgba(196, 98, 58, 0.25)"
|
||||
scss_var: "$shadow-tab"
|
||||
role: 选中Tab阴影
|
||||
|
||||
# --- 文字色 ---
|
||||
- token: --tk-text-secondary
|
||||
value: "#78716C"
|
||||
scss_var: "$tx3"
|
||||
role: 淡文字/辅助文字
|
||||
|
||||
# --- 卡片背景 ---
|
||||
- token: --tk-card-bg
|
||||
value: "#FFFFFF"
|
||||
scss_var: "$card"
|
||||
role: 卡片白底
|
||||
|
||||
# --- 医生端覆盖色(.doctor-mode 下自动替换 --tk-pri*) ---
|
||||
- token: --tk-pri.doctor
|
||||
value: "#3A6B8C"
|
||||
scss_var: "$doc-pri"
|
||||
role: 医生端主色/靛蓝
|
||||
note: 仅在 .doctor-mode 下覆盖 --tk-pri
|
||||
|
||||
- token: --tk-pri-l.doctor
|
||||
value: "#D4E5F0"
|
||||
scss_var: "$doc-pri-l"
|
||||
role: 医生端浅色
|
||||
|
||||
- token: --tk-pri-d.doctor
|
||||
value: "#2A4F6A"
|
||||
scss_var: "$doc-pri-d"
|
||||
role: 医生端深色
|
||||
|
||||
# --- 未映射到 Token 的 SCSS 变量(原型中有但 tokens.scss 未声明为 CSS 变量) ---
|
||||
unmapped_scss_variables:
|
||||
- scss_var: "$bg"
|
||||
value: "#F5F0EB"
|
||||
role: 页面主背景/温润米底
|
||||
note: "原型 T.bg 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$tx"
|
||||
value: "#2D2A26"
|
||||
role: 主文字色/暖黑
|
||||
note: "原型 T.tx 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$tx2"
|
||||
value: "#5A554F"
|
||||
role: 次文字色/暖灰
|
||||
note: "原型 T.tx2 近似映射,elder-mode 下 --tk-text-secondary 覆盖为此值"
|
||||
|
||||
- scss_var: "$bd"
|
||||
value: "#E8E2DC"
|
||||
role: 边框色
|
||||
note: "原型 T.bd 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$bd-l"
|
||||
value: "#F0EBE5"
|
||||
role: 浅边框色
|
||||
|
||||
- scss_var: "$acc"
|
||||
value: "#5B7A5E"
|
||||
role: 鼠尾草绿/成功色
|
||||
note: "原型中成功色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$acc-l"
|
||||
value: "#E8F0E8"
|
||||
role: 成功浅色
|
||||
|
||||
- scss_var: "$dan"
|
||||
value: "#B54A4A"
|
||||
role: 危险色/柔红
|
||||
note: "原型中危险色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$dan-l"
|
||||
value: "#FDEAEA"
|
||||
role: 危险浅色
|
||||
|
||||
- scss_var: "$wrn"
|
||||
value: "#C4873A"
|
||||
role: 警告色/暖琥珀
|
||||
note: "原型中警告色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$wrn-l"
|
||||
value: "#FFF3E0"
|
||||
role: 警告浅色
|
||||
|
||||
- scss_var: "$surface-alt"
|
||||
value: "#EDE8E2"
|
||||
role: 辅助底色
|
||||
|
||||
- scss_var: "$wechat"
|
||||
value: "#07C160"
|
||||
role: 微信绿
|
||||
|
||||
typography:
|
||||
- token: --tk-font-display
|
||||
value: "72px"
|
||||
note: 大屏展示
|
||||
elder: "80px"
|
||||
|
||||
- token: --tk-font-hero
|
||||
value: "48px"
|
||||
note: 启动页标题
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-font-h1
|
||||
value: "28px"
|
||||
note: 页面标题 serif bold
|
||||
elder: "32px"
|
||||
|
||||
- token: --tk-font-h2
|
||||
value: "22px"
|
||||
note: 副标题、用户名 serif bold
|
||||
elder: "25px"
|
||||
|
||||
- token: --tk-font-body-lg
|
||||
value: "18px"
|
||||
note: 按钮文字、section 标题 fontWeight:600
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-body
|
||||
value: "16px"
|
||||
note: 正文、输入框、icon 文字(最常用 UI 字号)
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-body-sm
|
||||
value: "14px"
|
||||
note: 副文本、描述
|
||||
elder: "19px"
|
||||
|
||||
- token: --tk-font-num
|
||||
value: "30px"
|
||||
note: 数值 serif bold
|
||||
elder: "34px"
|
||||
|
||||
- token: --tk-font-num-lg
|
||||
value: "34px"
|
||||
note: 大数值
|
||||
elder: "40px"
|
||||
|
||||
- token: --tk-font-cap
|
||||
value: "13px"
|
||||
note: 说明文字(第一高频字号)
|
||||
elder: "18px"
|
||||
|
||||
- token: --tk-font-nav
|
||||
value: "18px"
|
||||
note: 导航栏标题 serif bold
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-micro
|
||||
value: "11px"
|
||||
note: 角标、tag
|
||||
elder: "17px"
|
||||
|
||||
structure:
|
||||
- token: --tk-line-height
|
||||
value: "1.5"
|
||||
elder: "1.7"
|
||||
|
||||
spacing:
|
||||
- token: --tk-gap-2xs
|
||||
value: "4px"
|
||||
scss_var: "$sp-2xs"
|
||||
elder: "6px"
|
||||
|
||||
- token: --tk-gap-xs
|
||||
value: "8px"
|
||||
scss_var: "$sp-xs"
|
||||
elder: "12px"
|
||||
|
||||
- token: --tk-gap-sm
|
||||
value: "12px"
|
||||
scss_var: "$sp-sm"
|
||||
elder: "16px"
|
||||
|
||||
- token: --tk-gap-md
|
||||
value: "16px"
|
||||
scss_var: "$sp-md"
|
||||
elder: "20px"
|
||||
|
||||
- token: --tk-section-gap
|
||||
value: "20px"
|
||||
scss_var: "$sp-section"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-gap-lg
|
||||
value: "24px"
|
||||
scss_var: "$sp-lg"
|
||||
elder: "32px"
|
||||
|
||||
- token: --tk-gap-xl
|
||||
value: "32px"
|
||||
scss_var: "$sp-xl"
|
||||
elder: "40px"
|
||||
|
||||
- token: --tk-gap-2xl
|
||||
value: "48px"
|
||||
scss_var: "$sp-2xl"
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-page-padding
|
||||
value: "20px"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-card-padding
|
||||
value: "20px"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-card-padding-sm
|
||||
value: "16px"
|
||||
elder: "20px"
|
||||
|
||||
- token: --tk-card-padding-lg
|
||||
value: "28px"
|
||||
elder: "36px"
|
||||
|
||||
radius:
|
||||
- token: --tk-card-radius
|
||||
value: "16px"
|
||||
scss_var: "$r"
|
||||
elder: "20px"
|
||||
|
||||
radius_unmapped:
|
||||
- scss_var: "$r-sm"
|
||||
value: "12px"
|
||||
note: "原型 T.rSm 映射目标"
|
||||
- scss_var: "$r-xs"
|
||||
value: "8px"
|
||||
note: "原型 T.rXs 映射目标"
|
||||
- scss_var: "$r-lg"
|
||||
value: "20px"
|
||||
- scss_var: "$r-pill"
|
||||
value: "999px"
|
||||
|
||||
sizing:
|
||||
- token: --tk-touch-min
|
||||
value: "48px"
|
||||
role: 最小触控区
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-btn-primary-h
|
||||
value: "52px"
|
||||
role: 主按钮高度
|
||||
elder: "60px"
|
||||
|
||||
- token: --tk-input-height
|
||||
value: "56px"
|
||||
role: 输入框高度
|
||||
elder: "64px"
|
||||
|
||||
- token: --tk-tabbar-space
|
||||
value: "100px"
|
||||
role: TabBar 底部安全区
|
||||
elder: "120px"
|
||||
|
||||
feedback:
|
||||
- token: --tk-touch-feedback-opacity
|
||||
value: "0.85"
|
||||
role: 触控反馈透明度
|
||||
elder: "0.8"
|
||||
|
||||
tag:
|
||||
- token: --tk-tag-font-size
|
||||
value: "11px"
|
||||
elder: "13px"
|
||||
|
||||
- token: --tk-tag-padding-v
|
||||
value: "3px"
|
||||
elder: "5px"
|
||||
|
||||
- token: --tk-tag-padding-h
|
||||
value: "8px"
|
||||
elder: "12px"
|
||||
|
||||
shadow_unmapped:
|
||||
# tokens.scss 中的 --tk-shadow-btn/tab 是复合值(含偏移+模糊+颜色)
|
||||
# 以下为 variables.scss 中的其他阴影,未声明为 CSS Token
|
||||
- scss_var: "$shadow-sm"
|
||||
value: "0 1px 4px rgba(45, 42, 38, 0.06)"
|
||||
role: 小阴影
|
||||
|
||||
- scss_var: "$shadow-md"
|
||||
value: "0 2px 12px rgba(45, 42, 38, 0.10)"
|
||||
role: 中阴影
|
||||
|
||||
- scss_var: "$shadow-lg"
|
||||
value: "0 8px 32px rgba(45, 42, 38, 0.15)"
|
||||
role: 大阴影
|
||||
|
||||
# ============================================================================
|
||||
# 原型 Key → Token 映射(aliases)
|
||||
# 用于设计移交时自动匹配原型属性到实际 Token
|
||||
# ============================================================================
|
||||
aliases:
|
||||
prototype_keys:
|
||||
T.pri:
|
||||
- value: "#C4623A"
|
||||
token: --tk-pri
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#3A6B8C"
|
||||
token: --tk-pri.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
T.priL:
|
||||
- value: "#F0DDD4"
|
||||
token: --tk-pri-l
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#D4E5F0"
|
||||
token: --tk-pri-l.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
T.priD:
|
||||
- value: "#8B3E1F"
|
||||
token: --tk-pri-d
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#2A4F6A"
|
||||
token: --tk-pri-d.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
T.bg:
|
||||
token: null
|
||||
nearest: --tk-card-bg
|
||||
scss_var: "$bg"
|
||||
value: "#F5F0EB"
|
||||
status: unmatched
|
||||
note: "原型页面背景色,tokens.scss 未声明为 CSS 变量,直接用 $bg SCSS 变量"
|
||||
|
||||
T.card:
|
||||
token: --tk-card-bg
|
||||
status: exact_match
|
||||
|
||||
T.surface:
|
||||
token: --tk-card-bg
|
||||
status: approximate
|
||||
note: "原型中 surface ≈ 卡片白底"
|
||||
|
||||
T.tx:
|
||||
token: null
|
||||
nearest: --tk-text-secondary
|
||||
scss_var: "$tx"
|
||||
value: "#2D2A26"
|
||||
status: unmatched
|
||||
note: "主文字色,tokens.scss 未声明为 CSS 变量,直接用 $tx SCSS 变量"
|
||||
|
||||
T.tx2:
|
||||
token: null
|
||||
nearest: --tk-text-secondary
|
||||
scss_var: "$tx2"
|
||||
value: "#5A554F"
|
||||
status: unmatched
|
||||
note: "次文字色,tokens.scss 未声明,elder-mode 下 --tk-text-secondary 覆盖为此值"
|
||||
|
||||
T.tx3:
|
||||
token: --tk-text-secondary
|
||||
scss_var: "$tx3"
|
||||
value: "#78716C"
|
||||
status: exact_match
|
||||
|
||||
T.bd:
|
||||
token: null
|
||||
scss_var: "$bd"
|
||||
value: "#E8E2DC"
|
||||
status: unmatched
|
||||
note: "边框色(不是圆角),tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
T.r:
|
||||
token: --tk-card-radius
|
||||
scss_var: "$r"
|
||||
value: "16px"
|
||||
status: exact_match
|
||||
|
||||
T.rSm:
|
||||
token: null
|
||||
scss_var: "$r-sm"
|
||||
value: "12px"
|
||||
status: unmatched
|
||||
note: "tokens.scss 未声明,需添加 --tk-radius-sm 或直接用 $r-sm SCSS 变量"
|
||||
|
||||
T.rXs:
|
||||
token: null
|
||||
scss_var: "$r-xs"
|
||||
value: "8px"
|
||||
status: unmatched
|
||||
note: "tokens.scss 未声明,需添加 --tk-radius-xs 或直接用 $r-xs SCSS 变量"
|
||||
79
.claude/skills/design-handoff/package-lock.json
generated
Normal file
79
.claude/skills/design-handoff/package-lock.json
generated
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "design-handoff",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "design-handoff",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.1",
|
||||
"playwright": "^1.58.0"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
|
||||
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
|
||||
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
.claude/skills/design-handoff/package.json
Normal file
17
.claude/skills/design-handoff/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "design-handoff",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.1",
|
||||
"playwright": "^1.58.0"
|
||||
}
|
||||
}
|
||||
114
.claude/skills/design-handoff/rules/interaction-rules.yml
Normal file
114
.claude/skills/design-handoff/rules/interaction-rules.yml
Normal file
@@ -0,0 +1,114 @@
|
||||
version: 1
|
||||
updated: "2026-05-17"
|
||||
|
||||
# ============================================================================
|
||||
# 交互推断规则
|
||||
# patterns 使用正则表达式,匹配 HTML/JS 源码中的实际代码模式
|
||||
# require_all: true 表示所有 pattern 必须同时匹配(默认 false,任一匹配即可)
|
||||
# ============================================================================
|
||||
|
||||
rules:
|
||||
- id: swiper-autoplay
|
||||
name: "自动轮播 + 手动滑动"
|
||||
patterns:
|
||||
- "linear-gradient"
|
||||
- "width.*24.*width.*8"
|
||||
require_all: false
|
||||
infer:
|
||||
component: "Swiper"
|
||||
props: "autoplay circular indicatorDots"
|
||||
behavior: "自动轮播,3-5秒切换"
|
||||
confidence: high
|
||||
|
||||
- id: card-tap
|
||||
name: "卡片点击跳转"
|
||||
patterns:
|
||||
- "\\.map\\("
|
||||
require_all: false
|
||||
infer:
|
||||
component: "ContentCard"
|
||||
props: "activeFeedback onPress"
|
||||
behavior: "卡片可点击,带触控反馈"
|
||||
confidence: medium
|
||||
|
||||
- id: form-submit
|
||||
name: "表单提交"
|
||||
patterns:
|
||||
- "<input"
|
||||
- "<button"
|
||||
require_all: false
|
||||
infer:
|
||||
component: "FormInput + PrimaryButton"
|
||||
props: ""
|
||||
behavior: "表单输入+提交按钮"
|
||||
confidence: high
|
||||
|
||||
- id: list-scroll
|
||||
name: "列表滚动"
|
||||
patterns:
|
||||
- "overflow.*auto|scroll"
|
||||
- "\\.map\\(.*\\.map\\(.*\\.map\\("
|
||||
require_all: false
|
||||
infer:
|
||||
component: "ScrollView"
|
||||
props: "scrollY onScrollToLower"
|
||||
behavior: "可滚动列表,支持上拉加载"
|
||||
confidence: medium
|
||||
|
||||
- id: tab-switch
|
||||
name: "标签页切换"
|
||||
patterns:
|
||||
- "tab"
|
||||
- "segment"
|
||||
- "filter"
|
||||
require_all: false
|
||||
infer:
|
||||
component: "TabFilter"
|
||||
props: "tabs onChange"
|
||||
behavior: "标签页切换筛选"
|
||||
confidence: medium
|
||||
|
||||
- id: static-decoration
|
||||
name: "纯装饰无交互"
|
||||
patterns:
|
||||
- "position.*absolute"
|
||||
- "opacity.*0\\."
|
||||
require_all: true
|
||||
infer:
|
||||
component: null
|
||||
props: ""
|
||||
behavior: "纯装饰性元素,无交互"
|
||||
confidence: high
|
||||
|
||||
- id: login-cta
|
||||
name: "登录/注册触发"
|
||||
patterns:
|
||||
- "登录"
|
||||
- "注册"
|
||||
- "微信登录"
|
||||
- "立即登录"
|
||||
exclude_patterns:
|
||||
- "立即关注"
|
||||
- "立即处理"
|
||||
- "立即查看"
|
||||
- "需要立即"
|
||||
require_all: false
|
||||
infer:
|
||||
component: "PrimaryButton"
|
||||
props: "onClick"
|
||||
behavior: "登录/注册引导按钮"
|
||||
confidence: high
|
||||
|
||||
- id: empty-fallback
|
||||
name: "空数据降级"
|
||||
patterns:
|
||||
- "\\.length > 0"
|
||||
- "\\.length === 0"
|
||||
- "暂无"
|
||||
- "没有"
|
||||
require_all: false
|
||||
infer:
|
||||
component: null
|
||||
props: ""
|
||||
behavior: "条件渲染:有数据显示列表,无数据显示空状态"
|
||||
confidence: medium
|
||||
194
.claude/skills/design-handoff/scripts/extract-screenshots.mjs
Normal file
194
.claude/skills/design-handoff/scripts/extract-screenshots.mjs
Normal file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* extract-screenshots.mjs — 从 huashu-design HTML 原型截取 IosFrame 屏幕内容
|
||||
*
|
||||
* 用法: node extract-screenshots.mjs <html-file> <output-dir>
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { resolve, basename } from 'node:path';
|
||||
import { mkdirSync, existsSync } from 'node:fs';
|
||||
|
||||
const IOS_FRAME_TRIM = { statusBar: 54, homeIndicator: 34, padding: 12 };
|
||||
const RENDER_WAIT_TIMEOUT = 10_000;
|
||||
const RENDER_POLL_INTERVAL = 200;
|
||||
|
||||
const ZH_EN_MAP = {
|
||||
'完整页': '', '—': '-', ' ': '-',
|
||||
'首页': 'home', '我的': 'profile', '登录': 'login', '健康': 'health',
|
||||
'消息': 'messages', '咨询': 'consultation', '预约': 'appointment',
|
||||
'商城': 'mall', '设置': 'settings', '访客': 'guest', '轮播': 'slide',
|
||||
'体检': 'checkup', '报告': 'report', '医生': 'doctor', '患者': 'patient',
|
||||
'记录': 'record', '详情': 'detail', '列表': 'list', '管理': 'manage',
|
||||
'编辑': 'edit', '新增': 'add', '搜索': 'search', '筛选': 'filter',
|
||||
'结果': 'result', '历史': 'history', '数据': 'data', '体征': 'vitals',
|
||||
'用药': 'medication', '随访': 'followup', '透析': 'dialysis',
|
||||
'日常': 'daily', '监测': 'monitor', '告警': 'alert', '家庭': 'family',
|
||||
'成员': 'member', '档案': 'profile', '商品': 'product', '兑换': 'exchange',
|
||||
'订单': 'order', '积分': 'points', '活动': 'activity',
|
||||
};
|
||||
|
||||
function translateLabel(label, fallbackIndex) {
|
||||
let result = label;
|
||||
const sortedKeys = Object.keys(ZH_EN_MAP).sort((a, b) => b.length - a.length);
|
||||
for (const zh of sortedKeys) {
|
||||
result = result.split(zh).join(ZH_EN_MAP[zh]);
|
||||
}
|
||||
result = result.replace(/[^\w\-.]/g, '-');
|
||||
result = result.replace(/^-+|-+$/g, '').replace(/-+/g, '-');
|
||||
return result || `screen-${fallbackIndex}`;
|
||||
}
|
||||
|
||||
function fail(msg) {
|
||||
process.stderr.write(`ERROR: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) fail('用法: node extract-screenshots.mjs <html-file> <output-dir>');
|
||||
|
||||
const htmlFile = resolve(args[0]);
|
||||
const outputDir = resolve(args[1]);
|
||||
if (!existsSync(htmlFile)) fail(`HTML 文件不存在: ${htmlFile}`);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 2400, height: 1400 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const fileUrl = `file:///${htmlFile.replace(/\\/g, '/')}`;
|
||||
await page.goto(fileUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
|
||||
// 等待 React 渲染
|
||||
const renderStart = Date.now();
|
||||
let rendered = false;
|
||||
while (Date.now() - renderStart < RENDER_WAIT_TIMEOUT) {
|
||||
rendered = await page.evaluate(() => {
|
||||
const root = document.getElementById('root');
|
||||
return root ? root.children.length > 0 : false;
|
||||
});
|
||||
if (rendered) break;
|
||||
await page.waitForTimeout(RENDER_POLL_INTERVAL);
|
||||
}
|
||||
if (!rendered) fail(`React 渲染超时: ${basename(htmlFile)}`);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 查找 .screen-wrap 容器
|
||||
const screenCount = await page.locator('.screen-wrap').count();
|
||||
|
||||
if (screenCount === 0) {
|
||||
// 降级:整页截图
|
||||
await page.screenshot({ path: resolve(outputDir, 'full-page.png'), fullPage: true });
|
||||
process.stdout.write(JSON.stringify({
|
||||
source: basename(htmlFile), totalScreens: 0, fallback: true,
|
||||
files: [{ label: 'full-page', file: 'full-page.png' }],
|
||||
}, null, 2) + '\n');
|
||||
await browser.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 page.evaluate 定位并截取每个 screen
|
||||
const screenData = await page.evaluate(() => {
|
||||
const wraps = document.querySelectorAll('.screen-wrap');
|
||||
return Array.from(wraps).map((wrap, i) => {
|
||||
// 获取标签
|
||||
const labelEl = wrap.querySelector('.screen-label');
|
||||
const label = labelEl ? labelEl.textContent.trim() : '';
|
||||
|
||||
// 找 IosFrame wrapper: screen-wrap 下有 borderRadius:60 + background:#000 的 div
|
||||
// 或者用策略:找第一个有 style 含 borderRadius 的 div
|
||||
const allDivs = wrap.querySelectorAll(':scope > div');
|
||||
let wrapperDiv = null;
|
||||
for (const div of allDivs) {
|
||||
const style = div.getAttribute('style') || '';
|
||||
// IosFrame wrapper 特征: background:#000 或 background: black
|
||||
if (style.includes('background:')) {
|
||||
wrapperDiv = div;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 如果没找到 background 的,取第一个 div
|
||||
if (!wrapperDiv && allDivs.length > 0) wrapperDiv = allDivs[0];
|
||||
|
||||
if (!wrapperDiv) return { label, index: i, found: false };
|
||||
|
||||
const wrapperBox = wrapperDiv.getBoundingClientRect();
|
||||
return {
|
||||
label,
|
||||
index: i,
|
||||
found: true,
|
||||
wrapperBox: {
|
||||
x: wrapperBox.x,
|
||||
y: wrapperBox.y,
|
||||
width: wrapperBox.width,
|
||||
height: wrapperBox.height,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const { statusBar, homeIndicator, padding } = IOS_FRAME_TRIM;
|
||||
const files = [];
|
||||
const usedNames = new Set();
|
||||
|
||||
for (const screen of screenData) {
|
||||
if (!screen.found || !screen.wrapperBox) {
|
||||
process.stderr.write(`WARNING: screen ${screen.index + 1} 未找到 IosFrame,跳过\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const box = screen.wrapperBox;
|
||||
const clip = {
|
||||
x: box.x + padding,
|
||||
y: box.y + padding + statusBar,
|
||||
width: box.width - padding * 2,
|
||||
height: box.height - padding * 2 - statusBar - homeIndicator,
|
||||
};
|
||||
|
||||
if (clip.width <= 0 || clip.height <= 0) {
|
||||
process.stderr.write(`WARNING: screen ${screen.index + 1} clip 无效 (${clip.width}x${clip.height})\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let safeName = translateLabel(screen.label || '', screen.index + 1);
|
||||
if (usedNames.has(safeName)) {
|
||||
let s = 2;
|
||||
while (usedNames.has(`${safeName}-${s}`)) s++;
|
||||
safeName = `${safeName}-${s}`;
|
||||
}
|
||||
usedNames.add(safeName);
|
||||
|
||||
const filename = `${safeName}.png`;
|
||||
await page.screenshot({ path: resolve(outputDir, filename), clip });
|
||||
|
||||
files.push({
|
||||
label: screen.label || `screen-${screen.index + 1}`,
|
||||
file: filename,
|
||||
clip: {
|
||||
x: Math.round(clip.x), y: Math.round(clip.y),
|
||||
width: Math.round(clip.width), height: Math.round(clip.height),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
source: basename(htmlFile),
|
||||
totalScreens: screenCount,
|
||||
extracted: files.length,
|
||||
files,
|
||||
}, null, 2) + '\n');
|
||||
|
||||
} catch (err) {
|
||||
fail(`Playwright 执行失败: ${err.message}`);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
222
.claude/skills/design-handoff/scripts/infer-interactions.mjs
Normal file
222
.claude/skills/design-handoff/scripts/infer-interactions.mjs
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* infer-interactions.mjs -- 对 HTML 原型源码进行静态模式匹配,推断页面交互行为
|
||||
*
|
||||
* 读取 HTML 文件和 interaction-rules.yml,用正则匹配源码中的模式,
|
||||
* 输出推断的交互组件、props 和行为描述。
|
||||
*
|
||||
* 用法: node infer-interactions.mjs <html-file> <interaction-rules.yml>
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, basename } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
// js-yaml 是 CJS 包,需要用 createRequire 加载
|
||||
const require = createRequire(import.meta.url);
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
// -- 工具函数 ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 输出错误到 stderr 并以 code 1 退出
|
||||
*/
|
||||
function fail(message) {
|
||||
process.stderr.write(JSON.stringify({ error: message }, null, 2) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容,失败时调用 fail
|
||||
*/
|
||||
function readFileOrFail(filePath, description) {
|
||||
try {
|
||||
return readFileSync(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
fail(`无法读取${description}: ${filePath} -- ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// -- 组件函数位置追踪 ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 提取源码中所有 function/const 组件声明的位置和名称。
|
||||
* 返回 [{ name, start, end }],按 start 排序。
|
||||
*
|
||||
* end 的计算采用简化策略:找到下一个同级 function/const 声明或源码末尾。
|
||||
* 对于 locations 追踪来说已经足够精确——只要知道 pattern 落在哪个函数内即可。
|
||||
*/
|
||||
function extractFunctionRanges(source) {
|
||||
const ranges = [];
|
||||
|
||||
// 匹配 function 声明: function XxxName(
|
||||
const funcPattern = /function\s+([A-Z]\w*)\s*\(/g;
|
||||
let match;
|
||||
while ((match = funcPattern.exec(source)) !== null) {
|
||||
ranges.push({ name: match[1], start: match.index });
|
||||
}
|
||||
|
||||
// 匹配 const 箭头函数: const XxxName = (...
|
||||
const arrowPattern = /const\s+([A-Z]\w*)\s*=\s*(?:\([^)]*\)|\w+)\s*=>/g;
|
||||
while ((match = arrowPattern.exec(source)) !== null) {
|
||||
ranges.push({ name: match[1], start: match.index });
|
||||
}
|
||||
|
||||
// 按 start 排序,计算 end(下一个函数的 start 或源码末尾)
|
||||
ranges.sort((a, b) => a.start - b.start);
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const nextStart = i + 1 < ranges.length ? ranges[i + 1].start : source.length;
|
||||
ranges[i].end = nextStart;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给定 pattern 在 source 中的匹配位置,找到它所属的函数名。
|
||||
* 如果不在任何函数内,返回 '全局'。
|
||||
*/
|
||||
function findEnclosingFunction(ranges, matchIndex) {
|
||||
for (const range of ranges) {
|
||||
if (matchIndex >= range.start && matchIndex < range.end) {
|
||||
return range.name;
|
||||
}
|
||||
}
|
||||
return '全局';
|
||||
}
|
||||
|
||||
// -- 核心匹配逻辑 ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 对单条 rule 执行模式匹配。
|
||||
* 返回 { matched, matchedPatterns, locations }。
|
||||
*/
|
||||
function matchRule(rule, source, funcRanges) {
|
||||
const requireAll = rule.require_all === true;
|
||||
const matchedPatterns = [];
|
||||
const locationSet = new Set();
|
||||
|
||||
for (const patternStr of rule.patterns) {
|
||||
try {
|
||||
const regex = new RegExp(patternStr, 'g');
|
||||
let match;
|
||||
let patternMatched = false;
|
||||
|
||||
while ((match = regex.exec(source)) !== null) {
|
||||
patternMatched = true;
|
||||
const enclosing = findEnclosingFunction(funcRanges, match.index);
|
||||
locationSet.add(enclosing);
|
||||
}
|
||||
|
||||
if (patternMatched) {
|
||||
matchedPatterns.push(patternStr);
|
||||
}
|
||||
} catch (err) {
|
||||
// 正则语法错误,跳过此 pattern 并报告到 stderr
|
||||
process.stderr.write(`警告: rule "${rule.id}" 的 pattern "${patternStr}" 正则语法错误: ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const allMatched = requireAll
|
||||
? matchedPatterns.length === rule.patterns.length
|
||||
: matchedPatterns.length > 0;
|
||||
|
||||
// 排除模式检查:如果任一 exclude_pattern 命中,该规则不匹配
|
||||
if (allMatched && Array.isArray(rule.exclude_patterns)) {
|
||||
for (const exclPattern of rule.exclude_patterns) {
|
||||
try {
|
||||
const exclRegex = new RegExp(exclPattern);
|
||||
if (exclRegex.test(source)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedPatterns: [],
|
||||
locations: [],
|
||||
excluded_by: exclPattern,
|
||||
};
|
||||
}
|
||||
} catch (_e) { /* ignore regex errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matched: allMatched,
|
||||
matchedPatterns: allMatched ? matchedPatterns : [],
|
||||
locations: allMatched ? [...locationSet] : [],
|
||||
};
|
||||
}
|
||||
|
||||
// -- 主流程 ------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
fail('用法: node infer-interactions.mjs <html-file> <interaction-rules.yml>');
|
||||
}
|
||||
|
||||
const htmlPath = resolve(args[0]);
|
||||
const rulesPath = resolve(args[1]);
|
||||
|
||||
// 读取源文件
|
||||
const source = readFileOrFail(htmlPath, 'HTML 文件');
|
||||
|
||||
// 读取并解析 rules YAML
|
||||
const rulesContent = readFileOrFail(rulesPath, 'interaction-rules.yml');
|
||||
let rulesDoc;
|
||||
try {
|
||||
rulesDoc = yaml.load(rulesContent);
|
||||
} catch (err) {
|
||||
fail(`YAML 解析失败: ${err.message}`);
|
||||
}
|
||||
|
||||
if (!rulesDoc || !Array.isArray(rulesDoc.rules)) {
|
||||
fail('interaction-rules.yml 格式错误: 缺少顶层的 rules 数组');
|
||||
}
|
||||
|
||||
const rules = rulesDoc.rules;
|
||||
|
||||
// 提取函数位置范围表(用于 locations 追踪)
|
||||
const funcRanges = extractFunctionRanges(source);
|
||||
|
||||
// 逐条匹配
|
||||
const interactions = [];
|
||||
let matchedCount = 0;
|
||||
|
||||
for (const rule of rules) {
|
||||
const { matched, matchedPatterns, locations } = matchRule(rule, source, funcRanges);
|
||||
|
||||
const entry = {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
matched,
|
||||
};
|
||||
|
||||
if (matched) {
|
||||
matchedCount++;
|
||||
entry.matchedPatterns = matchedPatterns;
|
||||
entry.component = rule.infer?.component ?? null;
|
||||
entry.props = rule.infer?.props ?? '';
|
||||
entry.behavior = rule.infer?.behavior ?? '';
|
||||
entry.confidence = rule.confidence ?? 'medium';
|
||||
if (locations.length > 0) {
|
||||
entry.locations = locations;
|
||||
}
|
||||
}
|
||||
|
||||
interactions.push(entry);
|
||||
}
|
||||
|
||||
// 组装输出
|
||||
const result = {
|
||||
source: basename(htmlPath),
|
||||
interactions,
|
||||
summary: {
|
||||
total: rules.length,
|
||||
matched: matchedCount,
|
||||
unmatched: rules.length - matchedCount,
|
||||
},
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
|
||||
main();
|
||||
706
.claude/skills/design-handoff/scripts/match-tokens.mjs
Normal file
706
.claude/skills/design-handoff/scripts/match-tokens.mjs
Normal file
@@ -0,0 +1,706 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* match-tokens.mjs — 三层 Token 匹配
|
||||
*
|
||||
* 接收 parse-prototype.mjs 的输出 JSON 和 tokens.yml 配置,
|
||||
* 对每个原型 Token(别名 / 内联样式值)执行三层匹配,
|
||||
* 输出映射关系到 stdout。
|
||||
*
|
||||
* 三层匹配算法:
|
||||
* Layer 1: 别名直查(aliases.prototype_keys)
|
||||
* Layer 2: 值精确匹配(带 CSS 属性上下文消歧)
|
||||
* Layer 3: 色彩模糊匹配(RGB 欧几里得距离 < 30)
|
||||
*
|
||||
* 用法:
|
||||
* node match-tokens.mjs <parse-result.json> <tokens.yml>
|
||||
*/
|
||||
|
||||
import yaml from 'js-yaml';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工具函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 解析 hex 颜色字符串为 {r, g, b}
|
||||
* 支持 #RGB / #RRGGBB / RRGGBB
|
||||
*/
|
||||
function hexToRgb(hex) {
|
||||
if (!hex || typeof hex !== 'string') return null;
|
||||
const cleaned = hex.replace(/^#/, '').trim();
|
||||
if (cleaned.length === 3) {
|
||||
const r = parseInt(cleaned[0] + cleaned[0], 16);
|
||||
const g = parseInt(cleaned[1] + cleaned[1], 16);
|
||||
const b = parseInt(cleaned[2] + cleaned[2], 16);
|
||||
return { r, g, b };
|
||||
}
|
||||
if (cleaned.length === 6) {
|
||||
const r = parseInt(cleaned.substring(0, 2), 16);
|
||||
const g = parseInt(cleaned.substring(2, 4), 16);
|
||||
const b = parseInt(cleaned.substring(4, 6), 16);
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
|
||||
return { r, g, b };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RGB 欧几里得距离
|
||||
*/
|
||||
function colorDistance(rgb1, rgb2) {
|
||||
return Math.sqrt(
|
||||
(rgb1.r - rgb2.r) ** 2 +
|
||||
(rgb1.g - rgb2.g) ** 2 +
|
||||
(rgb1.b - rgb2.b) ** 2,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为颜色值(hex 格式)
|
||||
*/
|
||||
function isHexColor(value) {
|
||||
if (!value || typeof value !== 'string') return false;
|
||||
return /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化数值:去掉 px 后缀,返回数值字符串(保留小数)
|
||||
*/
|
||||
function normalizeNumericValue(value) {
|
||||
if (value == null) return null;
|
||||
const str = String(value).trim();
|
||||
if (str.endsWith('px')) {
|
||||
return str.slice(0, -2).trim();
|
||||
}
|
||||
if (str.endsWith('rem') || str.endsWith('em')) {
|
||||
return null; // rem/em 标记为 pending,不参与数值匹配
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化颜色值:统一为小写 #rrggbb
|
||||
*/
|
||||
function normalizeColor(value) {
|
||||
if (!value) return null;
|
||||
const str = String(value).trim();
|
||||
const hex = str.startsWith('#') ? str : `#${str}`;
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return null;
|
||||
return `#${rgb.r.toString(16).padStart(2, '0')}${rgb.g.toString(16).padStart(2, '0')}${rgb.b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 属性到 token 类别的映射
|
||||
*/
|
||||
const CSS_PROPERTY_CATEGORIES = {
|
||||
// 颜色类
|
||||
color: 'color',
|
||||
backgroundColor: 'color',
|
||||
'background-color': 'color',
|
||||
background: 'color',
|
||||
borderColor: 'color',
|
||||
'border-color': 'color',
|
||||
borderTopColor: 'color',
|
||||
borderBottomColor: 'color',
|
||||
borderLeftColor: 'color',
|
||||
borderRightColor: 'color',
|
||||
outlineColor: 'color',
|
||||
textColor: 'color',
|
||||
fill: 'color',
|
||||
|
||||
// 间距类
|
||||
padding: 'spacing',
|
||||
paddingTop: 'spacing',
|
||||
paddingBottom: 'spacing',
|
||||
paddingLeft: 'spacing',
|
||||
paddingRight: 'spacing',
|
||||
paddingVertical: 'spacing',
|
||||
paddingHorizontal: 'spacing',
|
||||
margin: 'spacing',
|
||||
marginTop: 'spacing',
|
||||
marginBottom: 'spacing',
|
||||
marginLeft: 'spacing',
|
||||
marginRight: 'spacing',
|
||||
gap: 'spacing',
|
||||
rowGap: 'spacing',
|
||||
columnGap: 'spacing',
|
||||
top: 'spacing',
|
||||
bottom: 'spacing',
|
||||
left: 'spacing',
|
||||
right: 'spacing',
|
||||
|
||||
// 排版类
|
||||
fontSize: 'typography',
|
||||
lineHeight: 'typography',
|
||||
fontWeight: 'typography',
|
||||
letterSpacing: 'typography',
|
||||
|
||||
// 圆角类
|
||||
borderRadius: 'radius',
|
||||
borderTopLeftRadius: 'radius',
|
||||
borderTopRightRadius: 'radius',
|
||||
borderBottomLeftRadius: 'radius',
|
||||
borderBottomRightRadius: 'radius',
|
||||
|
||||
// 尺寸类
|
||||
width: 'sizing',
|
||||
height: 'sizing',
|
||||
minWidth: 'sizing',
|
||||
minHeight: 'sizing',
|
||||
maxWidth: 'sizing',
|
||||
maxHeight: 'sizing',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token 注册表构建
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 从 tokens.yml 构建可查找的 Token 列表
|
||||
* 返回 { all: [...], byCategory: { color: [...], spacing: [...], ... } }
|
||||
*/
|
||||
function buildTokenRegistry(tokensConfig) {
|
||||
const all = [];
|
||||
const byCategory = {};
|
||||
|
||||
function addToken(entry, category) {
|
||||
const record = {
|
||||
token: entry.token || null,
|
||||
scssVar: entry.scss_var || null,
|
||||
value: entry.value || null,
|
||||
category,
|
||||
role: entry.role || null,
|
||||
note: entry.note || null,
|
||||
};
|
||||
all.push(record);
|
||||
if (!byCategory[category]) byCategory[category] = [];
|
||||
byCategory[category].push(record);
|
||||
}
|
||||
|
||||
// colors
|
||||
if (Array.isArray(tokensConfig.colors)) {
|
||||
for (const c of tokensConfig.colors) {
|
||||
addToken(c, 'color');
|
||||
}
|
||||
}
|
||||
|
||||
// unmapped_scss_variables(颜色类)
|
||||
if (Array.isArray(tokensConfig.unmapped_scss_variables)) {
|
||||
for (const v of tokensConfig.unmapped_scss_variables) {
|
||||
addToken(v, 'color');
|
||||
}
|
||||
}
|
||||
|
||||
// typography
|
||||
if (Array.isArray(tokensConfig.typography)) {
|
||||
for (const t of tokensConfig.typography) {
|
||||
addToken(t, 'typography');
|
||||
}
|
||||
}
|
||||
|
||||
// structure (line-height 归入 typography)
|
||||
if (Array.isArray(tokensConfig.structure)) {
|
||||
for (const s of tokensConfig.structure) {
|
||||
addToken(s, 'typography');
|
||||
}
|
||||
}
|
||||
|
||||
// spacing
|
||||
if (Array.isArray(tokensConfig.spacing)) {
|
||||
for (const s of tokensConfig.spacing) {
|
||||
addToken(s, 'spacing');
|
||||
}
|
||||
}
|
||||
|
||||
// radius
|
||||
if (Array.isArray(tokensConfig.radius)) {
|
||||
for (const r of tokensConfig.radius) {
|
||||
if (r && typeof r === 'object') {
|
||||
addToken(r, 'radius');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(tokensConfig.radius_unmapped)) {
|
||||
for (const r of tokensConfig.radius_unmapped) {
|
||||
if (r && typeof r === 'object') {
|
||||
addToken(r, 'radius');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sizing
|
||||
if (Array.isArray(tokensConfig.sizing)) {
|
||||
for (const s of tokensConfig.sizing) {
|
||||
addToken(s, 'sizing');
|
||||
}
|
||||
}
|
||||
|
||||
// feedback(归入 sizing 或单独)
|
||||
if (Array.isArray(tokensConfig.feedback)) {
|
||||
for (const f of tokensConfig.feedback) {
|
||||
addToken(f, 'feedback');
|
||||
}
|
||||
}
|
||||
|
||||
// tag
|
||||
if (Array.isArray(tokensConfig.tag)) {
|
||||
for (const t of tokensConfig.tag) {
|
||||
addToken(t, 'tag');
|
||||
}
|
||||
}
|
||||
|
||||
// shadow_unmapped
|
||||
if (Array.isArray(tokensConfig.shadow_unmapped)) {
|
||||
for (const s of tokensConfig.shadow_unmapped) {
|
||||
addToken(s, 'shadow');
|
||||
}
|
||||
}
|
||||
|
||||
return { all, byCategory };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 三层匹配
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Layer 1: 别名直查(支持值条件映射)
|
||||
*
|
||||
* alias 格式有两种:
|
||||
* 单条: { token: "...", status: "exact_match" }
|
||||
* 数组: [{ value: "#C4623A", token: "--tk-pri", variant: "patient" },
|
||||
* { value: "#3A6B8C", token: "--tk-pri.doctor", variant: "doctor" }]
|
||||
*
|
||||
* 数组格式按值匹配,单条格式直接匹配(兼容旧格式)。
|
||||
*/
|
||||
function matchByAlias(key, prototypeValue, aliases) {
|
||||
if (!aliases || !aliases.prototype_keys) return null;
|
||||
const alias = aliases.prototype_keys[key];
|
||||
if (!alias) return null;
|
||||
|
||||
// 数组格式:按值条件匹配
|
||||
if (Array.isArray(alias)) {
|
||||
const normalizedInput = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue);
|
||||
for (const entry of alias) {
|
||||
const normalizedAlias = normalizeColor(entry.value) || normalizeNumericValue(entry.value) || String(entry.value);
|
||||
if (normalizedInput === normalizedAlias) {
|
||||
const result = {
|
||||
method: 'alias',
|
||||
confidence: entry.status === 'exact_match' ? 'confirmed' : 'pending',
|
||||
token: entry.token || null,
|
||||
scssVar: entry.scss_var || null,
|
||||
};
|
||||
if (entry.variant) result.variant = entry.variant;
|
||||
if (entry.note) result.note = entry.note;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// 数组中无值匹配 → 降级到 Layer 2
|
||||
return null;
|
||||
}
|
||||
|
||||
// 单条格式(兼容旧格式)
|
||||
const result = { method: 'alias', confidence: null };
|
||||
|
||||
// 值校验:如果 alias 有 value 字段,比较值
|
||||
if (alias.value && prototypeValue != null) {
|
||||
const normAlias = normalizeColor(alias.value) || normalizeNumericValue(alias.value) || String(alias.value);
|
||||
const normProto = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue);
|
||||
if (normAlias !== normProto) {
|
||||
// 值不匹配 → 降级到 Layer 2
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (alias.status === 'exact_match') {
|
||||
result.token = alias.token || null;
|
||||
result.scssVar = alias.scss_var || null;
|
||||
result.confidence = 'confirmed';
|
||||
} else if (alias.status === 'unmatched') {
|
||||
result.token = alias.token || null;
|
||||
result.scssVar = alias.scss_var || null;
|
||||
result.tokenValue = alias.value || null;
|
||||
result.confidence = 'pending';
|
||||
if (alias.note) result.note = alias.note;
|
||||
if (!result.token && alias.scssVar) {
|
||||
result.note = result.note || `tokens.scss 未声明为 CSS 变量,直接使用 SCSS 变量 ${alias.scssVar}`;
|
||||
}
|
||||
} else if (alias.status === 'approximate') {
|
||||
result.token = alias.token || null;
|
||||
result.scssVar = alias.scss_var || null;
|
||||
result.confidence = 'approximate';
|
||||
if (alias.note) result.note = alias.note;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用于值匹配的候选 token 列表
|
||||
* 根据 CSS 属性确定查找类别
|
||||
*/
|
||||
function getCandidatesByProperty(propertyName, registry) {
|
||||
const category = CSS_PROPERTY_CATEGORIES[propertyName];
|
||||
|
||||
if (!category) {
|
||||
// 未知属性,在所有 token 中搜索
|
||||
return registry.all;
|
||||
}
|
||||
|
||||
if (category === 'color') {
|
||||
// color 类同时搜索 colors + unmapped_scss_variables
|
||||
return [
|
||||
...(registry.byCategory.color || []),
|
||||
...(registry.byCategory.shadow || []), // 阴影也含颜色信息
|
||||
];
|
||||
}
|
||||
|
||||
if (category === 'spacing') {
|
||||
return [
|
||||
...(registry.byCategory.spacing || []),
|
||||
...(registry.byCategory.tag || []), // tag 也有 padding
|
||||
];
|
||||
}
|
||||
|
||||
if (category === 'typography') {
|
||||
return registry.byCategory.typography || [];
|
||||
}
|
||||
|
||||
if (category === 'radius') {
|
||||
return registry.byCategory.radius || [];
|
||||
}
|
||||
|
||||
if (category === 'sizing') {
|
||||
return [
|
||||
...(registry.byCategory.sizing || []),
|
||||
...(registry.byCategory.feedback || []),
|
||||
];
|
||||
}
|
||||
|
||||
return registry.byCategory[category] || registry.all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 2: 值精确匹配(带 CSS 属性上下文消歧)
|
||||
* @param {string} value - 原型中的值
|
||||
* @param {string|null} cssProperty - 关联的 CSS 属性名(用于消歧)
|
||||
* @param {object} registry - Token 注册表
|
||||
* @returns {object|null} 匹配结果
|
||||
*/
|
||||
function matchByValue(value, cssProperty, registry) {
|
||||
if (value == null) return null;
|
||||
|
||||
const candidates = getCandidatesByProperty(cssProperty, registry);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// 颜色匹配
|
||||
if (isHexColor(value)) {
|
||||
const normalizedInput = normalizeColor(value);
|
||||
if (!normalizedInput) return null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.value) continue;
|
||||
if (!isHexColor(candidate.value)) continue;
|
||||
const normalizedCandidate = normalizeColor(candidate.value);
|
||||
if (normalizedCandidate === normalizedInput) {
|
||||
return buildValueMatchResult(candidate, value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 数值匹配(含 px 后缀)
|
||||
const normalizedInput = normalizeNumericValue(value);
|
||||
if (normalizedInput === null) {
|
||||
// rem/em — 标记为 pending
|
||||
return {
|
||||
token: null,
|
||||
prototypeValue: value,
|
||||
method: 'value_exact',
|
||||
confidence: 'pending',
|
||||
note: 'rem/em 值无法自动匹配,需手动确认',
|
||||
};
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.value) continue;
|
||||
const normalizedCandidate = normalizeNumericValue(candidate.value);
|
||||
if (normalizedCandidate === null) continue;
|
||||
if (normalizedCandidate === normalizedInput) {
|
||||
return buildValueMatchResult(candidate, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 无单位数值直接比较(如 lineHeight: 1.5)
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.value) continue;
|
||||
if (String(candidate.value).trim() === String(value).trim()) {
|
||||
return buildValueMatchResult(candidate, value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建值匹配结果
|
||||
*/
|
||||
function buildValueMatchResult(candidate, prototypeValue) {
|
||||
const result = {
|
||||
token: candidate.token || null,
|
||||
scssVar: candidate.scssVar || null,
|
||||
prototypeValue,
|
||||
tokenValue: candidate.value,
|
||||
method: 'value_exact',
|
||||
confidence: candidate.token ? 'confirmed' : 'pending',
|
||||
};
|
||||
if (candidate.role) result.role = candidate.role;
|
||||
if (!candidate.token && candidate.scssVar) {
|
||||
result.note = `匹配到 SCSS 变量 ${candidate.scssVar},但无对应 CSS Token`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 3: 色彩模糊匹配(RGB 欧几里得距离)
|
||||
* @param {string} value - hex 颜色值
|
||||
* @param {object} registry - Token 注册表
|
||||
* @param {number} threshold - 距离阈值,默认 30
|
||||
* @returns {object|null} 最佳近似匹配
|
||||
*/
|
||||
function matchByColorFuzzy(value, registry, threshold = 30) {
|
||||
const inputRgb = hexToRgb(value);
|
||||
if (!inputRgb) return null;
|
||||
|
||||
// 在所有颜色类 token 中搜索
|
||||
const colorCandidates = [
|
||||
...(registry.byCategory.color || []),
|
||||
];
|
||||
|
||||
let bestMatch = null;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
for (const candidate of colorCandidates) {
|
||||
if (!candidate.value) continue;
|
||||
const candidateRgb = hexToRgb(candidate.value);
|
||||
if (!candidateRgb) continue;
|
||||
|
||||
const dist = colorDistance(inputRgb, candidateRgb);
|
||||
if (dist < bestDistance) {
|
||||
bestDistance = dist;
|
||||
bestMatch = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch && bestDistance < threshold) {
|
||||
return {
|
||||
token: bestMatch.token || null,
|
||||
scssVar: bestMatch.scssVar || null,
|
||||
prototypeValue: value,
|
||||
tokenValue: bestMatch.value,
|
||||
method: 'color_fuzzy',
|
||||
confidence: 'approximate',
|
||||
distance: Math.round(bestDistance * 100) / 100,
|
||||
note: `颜色近似匹配(RGB 距离 ${Math.round(bestDistance * 100) / 100})`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个值执行完整三层匹配
|
||||
* @param {string} value - 要匹配的值
|
||||
* @param {string|null} cssProperty - CSS 属性名(消歧用)
|
||||
* @param {object} registry - Token 注册表
|
||||
* @returns {object} 匹配结果
|
||||
*/
|
||||
function fullMatchValue(value, cssProperty, registry) {
|
||||
// Layer 2: 值精确匹配
|
||||
const exact = matchByValue(value, cssProperty, registry);
|
||||
if (exact) return exact;
|
||||
|
||||
// Layer 3: 颜色模糊匹配(仅对颜色值)
|
||||
if (isHexColor(value)) {
|
||||
const fuzzy = matchByColorFuzzy(value, registry);
|
||||
if (fuzzy) return fuzzy;
|
||||
}
|
||||
|
||||
// 未匹配
|
||||
return {
|
||||
token: null,
|
||||
scssVar: null,
|
||||
prototypeValue: value,
|
||||
method: 'none',
|
||||
confidence: 'unmatched',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 主流程
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length < 2) {
|
||||
process.stderr.write('用法: node match-tokens.mjs <parse-result.json> <tokens.yml>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [parseResultPath, tokensYmlPath] = args;
|
||||
|
||||
// 读取输入文件
|
||||
let parseResult;
|
||||
try {
|
||||
const raw = readFileSync(resolve(parseResultPath), 'utf-8');
|
||||
parseResult = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
process.stderr.write(`无法读取/解析 parse-result JSON: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let tokensConfig;
|
||||
try {
|
||||
const raw = readFileSync(resolve(tokensYmlPath), 'utf-8');
|
||||
tokensConfig = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
||||
} catch (err) {
|
||||
process.stderr.write(`无法读取/解析 tokens.yml: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 构建 Token 注册表
|
||||
const registry = buildTokenRegistry(tokensConfig);
|
||||
const aliases = tokensConfig.aliases || null;
|
||||
|
||||
// ---- 匹配原型 Token 别名 ----
|
||||
const matched = {};
|
||||
const unmatched = [];
|
||||
|
||||
const prototypeTokens = parseResult.tokens || {};
|
||||
for (const [key, value] of Object.entries(prototypeTokens)) {
|
||||
// Layer 1: 别名直查
|
||||
const aliasResult = matchByAlias(key, value, aliases);
|
||||
if (aliasResult) {
|
||||
matched[key] = {
|
||||
...aliasResult,
|
||||
prototypeValue: value,
|
||||
// 如果 alias 没有覆盖 tokenValue,从 token 注册表中查找
|
||||
tokenValue: aliasResult.tokenValue || null,
|
||||
};
|
||||
// 补充 tokenValue
|
||||
if (aliasResult.token && !matched[key].tokenValue) {
|
||||
const found = registry.all.find(t => t.token === aliasResult.token);
|
||||
if (found) matched[key].tokenValue = found.value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 无别名,尝试值匹配(需要推断 CSS 属性上下文)
|
||||
const inferredProperty = inferPropertyFromTokenKey(key);
|
||||
const valueResult = fullMatchValue(value, inferredProperty, registry);
|
||||
if (valueResult.confidence !== 'unmatched') {
|
||||
matched[key] = valueResult;
|
||||
} else {
|
||||
unmatched.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 匹配内联样式值 ----
|
||||
const inlineTokenMap = {};
|
||||
const inlineStyles = parseResult.inlineStyles || {};
|
||||
|
||||
for (const [cssProperty, values] of Object.entries(inlineStyles)) {
|
||||
for (const rawValue of values) {
|
||||
const mapKey = `${cssProperty}:${rawValue}`;
|
||||
const result = fullMatchValue(rawValue, cssProperty, registry);
|
||||
if (result.confidence !== 'unmatched') {
|
||||
inlineTokenMap[mapKey] = {
|
||||
token: result.token,
|
||||
scssVar: result.scssVar || undefined,
|
||||
tokenValue: result.tokenValue || undefined,
|
||||
confidence: result.confidence,
|
||||
method: result.method,
|
||||
};
|
||||
if (result.note) inlineTokenMap[mapKey].note = result.note;
|
||||
if (result.distance != null) inlineTokenMap[mapKey].distance = result.distance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 汇总统计 ----
|
||||
let confirmed = 0;
|
||||
let pending = 0;
|
||||
let approximate = 0;
|
||||
|
||||
for (const entry of Object.values(matched)) {
|
||||
if (entry.confidence === 'confirmed') confirmed++;
|
||||
else if (entry.confidence === 'pending') pending++;
|
||||
else if (entry.confidence === 'approximate') approximate++;
|
||||
}
|
||||
|
||||
for (const entry of Object.values(inlineTokenMap)) {
|
||||
if (entry.confidence === 'confirmed') confirmed++;
|
||||
else if (entry.confidence === 'pending') pending++;
|
||||
else if (entry.confidence === 'approximate') approximate++;
|
||||
}
|
||||
|
||||
const totalAlias = Object.keys(prototypeTokens).length;
|
||||
const totalInline = Object.values(inlineStyles).reduce((sum, arr) => sum + arr.length, 0);
|
||||
|
||||
// 检测 variant
|
||||
const variantEntries = Object.values(matched).filter(e => e.variant);
|
||||
const variant = variantEntries.length > 0 ? variantEntries[0].variant : null;
|
||||
|
||||
// ---- 输出 ----
|
||||
const output = {
|
||||
source: parseResult.source || null,
|
||||
variant,
|
||||
matched,
|
||||
unmatched,
|
||||
inlineTokenMap,
|
||||
summary: {
|
||||
total: totalAlias + totalInline,
|
||||
aliasTokens: totalAlias,
|
||||
inlineValues: totalInline,
|
||||
confirmed,
|
||||
pending,
|
||||
approximate,
|
||||
unmatched: unmatched.length,
|
||||
},
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Token 别名 key 推断 CSS 属性(用于无别名时的消歧)
|
||||
*/
|
||||
function inferPropertyFromTokenKey(key) {
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
if (lower.includes('pri') || lower.includes('color') || lower.includes('bg') ||
|
||||
lower.includes('tx') || lower.includes('bd') || lower.includes('card') ||
|
||||
lower.includes('surface') || lower.includes('acc') || lower.includes('dan') ||
|
||||
lower.includes('wrn') || lower.includes('wechat')) {
|
||||
return 'color';
|
||||
}
|
||||
if (lower.includes('font') || lower.includes('text') && !lower.includes('color')) {
|
||||
return 'fontSize';
|
||||
}
|
||||
if (lower.includes('r') && (lower.includes('sm') || lower.includes('xs') || lower.length <= 3)) {
|
||||
return 'borderRadius';
|
||||
}
|
||||
if (lower.includes('gap') || lower.includes('pad') || lower.includes('margin')) {
|
||||
return 'padding';
|
||||
}
|
||||
|
||||
return null; // 未知,不限制类别
|
||||
}
|
||||
|
||||
// 启动
|
||||
main();
|
||||
384
.claude/skills/design-handoff/scripts/parse-prototype.mjs
Normal file
384
.claude/skills/design-handoff/scripts/parse-prototype.mjs
Normal file
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* parse-prototype.mjs — 解析 huashu-design 产出的 HTML 原型文件
|
||||
*
|
||||
* 提取设计 Token (T 对象)、内联样式硬编码值、屏幕信息和组件定义。
|
||||
* 输出 JSON 到 stdout。
|
||||
*
|
||||
* 用法: node parse-prototype.mjs <html-file-path>
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// ─── 工具函数 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 输出错误 JSON 到 stderr 并以 code 1 退出
|
||||
*/
|
||||
function fail(message) {
|
||||
process.stderr.write(JSON.stringify({ valid: false, error: message }, null, 2) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 括号深度计数法:从 source[startPos] 开始(startPos 应指向 '{'),
|
||||
* 提取完整的平衡花括号内容。
|
||||
*
|
||||
* 正确处理:
|
||||
* - 单引号/双引号内的花括号不计数
|
||||
* - 模板字符串(`)内的花括号不计数
|
||||
* - 转义字符(\" \' \\ \x \u)跳过
|
||||
*
|
||||
* 返回包含外层花括号的完整字符串,或在无法平衡时返回 null。
|
||||
*/
|
||||
function extractBalancedBraces(source, startPos) {
|
||||
if (source[startPos] !== '{') return null;
|
||||
|
||||
let depth = 0;
|
||||
let i = startPos;
|
||||
const len = source.length;
|
||||
|
||||
while (i < len) {
|
||||
const ch = source[i];
|
||||
|
||||
if (ch === "'" || ch === '"') {
|
||||
// 字符串字面量:跳到配对的结束引号
|
||||
const quote = ch;
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') {
|
||||
i += 2; // 跳过转义字符
|
||||
continue;
|
||||
}
|
||||
if (source[i] === quote) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '`') {
|
||||
// 模板字符串:处理 ${...} 内的嵌套,以及转义
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (source[i] === '`') {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
if (source[i] === '$' && i + 1 < len && source[i + 1] === '{') {
|
||||
// 模板表达式 ${...},需要单独平衡
|
||||
i += 2; // 跳过 ${
|
||||
let tmplDepth = 1;
|
||||
while (i < len && tmplDepth > 0) {
|
||||
const tc = source[i];
|
||||
if (tc === '{') tmplDepth++;
|
||||
else if (tc === '}') tmplDepth--;
|
||||
// 简化:模板表达式内也可以有字符串,但这里不递归处理
|
||||
// 因为 T 对象的值不太可能有如此复杂的嵌套
|
||||
if (tc === "'" || tc === '"') {
|
||||
const tq = tc;
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') { i += 2; continue; }
|
||||
if (source[i] === tq) { i++; break; }
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (tc === '`') {
|
||||
// 嵌套模板字符串
|
||||
i++;
|
||||
let nestedTmplDepth = 0;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') { i += 2; continue; }
|
||||
if (source[i] === '`') { i++; break; }
|
||||
if (source[i] === '$' && i + 1 < len && source[i + 1] === '{') {
|
||||
// 这里简化处理,不递归
|
||||
i += 2;
|
||||
nestedTmplDepth++;
|
||||
continue;
|
||||
}
|
||||
if (source[i] === '{') nestedTmplDepth++;
|
||||
if (source[i] === '}') {
|
||||
if (nestedTmplDepth > 0) nestedTmplDepth--;
|
||||
else { i++; break; }
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '/' && i + 1 < len) {
|
||||
// 注释处理
|
||||
if (source[i + 1] === '/') {
|
||||
// 单行注释
|
||||
while (i < len && source[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (source[i + 1] === '*') {
|
||||
// 多行注释
|
||||
i += 2;
|
||||
while (i + 1 < len && !(source[i] === '*' && source[i + 1] === '/')) i++;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return source.slice(startPos, i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return null; // 未平衡
|
||||
}
|
||||
|
||||
// ─── Step 0: 格式校验 ────────────────────────────────────────
|
||||
|
||||
function validateSource(source) {
|
||||
const hasReact = source.includes('react@') || source.includes('react.production');
|
||||
const hasBabel = source.includes('@babel/standalone') || source.includes('babel.min');
|
||||
const hasToken = /const\s+T\s*=\s*\{/.test(source);
|
||||
|
||||
if (!hasReact) return '文件不包含 React 引用';
|
||||
if (!hasBabel) return '文件不包含 Babel 引用';
|
||||
if (!hasToken) return '文件不包含 T 对象定义 (const T = {)';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Step 1: 提取 T 对象 ─────────────────────────────────────
|
||||
|
||||
function extractTokens(source) {
|
||||
// 匹配 const T = { / const T ={ / const T={ 等各种空格变体
|
||||
const markerPattern = /const\s+T\s*=\s*\{/;
|
||||
const markerMatch = markerPattern.exec(source);
|
||||
if (!markerMatch) return {};
|
||||
|
||||
const braceStart = markerMatch.index + markerMatch[0].length - 1; // 指向 '{'
|
||||
const rawObject = extractBalancedBraces(source, braceStart);
|
||||
|
||||
if (!rawObject) return {};
|
||||
|
||||
// 将 JS 对象字面量转为可求值字符串,用 Function 构造器执行
|
||||
try {
|
||||
const fn = new Function('return (' + rawObject + ')');
|
||||
const tObj = fn();
|
||||
|
||||
// 展平为 "T.key": "value" 格式
|
||||
const flat = {};
|
||||
for (const [key, value] of Object.entries(tObj)) {
|
||||
flat[`T.${key}`] = String(value);
|
||||
}
|
||||
return flat;
|
||||
} catch {
|
||||
// 如果 Function 执行失败,尝试用正则提取简单键值对作为降级方案
|
||||
const fallback = {};
|
||||
const kvPattern = /(\w+)\s*:\s*'([^']*)'/g;
|
||||
const kvPattern2 = /(\w+)\s*:\s*"([^"]*)"/g;
|
||||
const kvPatternNum = /(\w+)\s*:\s*(\d+(?:\.\d+)?)/g;
|
||||
|
||||
let match;
|
||||
const innerContent = rawObject.slice(1, -1);
|
||||
while ((match = kvPattern.exec(innerContent)) !== null) {
|
||||
fallback[`T.${match[1]}`] = match[2];
|
||||
}
|
||||
while ((match = kvPattern2.exec(innerContent)) !== null) {
|
||||
fallback[`T.${match[1]}`] = match[2];
|
||||
}
|
||||
while ((match = kvPatternNum.exec(innerContent)) !== null) {
|
||||
fallback[`T.${match[1]}`] = match[2];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step 2: 提取内联样式硬编码值 ─────────────────────────────
|
||||
|
||||
const STYLE_PROPERTIES = ['fontSize', 'padding', 'borderRadius', 'gap', 'margin', 'fontWeight', 'lineHeight', 'width', 'height', 'letterSpacing'];
|
||||
|
||||
function extractInlineStyles(source) {
|
||||
const result = {};
|
||||
|
||||
for (const prop of STYLE_PROPERTIES) {
|
||||
const values = new Set();
|
||||
|
||||
// 匹配 camelCase 属性: fontSize: 16, fontSize: '16px', fontSize: "16px"
|
||||
// 也匹配 object shorthand: { fontSize: 16 }
|
||||
const pattern = new RegExp(
|
||||
prop + '\\s*:\\s*([\'"`]?)([\\w\\s%.,()-]+?)\\1\\s*[,}]',
|
||||
'g'
|
||||
);
|
||||
|
||||
let match;
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
let val = match[2].trim();
|
||||
// 过滤掉变量引用(如 T.pri, T.r 等)
|
||||
if (val.startsWith('T.') || val.startsWith('${')) continue;
|
||||
// 过滤模板字符串插值
|
||||
if (val.includes('${')) continue;
|
||||
// 过滤掉 CSS 变量引用
|
||||
if (val.startsWith('var(')) continue;
|
||||
values.add(val);
|
||||
}
|
||||
|
||||
if (values.size > 0) {
|
||||
// 去重并排序:数值优先(降序),字符串按字母序
|
||||
const sorted = [...values].sort((a, b) => {
|
||||
const numA = parseFloat(a);
|
||||
const numB = parseFloat(b);
|
||||
if (!isNaN(numA) && !isNaN(numB)) return numB - numA;
|
||||
if (!isNaN(numA)) return -1;
|
||||
if (!isNaN(numB)) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
result[prop] = sorted;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Step 3: 提取 screen 信息 ────────────────────────────────
|
||||
|
||||
function extractScreens(source) {
|
||||
const screens = [];
|
||||
|
||||
// 策略 A: 找静态 screen-label 文本(外部 span/div 标签)
|
||||
const labelPattern = /<(?:span|div)\s+(?:className|class)="screen-label"[^>]*>([^<]*)<\/(?:span|div)>/g;
|
||||
const labels = [];
|
||||
let labelMatch;
|
||||
while ((labelMatch = labelPattern.exec(source)) !== null) {
|
||||
labels.push(labelMatch[1].trim());
|
||||
}
|
||||
|
||||
// 策略 B: 找 IosFrame 的 label prop(JSX 属性模式)
|
||||
// 匹配: <IosFrame ... label="文本" ...>
|
||||
const iosFrameLabelPattern = /<IosFrame[^>]*\blabel=["']([^"']+)["'][^>]*>/g;
|
||||
const iosLabels = [];
|
||||
let iosLabelMatch;
|
||||
while ((iosLabelMatch = iosFrameLabelPattern.exec(source)) !== null) {
|
||||
iosLabels.push(iosLabelMatch[1].trim());
|
||||
}
|
||||
|
||||
// 提取 IosFrame children 组件
|
||||
const iosFramePattern = /<IosFrame[^>]*>\s*(?:<React\.Fragment[^>]*>)?\s*<(\w+)\s*\/?>/g;
|
||||
const components = [];
|
||||
let compMatch;
|
||||
while ((compMatch = iosFramePattern.exec(source)) !== null) {
|
||||
const name = compMatch[1];
|
||||
if (name !== 'div' && name !== 'span' && name[0] === name[0].toUpperCase()) {
|
||||
components.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// 选择 label 来源:优先静态标签,如果数量不匹配则尝试 JSX prop
|
||||
const useIosLabels = labels.length === 0 || (iosLabels.length > 0 && iosLabels.length === components.length && labels.length !== components.length);
|
||||
const selectedLabels = useIosLabels ? iosLabels : labels;
|
||||
|
||||
// 配对
|
||||
const count = Math.max(selectedLabels.length, components.length);
|
||||
for (let i = 0; i < count; i++) {
|
||||
screens.push({
|
||||
label: selectedLabels[i] || '',
|
||||
component: components[i] || '',
|
||||
});
|
||||
}
|
||||
|
||||
return screens;
|
||||
}
|
||||
|
||||
// ─── Step 4: 识别组件函数 ─────────────────────────────────────
|
||||
|
||||
function extractComponents(source) {
|
||||
const components = new Set();
|
||||
|
||||
// 匹配 function 声明: function XxxPage() / function Xxx()
|
||||
const funcPattern = /function\s+([A-Z]\w*)\s*\(/g;
|
||||
let match;
|
||||
while ((match = funcPattern.exec(source)) !== null) {
|
||||
components.add(match[1]);
|
||||
}
|
||||
|
||||
// 匹配 const Xxx = () => / const Xxx = () =>
|
||||
const arrowPattern = /const\s+([A-Z]\w*)\s*=\s*(?:\([^)]*\)|[^=])\s*=>/g;
|
||||
while ((match = arrowPattern.exec(source)) !== null) {
|
||||
components.add(match[1]);
|
||||
}
|
||||
|
||||
return [...components].sort((a, b) => {
|
||||
// IosFrame 排到最前面(基础设施组件)
|
||||
if (a === 'IosFrame') return -1;
|
||||
if (b === 'IosFrame') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 主流程 ──────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 1) {
|
||||
fail('用法: node parse-prototype.mjs <html-file-path>');
|
||||
}
|
||||
|
||||
const filePath = resolve(args[0]);
|
||||
|
||||
let source;
|
||||
try {
|
||||
source = readFileSync(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
fail(`无法读取文件: ${filePath} — ${err.message}`);
|
||||
}
|
||||
|
||||
// Step 0: 格式校验
|
||||
const validationError = validateSource(source);
|
||||
if (validationError) {
|
||||
fail(`格式校验失败: ${validationError}`);
|
||||
}
|
||||
|
||||
// Step 1: 提取 T 对象
|
||||
const tokens = extractTokens(source);
|
||||
|
||||
// Step 2: 提取内联样式
|
||||
const inlineStyles = extractInlineStyles(source);
|
||||
|
||||
// Step 3: 提取 screen 信息
|
||||
const screens = extractScreens(source);
|
||||
|
||||
// Step 4: 识别组件函数
|
||||
const components = extractComponents(source);
|
||||
|
||||
// 组装输出
|
||||
const result = {
|
||||
valid: true,
|
||||
filePath: filePath.replace(/\\/g, '/'),
|
||||
tokens,
|
||||
inlineStyles,
|
||||
screens,
|
||||
components,
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
|
||||
main();
|
||||
73
.claude/skills/design-handoff/templates/spec-template.md
Normal file
73
.claude/skills/design-handoff/templates/spec-template.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# {{pageTitle}} 设计规格
|
||||
|
||||
> 来源: {{sourceFile}} | 平台: {{platform}} | 页面数: {{screenCount}} | 生成: {{date}}
|
||||
|
||||
## 页面索引
|
||||
|
||||
| 页面 | 截图 | 路由 |
|
||||
|------|------|------|
|
||||
{{#each screens}}
|
||||
| {{label}} |  | {{route}} |
|
||||
{{/each}}
|
||||
|
||||
## 一、Token 映射
|
||||
|
||||
| 原型值 | 项目 Token | 状态 |
|
||||
|--------|-----------|------|
|
||||
{{#each tokenMap}}
|
||||
| {{prototypeValue}} ({{key}}) | {{tokenRef}} | {{statusIcon}} |
|
||||
{{/each}}
|
||||
|
||||
> 状态标记: ✅ confirmed 直接使用 | ⚠️ pending 需人工复核 | ❌ unmatched 需硬编码或新建 Token
|
||||
|
||||
## 二、页面结构
|
||||
|
||||
{{#each screens}}
|
||||
### {{ordinal}}. {{label}}
|
||||
|
||||

|
||||
|
||||
布局层级(从上到下):
|
||||
|
||||
{{layoutDescription}}
|
||||
|
||||
{{/each}}
|
||||
|
||||
## 三、组件映射
|
||||
|
||||
| 原型元素 | 推荐组件 | 来源 | 备注 |
|
||||
|----------|---------|------|------|
|
||||
{{#each componentMap}}
|
||||
| {{prototypeElement}} | {{component}} | {{source}} | {{notes}} |
|
||||
{{/each}}
|
||||
|
||||
{{#each newComponents}}
|
||||
> ⚠️ **需新建**: {{name}} — {{reason}}
|
||||
{{/each}}
|
||||
|
||||
## 四、交互规格
|
||||
|
||||
| 元素 | 交互 | 触发 | 反馈 | 备注 |
|
||||
|------|------|------|------|------|
|
||||
{{#each interactions}}
|
||||
| {{element}} | {{type}} | {{trigger}} | {{feedback}} | {{notes}} |
|
||||
{{/each}}
|
||||
|
||||
## 五、状态变体
|
||||
|
||||
{{#each stateVariants}}
|
||||
- **{{name}}**: {{description}}
|
||||
{{/each}}
|
||||
|
||||
## 六、样式清单
|
||||
|
||||
{{styleSummary}}
|
||||
|
||||
---
|
||||
|
||||
> 此规格由 design-handoff skill 自动生成。LLM 实施时请:
|
||||
> 1. 先阅读截图建立视觉印象
|
||||
> 2. 按 Token 映射表使用项目 Token(✅ 标记的直接用)
|
||||
> 3. 优先使用"组件映射"中列出的已有组件
|
||||
> 4. 参考"交互规格"实现对应的交互逻辑
|
||||
> 5. "需新建"的组件参考截图和布局描述从头实现
|
||||
423
.design/tokens.yml
Normal file
423
.design/tokens.yml
Normal file
@@ -0,0 +1,423 @@
|
||||
version: 1
|
||||
updated: "2026-05-17"
|
||||
|
||||
# ============================================================================
|
||||
# Design Token 注册表
|
||||
# 数据源: apps/miniprogram/src/styles/tokens.scss + variables.scss
|
||||
# ============================================================================
|
||||
|
||||
colors:
|
||||
# --- 主色系(赤土橙) ---
|
||||
- token: --tk-pri
|
||||
value: "#C4623A"
|
||||
scss_var: "$pri"
|
||||
role: 主色/赤土橙 accent
|
||||
|
||||
- token: --tk-pri-l
|
||||
value: "#F0DDD4"
|
||||
scss_var: "$pri-l"
|
||||
role: 主色浅/赤土浅
|
||||
|
||||
- token: --tk-pri-d
|
||||
value: "#8B3E1F"
|
||||
scss_var: "$pri-d"
|
||||
role: 主色深/赤土深
|
||||
|
||||
# --- 阴影色(含透明度) ---
|
||||
- token: --tk-shadow-btn
|
||||
value: "0 4px 16px rgba(196, 98, 58, 0.3)"
|
||||
scss_var: "$shadow-btn"
|
||||
role: 主按钮阴影
|
||||
|
||||
- token: --tk-shadow-tab
|
||||
value: "0 2px 8px rgba(196, 98, 58, 0.25)"
|
||||
scss_var: "$shadow-tab"
|
||||
role: 选中Tab阴影
|
||||
|
||||
# --- 文字色 ---
|
||||
- token: --tk-text-secondary
|
||||
value: "#78716C"
|
||||
scss_var: "$tx3"
|
||||
role: 淡文字/辅助文字
|
||||
|
||||
# --- 卡片背景 ---
|
||||
- token: --tk-card-bg
|
||||
value: "#FFFFFF"
|
||||
scss_var: "$card"
|
||||
role: 卡片白底
|
||||
|
||||
# --- 医生端覆盖色(.doctor-mode 下自动替换 --tk-pri*) ---
|
||||
- token: --tk-pri.doctor
|
||||
value: "#3A6B8C"
|
||||
scss_var: "$doc-pri"
|
||||
role: 医生端主色/靛蓝
|
||||
note: 仅在 .doctor-mode 下覆盖 --tk-pri
|
||||
|
||||
- token: --tk-pri-l.doctor
|
||||
value: "#D4E5F0"
|
||||
scss_var: "$doc-pri-l"
|
||||
role: 医生端浅色
|
||||
|
||||
- token: --tk-pri-d.doctor
|
||||
value: "#2A4F6A"
|
||||
scss_var: "$doc-pri-d"
|
||||
role: 医生端深色
|
||||
|
||||
# --- 未映射到 Token 的 SCSS 变量(原型中有但 tokens.scss 未声明为 CSS 变量) ---
|
||||
unmapped_scss_variables:
|
||||
- scss_var: "$bg"
|
||||
value: "#F5F0EB"
|
||||
role: 页面主背景/温润米底
|
||||
note: "原型 T.bg 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$tx"
|
||||
value: "#2D2A26"
|
||||
role: 主文字色/暖黑
|
||||
note: "原型 T.tx 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$tx2"
|
||||
value: "#5A554F"
|
||||
role: 次文字色/暖灰
|
||||
note: "原型 T.tx2 近似映射,elder-mode 下 --tk-text-secondary 覆盖为此值"
|
||||
|
||||
- scss_var: "$bd"
|
||||
value: "#E8E2DC"
|
||||
role: 边框色
|
||||
note: "原型 T.bd 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$bd-l"
|
||||
value: "#F0EBE5"
|
||||
role: 浅边框色
|
||||
|
||||
- scss_var: "$acc"
|
||||
value: "#5B7A5E"
|
||||
role: 鼠尾草绿/成功色
|
||||
note: "原型中成功色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$acc-l"
|
||||
value: "#E8F0E8"
|
||||
role: 成功浅色
|
||||
|
||||
- scss_var: "$dan"
|
||||
value: "#B54A4A"
|
||||
role: 危险色/柔红
|
||||
note: "原型中危险色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$dan-l"
|
||||
value: "#FDEAEA"
|
||||
role: 危险浅色
|
||||
|
||||
- scss_var: "$wrn"
|
||||
value: "#C4873A"
|
||||
role: 警告色/暖琥珀
|
||||
note: "原型中警告色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$wrn-l"
|
||||
value: "#FFF3E0"
|
||||
role: 警告浅色
|
||||
|
||||
- scss_var: "$surface-alt"
|
||||
value: "#EDE8E2"
|
||||
role: 辅助底色
|
||||
|
||||
- scss_var: "$wechat"
|
||||
value: "#07C160"
|
||||
role: 微信绿
|
||||
|
||||
typography:
|
||||
- token: --tk-font-display
|
||||
value: "72px"
|
||||
note: 大屏展示
|
||||
elder: "80px"
|
||||
|
||||
- token: --tk-font-hero
|
||||
value: "48px"
|
||||
note: 启动页标题
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-font-h1
|
||||
value: "28px"
|
||||
note: 页面标题 serif bold
|
||||
elder: "32px"
|
||||
|
||||
- token: --tk-font-h2
|
||||
value: "22px"
|
||||
note: 副标题、用户名 serif bold
|
||||
elder: "25px"
|
||||
|
||||
- token: --tk-font-body-lg
|
||||
value: "18px"
|
||||
note: 按钮文字、section 标题 fontWeight:600
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-body
|
||||
value: "16px"
|
||||
note: 正文、输入框、icon 文字(最常用 UI 字号)
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-body-sm
|
||||
value: "14px"
|
||||
note: 副文本、描述
|
||||
elder: "19px"
|
||||
|
||||
- token: --tk-font-num
|
||||
value: "30px"
|
||||
note: 数值 serif bold
|
||||
elder: "34px"
|
||||
|
||||
- token: --tk-font-num-lg
|
||||
value: "34px"
|
||||
note: 大数值
|
||||
elder: "40px"
|
||||
|
||||
- token: --tk-font-cap
|
||||
value: "13px"
|
||||
note: 说明文字(第一高频字号)
|
||||
elder: "18px"
|
||||
|
||||
- token: --tk-font-nav
|
||||
value: "18px"
|
||||
note: 导航栏标题 serif bold
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-micro
|
||||
value: "11px"
|
||||
note: 角标、tag
|
||||
elder: "17px"
|
||||
|
||||
structure:
|
||||
- token: --tk-line-height
|
||||
value: "1.5"
|
||||
elder: "1.7"
|
||||
|
||||
spacing:
|
||||
- token: --tk-gap-2xs
|
||||
value: "4px"
|
||||
scss_var: "$sp-2xs"
|
||||
elder: "6px"
|
||||
|
||||
- token: --tk-gap-xs
|
||||
value: "8px"
|
||||
scss_var: "$sp-xs"
|
||||
elder: "12px"
|
||||
|
||||
- token: --tk-gap-sm
|
||||
value: "12px"
|
||||
scss_var: "$sp-sm"
|
||||
elder: "16px"
|
||||
|
||||
- token: --tk-gap-md
|
||||
value: "16px"
|
||||
scss_var: "$sp-md"
|
||||
elder: "20px"
|
||||
|
||||
- token: --tk-section-gap
|
||||
value: "20px"
|
||||
scss_var: "$sp-section"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-gap-lg
|
||||
value: "24px"
|
||||
scss_var: "$sp-lg"
|
||||
elder: "32px"
|
||||
|
||||
- token: --tk-gap-xl
|
||||
value: "32px"
|
||||
scss_var: "$sp-xl"
|
||||
elder: "40px"
|
||||
|
||||
- token: --tk-gap-2xl
|
||||
value: "48px"
|
||||
scss_var: "$sp-2xl"
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-page-padding
|
||||
value: "20px"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-card-padding
|
||||
value: "20px"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-card-padding-sm
|
||||
value: "16px"
|
||||
elder: "20px"
|
||||
|
||||
- token: --tk-card-padding-lg
|
||||
value: "28px"
|
||||
elder: "36px"
|
||||
|
||||
radius:
|
||||
- token: --tk-card-radius
|
||||
value: "16px"
|
||||
scss_var: "$r"
|
||||
elder: "20px"
|
||||
|
||||
radius_unmapped:
|
||||
- scss_var: "$r-sm"
|
||||
value: "12px"
|
||||
note: "原型 T.rSm 映射目标"
|
||||
- scss_var: "$r-xs"
|
||||
value: "8px"
|
||||
note: "原型 T.rXs 映射目标"
|
||||
- scss_var: "$r-lg"
|
||||
value: "20px"
|
||||
- scss_var: "$r-pill"
|
||||
value: "999px"
|
||||
|
||||
sizing:
|
||||
- token: --tk-touch-min
|
||||
value: "48px"
|
||||
role: 最小触控区
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-btn-primary-h
|
||||
value: "52px"
|
||||
role: 主按钮高度
|
||||
elder: "60px"
|
||||
|
||||
- token: --tk-input-height
|
||||
value: "56px"
|
||||
role: 输入框高度
|
||||
elder: "64px"
|
||||
|
||||
- token: --tk-tabbar-space
|
||||
value: "100px"
|
||||
role: TabBar 底部安全区
|
||||
elder: "120px"
|
||||
|
||||
feedback:
|
||||
- token: --tk-touch-feedback-opacity
|
||||
value: "0.85"
|
||||
role: 触控反馈透明度
|
||||
elder: "0.8"
|
||||
|
||||
tag:
|
||||
- token: --tk-tag-font-size
|
||||
value: "11px"
|
||||
elder: "13px"
|
||||
|
||||
- token: --tk-tag-padding-v
|
||||
value: "3px"
|
||||
elder: "5px"
|
||||
|
||||
- token: --tk-tag-padding-h
|
||||
value: "8px"
|
||||
elder: "12px"
|
||||
|
||||
shadow_unmapped:
|
||||
# tokens.scss 中的 --tk-shadow-btn/tab 是复合值(含偏移+模糊+颜色)
|
||||
# 以下为 variables.scss 中的其他阴影,未声明为 CSS Token
|
||||
- scss_var: "$shadow-sm"
|
||||
value: "0 1px 4px rgba(45, 42, 38, 0.06)"
|
||||
role: 小阴影
|
||||
|
||||
- scss_var: "$shadow-md"
|
||||
value: "0 2px 12px rgba(45, 42, 38, 0.10)"
|
||||
role: 中阴影
|
||||
|
||||
- scss_var: "$shadow-lg"
|
||||
value: "0 8px 32px rgba(45, 42, 38, 0.15)"
|
||||
role: 大阴影
|
||||
|
||||
# ============================================================================
|
||||
# 原型 Key → Token 映射(aliases)
|
||||
# 用于设计移交时自动匹配原型属性到实际 Token
|
||||
# ============================================================================
|
||||
aliases:
|
||||
prototype_keys:
|
||||
T.pri:
|
||||
- value: "#C4623A"
|
||||
token: --tk-pri
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#3A6B8C"
|
||||
token: --tk-pri.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
T.priL:
|
||||
- value: "#F0DDD4"
|
||||
token: --tk-pri-l
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#D4E5F0"
|
||||
token: --tk-pri-l.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
T.priD:
|
||||
- value: "#8B3E1F"
|
||||
token: --tk-pri-d
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#2A4F6A"
|
||||
token: --tk-pri-d.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
T.bg:
|
||||
token: null
|
||||
nearest: --tk-card-bg
|
||||
scss_var: "$bg"
|
||||
value: "#F5F0EB"
|
||||
status: unmatched
|
||||
note: "原型页面背景色,tokens.scss 未声明为 CSS 变量,直接用 $bg SCSS 变量"
|
||||
|
||||
T.card:
|
||||
token: --tk-card-bg
|
||||
status: exact_match
|
||||
|
||||
T.surface:
|
||||
token: --tk-card-bg
|
||||
status: approximate
|
||||
note: "原型中 surface ≈ 卡片白底"
|
||||
|
||||
T.tx:
|
||||
token: null
|
||||
nearest: --tk-text-secondary
|
||||
scss_var: "$tx"
|
||||
value: "#2D2A26"
|
||||
status: unmatched
|
||||
note: "主文字色,tokens.scss 未声明为 CSS 变量,直接用 $tx SCSS 变量"
|
||||
|
||||
T.tx2:
|
||||
token: null
|
||||
nearest: --tk-text-secondary
|
||||
scss_var: "$tx2"
|
||||
value: "#5A554F"
|
||||
status: unmatched
|
||||
note: "次文字色,tokens.scss 未声明,elder-mode 下 --tk-text-secondary 覆盖为此值"
|
||||
|
||||
T.tx3:
|
||||
token: --tk-text-secondary
|
||||
scss_var: "$tx3"
|
||||
value: "#78716C"
|
||||
status: exact_match
|
||||
|
||||
T.bd:
|
||||
token: null
|
||||
scss_var: "$bd"
|
||||
value: "#E8E2DC"
|
||||
status: unmatched
|
||||
note: "边框色(不是圆角),tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
T.r:
|
||||
token: --tk-card-radius
|
||||
scss_var: "$r"
|
||||
value: "16px"
|
||||
status: exact_match
|
||||
|
||||
T.rSm:
|
||||
token: null
|
||||
scss_var: "$r-sm"
|
||||
value: "12px"
|
||||
status: unmatched
|
||||
note: "tokens.scss 未声明,需添加 --tk-radius-sm 或直接用 $r-sm SCSS 变量"
|
||||
|
||||
T.rXs:
|
||||
token: null
|
||||
scss_var: "$r-xs"
|
||||
value: "8px"
|
||||
status: unmatched
|
||||
note: "tokens.scss 未声明,需添加 --tk-radius-xs 或直接用 $r-xs SCSS 变量"
|
||||
57
.dockerignore
Normal file
57
.dockerignore
Normal file
@@ -0,0 +1,57 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitea
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
wiki/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Screenshots and temp files
|
||||
screenshots/
|
||||
tmp/
|
||||
*.log
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.txt
|
||||
!config/*.toml
|
||||
|
||||
# Python
|
||||
*.py
|
||||
__pycache__/
|
||||
|
||||
# Test artifacts
|
||||
plans/
|
||||
.claude/
|
||||
|
||||
# Docker
|
||||
docker/
|
||||
|
||||
# Build artifacts (rebuilt in container)
|
||||
target/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
|
||||
# Environment files (use docker env)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Large binary files
|
||||
*.traineddata
|
||||
@@ -67,3 +67,19 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile && pnpm audit
|
||||
|
||||
miniprogram-test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/miniprogram
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: corepack enable && pnpm install --frozen-lockfile
|
||||
- name: TypeScript check
|
||||
run: npx tsc --noEmit
|
||||
- name: Run tests
|
||||
run: npx vitest run
|
||||
|
||||
37
.github/workflows/test.yml
vendored
37
.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
|
||||
@@ -49,6 +49,9 @@ jobs:
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
|
||||
- name: Security audit (Rust)
|
||||
run: cargo audit
|
||||
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -76,3 +79,31 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Security audit (npm)
|
||||
run: npx npm-audit --audit-level=high
|
||||
|
||||
miniprogram-test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/miniprogram
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
cache-dependency-path: apps/miniprogram/pnpm-lock.yaml
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: TypeScript check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run tests
|
||||
run: npx vitest run
|
||||
|
||||
45
.gitignore
vendored
45
.gitignore
vendored
@@ -28,6 +28,16 @@ docker/redis_data/
|
||||
|
||||
# Test artifacts
|
||||
.test_token
|
||||
test-results/
|
||||
|
||||
# Build outputs
|
||||
apps/miniprogram/dist-h5/
|
||||
|
||||
# Runtime uploads
|
||||
uploads/
|
||||
|
||||
# Temp logs
|
||||
_server_out.txt
|
||||
*.heapsnapshot
|
||||
perf-trace-*.json
|
||||
docs/debug-*.png
|
||||
@@ -63,4 +73,37 @@ plans/
|
||||
chi_sim.traineddata
|
||||
|
||||
# Local settings
|
||||
.claude/settings.local.json
|
||||
.claude/settings.local.json
|
||||
tools/
|
||||
|
||||
# Temp/debug files
|
||||
_temp/
|
||||
tmp/
|
||||
screenshots/
|
||||
server-log.txt
|
||||
snapshot_*.txt
|
||||
_*.txt
|
||||
_server_*.txt
|
||||
tmp_*.txt
|
||||
direct_*.txt
|
||||
server_*.txt
|
||||
server_combined.txt
|
||||
out.txt
|
||||
_wx_login.json
|
||||
.claude/settings.json
|
||||
|
||||
# Trace/debug JSON
|
||||
trace-*.json
|
||||
|
||||
# Graphify knowledge graph (regenerated locally)
|
||||
graphify-out/
|
||||
|
||||
# Native miniprogram (separate project)
|
||||
apps/mp-native/
|
||||
|
||||
# Misc untracked
|
||||
err.txt
|
||||
uploads/g:/hms/.superpowers/
|
||||
.claude/skills/design-handoff/node_modules/
|
||||
.design/config.yml
|
||||
.superpowers/
|
||||
|
||||
583
CLAUDE-1.md
583
CLAUDE-1.md
@@ -1,583 +0,0 @@
|
||||
# ZCLAW 协作与实现规则
|
||||
|
||||
> **ZCLAW 是一个独立成熟的 AI Agent 桌面客户端**,专注于提供真实可用的 AI 能力,而不是演示 UI。
|
||||
|
||||
> **当前阶段: 发布前管家模式实施。** 稳定化基线已达成,管家模式6交付物已完成。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
### 1.1 ZCLAW 是什么
|
||||
|
||||
ZCLAW 是面向中文用户的 AI Agent 桌面端,核心能力包括:
|
||||
|
||||
- **智能对话** - 多模型支持(8 Provider)、流式响应、上下文管理
|
||||
- **自主能力** - 9 个启用的 Hands(另有 Predictor/Lead 已禁用)
|
||||
- **技能系统** - 75 个 SKILL.md 技能定义
|
||||
- **工作流编排** - Pipeline DSL + 10 行业模板
|
||||
- **安全审计** - 完整的操作日志和权限控制
|
||||
|
||||
### 1.2 决策原则
|
||||
|
||||
**任何改动都要问:这对 ZCLAW 用户今天能产生价值吗?**
|
||||
|
||||
- ✅ 修复已知的 P0/P1 缺陷 → 最高优先
|
||||
- ✅ 接通"写了没接"的断链 → 高优先
|
||||
- ✅ 清理死代码和孤立文件 → 应该做
|
||||
- ❌ 新增功能/页面/端点 → 稳定化完成前禁止
|
||||
- ❌ 增加复杂度但无实际价值 → 永远不做
|
||||
- ❌ 折中方案掩盖根因 → 永远不做
|
||||
|
||||
### 1.3 稳定化铁律
|
||||
|
||||
**稳定化基线达成后仍需遵守以下约束:**
|
||||
|
||||
| 禁止行为 | 原因 |
|
||||
|----------|------|
|
||||
| 新增 SaaS API 端点 | 已有 140 个(含 2 个 dev-only),前端未全部接通 |
|
||||
| 新增 SKILL.md 文件 | 已有 75 个,大部分未执行验证 |
|
||||
| 新增 Tauri 命令 | 已有 189 个,70 个无前端调用且无 @reserved |
|
||||
| 新增中间件/Store | 已有 13 层中间件 + 18 个 Store |
|
||||
| 新增 admin 页面 | 已有 15 页 |
|
||||
|
||||
### 1.4 系统真实状态
|
||||
|
||||
参见 [docs/TRUTH.md](docs/TRUTH.md) — 这是唯一的真相源,所有其他文档中的数字如果与此冲突,以 TRUTH.md 为准。
|
||||
***
|
||||
|
||||
## 2. 项目结构
|
||||
|
||||
```text
|
||||
ZCLAW/
|
||||
├── crates/ # Rust Workspace (10 crates)
|
||||
│ ├── zclaw-types/ # L1: 基础类型 (AgentId, Message, Error)
|
||||
│ ├── zclaw-memory/ # L2: 存储层 (SQLite, KV, 会话管理)
|
||||
│ ├── zclaw-runtime/ # L3: 运行时 (4 Driver, 7 工具, 12 层中间件)
|
||||
│ ├── zclaw-kernel/ # L4: 核心协调 (182 Tauri 命令)
|
||||
│ ├── zclaw-skills/ # 技能系统 (75 SKILL.md 解析, 语义路由)
|
||||
│ ├── zclaw-hands/ # 自主能力 (9 启用, 106 Rust 测试)
|
||||
│ ├── zclaw-protocols/ # 协议支持 (MCP 完整, A2A feature-gated)
|
||||
│ ├── zclaw-pipeline/ # Pipeline DSL (v1/v2, 10 行业模板)
|
||||
│ ├── zclaw-growth/ # 记忆增长 (FTS5 + TF-IDF)
|
||||
│ └── zclaw-saas/ # SaaS 后端 (130 API, Axum + PostgreSQL)
|
||||
├── admin-v2/ # 管理后台 (Vite + Ant Design Pro, 13 页)
|
||||
├── desktop/ # Tauri 桌面应用
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React UI 组件 (含 SaaS 集成)
|
||||
│ │ ├── store/ # Zustand 状态管理 (含 saasStore)
|
||||
│ │ └── lib/ # 客户端通信 / 工具函数 (含 saas-client)
|
||||
│ └── src-tauri/ # Tauri Rust 后端 (集成 Kernel)
|
||||
├── skills/ # SKILL.md 技能定义
|
||||
├── hands/ # HAND.toml 自主能力配置
|
||||
├── config/ # TOML 配置文件
|
||||
├── saas-config.toml # SaaS 后端配置 (PostgreSQL 连接等)
|
||||
├── docker-compose.yml # PostgreSQL 容器配置
|
||||
├── docs/ # 架构文档和知识库
|
||||
└── tests/ # Vitest 回归测试
|
||||
```
|
||||
|
||||
### 2.1 核心数据流
|
||||
|
||||
```text
|
||||
用户操作 → React UI → Zustand Store → Tauri Commands → zclaw-kernel → LLM/Tools/Skills/Hands
|
||||
```
|
||||
|
||||
### 2.2 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
| ---- | --------------------- |
|
||||
| 前端框架 | React 19 + TypeScript |
|
||||
| 状态管理 | Zustand 5 |
|
||||
| 桌面框架 | Tauri 2.x |
|
||||
| 样式方案 | Tailwind 4 |
|
||||
| 配置格式 | TOML |
|
||||
| 后端核心 | Rust Workspace (10 crates, ~66K 行) |
|
||||
| SaaS 后端 | Axum + PostgreSQL (zclaw-saas) |
|
||||
| 管理后台 | Vite + Ant Design Pro (admin-v2/) |
|
||||
|
||||
### 2.3 Crate 依赖关系
|
||||
|
||||
```text
|
||||
zclaw-types (无依赖)
|
||||
↑
|
||||
zclaw-memory (→ types)
|
||||
↑
|
||||
zclaw-runtime (→ types, memory)
|
||||
↑
|
||||
zclaw-kernel (→ types, memory, runtime)
|
||||
↑
|
||||
zclaw-saas (→ types, 独立运行于 8080 端口)
|
||||
↑
|
||||
desktop/src-tauri (→ kernel, skills, hands, protocols)
|
||||
```
|
||||
|
||||
***
|
||||
|
||||
## 3. 工作风格
|
||||
|
||||
### 3.1 交付导向
|
||||
|
||||
- **先做最高杠杆问题** - 解决用户最痛的点
|
||||
- **真实能力优先** - 不做假数据占位
|
||||
- **完整闭环** - 每个功能都要能真正使用
|
||||
|
||||
### 3.2 根因优先
|
||||
|
||||
遇到问题时,先确认属于哪一类:
|
||||
|
||||
1. **协议问题** - API 端点、请求格式、响应解析
|
||||
2. **状态问题** - Store 更新、组件同步
|
||||
3. **UI 问题** - 交互逻辑、样式显示
|
||||
4. **配置问题** - TOML 解析、环境变量
|
||||
5. **运行时问题** - 服务启动、端口占用
|
||||
|
||||
不在根因未明时盲目堆补丁。
|
||||
|
||||
### 3.3 闭环工作法(强制)
|
||||
|
||||
每次改动**必须**按顺序完成以下步骤,不允许跳过:
|
||||
|
||||
1. **定位问题** — 理解根因,不盲目堆补丁
|
||||
2. **最小修复** — 只改必要的代码
|
||||
3. **自动验证** — `tsc --noEmit` / `cargo check` / `vitest run` 必须通过
|
||||
4. **提交推送** — 按 §11 规范提交,**立即 `git push`**,不积压
|
||||
5. **文档同步** — 按 §8.3 检查并更新相关文档,提交并推送
|
||||
|
||||
**铁律:步骤 4 和 5 是任务完成的硬性条件。不允许"等一下再提交"或"最后一起推送"。**
|
||||
|
||||
***
|
||||
|
||||
## 4. 实现规则
|
||||
|
||||
### 4.1 通信层
|
||||
|
||||
所有与后端的通信必须通过统一的客户端层:
|
||||
|
||||
- `desktop/src/lib/gateway-client.ts` - 主要通信客户端
|
||||
- `desktop/src/lib/tauri-gateway.ts` - Tauri 原生命令
|
||||
|
||||
**禁止**在组件内直接创建 WebSocket 或拼装 HTTP 请求。
|
||||
|
||||
### 4.2 分层职责
|
||||
|
||||
```
|
||||
UI 组件 → 只负责展示和交互
|
||||
Store → 负责状态组织和流程编排
|
||||
Client → 负责网络通信和协议转换
|
||||
```
|
||||
|
||||
### 4.3 代码自检规则
|
||||
|
||||
**每次修改代码前必须检查:**
|
||||
|
||||
1. **是否已有相同能力的代码?** — 先搜索再写,避免重复
|
||||
2. **前端是否有人调用?** — 没有 Rust 调用者的 Tauri 命令,先标注 `@reserved`
|
||||
3. **错误是否静默吞掉?** — `let _ =` 必须替换为 `log::warn!` 或更高级别处理
|
||||
4. **文档数字是否需要更新?** — 改了数量就要改文档```
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
### 4.4 代码规范
|
||||
|
||||
**TypeScript:**
|
||||
- 避免 `any`,优先 `unknown + 类型守卫`
|
||||
- 外部数据必须做容错解析
|
||||
- 不假设 API 响应永远只有一种格式
|
||||
|
||||
**React:**
|
||||
- 使用函数组件 + hooks
|
||||
- 复杂副作用收敛到 store
|
||||
- 组件保持"展示层"职责
|
||||
|
||||
**配置处理:**
|
||||
- 使用 TOML 解析器
|
||||
- 支持环境变量插值 `${VAR_NAME}`
|
||||
- 写回时保持格式一致
|
||||
|
||||
---
|
||||
|
||||
## 5. UI 完成度标准
|
||||
|
||||
### 5.1 允许存在的 UI
|
||||
|
||||
- 已接入真实后端能力的 UI
|
||||
- 明确标注"开发中 / 只读"的 UI
|
||||
- 有降级方案的 UI
|
||||
|
||||
### 5.2 不允许存在的 UI
|
||||
|
||||
- 看似可编辑但不会生效的设置
|
||||
- 展示假状态的面板
|
||||
- 用 mock 数据掩盖未完成能力
|
||||
|
||||
### 5.3 核心功能 UI
|
||||
|
||||
| 功能 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 聊天界面 | ✅ 完成 | 流式响应、多模型切换 |
|
||||
| 分身管理 | ✅ 完成 | 创建、配置、切换 Agent |
|
||||
| 自动化面板 | ✅ 完成 | Hands 触发、参数配置 |
|
||||
| 技能市场 | 🚧 进行中 | 技能浏览和安装 |
|
||||
| 工作流编辑 | 🚧 进行中 | Pipeline 工作流编辑器 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 自主能力系统 (Hands)
|
||||
|
||||
ZCLAW 提供 11 个自主能力包(9 启用 + 2 禁用):
|
||||
|
||||
| Hand | 功能 | 状态 |
|
||||
|------|------|------|
|
||||
| Browser | 浏览器自动化 | ✅ 可用 |
|
||||
| Collector | 数据收集聚合 | ✅ 可用 |
|
||||
| Researcher | 深度研究 | ✅ 可用 |
|
||||
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||
| Twitter | Twitter 自动化 | ✅ 可用(12 个 API v2 真实调用,写操作需 OAuth 1.0a) |
|
||||
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
||||
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
||||
| Speech | 语音合成 | ✅ 可用(Browser TTS 前端集成完成) |
|
||||
| Quiz | 测验生成 | ✅ 可用 |
|
||||
|
||||
**触发 Hand 时:**
|
||||
1. 检查依赖是否满足
|
||||
2. 收集必要参数
|
||||
3. 处理 `needs_approval` 状态
|
||||
4. 记录执行日志
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试与验证
|
||||
|
||||
### 7.1 必测场景
|
||||
|
||||
修改以下内容后必须验证:
|
||||
|
||||
- 聊天 / 流式响应
|
||||
- Store 状态更新
|
||||
- 配置读写
|
||||
- Hand 触发
|
||||
|
||||
### 7.2 前端调试优先使用 WebMCP
|
||||
|
||||
ZCLAW 注册了 WebMCP 结构化调试工具(`desktop/src/lib/webmcp-tools.ts`),AI 代理可直接查询应用状态而无需 DOM 截图。
|
||||
|
||||
**原则:能用 WebMCP 工具完成的调试,优先使用 WebMCP 而非 DevTools MCP(`take_snapshot`/`evaluate_script`),以减少约 67% 的 token 消耗。**
|
||||
|
||||
已注册的 WebMCP 工具:
|
||||
|
||||
| 工具名 | 用途 |
|
||||
|--------|------|
|
||||
| `get_zclaw_state` | 综合状态概览(连接、登录、流式、模型) |
|
||||
| `check_connection` | 连接状态检查 |
|
||||
| `send_message` | 发送聊天消息 |
|
||||
| `cancel_stream` | 取消当前流式响应 |
|
||||
| `get_streaming_state` | 流式响应详细状态 |
|
||||
| `list_conversations` | 列出最近对话 |
|
||||
| `get_current_conversation` | 获取当前对话完整消息 |
|
||||
| `switch_conversation` | 切换到指定对话 |
|
||||
| `get_token_usage` | Token 用量统计 |
|
||||
| `get_offline_queue` | 离线消息队列 |
|
||||
| `get_saas_account` | SaaS 账户和订阅信息 |
|
||||
| `get_available_models` | 可用 LLM 模型列表 |
|
||||
| `get_current_agent` | 当前 Agent 详情 |
|
||||
| `list_agents` | 所有 Agent 列表 |
|
||||
| `get_console_errors` | 应用日志中的错误 |
|
||||
|
||||
**使用前提**:Chrome 146+ 并启用 `chrome://flags/#enable-webmcp-testing`。仅在开发模式注册。
|
||||
|
||||
**何时仍需 DevTools MCP**:UI 布局/样式问题、点击交互、截图对比、网络请求检查。
|
||||
|
||||
### 7.3 验证命令
|
||||
|
||||
```bash
|
||||
# TypeScript 类型检查
|
||||
pnpm tsc --noEmit
|
||||
|
||||
# 前端单元测试
|
||||
cd desktop && pnpm vitest run
|
||||
|
||||
# Rust 全量测试(排除 SaaS)
|
||||
cargo test --workspace --exclude zclaw-saas
|
||||
|
||||
# SaaS 集成测试(需要 PostgreSQL)
|
||||
export TEST_DATABASE_URL="postgresql://postgres:123123@localhost:5432/zclaw"
|
||||
cargo test -p zclaw-saas -- --test-threads=1
|
||||
|
||||
# 启动开发环境
|
||||
pnpm start:dev
|
||||
````
|
||||
|
||||
### 7.4 人工验证清单
|
||||
|
||||
- [ ] 能否正常连接后端服务
|
||||
- [ ] 能否发送消息并获得流式响应
|
||||
- [ ] 模型切换是否生效
|
||||
- [ ] Hand 触发是否正常执行
|
||||
- [ ] 配置保存是否持久化
|
||||
|
||||
***
|
||||
|
||||
## 8. 文档管理
|
||||
|
||||
### 8.1 文档结构
|
||||
|
||||
```text
|
||||
docs/
|
||||
├── features/ # 功能文档
|
||||
│ ├── README.md # 功能索引
|
||||
│ └── */ # 各功能详细文档
|
||||
├── knowledge-base/ # 技术知识库
|
||||
│ ├── troubleshooting.md
|
||||
│ └── *.md
|
||||
└── archive/ # 归档文档
|
||||
```
|
||||
|
||||
### 8.2 文档更新原则
|
||||
|
||||
- **修完就记** - 解决问题后立即更新文档
|
||||
- **面向未来** - 文档要帮助未来的开发者快速理解
|
||||
- **中文优先** - 所有面向用户的文档使用中文
|
||||
|
||||
### 8.3 完成工作后的收尾流程(强制,不可跳过)
|
||||
|
||||
每次完成功能实现、架构变更、问题修复后,**必须立即执行以下收尾**:
|
||||
|
||||
#### 步骤 A:文档同步(代码提交前)
|
||||
|
||||
检查以下文档是否需要更新,有变更则立即修改:
|
||||
|
||||
1. **CLAUDE.md** — 项目结构、技术栈、工作流程、命令变化时
|
||||
2. **CLAUDE.md §13 架构快照** — 涉及子系统变更时,更新 `<!-- ARCH-SNAPSHOT-START/END -->` 标记区域(可执行 `/sync-arch` 技能自动分析)
|
||||
3. **docs/ARCHITECTURE_BRIEF.md** — 架构决策或关键组件变更时
|
||||
4. **docs/features/** — 功能状态变化时
|
||||
5. **docs/knowledge-base/** — 新的排查经验或配置说明
|
||||
6. **docs/TRUTH.md** — 数字(命令数、Store 数、crates 数等)变化时
|
||||
|
||||
#### 步骤 B:提交(按逻辑分组)
|
||||
|
||||
```
|
||||
代码变更 → 一个或多个逻辑提交
|
||||
文档变更 → 独立提交(如果和代码分开更清晰)
|
||||
```
|
||||
|
||||
#### 步骤 C:推送(立即)
|
||||
|
||||
```
|
||||
git push
|
||||
```
|
||||
|
||||
**不允许积压。** 每次完成一个独立工作单元后立即推送。不要留到"最后一起推"。
|
||||
|
||||
**判断标准:** 如果工作目录有未提交文件,说明收尾流程没完成。
|
||||
|
||||
***
|
||||
|
||||
## 9. 常见问题排查
|
||||
|
||||
### 9.1 连接问题
|
||||
|
||||
1. 检查后端服务是否启动(端口 50051)
|
||||
2. 检查 Vite 代理配置
|
||||
3. 检查防火墙设置
|
||||
|
||||
### 9.2 状态问题
|
||||
|
||||
1. 检查 Store 是否正确订阅
|
||||
2. 检查组件是否在正确的 Store 获取数据
|
||||
3. 检查是否有多个 Store 实例
|
||||
|
||||
### 9.3 配置问题
|
||||
|
||||
1. 检查 TOML 语法
|
||||
2. 检查环境变量是否设置
|
||||
3. 检查配置文件路径
|
||||
|
||||
***
|
||||
|
||||
## 10. 常用命令
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 开发模式
|
||||
pnpm start:dev
|
||||
|
||||
# 仅启动桌面端
|
||||
pnpm desktop
|
||||
|
||||
# 构建生产版本
|
||||
pnpm build
|
||||
|
||||
# 类型检查
|
||||
pnpm tsc --noEmit
|
||||
|
||||
# 运行测试
|
||||
pnpm vitest run
|
||||
|
||||
# 停止所有服务
|
||||
pnpm start:stop
|
||||
```
|
||||
|
||||
***
|
||||
|
||||
## 11. 提交规范
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
|
||||
**类型:**
|
||||
|
||||
- `feat` - 新功能
|
||||
- `fix` - 修复问题
|
||||
- `refactor` - 重构
|
||||
- `docs` - 文档更新
|
||||
- `test` - 测试相关
|
||||
- `chore` - 杂项
|
||||
|
||||
**示例:**
|
||||
|
||||
```
|
||||
feat(hands): 添加参数预设保存功能
|
||||
fix(chat): 修复流式响应中断问题
|
||||
refactor(store): 统一 Store 数据获取方式
|
||||
```
|
||||
|
||||
***
|
||||
|
||||
|
||||
## 12. 安全注意事项
|
||||
|
||||
- 不在代码中硬编码密钥
|
||||
- 用户输入必须验证
|
||||
- 敏感操作需要确认
|
||||
- 保留操作审计日志
|
||||
- 环境变量 `ZCLAW_SAAS_DEV` 模式放宽安全限制(开发环境设 `ZCLAW_SAAS_DEV=true`)
|
||||
|
||||
### 认证安全
|
||||
|
||||
- **JWT password_version**: 密码修改后自动使所有已签发的 JWT 失效(Claims 含 `pwv`,中间件比对 DB)
|
||||
- **账户锁定**: 5 次登录失败后锁定 15 分钟
|
||||
- **邮箱验证**: RFC 5322 正则 + 254 字符长度限制
|
||||
- **JWT 密钥**: `#[cfg(debug_assertions)]` 保护 fallback,release 模式 `bail` 拒绝启动
|
||||
- **TOTP 加密密钥**: 生产环境强制独立 `ZCLAW_TOTP_ENCRYPTION_KEY`(64 字符 hex),不从 JWT 密钥派生
|
||||
- **TOTP/API Key 加密**: AES-256-GCM + 随机 Nonce
|
||||
- **密码存储**: Argon2id + OsRng 随机盐
|
||||
- **Refresh Token 轮换**: 单次使用,Logout 时撤销到 DB,rotation 校验已撤销的旧 token
|
||||
|
||||
### 网络安全
|
||||
|
||||
- **Cookie**: HttpOnly + Secure + SameSite=Strict + 路径作用域
|
||||
- **Cookie Secure**: 开发环境 false,生产 true
|
||||
- **CORS**: 生产强制白名单,缺失拒绝启动
|
||||
- **TLS**: 反向代理(nginx/caddy)提供 HTTPS 终止,Axum 不负责 TLS
|
||||
- **Docker**: SaaS 端口绑定 `127.0.0.1`,仅通过 nginx 反代访问
|
||||
- **XFF**: 仅信任配置的代理 IP
|
||||
|
||||
### 限流
|
||||
|
||||
- `/api/auth/login` — 5次/分钟/IP(防暴力破解)+ 持久化到 PostgreSQL
|
||||
- `/api/auth/register` — 3次/小时/IP(防刷注册)
|
||||
- 公共端点默认 20次/分钟/IP(防滥用)
|
||||
|
||||
### 前端安全
|
||||
|
||||
- **Admin Token**: HttpOnly Cookie 传递,JS 不存储/读取 token
|
||||
- **Tauri CSP**: 移除 `unsafe-inline` script,`connect-src` 限制为 `http://localhost:*` + `https://*`
|
||||
- **Pipeline 日志**: Debug 日志截断 + 仅记录 keys 不记录 values
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 用途 |
|
||||
|------|------|
|
||||
| `DB_PASSWORD` | 数据库密码 |
|
||||
| `ZCLAW_DATABASE_URL` | 完整数据库连接 URL(优先级最高) |
|
||||
| `ZCLAW_SAAS_JWT_SECRET` | JWT 签名密钥 (>= 32 字符) |
|
||||
| `ZCLAW_TOTP_ENCRYPTION_KEY` | TOTP/API Key 加密密钥 (64 hex) |
|
||||
| `ZCLAW_ADMIN_USERNAME` | 初始管理员用户名 |
|
||||
| `ZCLAW_ADMIN_PASSWORD` | 初始管理员密码 |
|
||||
| `ZCLAW_SAAS_DEV` | 开发模式标志 (true=开发, false=生产) |
|
||||
|
||||
`saas-config.toml` 支持 `${ENV_VAR}` 模式环境变量插值。
|
||||
|
||||
### 生产环境清单
|
||||
|
||||
- [ ] nginx/caddy 配置反向代理 + HTTPS
|
||||
- [ ] 确保设置 `ZCLAW_SAAS_DEV=false`(或不设置)
|
||||
- [ ] 启用 CORS 白名单(`cors_origins` 配置实际域名)
|
||||
- [ ] Cookie Secure=true + HttpOnly=true + SameSite=Strict
|
||||
- [ ] JWT 签名密钥 >= 32 字符随机字符串
|
||||
- [ ] `ZCLAW_TOTP_ENCRYPTION_KEY` 独立设置
|
||||
- [ ] 数据库密码通过 `${DB_PASSWORD}` 引用
|
||||
|
||||
### 完整审计报告
|
||||
|
||||
参见 `docs/features/SECURITY_PENETRATION_TEST_V1.md`
|
||||
|
||||
***
|
||||
|
||||
<!-- ARCH-SNAPSHOT-START -->
|
||||
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-09 -->
|
||||
|
||||
## 13. 当前架构快照
|
||||
|
||||
### 活跃子系统
|
||||
|
||||
| 子系统 | 状态 | 最新变更 |
|
||||
|--------|------|----------|
|
||||
| 管家模式 (Butler) | ✅ 活跃 | 04-09 ButlerRouter + 双模式UI + 痛点持久化 + 冷启动 |
|
||||
| Hermes 管线 | ✅ 活跃 | 04-09 4 Chunk: 自我改进+用户建模+NL Cron+轨迹压缩 (684 tests) |
|
||||
| 聊天流 (ChatStream) | ✅ 稳定 | 04-02 ChatStore 拆分为 4 Store (stream/conversation/message/chat) |
|
||||
| 记忆管道 (Memory) | ✅ 稳定 | 04-02 闭环修复: 对话→提取→FTS5+TF-IDF→检索→注入 |
|
||||
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
|
||||
| Pipeline DSL | ✅ 稳定 | 04-01 17 个 YAML 模板 + DAG 执行器 |
|
||||
| Hands 系统 | ✅ 稳定 | 9 启用 (Browser/Collector/Researcher/Twitter/Whiteboard/Slideshow/Speech/Quiz/Clip) |
|
||||
| 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 |
|
||||
| 中间件链 | ✅ 稳定 | 14 层 (含 DataMasking@90, ButlerRouter, TrajectoryRecorder@650) |
|
||||
|
||||
### 关键架构模式
|
||||
|
||||
- **Hermes 管线**: 4模块闭环 — ExperienceStore(FTS5经验存取) + UserProfiler(结构化用户画像) + NlScheduleParser(中文时间→cron) + TrajectoryRecorder+Compressor(轨迹记录压缩)。通过中间件链+intelligence hooks调用
|
||||
- **管家模式**: 双模式UI (默认简洁/解锁专业) + ButlerRouter 4域关键词分类 (healthcare/data_report/policy/meeting) + 冷启动4阶段hook (idle→greeting→waiting→completed) + 痛点双写 (内存Vec+SQLite)
|
||||
- **聊天流**: 3种实现 → GatewayClient(WebSocket) / KernelClient(Tauri Event) / SaaSRelay(SSE) + 5min超时守护。详见 [ARCHITECTURE_BRIEF.md](docs/ARCHITECTURE_BRIEF.md)
|
||||
- **客户端路由**: `getClient()` 4分支决策树 → Admin路由 / SaaS Relay(可降级到本地) / Local Kernel / External Gateway
|
||||
- **SaaS 认证**: JWT→OS keyring 存储 + HttpOnly cookie + Token池 RPM/TPM 限流轮换 + SaaS unreachable 自动降级
|
||||
- **记忆闭环**: 对话→extraction_adapter→FTS5全文+TF-IDF权重→检索→注入系统提示
|
||||
- **LLM 驱动**: 4 Rust Driver (Anthropic/OpenAI/Gemini/Local) + 国内兼容 (DeepSeek/Qwen/Moonshot 通过 base_url)
|
||||
|
||||
### 最近变更
|
||||
|
||||
1. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
|
||||
2. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
|
||||
3. [04-08] 侧边栏 AnimatePresence bug + TopBar 重复 Z 修复 + 发布评估报告
|
||||
3. [04-07] @reserved 标注 5 个 butler Tauri 命令 + 痛点持久化 SQLite
|
||||
4. [04-06] 4 个发布前 bug 修复 (身份覆盖/模型配置/agent同步/自动身份)
|
||||
|
||||
<!-- ARCH-SNAPSHOT-END -->
|
||||
|
||||
<!-- ANTI-PATTERN-START -->
|
||||
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-09 -->
|
||||
|
||||
## 14. AI 协作注意事项
|
||||
|
||||
### 反模式警告
|
||||
|
||||
- ❌ **不要**建议新增 SaaS API 端点 — 已有 140 个,稳定化约束禁止新增
|
||||
- ❌ **不要**忽略管家模式 — 已上线且为默认模式,所有聊天经过 ButlerRouter
|
||||
- ❌ **不要**假设 Tauri 直连 LLM — 实际通过 SaaS Token 池中转,SaaS unreachable 时降级到本地 Kernel
|
||||
- ❌ **不要**建议从零实现已有能力 — 先查 Hand(9个)/Skill(75个)/Pipeline(17模板) 现有库
|
||||
- ❌ **不要**在 CLAUDE.md 以外创建项目级配置或规则文件 — 单一入口原则
|
||||
|
||||
### 场景化指令
|
||||
|
||||
- 当遇到**聊天相关** → 记住有 3 种 ChatStream 实现,先用 `getClient()` 判断当前路由模式
|
||||
- 当遇到**认证相关** → 记住 Tauri 模式用 OS keyring 存 JWT,SaaS 模式用 HttpOnly cookie
|
||||
- 当遇到**新功能建议** → 先查 [TRUTH.md](docs/TRUTH.md) 确认可用能力清单,避免重复建设
|
||||
- 当遇到**记忆/上下文相关** → 记住闭环已接通: FTS5+TF-IDF+embedding,不是空壳
|
||||
- 当遇到**管家/Butler** → 管家模式是默认模式,ButlerRouter 在中间件链中做关键词分类+system prompt 增强
|
||||
|
||||
<!-- ANTI-PATTERN-END -->
|
||||
215
CLAUDE.md
215
CLAUDE.md
@@ -116,15 +116,90 @@
|
||||
- `cargo test --workspace` — 所有测试通过(有相关测试时)
|
||||
- 功能验证 — 启动后端 + 前端服务,在浏览器中实际操作验证改动生效(涉及 API 或 UI 时)
|
||||
- `pnpm build` — 前端生产构建通过(涉及前端时)
|
||||
5. **提交** — 验证通过后按 §5 规范提交
|
||||
6. **文档同步** — 更新相关文档(如果涉及架构、接口、模块变化)
|
||||
7. **推送到仓库** — 提交后立即 `git push`,确保远程仓库同步
|
||||
5. **提交 + 文档 + 推送(三合一,强制)** — 验证通过后按顺序执行:
|
||||
- a. 按 §5 规范提交代码
|
||||
- b. 检查本次变更是否触发 wiki 更新(见下方 wiki 更新触发条件),触发则更新后单独 `docs(wiki)` 提交
|
||||
- c. `git push` 立即推送,不允许"等一下再推"
|
||||
- **禁止连续 5 个非 docs 提交而不更新 wiki 关键数字**
|
||||
|
||||
#### wiki 更新触发条件(步骤 5b 的判定标准)
|
||||
|
||||
以下任一条件满足时,**必须**更新 wiki 后才能继续下一任务:
|
||||
|
||||
- **fix 提交** → `wiki/index.md` 症状导航新增条目或标记"已修复"
|
||||
- **feat 提交(新功能)** → `wiki/index.md` 关键数字更新 + 对应模块 wiki 页更新(实体数/路由数/端点数等)
|
||||
- **数据库迁移变化** → 关键数字中的迁移数/表数更新
|
||||
- **API 路由变化** → 路由数更新
|
||||
- **测试数量变化** → 测试数/断言数更新
|
||||
- **连续 5 个代码提交** → 强制做一次 wiki/index.md 关键数字全文校正(对比代码实际数量)
|
||||
|
||||
**铁律:**
|
||||
- **步骤 0 阅读 Wiki 是绝对起点** — 不读 wiki 就开干 = 连环境配置都不知道,所有验证步骤都是空谈。
|
||||
- **步骤 1 现状确认是强制起点** — 不检查就开干 = 脱离实际,所有产出不可信。
|
||||
- **步骤 4 功能验证必须实际操作** — 只看编译通过不算验证,必须启动服务、在浏览器中确认功能正常。
|
||||
- **步骤 7 推送是强制环节**,不推送就等于没完成。不允许"等一下再推"。
|
||||
- **步骤 5 三合一是强制流程** — 提交后必须检查 wiki、必须推送,缺一不可。
|
||||
- **每次新会话开始时,先检查是否有未推送的提交并立即推送**。
|
||||
|
||||
### 2.6 Feature DoD — 功能完成定义(强制)
|
||||
|
||||
> 历史数据显示 24% 的提交是 fix,根因是缺少统一的完成标准。
|
||||
> 每个功能标记"完成"前,**必须**逐项检查以下清单,不允许跳过。
|
||||
|
||||
#### 后端
|
||||
|
||||
- [ ] Entity 包含所有标准字段(`id`/`tenant_id`/`created_at`/`updated_at`/`created_by`/`updated_by`/`deleted_at`/`version`)
|
||||
- [ ] Handler 添加 `require_permission` 权限守卫
|
||||
- [ ] 权限码已写入 seed 迁移(每个实体 `.list` + `.manage`,权限码前缀与实体名一致)
|
||||
- [ ] utoipa 注解已添加(`#[derive(utoipa::OpenApi)]` + path/response schema)
|
||||
- [ ] Service 层核心路径有单元/集成测试
|
||||
- [ ] 多租户隔离正确(所有查询含 `tenant_id` 过滤,无手写 SQL 拼接)
|
||||
- [ ] 输入验证完整(必填字段 + 格式校验 + 长度限制)
|
||||
- [ ] 错误处理统一(`AppError`,不 panic,不 unwrap 生产代码)
|
||||
- [ ] 关键操作有 `tracing` 日志(info/warn/error 级别合理)
|
||||
|
||||
#### 前端(Web)
|
||||
|
||||
- [ ] API 路径与后端 OpenAPI spec 一致(不手写路径,从 `api/health/` 模块调用)
|
||||
- [ ] 路由声明权限码(`permissions: [...]`),与后端 handler 一致
|
||||
- [ ] 菜单配置已更新(`parent_id` 正确 + `permission` 字段 + `menu_roles` 关联)
|
||||
- [ ] 错误状态有用户友好提示(不显示原始 error message)
|
||||
- [ ] 不使用 `any` 类型(用 `unknown` + 类型守卫)
|
||||
|
||||
#### 前端(小程序)
|
||||
|
||||
- [ ] Service 层接口契约与后端 DTO 一致(字段名/类型/结构体)
|
||||
- [ ] 登录态处理正确(`useDidShow` 恢复认证、退出清理 Storage)
|
||||
- [ ] 页面间数据通过 API 获取,不用 Storage 传递
|
||||
- [ ] 长者模式适配完成(字号 ≥ 22px)
|
||||
- [ ] 图片使用合法 URL(HTTPS 或相对路径,不用 HTTP)
|
||||
|
||||
#### 安全
|
||||
|
||||
- [ ] 新增端点有权限声明(默认拒绝,不是默认放行)
|
||||
- [ ] 敏感数据有脱敏/加密处理(PII 字段走 AES-256-GCM)
|
||||
- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS、命令注入、路径穿越)
|
||||
- [ ] 无 CORS 通配符、无硬编码密钥、无 fallback 默认密钥
|
||||
- [ ] 日志中无敏感数据输出(密码、token、身份证号、手机号等)
|
||||
- [ ] 文件上传有 MIME 类型验证 + 大小限制 + 路径穿越防护
|
||||
- [ ] API 响应不暴露内部实现细节(数据库错误、堆栈跟踪、文件路径)
|
||||
- [ ] 速率限制已配置(认证端点更严格)
|
||||
- [ ] 密钥通过环境变量注入,`.env.example` 已同步更新
|
||||
|
||||
#### 文档一致性
|
||||
|
||||
- [ ] `wiki/index.md` 关键数字与代码实际状态一致(迁移数、路由数、实体数、测试数等)
|
||||
- [ ] 新增/修复的 bug 已记录在症状导航中(含根因+解决方案)
|
||||
- [ ] 新增功能已记录在对应模块 wiki 页面中(实体、端点、事件等)
|
||||
- [ ] wiki 页面的"最后更新"日期已刷新为当天
|
||||
|
||||
#### 端到端验证
|
||||
|
||||
- [ ] `cargo check` 全 workspace 通过
|
||||
- [ ] `cargo test` 全部通过
|
||||
- [ ] 浏览器中手动验证功能正常(列表/创建/编辑/删除/权限拦截)
|
||||
- [ ] 小程序中验证(涉及小程序页面时)
|
||||
- [ ] 相关路由权限按角色测试通过(至少 admin + 只读角色)
|
||||
- [ ] 本地提交已推送到远程仓库
|
||||
|
||||
---
|
||||
|
||||
@@ -152,6 +227,46 @@
|
||||
- utoipa 自动生成 OpenAPI 文档
|
||||
- 租户 ID 从 JWT 中间件注入,**不在** API 路径中传递(管理员接口除外)
|
||||
|
||||
#### 新增 API 端点安全检查(强制)
|
||||
|
||||
> 默认拒绝是安全基线 — 绝大多数安全修复源于默认放行模式。
|
||||
> 新增端点时**必须**逐项确认:
|
||||
|
||||
- [ ] 端点已添加 `require_permission` 权限守卫(非公开端点)
|
||||
- [ ] 公开端点已显式标记为 `public`(不继承认证中间件)
|
||||
- [ ] 路由使用 `.nest()` 注册带中间件的子路由(禁止 `.merge()` 防止中间件泄漏)
|
||||
- [ ] 敏感操作有速率限制
|
||||
- [ ] 无 `format!` 拼接 SQL — 所有查询使用 SeaORM 参数化
|
||||
- [ ] FHIR/第三方端点有 `tenant_id` 和 `allowed_patient_ids` 范围过滤
|
||||
- [ ] 无硬编码密钥或 fallback 默认值
|
||||
|
||||
#### 前后端接口同步检查(强制)
|
||||
|
||||
> 前后端接口不一致是高频 bug 来源 — 任何 DTO 变更必须双向同步。
|
||||
> 后端 DTO 变更时**必须**同步检查前端:
|
||||
|
||||
- [ ] DTO 字段名变更 → 前端 TypeScript 接口同步更新
|
||||
- [ ] DTO 新增必填字段 → 前端表单和请求体同步更新
|
||||
- [ ] API 路径变更 → 前端 `api/` 模块路径同步更新
|
||||
- [ ] 返回数据结构变更(数组/对象/嵌套)→ 前端解析逻辑同步更新
|
||||
- [ ] 枚举值变更 → 前端类型定义和 UI 映射同步更新
|
||||
- [ ] 后端新增端点 → 前端 API 模块同步添加调用函数,不允许留空
|
||||
|
||||
#### DTO 输入校验检查(强制)
|
||||
|
||||
> DTO 输入校验是安全防线 — 缺失校验等于暴露攻击面,Update 和 Create 必须对称。
|
||||
> 新增/修改 DTO 时**必须**逐项确认:
|
||||
|
||||
- [ ] 所有请求结构体已 `derive(Validate)`(包括 Update\*Req、查询参数)
|
||||
- [ ] Update\*Req 与 Create\*Req 校验对称(不允许 Update 降级)
|
||||
- [ ] 字符串字段有 `#[validate(length(min, max))]`
|
||||
- [ ] 枚举/类型字段有 `#[validate(custom)]` 限制合法值
|
||||
- [ ] 集合字段有 `#[validate(length(min = 1))]` 非空检查
|
||||
- [ ] 数值范围字段有 `#[validate(range(min, max))]`
|
||||
- [ ] URL 字段有 SSRF 防护(禁止 localhost/内网地址,仅 http/https)
|
||||
- [ ] 密码字段有 `max = 128` 防止 DoS
|
||||
- [ ] handler 层已调用 `req.validate().map_err(|e| AppError::Validation(e.to_string()))?`
|
||||
|
||||
### 3.4 事件总线
|
||||
|
||||
- 模块间通信**只能**通过 `EventBus`
|
||||
@@ -181,6 +296,50 @@
|
||||
// 国际化文案使用 i18n key,不硬编码中文
|
||||
```
|
||||
|
||||
### 3.7 安全规范
|
||||
|
||||
#### 密钥与凭据管理
|
||||
|
||||
- 所有密钥、token、密码**必须**通过环境变量或密钥管理服务注入,**禁止**硬编码在源码中
|
||||
- 开发环境密钥**必须**与生产环境严格隔离(`cfg(debug_assertions)` 编译期防护)
|
||||
- 生产密钥**禁止**有 fallback 默认值,缺失时启动 panic
|
||||
- 新增密钥时必须同步更新 `.env.example` 和 `wiki/infrastructure.md`
|
||||
|
||||
#### 依赖安全
|
||||
|
||||
- 新增依赖前**必须**检查已知漏洞(`cargo audit` / `npm audit`)
|
||||
- 禁止引入有未修补高危漏洞的依赖版本
|
||||
- 定期更新依赖到最新安全补丁版本
|
||||
|
||||
#### 数据安全
|
||||
|
||||
- PII 数据(姓名、身份证号、手机号、地址等)**必须**加密存储(AES-256-GCM)
|
||||
- 日志中**禁止**输出 PII 数据和认证凭据(密码、token、session key)
|
||||
- 敏感操作(登录、权限变更、数据导出、批量删除)**必须**记录审计日志(操作者、时间、目标、结果)
|
||||
- 文件上传**必须**验证 MIME 类型 + 限制文件大小 + 防止路径穿越(文件名 sanitize)
|
||||
|
||||
#### 传输安全
|
||||
|
||||
- 生产环境**必须**强制 HTTPS,**禁止**降级到 HTTP
|
||||
- HTTP 响应**必须**包含安全头(HSTS、CSP、X-Frame-Options、X-Content-Type-Options、Permissions-Policy)
|
||||
- SSE/WebSocket 长连接认证 token 不通过 URL query 参数传递(使用 header 或 cookie)
|
||||
- API 响应**禁止**暴露内部实现细节(堆栈跟踪、数据库错误、文件路径、SQL 语句)
|
||||
|
||||
#### 认证与授权
|
||||
|
||||
- 密码**必须**使用单向哈希(bcrypt/argon2),**禁止**明文或可逆加密存储
|
||||
- JWT **必须**设置合理过期时间,支持 token 吊销机制
|
||||
- 敏感操作(删除数据、权限变更)需要二次确认
|
||||
- 权限检查在 handler 层执行,**禁止**仅依赖前端隐藏控制访问
|
||||
- `tenant_id` **必须**从 JWT 中间件注入,**禁止**信任客户端传递的值
|
||||
|
||||
#### 速率限制
|
||||
|
||||
- 所有 API 端点**必须**配置速率限制
|
||||
- 认证相关端点(登录、注册、密码重置)限制更严格
|
||||
- 批量操作和数据导出需要独立的速率限制策略
|
||||
- 速率限制超出时返回 429 状态码,响应包含 `Retry-After` header
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试与验证
|
||||
@@ -297,10 +456,26 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- ❌ **不要**在 plugin.toml 中使用与实体名不一致的权限码 — `permissions[].code` 前缀必须与 `schema.entities[].name` 完全一致(如实体 `customer_tag` → 权限码 `customer_tag.list`/`customer_tag.manage`,不能写成 `tag.manage`),否则页面 403
|
||||
- ❌ **不要**漏掉实体的 `.list` 权限 — 每个实体必须同时声明 `.list` 和 `.manage`,缺少 `.list` 导致列表页 403
|
||||
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
|
||||
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步
|
||||
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步。每次新会话开始先检查未推送提交
|
||||
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
|
||||
- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害
|
||||
- ❌ **不要**修复 bug 后跳过症状导航更新 — 每个修复都应该帮助未来遇到同类问题的人快速定位根因
|
||||
- ❌ **不要**新增功能后不更新 wiki 关键数字 — 迁移数/路由数/实体数/测试数必须与代码同步,否则 wiki 指标表就是废数据
|
||||
- ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死
|
||||
- ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘
|
||||
- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交将导致后续反复修复
|
||||
- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限
|
||||
- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口
|
||||
- ❌ **不要**写 Update DTO 时省略校验 — Update*Req 必须与 Create*Req 校验对称(Validate derive / 枚举 custom / Vec min=1 / 密码 max=128)
|
||||
- ❌ **不要**URL 字段不做 SSRF 防护 — 禁止 localhost/127.0.0.1/内网地址,仅允许 http/https 协议
|
||||
- ❌ **不要**handler 层跳过 `.validate()` — 所有 `Json<T>` handler 函数体第一行必须调 `req.validate().map_err(\|e\| AppError::Validation(e.to_string()))?`
|
||||
- ❌ **不要**在日志中输出敏感数据 — 密码、token、身份证号、手机号等 PII 信息禁止写入日志
|
||||
- ❌ **不要**信任客户端传递的 `tenant_id` — 必须从 JWT 中间件注入,客户端可伪造
|
||||
- ❌ **不要**在生产代码中使用 `unwrap()` — 必须处理所有错误,使用 `?` 或 `map_err`
|
||||
- ❌ **不要**将内部错误信息返回给客户端 — 数据库错误、堆栈跟踪、文件路径等必须转换为用户友好的错误消息
|
||||
- ❌ **不要**使用 HTTP 传输敏感数据 — 生产环境必须 HTTPS
|
||||
- ❌ **不要**跳过依赖安全检查 — 新增依赖前运行 `cargo audit` / `npm audit`,禁止引入有高危漏洞的版本
|
||||
- ❌ **不要**文件上传不做安全处理 — 必须验证 MIME 类型 + 限制大小 + sanitize 文件名防路径穿越
|
||||
|
||||
### 场景化指令
|
||||
|
||||
@@ -311,6 +486,7 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- 当遇到**新增表** → 创建 SeaORM migration + Entity,包含所有标准字段
|
||||
- 当遇到**新增页面** → 使用 Ant Design 组件,i18n key 引用文案
|
||||
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程和 `.claude/skills/plugin-development/SKILL.md`,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component。**权限码必须与实体名一致(每个实体声明 `.list` + `.manage`)**
|
||||
- 当遇到**新增/修改 DTO** → 参考 `wiki/architecture.md` §4 DTO 输入校验规范:`derive(Validate)` + 字段级校验 + handler 层 `validate()` 调用 + 单元测试
|
||||
|
||||
---
|
||||
|
||||
@@ -328,3 +504,32 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| 设计文档索引 | `wiki/index.md` |
|
||||
| 开发进度、模块状态 | `wiki/index.md` 关键数字 |
|
||||
| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 |
|
||||
|
||||
## graphify — 代码知识图谱
|
||||
|
||||
> 项目知识图谱位于 `graphify-out/`,当前规模:18,517 节点 / 22,666 边 / 1,841 社区(纯 AST 解析,无 API 成本)。
|
||||
> 工具:`python -m graphify`(已安装 graphifyy 0.8.18)。
|
||||
|
||||
### 开发流程中的使用场景
|
||||
|
||||
| 时机 | 命令 | 目的 |
|
||||
|------|------|------|
|
||||
| **接手新任务,理解代码关系** | `graphify query "概念名"` | 搜索相关节点,比 Grep 更精准(按调用/引用/包含关系) |
|
||||
| **排查 bug,追踪调用链** | `graphify path "A" "B"` | 查找两个模块/函数间的最短路径 |
|
||||
| **理解某个模块的职责** | `graphify explain "模块名"` | 自然语言解释节点及其邻居 |
|
||||
| **代码改动后** | `graphify update .` | 增量更新图谱(AST-only,秒级完成) |
|
||||
| **宏观架构审查** | 读 `graphify-out/GRAPH_REPORT.md` | 全局社区结构、跨文件关系概览 |
|
||||
|
||||
### 使用优先级(融入 §2.5 闭环工作法)
|
||||
|
||||
在 §2.5 步骤 1「现状确认」中,**优先使用 graphify 替代盲目 Grep**:
|
||||
|
||||
1. **先 `graphify query`** — 精确定位相关节点和社区(比 Grep 返回更结构化的结果)
|
||||
2. **再 `graphify path`** — 确认模块间依赖路径(避免遗漏间接依赖)
|
||||
3. **最后 Grep/Glob/Read** — 确认 graphify 发现的具体文件内容
|
||||
|
||||
### 注意事项
|
||||
|
||||
- `graphify update .` 纯本地 AST 解析,不消耗 LLM token,每次代码改动后都可以运行
|
||||
- 查询结果比 GRAPH_REPORT.md 更精准,优先使用 query/path/explain,仅在需要全局视图时读报告
|
||||
- 首次生成需几分钟(1712 文件),后续增量更新秒级完成
|
||||
|
||||
108
Cargo.lock
generated
108
Cargo.lock
generated
@@ -516,12 +516,24 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -1417,6 +1429,7 @@ dependencies = [
|
||||
"handlebars",
|
||||
"hex",
|
||||
"redis",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
@@ -1441,6 +1454,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"erp-core",
|
||||
"hex",
|
||||
"jsonwebtoken",
|
||||
@@ -1537,6 +1551,7 @@ dependencies = [
|
||||
"erp-core",
|
||||
"hex",
|
||||
"hmac",
|
||||
"image",
|
||||
"jsonwebtoken",
|
||||
"num-traits",
|
||||
"rand_core 0.6.4",
|
||||
@@ -1600,6 +1615,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
"validator",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
]
|
||||
@@ -1688,6 +1704,7 @@ dependencies = [
|
||||
"erp-workflow",
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
"metrics",
|
||||
"metrics-exporter-prometheus",
|
||||
"moka",
|
||||
@@ -1784,6 +1801,15 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -2520,6 +2546,32 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -2998,6 +3050,16 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
@@ -3488,6 +3550,19 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -3654,6 +3729,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
@@ -3669,6 +3750,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -3894,6 +3981,12 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
@@ -7058,3 +7151,18 @@ dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
@@ -33,7 +33,7 @@ tokio = { version = "1", features = ["full"] }
|
||||
# Web
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs", "set-header"] }
|
||||
|
||||
# Database
|
||||
sea-orm = { version = "1.1", features = [
|
||||
@@ -91,6 +91,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
hex = "0.4"
|
||||
regex-lite = "0.1"
|
||||
|
||||
# CSV and Excel export
|
||||
csv = "1"
|
||||
@@ -119,6 +120,9 @@ handlebars = "6"
|
||||
# HTML sanitization
|
||||
ammonia = "4"
|
||||
|
||||
# Document parsing
|
||||
pdf-extract = "0.7"
|
||||
|
||||
# Metrics
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.16"
|
||||
|
||||
113
Dockerfile
Normal file
113
Dockerfile
Normal file
@@ -0,0 +1,113 @@
|
||||
# ==============================
|
||||
# Stage 1: Build Rust backend
|
||||
# ==============================
|
||||
FROM rust:1-bookworm AS rust-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先复制依赖文件以利用 Docker 缓存
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/erp-core/Cargo.toml crates/erp-core/Cargo.toml
|
||||
COPY crates/erp-auth/Cargo.toml crates/erp-auth/Cargo.toml
|
||||
COPY crates/erp-config/Cargo.toml crates/erp-config/Cargo.toml
|
||||
COPY crates/erp-workflow/Cargo.toml crates/erp-workflow/Cargo.toml
|
||||
COPY crates/erp-message/Cargo.toml crates/erp-message/Cargo.toml
|
||||
COPY crates/erp-plugin/Cargo.toml crates/erp-plugin/Cargo.toml
|
||||
COPY crates/erp-health/Cargo.toml crates/erp-health/Cargo.toml
|
||||
COPY crates/erp-ai/Cargo.toml crates/erp-ai/Cargo.toml
|
||||
COPY crates/erp-dialysis/Cargo.toml crates/erp-dialysis/Cargo.toml
|
||||
COPY crates/erp-server/Cargo.toml crates/erp-server/Cargo.toml
|
||||
COPY crates/erp-server/migration/Cargo.toml crates/erp-server/migration/Cargo.toml
|
||||
COPY crates/erp-plugin-prototype/Cargo.toml crates/erp-plugin-prototype/Cargo.toml
|
||||
COPY crates/erp-plugin-test-sample/Cargo.toml crates/erp-plugin-test-sample/Cargo.toml
|
||||
COPY crates/erp-plugin-assessment/Cargo.toml crates/erp-plugin-assessment/Cargo.toml
|
||||
COPY crates/erp-plugin-crm/Cargo.toml crates/erp-plugin-crm/Cargo.toml
|
||||
COPY crates/erp-plugin-freelance/Cargo.toml crates/erp-plugin-freelance/Cargo.toml
|
||||
COPY crates/erp-plugin-inventory/Cargo.toml crates/erp-plugin-inventory/Cargo.toml
|
||||
COPY crates/erp-plugin-itops/Cargo.toml crates/erp-plugin-itops/Cargo.toml
|
||||
|
||||
# 创建空的 lib.rs/main.rs 占位以缓存依赖
|
||||
RUN mkdir -p crates/erp-core/src && echo "" > crates/erp-core/src/lib.rs \
|
||||
&& mkdir -p crates/erp-auth/src && echo "" > crates/erp-auth/src/lib.rs \
|
||||
&& mkdir -p crates/erp-config/src && echo "" > crates/erp-config/src/lib.rs \
|
||||
&& mkdir -p crates/erp-workflow/src && echo "" > crates/erp-workflow/src/lib.rs \
|
||||
&& mkdir -p crates/erp-message/src && echo "" > crates/erp-message/src/lib.rs \
|
||||
&& mkdir -p crates/erp-plugin/src && echo "" > crates/erp-plugin/src/lib.rs \
|
||||
&& mkdir -p crates/erp-health/src && echo "" > crates/erp-health/src/lib.rs \
|
||||
&& mkdir -p crates/erp-ai/src && echo "" > crates/erp-ai/src/lib.rs \
|
||||
&& mkdir -p crates/erp-dialysis/src && echo "" > crates/erp-dialysis/src/lib.rs \
|
||||
&& mkdir -p crates/erp-server/src && echo "fn main(){}" > crates/erp-server/src/main.rs \
|
||||
&& mkdir -p crates/erp-server/migration/src && echo "" > crates/erp-server/migration/src/lib.rs \
|
||||
&& for crate in erp-plugin-prototype erp-plugin-test-sample erp-plugin-assessment erp-plugin-crm erp-plugin-freelance erp-plugin-inventory erp-plugin-itops; do \
|
||||
mkdir -p crates/$crate/src && echo "" > crates/$crate/src/lib.rs; \
|
||||
done
|
||||
|
||||
# 构建依赖(仅当 Cargo.toml/Cargo.lock 变化时重新编译)
|
||||
RUN cargo build --release -p erp-server 2>/dev/null || true
|
||||
|
||||
# 复制实际源码
|
||||
COPY crates/ crates/
|
||||
|
||||
# 重新构建(增量编译,只编译业务代码)
|
||||
RUN cargo build --release -p erp-server
|
||||
|
||||
# ==============================
|
||||
# Stage 2: Build frontend
|
||||
# ==============================
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
COPY apps/web/package.json apps/web/pnpm-lock.yaml ./apps/web/
|
||||
|
||||
RUN cd apps/web && pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/web/ ./apps/web/
|
||||
|
||||
RUN cd apps/web && pnpm build
|
||||
|
||||
# ==============================
|
||||
# Stage 3: Production runtime
|
||||
# ==============================
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 Rust 二进制
|
||||
COPY --from=rust-builder /app/target/release/erp-server /app/erp-server
|
||||
|
||||
# 复制配置文件
|
||||
COPY config/ /app/config/
|
||||
|
||||
# 复制前端构建产物(可通过 volume 暴露给 OpenResty)
|
||||
COPY --from=frontend-builder /app/apps/web/dist/ /app/static/
|
||||
|
||||
# 创建上传目录
|
||||
RUN mkdir -p /app/uploads
|
||||
|
||||
# 非特权用户运行
|
||||
RUN useradd -r -s /bin/false appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
# 环境变量(运行时通过 docker-compose / .env 覆盖)
|
||||
ENV ERP__SERVER__HOST=0.0.0.0
|
||||
ENV ERP__SERVER__PORT=3000
|
||||
ENV ERP__SERVER__METRICS_PORT=9090
|
||||
ENV ERP__STORAGE__UPLOAD_DIR=/app/uploads
|
||||
|
||||
EXPOSE 3000 9090
|
||||
|
||||
VOLUME ["/app/uploads", "/app/static"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/api/v1/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/erp-server"]
|
||||
5
apps/miniprogram-uniapp/.gitignore
vendored
Normal file
5
apps/miniprogram-uniapp/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.uno/
|
||||
.cache/
|
||||
*.log
|
||||
14
apps/miniprogram-uniapp/index.html
Normal file
14
apps/miniprogram-uniapp/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
12042
apps/miniprogram-uniapp/package-lock.json
generated
Normal file
12042
apps/miniprogram-uniapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
apps/miniprogram-uniapp/package.json
Normal file
28
apps/miniprogram-uniapp/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "hms-miniprogram-uniapp",
|
||||
"version": "1.0.0",
|
||||
"description": "HMS 健康管理平台患者小程序(UniApp 验证版)",
|
||||
"scripts": {
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:mp-weixin": "uni build -p mp-weixin"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||
"sass": "^1.87.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
8101
apps/miniprogram-uniapp/pnpm-lock.yaml
generated
Normal file
8101
apps/miniprogram-uniapp/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
apps/miniprogram-uniapp/project.config.json
Normal file
41
apps/miniprogram-uniapp/project.config.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"appid": "wx20f4ef9cc2ec66c5",
|
||||
"miniprogramRoot": "dist/dev/mp-weixin/",
|
||||
"compileType": "miniprogram",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"automationAudits": true,
|
||||
"es6": false,
|
||||
"enhance": false,
|
||||
"compileHotReLoad": true,
|
||||
"postcss": false,
|
||||
"minified": false,
|
||||
"bundle": false,
|
||||
"ignoreUploadUnusedFiles": true,
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"minifyWXSS": true,
|
||||
"minifyWXML": true,
|
||||
"localPlugins": false,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false,
|
||||
"condition": false,
|
||||
"swc": false,
|
||||
"disableSWC": true,
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
}
|
||||
},
|
||||
"projectname": "hms-uniapp",
|
||||
"simulatorPluginLibVersion": {},
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"editorSetting": {}
|
||||
}
|
||||
21
apps/miniprogram-uniapp/project.private.config.json
Normal file
21
apps/miniprogram-uniapp/project.private.config.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"libVersion": "3.16.0",
|
||||
"projectname": "miniprogram-uniapp",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"coverView": false,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"useApiHook": true,
|
||||
"showShadowRootInWxmlPanel": false,
|
||||
"useStaticServer": false,
|
||||
"useLanDebug": false,
|
||||
"showES6CompileOption": false,
|
||||
"compileHotReLoad": true,
|
||||
"checkInvalidKey": true,
|
||||
"ignoreDevUnusedFiles": true,
|
||||
"bigPackageSizeSupport": false
|
||||
}
|
||||
}
|
||||
31
apps/miniprogram-uniapp/src/App.vue
Normal file
31
apps/miniprogram-uniapp/src/App.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
|
||||
onLaunch(() => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.restore()
|
||||
const uiStore = useUIStore()
|
||||
uiStore.restore()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.restore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/tokens.scss';
|
||||
@import './styles/mixins.scss';
|
||||
@import './styles/elder-mode.scss';
|
||||
|
||||
page {
|
||||
background-color: #F5F0EB;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #2D2A26;
|
||||
font-size: var(--tk-font-body);
|
||||
line-height: var(--tk-line-height);
|
||||
}
|
||||
</style>
|
||||
89
apps/miniprogram-uniapp/src/components/DeviceCard.vue
Normal file
89
apps/miniprogram-uniapp/src/components/DeviceCard.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<view class="device-card" @tap="handleSync">
|
||||
<view class="device-icon">{{ icon }}</view>
|
||||
<view class="device-info">
|
||||
<text class="device-name">{{ deviceName }}</text>
|
||||
<text class="device-status" :class="statusClass">{{ statusLabel }}</text>
|
||||
<text v-if="lastSyncAt" class="last-sync">最近同步: {{ lastSyncAt }}</text>
|
||||
</view>
|
||||
<view class="sync-btn">同步</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const DEVICE_ICONS: Record<string, string> = {
|
||||
blood_pressure: '🩺',
|
||||
blood_glucose: '💉',
|
||||
heart_rate: '❤️',
|
||||
blood_oxygen: '🫁',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
deviceName: string
|
||||
deviceType: string
|
||||
lastSyncAt?: string
|
||||
status: 'connected' | 'disconnected' | 'never'
|
||||
}>()
|
||||
|
||||
const icon = computed(() => DEVICE_ICONS[props.deviceType] || '📱')
|
||||
const statusLabel = computed(() => {
|
||||
const map: Record<string, string> = { connected: '已连接', disconnected: '未连接', never: '未配对' }
|
||||
return map[props.status] || props.status
|
||||
})
|
||||
const statusClass = computed(() => props.status === 'connected' ? 'connected' : 'idle')
|
||||
|
||||
function handleSync() {
|
||||
uni.navigateTo({ url: '/pages-sub/device-sync/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
font-size: 40px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
font-size: var(--tk-font-cap);
|
||||
margin-bottom: 2px;
|
||||
|
||||
&.connected { color: $acc; }
|
||||
&.idle { color: $tx3; }
|
||||
}
|
||||
|
||||
.last-sync {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
background: $pri;
|
||||
color: $white;
|
||||
border-radius: $r-pill;
|
||||
padding: 8px 24px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
</style>
|
||||
58
apps/miniprogram-uniapp/src/components/EcCanvas.vue
Normal file
58
apps/miniprogram-uniapp/src/components/EcCanvas.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<view class="ec-canvas-wrap">
|
||||
<canvas id="ec-canvas" class="ec-canvas" type="2d"
|
||||
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
option?: Record<string, any>
|
||||
width?: number
|
||||
height?: number
|
||||
}>(), {
|
||||
width: 350,
|
||||
height: 250,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'init', chart: any): void
|
||||
}>()
|
||||
|
||||
const canvasWidth = ref(props.width)
|
||||
const canvasHeight = ref(props.height)
|
||||
|
||||
// Minimal ECharts bridge — for full ECharts, use lime-echart plugin
|
||||
// This is a placeholder that renders a basic canvas with the option's title
|
||||
onMounted(() => {
|
||||
nextTick(initCanvas)
|
||||
})
|
||||
|
||||
function nextTick(fn: () => void) {
|
||||
setTimeout(fn, 50)
|
||||
}
|
||||
|
||||
function initCanvas() {
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('#ec-canvas').fields({ node: true, size: true }).exec((res) => {
|
||||
if (!res || !res[0] || !res[0].node) return
|
||||
emit('init', { canvas: res[0].node, width: res[0].width, height: res[0].height })
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.option, () => {
|
||||
nextTick(initCanvas)
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ec-canvas-wrap {
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.ec-canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
53
apps/miniprogram-uniapp/src/components/EmptyState.vue
Normal file
53
apps/miniprogram-uniapp/src/components/EmptyState.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<view class="empty-wrap">
|
||||
<text class="empty-icon">{{ icon }}</text>
|
||||
<text class="empty-title">{{ title }}</text>
|
||||
<text v-if="description" class="empty-desc">{{ description }}</text>
|
||||
<view v-if="actionText" class="empty-action" @tap="$emit('action')">
|
||||
{{ actionText }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon?: string
|
||||
title: string
|
||||
description?: string
|
||||
actionText?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{ action: [] }>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.empty-wrap {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding: 80px 40px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: var(--tk-font-hero);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
@include btn-primary;
|
||||
margin-top: 32px;
|
||||
width: auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
</style>
|
||||
76
apps/miniprogram-uniapp/src/components/ErrorBoundary.vue
Normal file
76
apps/miniprogram-uniapp/src/components/ErrorBoundary.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<slot v-if="!hasError" />
|
||||
<view v-else class="error-boundary">
|
||||
<view class="error-icon-wrap">
|
||||
<text class="error-icon-text">!</text>
|
||||
</view>
|
||||
<text class="error-title">页面出了点问题</text>
|
||||
<text class="error-desc">请返回重试</text>
|
||||
<view class="error-retry-btn" @tap="handleRetry">
|
||||
<text class="error-retry-text">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onErrorCaptured } from 'vue'
|
||||
|
||||
const hasError = ref(false)
|
||||
|
||||
onErrorCaptured((err) => {
|
||||
console.error('[ErrorBoundary]', err)
|
||||
hasError.value = true
|
||||
return false
|
||||
})
|
||||
|
||||
function handleRetry() {
|
||||
hasError.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.error-boundary {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding: 80px 40px;
|
||||
}
|
||||
|
||||
.error-icon-wrap {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: $dan-l;
|
||||
@include flex-center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-icon-text {
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.error-desc {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.error-retry-btn {
|
||||
background: $pri;
|
||||
border-radius: $r-pill;
|
||||
padding: 16px 48px;
|
||||
}
|
||||
|
||||
.error-retry-text {
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
</style>
|
||||
48
apps/miniprogram-uniapp/src/components/ErrorState.vue
Normal file
48
apps/miniprogram-uniapp/src/components/ErrorState.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<view class="error-state">
|
||||
<text class="error-state-icon">⚠️</text>
|
||||
<text class="error-state-text">{{ text }}</text>
|
||||
<view v-if="onRetry" class="error-state-retry" @tap="onRetry">
|
||||
<text class="error-state-retry-text">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
text?: string
|
||||
onRetry?: () => void
|
||||
}>(), {
|
||||
text: '加载失败,请稍后重试',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.error-state {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding: 80px 40px;
|
||||
}
|
||||
|
||||
.error-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-state-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-state-retry {
|
||||
background: $pri;
|
||||
border-radius: $r-pill;
|
||||
padding: 12px 40px;
|
||||
}
|
||||
|
||||
.error-state-retry-text {
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
</style>
|
||||
39
apps/miniprogram-uniapp/src/components/GuestGuard.vue
Normal file
39
apps/miniprogram-uniapp/src/components/GuestGuard.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<view v-if="!authStore.user" class="guest-wrap">
|
||||
<slot name="guest">
|
||||
<text class="guest-text">请先登录</text>
|
||||
<view class="guest-login-btn" @tap="goLogin">去登录</view>
|
||||
</slot>
|
||||
</view>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function goLogin() {
|
||||
uni.navigateTo({ url: '/pages/login/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.guest-wrap {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding: 80px 40px;
|
||||
}
|
||||
|
||||
.guest-text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.guest-login-btn {
|
||||
@include btn-primary;
|
||||
width: auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
</style>
|
||||
37
apps/miniprogram-uniapp/src/components/Loading.vue
Normal file
37
apps/miniprogram-uniapp/src/components/Loading.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<view class="loading-wrap">
|
||||
<view class="loading-spinner" />
|
||||
<text class="loading-text">{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ text?: string }>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading-wrap {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid $bd;
|
||||
border-top-color: $pri;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
67
apps/miniprogram-uniapp/src/components/ProgressRing.vue
Normal file
67
apps/miniprogram-uniapp/src/components/ProgressRing.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<view class="progress-ring" :style="{ width: size + 'px', height: size + 'px' }">
|
||||
<svg :width="size" :height="size" :viewBox="`0 0 ${size} ${size}`">
|
||||
<circle
|
||||
:cx="size / 2" :cy="size / 2" :r="radius"
|
||||
fill="none" :stroke="bgColor" :stroke-width="strokeWidth"
|
||||
/>
|
||||
<circle
|
||||
:cx="size / 2" :cy="size / 2" :r="radius"
|
||||
fill="none" :stroke="color" :stroke-width="strokeWidth"
|
||||
:stroke-dasharray="circumference"
|
||||
:stroke-dashoffset="offset"
|
||||
stroke-linecap="round"
|
||||
:transform="`rotate(-90 ${size / 2} ${size / 2})`"
|
||||
/>
|
||||
</svg>
|
||||
<view class="progress-text">
|
||||
<text class="progress-value">{{ Math.round(percent) }}</text>
|
||||
<text class="progress-unit">%</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
percent: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
color?: string
|
||||
bgColor?: string
|
||||
}>(), {
|
||||
size: 120,
|
||||
strokeWidth: 8,
|
||||
color: '#C4623A',
|
||||
bgColor: '#E8E2DC',
|
||||
})
|
||||
|
||||
const radius = computed(() => (props.size - props.strokeWidth) / 2)
|
||||
const circumference = computed(() => 2 * Math.PI * radius.value)
|
||||
const offset = computed(() => circumference.value * (1 - props.percent / 100))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.progress-ring {
|
||||
position: relative;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.progress-unit {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-left: 2px;
|
||||
}
|
||||
</style>
|
||||
96
apps/miniprogram-uniapp/src/components/StepIndicator.vue
Normal file
96
apps/miniprogram-uniapp/src/components/StepIndicator.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<view class="step-indicator">
|
||||
<view v-for="(step, idx) in steps" :key="step.label" class="step-item">
|
||||
<view v-if="idx > 0" class="step-line" :class="{ 'step-line-done': idx < current }" />
|
||||
<view class="step-dot"
|
||||
:class="{ 'step-current': idx === current, 'step-done': idx < current }"
|
||||
@tap="idx < current && onChange && onChange(idx)">
|
||||
<text v-if="idx < current" class="step-check">✓</text>
|
||||
<text v-else class="step-num">{{ idx + 1 }}</text>
|
||||
</view>
|
||||
<text class="step-label" :class="{ 'step-current': idx === current, 'step-done': idx < current }">
|
||||
{{ step.label }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
steps: { label: string }[]
|
||||
current: number
|
||||
onChange?: (index: number) => void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: -50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
.step-line-done {
|
||||
background: $pri;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid $bd-l;
|
||||
@include flex-center;
|
||||
margin-bottom: 8px;
|
||||
background: $white;
|
||||
}
|
||||
|
||||
.step-current {
|
||||
border-color: $pri;
|
||||
|
||||
&.step-dot { background: $pri; }
|
||||
&.step-label { color: $pri; font-weight: 600; }
|
||||
}
|
||||
|
||||
.step-done {
|
||||
border-color: $pri;
|
||||
background: $pri;
|
||||
|
||||
&.step-label { color: $acc; }
|
||||
}
|
||||
|
||||
.step-check {
|
||||
color: $white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
max-width: 80px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
175
apps/miniprogram-uniapp/src/components/TrendChart.vue
Normal file
175
apps/miniprogram-uniapp/src/components/TrendChart.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<view v-if="!data || data.length === 0" class="trend-chart-empty">
|
||||
<text class="trend-chart-empty-text">暂无数据</text>
|
||||
</view>
|
||||
<view v-else class="trend-chart" :style="{ height: (height || 500) + 'rpx' }">
|
||||
<canvas type="2d" id="trend-chart-canvas" class="trend-canvas"
|
||||
:style="{ width: '100%', height: '100%' }" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, onMounted, nextTick, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
data: { date: string; value: number }[]
|
||||
referenceMin?: number
|
||||
referenceMax?: number
|
||||
unit?: string
|
||||
height?: number
|
||||
}>(), {
|
||||
unit: '',
|
||||
height: 500,
|
||||
})
|
||||
|
||||
const canvasReady = ref(false)
|
||||
|
||||
function drawLine(ctx: any, points: { x: number; y: number }[]) {
|
||||
if (points.length < 2) return
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(points[0].x, points[0].y)
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1]
|
||||
const curr = points[i]
|
||||
const cpx = (prev.x + curr.x) / 2
|
||||
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!props.data || props.data.length === 0) return
|
||||
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('#trend-chart-canvas').fields({ node: true, size: true }).exec((res) => {
|
||||
if (!res || !res[0] || !res[0].node) return
|
||||
const canvas = res[0].node
|
||||
const ctx = canvas.getContext('2d')
|
||||
const dpr = uni.getSystemInfoSync().pixelRatio || 2
|
||||
const w = res[0].width
|
||||
const h = res[0].height
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const pad = { top: 20, right: 16, bottom: 32, left: 48 }
|
||||
const cw = w - pad.left - pad.right
|
||||
const ch = h - pad.top - pad.bottom
|
||||
|
||||
const values = props.data.map(d => d.value)
|
||||
let yMin = Math.min(...values)
|
||||
let yMax = Math.max(...values)
|
||||
if (props.referenceMin !== undefined) yMin = Math.min(yMin, props.referenceMin)
|
||||
if (props.referenceMax !== undefined) yMax = Math.max(yMax, props.referenceMax)
|
||||
const yPad = (yMax - yMin) * 0.1 || 1
|
||||
yMin -= yPad
|
||||
yMax += yPad
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// Reference band
|
||||
if (props.referenceMin !== undefined && props.referenceMax !== undefined) {
|
||||
const ry1 = pad.top + ch * (1 - (props.referenceMax - yMin) / (yMax - yMin))
|
||||
const ry2 = pad.top + ch * (1 - (props.referenceMin - yMin) / (yMax - yMin))
|
||||
ctx.fillStyle = 'rgba(91, 122, 94, 0.1)'
|
||||
ctx.fillRect(pad.left, ry1, cw, ry2 - ry1)
|
||||
}
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#e5e5e5'
|
||||
ctx.lineWidth = 0.5
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad.top + (ch / 4) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(pad.left, y)
|
||||
ctx.lineTo(pad.left + cw, y)
|
||||
ctx.stroke()
|
||||
const val = yMax - ((yMax - yMin) / 4) * i
|
||||
ctx.fillStyle = '#78716C'
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(val.toFixed(1), pad.left - 6, y + 3)
|
||||
}
|
||||
|
||||
// X labels
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillStyle = '#78716C'
|
||||
ctx.font = '10px sans-serif'
|
||||
const step = Math.max(1, Math.floor(props.data.length / 6))
|
||||
for (let i = 0; i < props.data.length; i += step) {
|
||||
const x = pad.left + (cw / Math.max(1, props.data.length - 1)) * i
|
||||
ctx.fillText(props.data[i].date.slice(5), x, h - 8)
|
||||
}
|
||||
|
||||
// Data points
|
||||
const points = props.data.map((d, i) => ({
|
||||
x: pad.left + (cw / Math.max(1, props.data.length - 1)) * i,
|
||||
y: pad.top + ch * (1 - (d.value - yMin) / (yMax - yMin)),
|
||||
}))
|
||||
|
||||
// Area fill
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(points[0].x, points[0].y)
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const cpx = (points[i - 1].x + points[i].x) / 2
|
||||
ctx.bezierCurveTo(cpx, points[i - 1].y, cpx, points[i].y, points[i].x, points[i].y)
|
||||
}
|
||||
ctx.lineTo(points[points.length - 1].x, pad.top + ch)
|
||||
ctx.lineTo(points[0].x, pad.top + ch)
|
||||
ctx.closePath()
|
||||
const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + ch)
|
||||
grad.addColorStop(0, 'rgba(196, 98, 58, 0.3)')
|
||||
grad.addColorStop(1, 'rgba(196, 98, 58, 0.02)')
|
||||
ctx.fillStyle = grad
|
||||
ctx.fill()
|
||||
|
||||
// Line
|
||||
ctx.strokeStyle = '#C4623A'
|
||||
ctx.lineWidth = 2
|
||||
drawLine(ctx, points)
|
||||
|
||||
// Dots
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const d = props.data[i]
|
||||
const outOfRange =
|
||||
(props.referenceMin !== undefined && d.value < props.referenceMin) ||
|
||||
(props.referenceMax !== undefined && d.value > props.referenceMax)
|
||||
ctx.beginPath()
|
||||
ctx.arc(points[i].x, points[i].y, outOfRange ? 5 : 3, 0, Math.PI * 2)
|
||||
ctx.fillStyle = outOfRange ? '#B54A4A' : '#C4623A'
|
||||
ctx.fill()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => { canvasReady.value = true; draw() })
|
||||
})
|
||||
|
||||
watch(() => [props.data, props.referenceMin, props.referenceMax], () => {
|
||||
if (canvasReady.value) nextTick(draw)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.trend-chart {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.trend-canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trend-chart-empty {
|
||||
@include flex-center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.trend-chart-empty-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
</style>
|
||||
152
apps/miniprogram-uniapp/src/components/WeekCalendar.vue
Normal file
152
apps/miniprogram-uniapp/src/components/WeekCalendar.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<view class="week-calendar">
|
||||
<view class="week-nav">
|
||||
<text class="week-arrow" @tap="weekOffset--">◀</text>
|
||||
<text class="week-label">{{ dates[0]?.slice(5) }} ~ {{ dates[6]?.slice(5) }}</text>
|
||||
<text class="week-arrow" @tap="weekOffset++">▶</text>
|
||||
</view>
|
||||
<view class="week-grid">
|
||||
<view v-for="(day, idx) in WEEKDAYS" :key="dates[idx]"
|
||||
class="week-cell"
|
||||
:class="{
|
||||
'cell-selected': dates[idx] === selectedDate,
|
||||
'cell-empty': !isScheduled(dates[idx]),
|
||||
'cell-past': dates[idx] < today
|
||||
}"
|
||||
@tap="onCellTap(dates[idx])">
|
||||
<text class="cell-weekday">{{ day }}</text>
|
||||
<text class="cell-date" :class="{ 'cell-today': dates[idx] === today }">
|
||||
{{ parseInt(dates[idx]?.slice(8) || '0') }}
|
||||
</text>
|
||||
<view v-if="isScheduled(dates[idx])" class="cell-dot" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
scheduledDates: string[]
|
||||
selectedDate: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'selectDate', date: string): void
|
||||
}>()
|
||||
|
||||
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']
|
||||
const weekOffset = ref(0)
|
||||
|
||||
const today = computed(() => {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const dates = computed(() => {
|
||||
const result: string[] = []
|
||||
const now = new Date()
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7) + weekOffset.value * 7)
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(monday)
|
||||
d.setDate(monday.getDate() + i)
|
||||
result.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function isScheduled(date: string): boolean {
|
||||
return props.scheduledDates.includes(date)
|
||||
}
|
||||
|
||||
function onCellTap(date: string) {
|
||||
if (isScheduled(date) && date >= today.value) {
|
||||
emit('selectDate', date)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.week-calendar {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.week-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.week-arrow {
|
||||
font-size: 28px;
|
||||
color: $pri;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.week-label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.week-grid {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.week-cell {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
border-radius: $r-sm;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cell-weekday {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cell-date {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cell-today {
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.cell-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $pri;
|
||||
}
|
||||
|
||||
.cell-selected {
|
||||
background: $pri-l;
|
||||
|
||||
.cell-date { color: $pri-d; }
|
||||
.cell-dot { background: $pri-d; }
|
||||
}
|
||||
|
||||
.cell-empty {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.cell-past {
|
||||
opacity: 0.5;
|
||||
|
||||
.cell-dot { background: $tx3; }
|
||||
}
|
||||
</style>
|
||||
8
apps/miniprogram-uniapp/src/composables/useElderClass.ts
Normal file
8
apps/miniprogram-uniapp/src/composables/useElderClass.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { computed } from 'vue'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
|
||||
export function useElderClass() {
|
||||
const uiStore = useUIStore()
|
||||
const elderClass = computed(() => uiStore.elderMode ? 'elder-mode' : '')
|
||||
return { elderClass }
|
||||
}
|
||||
7
apps/miniprogram-uniapp/src/env.d.ts
vendored
Normal file
7
apps/miniprogram-uniapp/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
10
apps/miniprogram-uniapp/src/main.ts
Normal file
10
apps/miniprogram-uniapp/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
return { app }
|
||||
}
|
||||
21
apps/miniprogram-uniapp/src/manifest.json
Normal file
21
apps/miniprogram-uniapp/src/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "hms-uniapp",
|
||||
"appid": "__UNI__HMS_VERIFY",
|
||||
"description": "HMS 健康管理平台(UniApp 验证版)",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"mp-weixin": {
|
||||
"appid": "wx20f4ef9cc2ec66c5",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"automationAudits": true,
|
||||
"es6": false,
|
||||
"enhance": false,
|
||||
"postcss": false,
|
||||
"minified": false,
|
||||
"compileHotReLoad": true
|
||||
},
|
||||
"usingComponents": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-else-if="!analysis" class="empty-wrap"><text class="empty-text">报告不存在</text></view>
|
||||
<template v-else>
|
||||
<view class="detail-card">
|
||||
<text class="detail-type">{{ TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type }}</text>
|
||||
<view class="detail-meta">
|
||||
<text class="meta-item">模型: {{ analysis.model_used }}</text>
|
||||
<text class="meta-item">{{ new Date(analysis.created_at).toLocaleString('zh-CN') }}</text>
|
||||
</view>
|
||||
<view v-if="isAutoAnalysis" class="auto-badge">
|
||||
<text class="auto-badge-text">系统自动分析</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="isTrendAnalysis" class="trend-tip-card">
|
||||
<text class="trend-tip-text">趋势分析基于最小二乘法线性回归和 2 倍标准差异常检测。R² 越接近 1 表示趋势拟合越好。</text>
|
||||
</view>
|
||||
<view class="content-card">
|
||||
<rich-text class="report-content" :nodes="htmlContent" />
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
lab_report_interpretation: '化验单解读', health_trend_analysis: '趋势分析',
|
||||
personalized_checkup_plan: '体检方案', report_summary_generation: '报告摘要',
|
||||
}
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/<\/?(?:iframe|object|embed|form|input|textarea|style)\b[^>]*>/gi, '')
|
||||
.replace(/<\/?(?:link|meta)\b[^>]*>/gi, '')
|
||||
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
||||
}
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
const escaped = sanitizeHtml(md)
|
||||
return escaped
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
.replace(/\n/g, '<br/>')
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const analysis = ref<AiAnalysisItem | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const htmlContent = computed(() => analysis.value?.result_content ? markdownToHtml(analysis.value.result_content) : '<p>暂无分析结果</p>')
|
||||
const isTrendAnalysis = computed(() => analysis.value?.analysis_type === 'trend')
|
||||
const isAutoAnalysis = computed(() => (analysis.value?.result_metadata as Record<string, unknown>)?.auto_analysis === true)
|
||||
|
||||
onLoad((query) => {
|
||||
const id = query?.id || ''
|
||||
if (!id) { loading.value = false; return }
|
||||
getAiAnalysisDetail(id).then(data => { analysis.value = data }).catch(() => uni.showToast({ title: '加载失败', icon: 'none' })).finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.empty-wrap { @include flex-center; padding: 120px 0; }
|
||||
.empty-text { font-size: var(--tk-font-body); color: $tx3; }
|
||||
.detail-card { @include card; margin-bottom: 16px; }
|
||||
.detail-type { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-bottom: 8px; }
|
||||
.detail-meta { display: flex; gap: 16px; }
|
||||
.meta-item { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.auto-badge { display: inline-block; margin-top: 8px; padding: 2px 10px; background: rgba($pri, 0.1); border-radius: 4px; }
|
||||
.auto-badge-text { font-size: var(--tk-font-micro); color: $pri; }
|
||||
.trend-tip-card { @include card; margin-bottom: 16px; background: rgba(250,173,20,0.08); }
|
||||
.trend-tip-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
|
||||
.content-card { @include card; }
|
||||
.report-content { font-size: var(--tk-font-body); line-height: 1.8; color: $tx; }
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<view :class="['ai-report-page', elderClass]">
|
||||
<view class="page-title">AI 分析报告</view>
|
||||
|
||||
<view v-if="list.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="" title="暂无 AI 分析报告" />
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="report-scroll" @scrolltolower="loadMore">
|
||||
<view v-for="item in list" :key="item.id" class="report-card" @tap="goDetail(item)">
|
||||
<view class="card-header">
|
||||
<text class="card-type">{{ TYPE_LABELS[item.analysis_type] || item.analysis_type }}</text>
|
||||
<text :class="['card-status', (STATUS_MAP[item.status] || { className: '' }).className]">
|
||||
{{ (STATUS_MAP[item.status] || { text: item.status }).text }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="card-footer">
|
||||
<text class="card-time">{{ new Date(item.created_at).toLocaleString('zh-CN') }}</text>
|
||||
<text class="card-model">{{ item.model_used }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && !hasMore && list.length > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
lab_report_interpretation: '化验单解读', health_trend_analysis: '趋势分析',
|
||||
personalized_checkup_plan: '体检方案', report_summary_generation: '报告摘要',
|
||||
}
|
||||
const STATUS_MAP: Record<string, { text: string; className: string }> = {
|
||||
completed: { text: '已完成', className: 'status-completed' },
|
||||
streaming: { text: '分析中', className: 'status-streaming' },
|
||||
failed: { text: '失败', className: 'status-failed' },
|
||||
pending: { text: '等待中', className: 'status-pending' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const list = ref<AiAnalysisItem[]>([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const hasMore = ref(true)
|
||||
|
||||
const loadList = async (p: number) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listAiAnalysis(p, 20)
|
||||
const items = res.data || []
|
||||
list.value = p === 1 ? items : [...list.value, ...items]
|
||||
page.value = p
|
||||
hasMore.value = items.length >= 20
|
||||
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const goDetail = (item: AiAnalysisItem) => {
|
||||
if (item.status === 'completed') uni.navigateTo({ url: `/pages-sub/ai-report/detail/index?id=${item.id}` })
|
||||
}
|
||||
const loadMore = () => { if (hasMore.value && !loading.value) loadList(page.value + 1) }
|
||||
|
||||
onMounted(() => loadList(1))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ai-report-page { min-height: 100vh; background: $bg; }
|
||||
.page-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; padding: 24px 24px 16px; }
|
||||
.report-scroll { height: calc(100vh - 64px); padding: 0 24px; }
|
||||
.report-card { @include card; margin-bottom: 12px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.card-type { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
|
||||
.card-status { font-size: var(--tk-font-cap); padding: 2px 8px; border-radius: 4px; }
|
||||
.status-completed { color: $acc; background: rgba(82,196,26,0.1); }
|
||||
.status-streaming { color: $pri; background: rgba($pri, 0.1); }
|
||||
.status-failed { color: $dan; background: rgba(255,77,79,0.1); }
|
||||
.status-pending { color: $tx3; background: rgba(0,0,0,0.05); }
|
||||
.card-footer { display: flex; justify-content: space-between; }
|
||||
.card-time, .card-model { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.empty-wrap { padding-top: 120px; }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<view class="page-content">
|
||||
<text class="page-title">新建预约</text>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">选择医生</text>
|
||||
<picker :range="doctorNames" @change="onDoctorChange">
|
||||
<view class="form-picker">
|
||||
{{ selectedDoctorName || '请选择医生' }}
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">预约日期</text>
|
||||
<picker mode="date" :value="date" @change="(e: any) => date = e.detail.value">
|
||||
<view class="form-picker">
|
||||
{{ date || '请选择日期' }}
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">预约时间</text>
|
||||
<picker mode="time" :value="time" @change="(e: any) => time = e.detail.value">
|
||||
<view class="form-picker">
|
||||
{{ time || '请选择时间' }}
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">备注</text>
|
||||
<textarea v-model="notes" class="form-textarea" placeholder="请输入备注信息" />
|
||||
</view>
|
||||
|
||||
<view class="submit-btn" @tap="handleSubmit">
|
||||
{{ submitting ? '提交中...' : '提交预约' }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api } from '@/services/request'
|
||||
|
||||
const doctors = ref<any[]>([])
|
||||
const selectedDoctorIdx = ref(-1)
|
||||
const date = ref('')
|
||||
const time = ref('')
|
||||
const notes = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const doctorNames = computed(() => doctors.value.map(d => d.name || d.display_name || '医生'))
|
||||
const selectedDoctorName = computed(() => selectedDoctorIdx.value >= 0 ? doctorNames.value[selectedDoctorIdx.value] : '')
|
||||
|
||||
function onDoctorChange(e: any) {
|
||||
selectedDoctorIdx.value = e.detail.value
|
||||
}
|
||||
|
||||
async function fetchDoctors() {
|
||||
try { doctors.value = await api.get<any[]>('/health/doctors') || [] }
|
||||
catch { doctors.value = [] }
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (selectedDoctorIdx.value < 0) {
|
||||
uni.showToast({ title: '请选择医生', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!date.value || !time.value) {
|
||||
uni.showToast({ title: '请选择日期和时间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await api.post('/health/appointments', {
|
||||
doctor_id: doctors.value[selectedDoctorIdx.value].id,
|
||||
appointment_time: `${date.value} ${time.value}`,
|
||||
notes: notes.value,
|
||||
})
|
||||
uni.showToast({ title: '预约成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
} catch (err: any) {
|
||||
uni.showToast({ title: err.message || '预约失败', icon: 'none' })
|
||||
}
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
onMounted(fetchDoctors)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
.page-title { @include section-title; }
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-picker {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
padding: 18px 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
padding: 18px 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
@include btn-primary;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<view class="detail-header">
|
||||
<view class="back-btn" @tap="goBack"><text class="back-text">返回</text></view>
|
||||
<text class="header-title">预约详情</text>
|
||||
<view class="header-placeholder" />
|
||||
</view>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<ErrorState v-else-if="error || !appointment" text="未找到预约信息" />
|
||||
<template v-else>
|
||||
<view class="status-card">
|
||||
<view :class="['status-tag', statusInfo.className]">
|
||||
<text class="status-tag-text">{{ statusInfo.label }}</text>
|
||||
</view>
|
||||
<text class="status-doctor">{{ appointment.doctor_name }}</text>
|
||||
<text class="status-dept">{{ appointment.department || '' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="info-section">
|
||||
<text class="section-title">预约信息</text>
|
||||
<view class="info-item">
|
||||
<view class="info-label-wrap"><text class="info-icon-serif">患</text><text class="info-label">就诊人</text></view>
|
||||
<text class="info-value">{{ appointment.patient_name }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<view class="info-label-wrap"><text class="info-icon-serif">日</text><text class="info-label">就诊日期</text></view>
|
||||
<text class="info-value info-date">{{ appointment.appointment_date }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<view class="info-label-wrap"><text class="info-icon-serif">时</text><text class="info-label">就诊时段</text></view>
|
||||
<text class="info-value info-time">{{ appointment.start_time }} - {{ appointment.end_time }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<view class="info-label-wrap"><text class="info-icon-serif">号</text><text class="info-label">预约单号</text></view>
|
||||
<text class="info-value info-id">{{ appointment.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="appointment.status === 'pending' || appointment.status === 'confirmed'" class="tips-card">
|
||||
<text class="tips-title">温馨提示</text>
|
||||
<text class="tips-text">请按预约时间提前15分钟到达,携带有效身份证件和医保卡。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="canCancel" class="bottom-bar">
|
||||
<view :class="['cancel-btn', cancelling ? 'cancel-disabled' : '']" @tap="cancelling ? undefined : handleCancel">
|
||||
<text class="cancel-text">{{ cancelling ? '处理中...' : '取消预约' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getAppointment, cancelAppointment, type Appointment } from '@/services/appointment'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
pending: { label: '待确认', className: 'tag-pending' },
|
||||
confirmed: { label: '已确认', className: 'tag-confirmed' },
|
||||
cancelled: { label: '已取消', className: 'tag-cancelled' },
|
||||
completed: { label: '已完成', className: 'tag-completed' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const appointment = ref<Appointment | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const cancelling = ref(false)
|
||||
let id = ''
|
||||
|
||||
const statusInfo = computed(() => appointment.value ? (STATUS_MAP[appointment.value.status] || { label: appointment.value.status, className: 'tag-pending' }) : { label: '未知', className: 'tag-pending' })
|
||||
const canCancel = computed(() => appointment.value && (appointment.value.status === 'pending' || appointment.value.status === 'confirmed'))
|
||||
const goBack = () => uni.navigateBack()
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!appointment.value || cancelling.value) return
|
||||
const res = await uni.showModal({ title: '确认取消', content: '确定要取消此预约吗?取消后需重新预约。' })
|
||||
if (!res.confirm) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await cancelAppointment(appointment.value.id, appointment.value.version)
|
||||
uni.showToast({ title: '已取消预约', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
} catch { uni.showToast({ title: '取消失败', icon: 'none' }) }
|
||||
finally { cancelling.value = false }
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
id = query?.id || ''
|
||||
if (!id) { error.value = true; loading.value = false; return }
|
||||
loading.value = true
|
||||
getAppointment(id).then(data => { appointment.value = data }).catch(() => { error.value = true }).finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; }
|
||||
.detail-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; background: $card; }
|
||||
.back-btn { padding: 6px 12px; }
|
||||
.back-text { font-size: var(--tk-font-body); color: $pri; }
|
||||
.header-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
|
||||
.header-placeholder { width: 50px; }
|
||||
.status-card { @include card; margin: 16px 24px; text-align: center; }
|
||||
.status-tag { display: inline-block; padding: 4px 16px; border-radius: 20px; margin-bottom: 8px; }
|
||||
.tag-pending { background: rgba(250,173,20,0.15); }
|
||||
.tag-confirmed { background: rgba($pri, 0.1); }
|
||||
.tag-cancelled { background: rgba(0,0,0,0.05); }
|
||||
.tag-completed { background: rgba(82,196,26,0.1); }
|
||||
.status-tag-text { font-size: var(--tk-font-cap); }
|
||||
.status-doctor { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-top: 8px; }
|
||||
.status-dept { font-size: var(--tk-font-caption); color: $tx3; display: block; margin-top: 4px; }
|
||||
.info-section { @include card; margin: 0 24px 16px; }
|
||||
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.info-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
|
||||
.info-item:last-child { border-bottom: none; }
|
||||
.info-label-wrap { display: flex; align-items: center; gap: 8px; }
|
||||
.info-icon-serif { width: 24px; height: 24px; border-radius: 4px; background: rgba($pri, 0.08); @include flex-center; font-size: var(--tk-font-micro); color: $pri; }
|
||||
.info-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.info-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.tips-card { @include card; margin: 0 24px 16px; background: rgba(250,173,20,0.08); }
|
||||
.tips-title { font-size: var(--tk-font-cap); font-weight: 500; color: $wrn; display: block; margin-bottom: 6px; }
|
||||
.tips-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
|
||||
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; padding: 12px 24px; background: $card; box-shadow: $shadow-sm; }
|
||||
.cancel-btn { height: $touch-min; border: 1px solid $dan; border-radius: $r; @include flex-center; }
|
||||
.cancel-disabled { opacity: 0.5; }
|
||||
.cancel-text { font-size: var(--tk-font-body); color: $dan; }
|
||||
</style>
|
||||
103
apps/miniprogram-uniapp/src/pages-sub/appointment/index.vue
Normal file
103
apps/miniprogram-uniapp/src/pages-sub/appointment/index.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<text class="page-title">我的预约</text>
|
||||
<Loading v-if="loading && list.length === 0" text="加载中..." />
|
||||
<EmptyState v-else-if="list.length === 0" icon="📅" title="暂无预约" action-text="去预约" @action="navigateTo('/pages-sub/appointment/create/index')" />
|
||||
<template v-else>
|
||||
<view v-for="item in list" :key="item.id" class="appt-card">
|
||||
<view class="appt-header">
|
||||
<text class="appt-doctor">{{ item.doctor_name || '医生' }}</text>
|
||||
<text :class="['appt-status', item.status]">{{ item.status_text || item.status }}</text>
|
||||
</view>
|
||||
<text class="appt-time">{{ formatDate(item.appointment_time, 'YYYY-MM-DD HH:mm') }}</text>
|
||||
<text class="appt-dept">{{ item.department || '' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 新建预约按钮 -->
|
||||
<view class="create-btn" @tap="navigateTo('/pages-sub/appointment/create/index')">
|
||||
新建预约
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/services/request'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const loading = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
|
||||
function navigateTo(url: string) { uni.navigateTo({ url }) }
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try { list.value = await api.get<any[]>('/health/appointments') || [] }
|
||||
catch { list.value = [] }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
.page-title { @include section-title; }
|
||||
|
||||
.appt-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.appt-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.appt-doctor {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.appt-status {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-pill;
|
||||
|
||||
&.confirmed { background: $acc-l; color: $acc; }
|
||||
&.pending { background: $wrn-l; color: $wrn; }
|
||||
&.cancelled { background: $bd-l; color: $tx3; }
|
||||
}
|
||||
|
||||
.appt-time {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.appt-dept {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
@include btn-primary;
|
||||
margin-top: 28px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<template v-else-if="article">
|
||||
<text class="article-title">{{ article.title }}</text>
|
||||
<view class="article-meta">
|
||||
<text class="article-date">{{ formatDate(article.created_at) }}</text>
|
||||
<text v-if="article.author" class="article-author">{{ article.author }}</text>
|
||||
</view>
|
||||
<view class="article-body">
|
||||
<rich-text :nodes="article.content || ''" />
|
||||
</view>
|
||||
</template>
|
||||
<EmptyState v-else icon="📰" title="文章不存在" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { api } from '@/services/request'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const loading = ref(false)
|
||||
const article = ref<any>(null)
|
||||
|
||||
onLoad(async (query: any) => {
|
||||
const id = query?.id
|
||||
if (!id) return
|
||||
loading.value = true
|
||||
try { article.value = await api.get<any>(`/health/articles/${id}`) }
|
||||
catch { article.value = null }
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
|
||||
.article-title {
|
||||
display: block;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.article-date, .article-author {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.article-body {
|
||||
font-size: var(--tk-font-body);
|
||||
line-height: var(--tk-line-height);
|
||||
color: $tx;
|
||||
}
|
||||
</style>
|
||||
99
apps/miniprogram-uniapp/src/pages-sub/article/index.vue
Normal file
99
apps/miniprogram-uniapp/src/pages-sub/article/index.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]" @scrolltolower="loadMore">
|
||||
<view class="page-content">
|
||||
<text class="page-title">健康文章</text>
|
||||
<Loading v-if="loading && list.length === 0" text="加载中..." />
|
||||
<EmptyState v-else-if="list.length === 0" icon="📰" title="暂无文章" />
|
||||
<template v-else>
|
||||
<view v-for="item in list" :key="item.id" class="article-card" @tap="goDetail(item.id)">
|
||||
<text class="article-title">{{ item.title }}</text>
|
||||
<text class="article-summary">{{ item.summary || item.content?.substring(0, 60) }}</text>
|
||||
<view class="article-meta">
|
||||
<text class="article-date">{{ formatDate(item.created_at) }}</text>
|
||||
<text v-if="item.category" class="article-tag">{{ item.category }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/services/request'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const loading = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/article/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try { list.value = await api.get<any[]>('/health/articles', { page: page.value, limit: 20 }) || [] }
|
||||
catch { list.value = [] }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
page.value++
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
.page-title { @include section-title; }
|
||||
|
||||
.article-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.article-summary {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.article-tag {
|
||||
@include tag($pri-l, $pri);
|
||||
font-size: var(--tk-font-micro);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<view :class="['chat-page', elderClass]">
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view scroll-y class="chat-scroll" :scroll-top="scrollTop" :scroll-with-animation="true">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<template v-else>
|
||||
<view v-for="msg in messages" :key="msg.id" :class="['msg-bubble', msg.sender === 'user' ? 'right' : 'left']">
|
||||
<text class="msg-text">{{ msg.content }}</text>
|
||||
<text class="msg-time">{{ formatDate(msg.created_at, 'HH:mm') }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<view class="input-bar">
|
||||
<input v-model="inputText" class="chat-input" placeholder="输入消息..." confirm-type="send" @confirm="send" />
|
||||
<view class="send-btn" @tap="send">
|
||||
<text class="send-text">发送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { api } from '@/services/request'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const consultationId = ref('')
|
||||
const loading = ref(false)
|
||||
const messages = ref<any[]>([])
|
||||
const inputText = ref('')
|
||||
const scrollTop = ref(0)
|
||||
|
||||
async function fetchMessages() {
|
||||
loading.value = true
|
||||
try {
|
||||
messages.value = await api.get<any[]>(`/health/consultations/${consultationId.value}/messages`) || []
|
||||
scrollTop.value = 99999
|
||||
} catch { messages.value = [] }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || !consultationId.value) return
|
||||
inputText.value = ''
|
||||
try {
|
||||
await api.post(`/health/consultations/${consultationId.value}/messages`, { content: text })
|
||||
await fetchMessages()
|
||||
} catch {
|
||||
uni.showToast({ title: '发送失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query: any) => {
|
||||
consultationId.value = query?.id || ''
|
||||
if (consultationId.value) fetchMessages()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
flex: 1;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.msg-bubble {
|
||||
max-width: 75%;
|
||||
padding: 16px 20px;
|
||||
border-radius: $r;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.left {
|
||||
background: $card;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
&.right {
|
||||
background: $pri;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
|
||||
.left & { color: $tx; }
|
||||
.right & { color: $white; }
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
display: block;
|
||||
font-size: var(--tk-font-micro);
|
||||
margin-top: 4px;
|
||||
|
||||
.left & { color: $tx3; }
|
||||
.right & { color: rgba(255,255,255,0.7); }
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd-l;
|
||||
@include safe-bottom;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
height: 72px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
margin-left: 12px;
|
||||
background: $pri;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.send-text {
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
95
apps/miniprogram-uniapp/src/pages-sub/consultation/index.vue
Normal file
95
apps/miniprogram-uniapp/src/pages-sub/consultation/index.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]" @scrolltolower="loadMore">
|
||||
<view class="page-content">
|
||||
<text class="page-title">咨询列表</text>
|
||||
<Loading v-if="loading && list.length === 0" text="加载中..." />
|
||||
<EmptyState v-else-if="list.length === 0" icon="💬" title="暂无咨询记录" action-text="发起咨询" @action="navigateTo('/pages-sub/consultation/create')" />
|
||||
<template v-else>
|
||||
<view v-for="item in list" :key="item.id" class="consult-card" @tap="goDetail(item.id)">
|
||||
<view class="consult-header">
|
||||
<text class="consult-doctor">{{ item.doctor_name || '医生' }}</text>
|
||||
<text :class="['consult-status', item.status]">{{ item.status_text || item.status }}</text>
|
||||
</view>
|
||||
<text class="consult-preview">{{ item.last_message || '暂无消息' }}</text>
|
||||
<text class="consult-time">{{ getRelativeTime(item.updated_at) }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/services/request'
|
||||
import { getRelativeTime } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const loading = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
|
||||
function navigateTo(url: string) { uni.navigateTo({ url }) }
|
||||
function goDetail(id: string) { uni.navigateTo({ url: `/pages-sub/consultation/detail/index?id=${id}` }) }
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try { list.value = await api.get<any[]>('/health/consultations') || [] }
|
||||
catch { list.value = [] }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function loadMore() { /* 预留 */ }
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
.page-title { @include section-title; }
|
||||
|
||||
.consult-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.consult-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.consult-doctor {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.consult-status {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-pill;
|
||||
|
||||
&.active { background: $acc-l; color: $acc; }
|
||||
&.closed { background: $bd-l; color: $tx3; }
|
||||
}
|
||||
|
||||
.consult-preview {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.consult-time {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
</style>
|
||||
199
apps/miniprogram-uniapp/src/pages-sub/device-sync/index.vue
Normal file
199
apps/miniprogram-uniapp/src/pages-sub/device-sync/index.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<view :class="['device-sync-page', elderClass]">
|
||||
<view class="sync-header">
|
||||
<text class="sync-header-title">设备同步</text>
|
||||
</view>
|
||||
|
||||
<view v-if="errorMsg" class="sync-error">
|
||||
<text class="sync-error-text">{{ errorMsg }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="pageState === 'scanning' || pageState === 'connecting' || pageState === 'syncing'" class="sync-loading">
|
||||
<text class="sync-loading-text">
|
||||
{{ pageState === 'scanning' && '正在扫描设备...' || pageState === 'connecting' && '正在连接设备...' || '正在上传数据...' }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 空闲状态 -->
|
||||
<template v-if="pageState === 'idle' || pageState === 'error'">
|
||||
<view class="sync-section">
|
||||
<view class="sync-hero">
|
||||
<text class="sync-hero-icon">D</text>
|
||||
<text class="sync-hero-title">设备同步</text>
|
||||
<text class="sync-hero-desc">连接智能手环、血压计、血糖仪,自动采集健康数据</text>
|
||||
</view>
|
||||
<view v-if="lastSyncAt || pendingCount > 0" class="sync-status-info">
|
||||
<text v-if="lastSyncAt" class="sync-status-time">上次同步: {{ new Date(lastSyncAt).toLocaleTimeString() }}</text>
|
||||
<text v-if="pendingCount > 0" class="sync-status-pending">{{ pendingCount }} 条数据待上传</text>
|
||||
</view>
|
||||
<view class="sync-action" @tap="handleScan">
|
||||
<text class="sync-action-text">扫描设备</text>
|
||||
</view>
|
||||
<view v-if="devices.length > 0" class="sync-device-list">
|
||||
<text class="sync-section-title">发现的设备</text>
|
||||
<view v-for="d in devices" :key="d.deviceId" class="sync-device-item" @tap="handleConnect(d)">
|
||||
<view class="sync-device-info">
|
||||
<text class="sync-device-name">{{ d.name }}</text>
|
||||
<text class="sync-device-adapter">{{ d.adapter?.name }}</text>
|
||||
</view>
|
||||
<text class="sync-device-rssi">信号 {{ d.RSSI > -60 ? '强' : d.RSSI > -80 ? '中' : '弱' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 已连接 -->
|
||||
<template v-if="pageState === 'connected'">
|
||||
<view class="sync-section">
|
||||
<view class="sync-status-card">
|
||||
<text class="sync-status-dot sync-status-dot--connected" />
|
||||
<text class="sync-status-text">已连接: {{ selectedDevice?.name }}</text>
|
||||
</view>
|
||||
<view v-if="liveReadings.length > 0" class="sync-readings-panel">
|
||||
<text class="sync-section-title">实时数据</text>
|
||||
<view v-for="(r, i) in liveReadings.slice(-5).reverse()" :key="i" class="sync-reading-item">
|
||||
<text class="sync-reading-type">
|
||||
{{ r.device_type === 'heart_rate' ? '心率' : r.device_type === 'blood_pressure' ? `血压(${r.metric === 'systolic' ? '收缩压' : r.metric === 'diastolic' ? '舒张压' : 'MAP'})` : r.device_type === 'blood_glucose' ? '血糖' : r.device_type }}
|
||||
</text>
|
||||
<text class="sync-reading-value">
|
||||
{{ r.device_type === 'heart_rate' ? `${r.values.heart_rate} bpm` : r.metric ? `${r.values.value} ${r.values.unit}` : JSON.stringify(r.values) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="sync-readings-count">已采集 {{ liveReadings.length }} 条数据</text>
|
||||
</view>
|
||||
<view class="sync-actions-row">
|
||||
<view class="sync-action sync-action--primary" @tap="handleSync"><text class="sync-action-text">上传数据</text></view>
|
||||
<view class="sync-action sync-action--danger" @tap="handleDisconnect"><text class="sync-action-text">断开连接</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 完成 -->
|
||||
<template v-if="pageState === 'done'">
|
||||
<view class="sync-section">
|
||||
<view class="sync-result-card">
|
||||
<text class="sync-result-icon">V</text>
|
||||
<text class="sync-result-title">同步完成</text>
|
||||
<text class="sync-result-count">成功上传 {{ syncCount }} 条数据</text>
|
||||
</view>
|
||||
<view class="sync-action" @tap="handleDone">
|
||||
<text class="sync-action-text">{{ returnTo === 'input' ? '返回录入' : '完成' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { BLEDevice, NormalizedReading } from '@/services/ble'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const pageState = ref<PageState>('idle')
|
||||
const devices = ref<BLEDevice[]>([])
|
||||
const selectedDevice = ref<BLEDevice | null>(null)
|
||||
const liveReadings = ref<NormalizedReading[]>([])
|
||||
const syncCount = ref(0)
|
||||
const errorMsg = ref('')
|
||||
const lastSyncAt = ref<number | null>(null)
|
||||
const pendingCount = ref(0)
|
||||
let returnTo = ''
|
||||
|
||||
const handleScan = () => {
|
||||
pageState.value = 'scanning'; devices.value = []; errorMsg.value = ''
|
||||
// BLE 扫描需要完整 BLE 适配器实现,此处预留
|
||||
setTimeout(() => {
|
||||
if (devices.value.length === 0) errorMsg.value = '未发现支持的设备,请确认设备已开启蓝牙并靠近手机'
|
||||
pageState.value = 'idle'
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const handleConnect = (_device: BLEDevice) => {
|
||||
selectedDevice.value = _device; pageState.value = 'connecting'; errorMsg.value = ''
|
||||
setTimeout(() => { pageState.value = 'connected' }, 2000)
|
||||
}
|
||||
|
||||
const handleSync = () => {
|
||||
if (!authStore.currentPatient || !selectedDevice.value) return
|
||||
pageState.value = 'syncing'; errorMsg.value = ''
|
||||
setTimeout(() => {
|
||||
syncCount.value = liveReadings.value.length || 1
|
||||
lastSyncAt.value = Date.now()
|
||||
pageState.value = 'done'
|
||||
if (returnTo === 'input' && liveReadings.value.length > 0) {
|
||||
const mapped: Record<string, number> = {}
|
||||
for (const r of liveReadings.value) {
|
||||
if (r.device_type === 'blood_pressure') {
|
||||
if (r.metric === 'systolic' && typeof r.values.value === 'number') mapped.systolic = r.values.value
|
||||
if (r.metric === 'diastolic' && typeof r.values.value === 'number') mapped.diastolic = r.values.value
|
||||
} else if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') {
|
||||
mapped.blood_sugar = r.values.blood_glucose as number
|
||||
} else if (r.device_type === 'heart_rate' && typeof r.values.heart_rate === 'number') {
|
||||
mapped.heart_rate = r.values.heart_rate as number
|
||||
}
|
||||
}
|
||||
if (Object.keys(mapped).length > 0) uni.setStorageSync('device_sync_result', JSON.stringify(mapped))
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const handleDisconnect = () => {
|
||||
pageState.value = 'idle'; selectedDevice.value = null; liveReadings.value = []; syncCount.value = 0; errorMsg.value = ''
|
||||
}
|
||||
|
||||
const handleDone = () => {
|
||||
handleDisconnect()
|
||||
if (returnTo === 'input') uni.navigateBack()
|
||||
}
|
||||
|
||||
onLoad((query) => { returnTo = query?.returnTo || '' })
|
||||
onShow(() => { /* BLE manager lifecycle placeholder */ })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device-sync-page { min-height: 100vh; background: $bg; }
|
||||
.sync-header { padding: 16px 24px; background: $card; }
|
||||
.sync-header-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
|
||||
.sync-error { margin: 12px 24px; padding: 10px 16px; background: rgba(255,77,79,0.08); border-radius: $r; }
|
||||
.sync-error-text { font-size: var(--tk-font-cap); color: $dan; }
|
||||
.sync-loading { @include flex-center; padding: 80px 0; }
|
||||
.sync-loading-text { font-size: var(--tk-font-body); color: $tx3; }
|
||||
.sync-section { padding: 24px; }
|
||||
.sync-hero { @include flex-center; flex-direction: column; padding: 40px 0; }
|
||||
.sync-hero-icon { font-size: var(--tk-font-hero); font-weight: 700; color: $pri; }
|
||||
.sync-hero-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; margin-top: 8px; }
|
||||
.sync-hero-desc { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; text-align: center; }
|
||||
.sync-status-info { display: flex; gap: 16px; justify-content: center; margin-bottom: 20px; }
|
||||
.sync-status-time, .sync-status-pending { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.sync-action { height: 48px; background: $pri; border-radius: $r; @include flex-center; margin-bottom: 12px; }
|
||||
.sync-action--primary { background: $pri; }
|
||||
.sync-action--danger { background: $dan; }
|
||||
.sync-action-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
|
||||
.sync-device-list { margin-top: 20px; }
|
||||
.sync-section-title { font-size: var(--tk-font-cap); font-weight: 500; color: $tx2; margin-bottom: 12px; display: block; }
|
||||
.sync-device-item { @include card; display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.sync-device-info { flex: 1; }
|
||||
.sync-device-name { font-size: var(--tk-font-body); color: $tx; display: block; }
|
||||
.sync-device-adapter { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 2px; }
|
||||
.sync-device-rssi { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.sync-status-card { @include card; display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
|
||||
.sync-status-dot { width: 10px; height: 10px; border-radius: 50%; background: $acc; }
|
||||
.sync-status-text { font-size: var(--tk-font-body); color: $tx; }
|
||||
.sync-readings-panel { @include card; margin-bottom: 20px; }
|
||||
.sync-reading-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
|
||||
.sync-reading-type { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.sync-reading-value { font-size: var(--tk-font-body); color: $tx; font-weight: 500; }
|
||||
.sync-readings-count { font-size: var(--tk-font-cap); color: $tx3; margin-top: 8px; display: block; text-align: center; }
|
||||
.sync-actions-row { display: flex; gap: 12px; }
|
||||
.sync-actions-row .sync-action { flex: 1; }
|
||||
.sync-result-card { @include card; @include flex-center; flex-direction: column; padding: 40px 24px; margin-bottom: 20px; }
|
||||
.sync-result-icon { font-size: var(--tk-font-hero); font-weight: 700; color: $acc; }
|
||||
.sync-result-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; margin-top: 8px; }
|
||||
.sync-result-count { font-size: var(--tk-font-body); color: $tx2; margin-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<scroll-view v-else scroll-y class="page-scroll">
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- Tab 筛选 -->
|
||||
<scroll-view scroll-x class="tab-bar">
|
||||
<view
|
||||
v-for="tab in FILTER_TABS"
|
||||
:key="tab.key"
|
||||
:class="['tab-chip', { 'tab-chip--active': activeFilter === tab.key }]"
|
||||
@tap="handleFilterChange(tab.key)"
|
||||
>
|
||||
<text class="tab-chip__text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 我的统计 -->
|
||||
<view v-if="stats" class="section">
|
||||
<text class="section-title">我的统计</text>
|
||||
<view class="stats-grid">
|
||||
<view class="stat-item">
|
||||
<text class="stat-item__value">{{ stats.pending }}</text>
|
||||
<text class="stat-item__label">待处理</text>
|
||||
</view>
|
||||
<view class="stat-item stat-item--warn">
|
||||
<text class="stat-item__value">{{ stats.overdue }}</text>
|
||||
<text class="stat-item__label">紧急事项</text>
|
||||
</view>
|
||||
<view class="stat-item stat-item--success">
|
||||
<text class="stat-item__value">{{ stats.completed_today }}</text>
|
||||
<text class="stat-item__label">今日完成</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 团队概览 -->
|
||||
<view v-if="team" class="section">
|
||||
<text class="section-title">团队概览</text>
|
||||
<view class="team-row">
|
||||
<text class="team-row__label">团队待处理</text>
|
||||
<text class="team-row__value">{{ team.total_pending }}</text>
|
||||
</view>
|
||||
<view class="team-row">
|
||||
<text class="team-row__label">平均响应时间</text>
|
||||
<text class="team-row__value">{{ formatResponseTime(team.avg_response_time) }}</text>
|
||||
</view>
|
||||
<view v-if="team.members.length > 0" class="team-members">
|
||||
<view
|
||||
v-for="member in team.members"
|
||||
:key="member.user_id"
|
||||
class="member-item"
|
||||
>
|
||||
<text class="member-item__name">{{ member.user_name }}</text>
|
||||
<text class="member-item__role">{{ member.role }}</text>
|
||||
<text
|
||||
:class="['member-item__tasks', member.active_tasks > 0 ? 'member-item__tasks--active' : '']"
|
||||
>
|
||||
{{ member.active_tasks }} 项
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的患者 -->
|
||||
<view class="section">
|
||||
<text class="section-title">我的患者</text>
|
||||
<EmptyState v-if="patients.length === 0" icon="👥" title="暂无患者" />
|
||||
<view v-else class="patient-cards">
|
||||
<view
|
||||
v-for="p in filteredPatients"
|
||||
:key="p.patient_id"
|
||||
class="patient-card"
|
||||
@tap="goPatientDetail(p.patient_id)"
|
||||
>
|
||||
<view class="patient-card__header">
|
||||
<text class="patient-card__name">{{ p.patient_name }}</text>
|
||||
<text v-if="p.bed_number" class="patient-card__bed">
|
||||
{{ p.bed_number }}床
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="p.primary_diagnosis" class="patient-card__diagnosis">
|
||||
<text class="patient-card__diagnosis-text">{{ p.primary_diagnosis }}</text>
|
||||
</view>
|
||||
<view class="patient-card__footer">
|
||||
<view v-if="p.open_action_count > 0" class="patient-card__actions">
|
||||
<text class="patient-card__actions-text">
|
||||
{{ p.open_action_count }} 项待办
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="p.care_plan_status" class="patient-card__plan">
|
||||
{{ p.care_plan_status }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import {
|
||||
getWorkbenchStats,
|
||||
getTeamOverview,
|
||||
getMyPatients,
|
||||
} from '@/services/doctor/actionInbox'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
interface WorkbenchStats {
|
||||
pending: number
|
||||
in_progress: number
|
||||
completed_today: number
|
||||
overdue: number
|
||||
}
|
||||
|
||||
interface TeamMember {
|
||||
user_id: string
|
||||
user_name: string
|
||||
role: string
|
||||
active_tasks: number
|
||||
}
|
||||
|
||||
interface TeamOverviewData {
|
||||
team_name: string
|
||||
members: TeamMember[]
|
||||
total_pending: number
|
||||
avg_response_time: number
|
||||
}
|
||||
|
||||
interface NursePatientSummary {
|
||||
patient_id: string
|
||||
patient_name: string
|
||||
bed_number?: string
|
||||
primary_diagnosis?: string
|
||||
care_plan_status?: string
|
||||
open_action_count: number
|
||||
}
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'urgent', label: '紧急' },
|
||||
] as const
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const stats = ref<WorkbenchStats | null>(null)
|
||||
const team = ref<TeamOverviewData | null>(null)
|
||||
const patients = ref<NursePatientSummary[]>([])
|
||||
const activeFilter = ref('')
|
||||
const pageLoading = ref(true)
|
||||
|
||||
const filteredPatients = computed(() => {
|
||||
const list = patients.value
|
||||
if (activeFilter.value === 'pending') {
|
||||
return list.filter((p) => p.open_action_count > 0)
|
||||
}
|
||||
if (activeFilter.value === 'urgent') {
|
||||
return list.filter((p) => p.open_action_count >= 3)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
function formatResponseTime(minutes: number): string {
|
||||
if (minutes < 60) return `${Math.round(minutes)} 分钟`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = Math.round(minutes % 60)
|
||||
return mins > 0 ? `${hours} 小时 ${mins} 分钟` : `${hours} 小时`
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const [s, t, p] = await Promise.all([
|
||||
getWorkbenchStats(true),
|
||||
getTeamOverview(),
|
||||
getMyPatients(),
|
||||
])
|
||||
stats.value = s
|
||||
team.value = t as TeamOverviewData
|
||||
patients.value = p || []
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterChange(key: string) {
|
||||
activeFilter.value = key
|
||||
}
|
||||
|
||||
function goPatientDetail(patientId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/patients/detail/index?id=${patientId}`,
|
||||
})
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadData().finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
// ── 标签栏 ──
|
||||
.tab-bar {
|
||||
white-space: nowrap;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 28px;
|
||||
min-height: $touch-min;
|
||||
border-radius: $r-pill;
|
||||
background: $card;
|
||||
box-shadow: $shadow-sm;
|
||||
margin-right: 12px;
|
||||
|
||||
&--active {
|
||||
background: $pri;
|
||||
|
||||
.tab-chip__text {
|
||||
color: $card;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 通用区块 ──
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
// ── 统计网格 ──
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
background: $pri-l;
|
||||
border-radius: $r;
|
||||
padding: 20px 12px;
|
||||
|
||||
&--warn {
|
||||
background: $wrn-l;
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: $acc-l;
|
||||
}
|
||||
|
||||
&__value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.stat-item--warn & {
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
.stat-item--success & {
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 团队概览 ──
|
||||
.team-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
}
|
||||
|
||||
.team-members {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid $bd-l;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__role {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__tasks {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
|
||||
&--active {
|
||||
color: $wrn;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 患者卡片 ──
|
||||
.patient-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.patient-card {
|
||||
background: $bg;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__bed {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
background: $card;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
&__diagnosis {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__diagnosis-text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
background: $wrn-l;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
&__actions-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $wrn;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__plan {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<view v-else-if="!alert" :class="['error-wrap', elderClass]">
|
||||
<text class="error-text">告警信息加载失败</text>
|
||||
</view>
|
||||
<scroll-view v-else scroll-y class="page-scroll">
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 告警标题 + 严重程度 -->
|
||||
<view class="section">
|
||||
<view class="alert-header">
|
||||
<text class="alert-header__title">{{ alert.title }}</text>
|
||||
<view
|
||||
class="alert-header__severity"
|
||||
:style="getSeverityStyle(alert.severity)"
|
||||
>
|
||||
<text class="alert-header__severity-text">
|
||||
{{ getSeverityLabel(alert.severity) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="status-row">
|
||||
<text class="status-row__label">状态</text>
|
||||
<view
|
||||
class="status-row__badge"
|
||||
:style="getStatusInlineStyle(alert.status)"
|
||||
>
|
||||
<text class="status-row__badge-text">
|
||||
{{ getStatusLabel(alert.status) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 患者与指标信息 -->
|
||||
<view class="section">
|
||||
<text class="section-title">告警详情</text>
|
||||
<view class="info-grid">
|
||||
<view v-if="alert.detail?.patient_name" class="info-item">
|
||||
<text class="info-label">患者</text>
|
||||
<text class="info-value">{{ alert.detail.patient_name }}</text>
|
||||
</view>
|
||||
<view v-if="alert.detail?.indicator_name" class="info-item">
|
||||
<text class="info-label">指标</text>
|
||||
<text class="info-value">{{ alert.detail.indicator_name }}</text>
|
||||
</view>
|
||||
<view v-if="alert.detail?.threshold_value != null" class="info-item">
|
||||
<text class="info-label">阈值</text>
|
||||
<text class="info-value">{{ alert.detail.threshold_value }}</text>
|
||||
</view>
|
||||
<view v-if="alert.detail?.actual_value != null" class="info-item">
|
||||
<text class="info-label">实际值</text>
|
||||
<text class="info-value info-value--warn">
|
||||
{{ alert.detail.actual_value }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<view class="section">
|
||||
<text class="section-title">时间线</text>
|
||||
<view class="timeline">
|
||||
<view class="timeline-item">
|
||||
<text class="timeline-item__label">创建时间</text>
|
||||
<text class="timeline-item__value">
|
||||
{{ formatDate(alert.created_at, 'YYYY-MM-DD HH:mm') }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="alert.acknowledged_at" class="timeline-item">
|
||||
<text class="timeline-item__label">确认时间</text>
|
||||
<text class="timeline-item__value">
|
||||
{{ formatDate(alert.acknowledged_at, 'YYYY-MM-DD HH:mm') }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="alert.resolved_at" class="timeline-item">
|
||||
<text class="timeline-item__label">解决时间</text>
|
||||
<text class="timeline-item__value">
|
||||
{{ formatDate(alert.resolved_at, 'YYYY-MM-DD HH:mm') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view v-if="hasActions" class="section">
|
||||
<text class="section-title">操作</text>
|
||||
<view class="action-buttons">
|
||||
<button
|
||||
v-if="alert.status === 'pending'"
|
||||
class="btn btn--primary"
|
||||
:loading="actionLoading === 'acknowledge'"
|
||||
@tap="handleAcknowledge"
|
||||
>
|
||||
确认告警
|
||||
</button>
|
||||
<button
|
||||
v-if="alert.status === 'pending'"
|
||||
class="btn btn--outline"
|
||||
:loading="actionLoading === 'dismiss'"
|
||||
@tap="handleDismiss"
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
<button
|
||||
v-if="alert.status === 'acknowledged'"
|
||||
class="btn btn--primary"
|
||||
:loading="actionLoading === 'resolve'"
|
||||
@tap="handleResolve"
|
||||
>
|
||||
标记已解决
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import {
|
||||
listAlerts,
|
||||
acknowledgeAlert,
|
||||
dismissAlert,
|
||||
resolveAlert,
|
||||
getCachedAlert,
|
||||
updateCachedAlert,
|
||||
} from '@/services/doctor/alerts'
|
||||
import type { Alert } from '@/services/doctor/alerts'
|
||||
import { getStatusInlineStyle, getStatusLabel, getSeverityStyle, getSeverityLabel } from '@/utils/statusTag'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const alert = ref<Alert | null>(null)
|
||||
const pageLoading = ref(true)
|
||||
const actionLoading = ref<string | null>(null)
|
||||
const alertId = ref('')
|
||||
|
||||
const hasActions = computed(() => {
|
||||
const s = alert.value?.status
|
||||
return s === 'pending' || s === 'acknowledged'
|
||||
})
|
||||
|
||||
async function loadAlert() {
|
||||
if (!alertId.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
// 优先从列表页缓存读取
|
||||
const cached = getCachedAlert(alertId.value)
|
||||
if (cached) {
|
||||
alert.value = cached
|
||||
return
|
||||
}
|
||||
// 缓存未命中时回退到列表查询
|
||||
const res = await listAlerts({ page: 1, page_size: 100 })
|
||||
const found = (res.data || []).find((a) => a.id === alertId.value)
|
||||
alert.value = found || null
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAcknowledge() {
|
||||
if (!alert.value || actionLoading.value) return
|
||||
actionLoading.value = 'acknowledge'
|
||||
try {
|
||||
const updated = await acknowledgeAlert(alert.value.id, alert.value.version)
|
||||
alert.value = { ...alert.value, ...updated }
|
||||
updateCachedAlert(alert.value)
|
||||
uni.showToast({ title: '已确认', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
actionLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismiss() {
|
||||
if (!alert.value || actionLoading.value) return
|
||||
actionLoading.value = 'dismiss'
|
||||
try {
|
||||
const updated = await dismissAlert(alert.value.id, alert.value.version)
|
||||
alert.value = { ...alert.value, ...updated }
|
||||
updateCachedAlert(alert.value)
|
||||
uni.showToast({ title: '已忽略', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
actionLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolve() {
|
||||
if (!alert.value || actionLoading.value) return
|
||||
actionLoading.value = 'resolve'
|
||||
try {
|
||||
const updated = await resolveAlert(alert.value.id, alert.value.version)
|
||||
alert.value = { ...alert.value, ...updated }
|
||||
updateCachedAlert(alert.value)
|
||||
uni.showToast({ title: '已解决', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
actionLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
alertId.value = query?.id || ''
|
||||
loadAlert()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.error-wrap {
|
||||
@include flex-center;
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 通用区块 ──
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
// ── 告警头部 ──
|
||||
.alert-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__title {
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
margin-right: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__severity {
|
||||
@include status-inline;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__severity-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 状态行 ──
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
&__badge-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 信息网格 ──
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
|
||||
&--warn {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 时间线 ──
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 操作按钮 ──
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: $btn-primary-h;
|
||||
border-radius: $r;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
@include touch-target;
|
||||
|
||||
&--primary {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
&--outline {
|
||||
@include btn-outline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
317
apps/miniprogram-uniapp/src/pages-sub/doctor/alerts/index.vue
Normal file
317
apps/miniprogram-uniapp/src/pages-sub/doctor/alerts/index.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading && alerts.length === 0" text="加载中..." />
|
||||
<scroll-view
|
||||
v-else
|
||||
scroll-y
|
||||
class="page-scroll"
|
||||
@scrolltolower="onLoadMore"
|
||||
>
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 严重程度筛选 -->
|
||||
<scroll-view scroll-x class="tab-bar">
|
||||
<view
|
||||
v-for="tab in SEVERITY_TABS"
|
||||
:key="tab.key"
|
||||
:class="['tab-chip', { 'tab-chip--active': activeSeverity === tab.key }]"
|
||||
@tap="handleSeverityChange(tab.key)"
|
||||
>
|
||||
<text class="tab-chip__text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 列表统计 -->
|
||||
<view v-if="filteredAlerts.length > 0" class="list-meta">
|
||||
<text class="list-meta__text">共 {{ filteredAlerts.length }} 条告警</text>
|
||||
</view>
|
||||
|
||||
<!-- 告警卡片 -->
|
||||
<EmptyState v-if="!pageLoading && filteredAlerts.length === 0" icon="🔔" title="暂无告警" />
|
||||
<view v-else class="alert-cards">
|
||||
<view
|
||||
v-for="alert in filteredAlerts"
|
||||
:key="alert.id"
|
||||
class="alert-card"
|
||||
@tap="goDetail(alert.id)"
|
||||
>
|
||||
<view class="alert-card__header">
|
||||
<text class="alert-card__title">{{ alert.title }}</text>
|
||||
<view
|
||||
class="alert-card__severity"
|
||||
:style="getSeverityStyle(alert.severity)"
|
||||
>
|
||||
<text class="alert-card__severity-text">
|
||||
{{ getSeverityLabel(alert.severity) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="alert-card__body">
|
||||
<text v-if="alert.detail?.patient_name" class="alert-card__patient">
|
||||
{{ alert.detail.patient_name }}
|
||||
</text>
|
||||
<text v-if="alert.detail?.indicator_name" class="alert-card__indicator">
|
||||
{{ alert.detail.indicator_name }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="alert-card__footer">
|
||||
<text class="alert-card__time">{{ formatAlertTime(alert.created_at) }}</text>
|
||||
<view
|
||||
class="alert-card__status"
|
||||
:style="getStatusInlineStyle(alert.status)"
|
||||
>
|
||||
<text class="alert-card__status-text">{{ getStatusLabel(alert.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="!loadingMore && alerts.length >= total && total > 0" class="load-hint-wrap">
|
||||
<text class="load-hint">没有更多了</text>
|
||||
</view>
|
||||
<Loading v-if="loadingMore" text="加载中..." />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { listAlerts, cacheAlerts } from '@/services/doctor/alerts'
|
||||
import type { Alert } from '@/services/doctor/alerts'
|
||||
import { getStatusInlineStyle, getStatusLabel, getSeverityStyle, getSeverityLabel } from '@/utils/statusTag'
|
||||
import { formatDate, getRelativeTime } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const SEVERITY_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'info', label: getSeverityLabel('info') },
|
||||
{ key: 'warning', label: getSeverityLabel('warning') },
|
||||
{ key: 'critical', label: getSeverityLabel('critical') },
|
||||
{ key: 'urgent', label: getSeverityLabel('urgent') },
|
||||
] as const
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const alerts = ref<Alert[]>([])
|
||||
const activeSeverity = ref('')
|
||||
const pageLoading = ref(true)
|
||||
const loadingMore = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
const filteredAlerts = computed(() => {
|
||||
if (!activeSeverity.value) return alerts.value
|
||||
return alerts.value.filter((a) => a.severity === activeSeverity.value)
|
||||
})
|
||||
|
||||
function formatAlertTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (diffDays < 1) return getRelativeTime(dateStr)
|
||||
if (diffDays < 7) return `${diffDays}天前`
|
||||
return formatDate(dateStr, 'MM-DD HH:mm')
|
||||
}
|
||||
|
||||
async function loadAlerts(pageNum: number, isRefresh = false) {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
if (isRefresh) {
|
||||
pageLoading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const res = await listAlerts({
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
})
|
||||
const list = res.data || []
|
||||
if (isRefresh) {
|
||||
alerts.value = list
|
||||
} else {
|
||||
alerts.value = [...alerts.value, ...list]
|
||||
}
|
||||
cacheAlerts(list)
|
||||
total.value = res.total || 0
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
loadingMore.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSeverityChange(key: string) {
|
||||
activeSeverity.value = key
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/doctor/alerts/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
if (!isLoading.value && alerts.value.length < total.value) {
|
||||
loadAlerts(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadAlerts(1, true)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadAlerts(1, true).finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
// ── 标签栏 ──
|
||||
.tab-bar {
|
||||
white-space: nowrap;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 28px;
|
||||
min-height: $touch-min;
|
||||
border-radius: $r-pill;
|
||||
background: $card;
|
||||
box-shadow: $shadow-sm;
|
||||
margin-right: 12px;
|
||||
|
||||
&--active {
|
||||
background: $pri;
|
||||
|
||||
.tab-chip__text {
|
||||
color: $card;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 列表统计 ──
|
||||
.list-meta {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 告警卡片 ──
|
||||
.alert-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
@include card;
|
||||
position: relative;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
margin-right: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__severity {
|
||||
@include status-inline;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__severity-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__patient {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__status {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
&__status-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载提示 ──
|
||||
.load-hint-wrap {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.load-hint {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<view :class="['chat-page', elderClass]">
|
||||
<!-- Header -->
|
||||
<view class="chat-header">
|
||||
<text class="chat-header__title">{{ session?.subject || '在线咨询' }}</text>
|
||||
<text v-if="isOpen" class="chat-header__close" @tap="handleClose">关闭会话</text>
|
||||
</view>
|
||||
|
||||
<!-- Loading -->
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
|
||||
<!-- Error -->
|
||||
<ErrorState v-else-if="error" :text="error" @retry="loadData" />
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view v-else
|
||||
scroll-y
|
||||
class="chat-messages"
|
||||
:scroll-into-view="scrollInto"
|
||||
scroll-with-animation
|
||||
>
|
||||
<template v-if="messages.length > 0">
|
||||
<view
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="msg.id"
|
||||
:id="`msg-${idx + 1}`"
|
||||
:class="['msg-row', msg.sender_role === 'doctor' ? 'msg-row--self' : '']"
|
||||
>
|
||||
<view :class="['msg-bubble', msg.sender_role === 'doctor' ? 'msg-bubble--self' : 'msg-bubble--other']">
|
||||
<text class="msg-text">{{ msg.content }}</text>
|
||||
<text class="msg-time">{{ formatTime(msg.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<view v-else class="chat-empty">
|
||||
<text class="chat-empty__text">暂无消息,发送第一条消息开始对话</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入栏(会话进行中) -->
|
||||
<view v-if="!loading && !error && isOpen" class="chat-input-bar">
|
||||
<input
|
||||
class="chat-input"
|
||||
placeholder="输入消息..."
|
||||
:value="inputText"
|
||||
@input="(e: any) => inputText = e.detail.value"
|
||||
confirm-type="send"
|
||||
@confirm="handleSend"
|
||||
:disabled="sending"
|
||||
/>
|
||||
<view
|
||||
:class="['chat-send-btn', (!inputText.trim() || sending) ? 'chat-send-btn--disabled' : '']"
|
||||
@tap="handleSend"
|
||||
>
|
||||
<text class="chat-send-btn__text">{{ sending ? '...' : '发送' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 已关闭提示 -->
|
||||
<view v-else-if="!loading && !error" class="chat-closed-bar">
|
||||
<text class="chat-closed-bar__text">会话已关闭</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const sessionId = ref('')
|
||||
const session = ref<doctorApi.ConsultationSession | null>(null)
|
||||
const messages = ref<doctorApi.ConsultationMessage[]>([])
|
||||
const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const scrollInto = ref('')
|
||||
|
||||
const isOpen = computed(() => session.value?.status !== 'closed')
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const m = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
}
|
||||
|
||||
function scrollToBottom(count: number) {
|
||||
nextTick(() => {
|
||||
scrollInto.value = `msg-${count}`
|
||||
})
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!sessionId.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [s, m] = await Promise.all([
|
||||
doctorApi.getSession(sessionId.value),
|
||||
doctorApi.listMessages(sessionId.value, { page: 1, page_size: 50 }),
|
||||
])
|
||||
session.value = s
|
||||
messages.value = m.data || []
|
||||
scrollToBottom(messages.value.length)
|
||||
} catch {
|
||||
error.value = '加载失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead() {
|
||||
if (!sessionId.value) return
|
||||
try {
|
||||
await doctorApi.markSessionRead(sessionId.value)
|
||||
} catch {
|
||||
// 静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || sending.value) return
|
||||
sending.value = true
|
||||
inputText.value = ''
|
||||
try {
|
||||
const msg = await doctorApi.sendMessage(sessionId.value, text)
|
||||
messages.value = [...messages.value, msg]
|
||||
scrollToBottom(messages.value.length)
|
||||
} catch {
|
||||
uni.showToast({ title: '发送失败', icon: 'none' })
|
||||
inputText.value = text
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
uni.showModal({
|
||||
title: '确认关闭',
|
||||
content: '关闭后将无法继续对话,确认关闭?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await doctorApi.closeSession(sessionId.value, session.value?.version ?? 0)
|
||||
uni.showToast({ title: '已关闭', icon: 'success' })
|
||||
loadData()
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
sessionId.value = query?.id || ''
|
||||
if (sessionId.value) {
|
||||
loadData()
|
||||
markRead()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
background: $card;
|
||||
border-bottom: 1px solid $bd;
|
||||
|
||||
&__title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__close {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $dan;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&--self {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-bubble {
|
||||
max-width: 70%;
|
||||
padding: 20px 24px;
|
||||
border-radius: $r-lg;
|
||||
position: relative;
|
||||
|
||||
&--other {
|
||||
background: $card;
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
&--self {
|
||||
background: $pri;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
display: block;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
|
||||
.msg-bubble--self & {
|
||||
color: $card;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
|
||||
.msg-bubble--self & {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
text-align: center;
|
||||
padding: 120px 32px;
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd;
|
||||
@include safe-bottom;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
background: $bd-l;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
background: $pri;
|
||||
border-radius: $r;
|
||||
padding: 16px 28px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $card;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-closed-bar {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd;
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['consultation-page', elderClass]">
|
||||
<!-- Tab 筛选 -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
:class="['tab', activeTab === t.key ? 'tab--active' : '']"
|
||||
@tap="handleTabChange(t.key)"
|
||||
>
|
||||
<text>{{ t.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载态 -->
|
||||
<Loading v-if="loading && sessions.length === 0" text="加载中..." />
|
||||
|
||||
<!-- 空态 -->
|
||||
<EmptyState v-else-if="sessions.length === 0" icon="💬" title="暂无咨询会话" />
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<view v-else class="session-list">
|
||||
<view
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
class="session-card"
|
||||
@tap="goDetail(s.id)"
|
||||
>
|
||||
<view class="session-card__top">
|
||||
<text class="session-card__subject">{{ s.subject || '在线咨询' }}</text>
|
||||
<view class="session-card__status" :style="getStatusInlineStyle(s.status)">
|
||||
<text class="session-card__status-text">{{ getStatusLabel(s.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="session-card__info">
|
||||
<text class="session-card__type">
|
||||
{{ s.consultation_type === 'text' ? '图文' : s.consultation_type === 'video' ? '视频' : '咨询' }}
|
||||
</text>
|
||||
<text class="session-card__time">{{ formatTime(s.last_message_at) }}</text>
|
||||
</view>
|
||||
<text v-if="s.last_message" class="session-card__preview">{{ s.last_message }}</text>
|
||||
<view v-if="(s.unread_count_doctor ?? 0) > 0" class="session-card__badge">
|
||||
<text class="session-card__badge-text">{{ s.unread_count_doctor }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分页 -->
|
||||
<view v-if="total > 20" class="pagination">
|
||||
<text
|
||||
:class="['pagination__btn', page <= 1 ? 'disabled' : '']"
|
||||
@tap="page > 1 && (page = page - 1)"
|
||||
>上一页</text>
|
||||
<text class="pagination__info">{{ page }} / {{ totalPages }}</text>
|
||||
<text
|
||||
:class="['pagination__btn', page >= totalPages ? 'disabled' : '']"
|
||||
@tap="page < totalPages && (page = page + 1)"
|
||||
>下一页</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'active', label: '进行中' },
|
||||
{ key: 'waiting', label: '等待中' },
|
||||
{ key: 'closed', label: '已关闭' },
|
||||
] as const
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const sessions = ref<doctorApi.ConsultationSession[]>([])
|
||||
const activeTab = ref('')
|
||||
const loading = ref(true)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / 20))
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/doctor/consultation/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
function handleTabChange(key: string) {
|
||||
activeTab.value = key
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function formatTime(dateStr?: string | null): string {
|
||||
if (!dateStr) return ''
|
||||
return formatDate(dateStr, 'MM-DD HH:mm')
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await doctorApi.listSessions({
|
||||
page: page.value,
|
||||
page_size: 20,
|
||||
status: activeTab.value || undefined,
|
||||
})
|
||||
sessions.value = res.data || []
|
||||
total.value = res.total || 0
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([page, activeTab], () => { loadSessions() })
|
||||
|
||||
onShow(() => {
|
||||
loadSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.consultation-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: $card;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid $bd;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-list {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
@include card;
|
||||
position: relative;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__subject {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__status-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__type {
|
||||
@include tag($pri-l, $pri);
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
background: $dan;
|
||||
border-radius: $r-pill;
|
||||
@include flex-center;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
&__badge-text {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $card;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
|
||||
&__btn {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
&.disabled {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,435 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- 患者选择 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">选择患者</text>
|
||||
<view class="patient-search">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名"
|
||||
:value="patientSearch"
|
||||
@input="(e: any) => { patientSearch = e.detail.value; searchPatients() }"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="searchingPatient" class="loading-hint">
|
||||
<text class="loading-hint__text">搜索中...</text>
|
||||
</view>
|
||||
<view v-else-if="patientResults.length > 0" class="patient-list">
|
||||
<view
|
||||
v-for="p in patientResults"
|
||||
:key="p.id"
|
||||
:class="['patient-item', form.patient_id === p.id ? 'patient-item--selected' : '']"
|
||||
@tap="selectPatient(p)"
|
||||
>
|
||||
<text class="patient-item__name">{{ p.name }}</text>
|
||||
<text class="patient-item__info">{{ p.gender === 'male' ? '男' : p.gender === 'female' ? '女' : '' }}</text>
|
||||
<view v-if="form.patient_id === p.id" class="patient-item__check">
|
||||
<text class="check-icon">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="patientSearch && !searchingPatient" class="empty-hint">
|
||||
<text class="empty-hint__text">未找到患者</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 透析信息 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">透析信息</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">透析日期 <text class="required">*</text></text>
|
||||
<picker mode="date" :value="form.dialysis_date" @change="(e: any) => form.dialysis_date = e.detail.value">
|
||||
<view :class="['picker-display', form.dialysis_date ? '' : 'placeholder']">
|
||||
{{ form.dialysis_date || '请选择日期' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">透析方式 <text class="required">*</text></text>
|
||||
<picker :range="dialysisTypes" :range-key="'label'" @change="(e: any) => form.dialysis_type = dialysisTypes[e.detail.value].value">
|
||||
<view :class="['picker-display', form.dialysis_type ? '' : 'placeholder']">
|
||||
{{ currentDialysisTypeLabel || '请选择透析方式' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">开始时间</text>
|
||||
<picker mode="time" :value="form.start_time" @change="(e: any) => form.start_time = e.detail.value">
|
||||
<view :class="['picker-display', form.start_time ? '' : 'placeholder']">
|
||||
{{ form.start_time || '选择时间' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">结束时间</text>
|
||||
<picker mode="time" :value="form.end_time" @change="(e: any) => form.end_time = e.detail.value">
|
||||
<view :class="['picker-display', form.end_time ? '' : 'placeholder']">
|
||||
{{ form.end_time || '选择时间' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 体征输入 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">体征数据</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">透前体重 (kg)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="请输入"
|
||||
:value="form.pre_weight ?? ''"
|
||||
@input="(e: any) => updateNumericField('pre_weight', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">干体重 (kg)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="请输入"
|
||||
:value="form.dry_weight ?? ''"
|
||||
@input="(e: any) => updateNumericField('dry_weight', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">超滤目标 (ml)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="请输入"
|
||||
:value="form.ultrafiltration_volume ?? ''"
|
||||
@input="(e: any) => updateNumericField('ultrafiltration_volume', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">透前收缩压</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="mmHg"
|
||||
:value="form.pre_bp_systolic ?? ''"
|
||||
@input="(e: any) => updateNumericField('pre_bp_systolic', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">透前舒张压</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="mmHg"
|
||||
:value="form.pre_bp_diastolic ?? ''"
|
||||
@input="(e: any) => updateNumericField('pre_bp_diastolic', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">备注</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="并发症记录或其他备注(选填)"
|
||||
:value="form.complication_notes"
|
||||
@input="(e: any) => form.complication_notes = e.detail.value"
|
||||
:maxlength="1000"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 提交 -->
|
||||
<view class="submit-wrap">
|
||||
<view :class="['action-btn', submitting ? 'disabled' : '']" @tap="submitting ? undefined : handleSubmit">
|
||||
<text class="action-btn-text">{{ submitting ? '提交中...' : '提交记录' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { createDialysisRecord } from '@/services/doctor/dialysis'
|
||||
import { listPatients } from '@/services/doctor/patient'
|
||||
import type { PatientItem } from '@/services/doctor/patient'
|
||||
|
||||
const DIALYSIS_TYPES = [
|
||||
{ label: '血液透析', value: 'hemodialysis' },
|
||||
{ label: '腹膜透析', value: 'peritoneal' },
|
||||
] as const
|
||||
|
||||
const dialysisTypes = DIALYSIS_TYPES
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const form = reactive({
|
||||
patient_id: '',
|
||||
dialysis_date: '',
|
||||
dialysis_type: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
pre_weight: undefined as number | undefined,
|
||||
dry_weight: undefined as number | undefined,
|
||||
ultrafiltration_volume: undefined as number | undefined,
|
||||
pre_bp_systolic: undefined as number | undefined,
|
||||
pre_bp_diastolic: undefined as number | undefined,
|
||||
complication_notes: '',
|
||||
})
|
||||
|
||||
const patientSearch = ref('')
|
||||
const patientResults = ref<PatientItem[]>([])
|
||||
const searchingPatient = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const currentDialysisTypeLabel = computed(() => {
|
||||
const found = DIALYSIS_TYPES.find((t) => t.value === form.dialysis_type)
|
||||
return found ? found.label : ''
|
||||
})
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function searchPatients() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (!patientSearch.value.trim()) {
|
||||
patientResults.value = []
|
||||
return
|
||||
}
|
||||
searchTimer = setTimeout(async () => {
|
||||
searchingPatient.value = true
|
||||
try {
|
||||
const res = await listPatients({ search: patientSearch.value.trim(), page_size: 10 })
|
||||
patientResults.value = res.data || []
|
||||
} catch {
|
||||
patientResults.value = []
|
||||
} finally {
|
||||
searchingPatient.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectPatient(p: PatientItem) {
|
||||
form.patient_id = form.patient_id === p.id ? '' : p.id
|
||||
}
|
||||
|
||||
function updateNumericField(field: keyof typeof form, raw: string) {
|
||||
const val = raw.trim() === '' ? undefined : Number(raw)
|
||||
;(form as any)[field] = isNaN(val as number) ? undefined : val
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.patient_id) {
|
||||
uni.showToast({ title: '请选择患者', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!form.dialysis_date) {
|
||||
uni.showToast({ title: '请选择透析日期', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!form.dialysis_type) {
|
||||
uni.showToast({ title: '请选择透析方式', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await createDialysisRecord({
|
||||
patient_id: form.patient_id,
|
||||
dialysis_date: form.dialysis_date,
|
||||
dialysis_type: form.dialysis_type,
|
||||
start_time: form.start_time || undefined,
|
||||
end_time: form.end_time || undefined,
|
||||
pre_weight: form.pre_weight,
|
||||
dry_weight: form.dry_weight,
|
||||
ultrafiltration_volume: form.ultrafiltration_volume,
|
||||
pre_bp_systolic: form.pre_bp_systolic,
|
||||
pre_bp_diastolic: form.pre_bp_diastolic,
|
||||
complication_notes: form.complication_notes.trim() || undefined,
|
||||
})
|
||||
uni.showToast({ title: '创建成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 800)
|
||||
} catch {
|
||||
uni.showToast({ title: '创建失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 160px; }
|
||||
|
||||
.section-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Search
|
||||
.patient-search { margin-bottom: 12px; }
|
||||
|
||||
.search-input {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loading-hint, .empty-hint {
|
||||
@include flex-center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.loading-hint__text, .empty-hint__text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.patient-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.patient-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-radius: $r-xs;
|
||||
|
||||
&:active { background: $bd-l; }
|
||||
|
||||
&--selected {
|
||||
background: $pri-l;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
&__check {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@include flex-center;
|
||||
background: $pri;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
|
||||
// Form
|
||||
.form-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-field--half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.required { color: $dan; }
|
||||
|
||||
.form-input {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.picker-display {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.picker-display.placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 12px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
// Submit
|
||||
.submit-wrap {
|
||||
margin: 0 24px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
.action-btn.disabled { opacity: 0.5; }
|
||||
|
||||
.action-btn-text {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<ErrorState v-else-if="error || !record" text="记录加载失败" :on-retry="loadData" />
|
||||
<scroll-view v-else scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- 基本信息 -->
|
||||
<view class="info-card">
|
||||
<view class="info-header">
|
||||
<text class="patient-name">{{ recordPatientName }}</text>
|
||||
<view class="status-tag" :style="getStatusInlineStyle(record.status)">
|
||||
<text class="status-tag__text">{{ getStatusLabel(record.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="info-row">
|
||||
<text class="info-label">透析日期</text>
|
||||
<text class="info-value">{{ record.dialysis_date }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">透析方式</text>
|
||||
<text class="info-value">{{ dialysisTypeLabel(record.dialysis_type) }}</text>
|
||||
</view>
|
||||
<view v-if="record.start_time" class="info-row">
|
||||
<text class="info-label">开始时间</text>
|
||||
<text class="info-value">{{ record.start_time }}</text>
|
||||
</view>
|
||||
<view v-if="record.end_time" class="info-row">
|
||||
<text class="info-label">结束时间</text>
|
||||
<text class="info-value">{{ record.end_time }}</text>
|
||||
</view>
|
||||
<view v-if="record.dialysis_duration" class="info-row">
|
||||
<text class="info-label">透析时长</text>
|
||||
<text class="info-value">{{ record.dialysis_duration }} 分钟</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 体征数据 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">体征数据</text>
|
||||
<view class="vitals-grid">
|
||||
<view v-if="record.pre_bp_systolic != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.pre_bp_systolic }}/{{ record.pre_bp_diastolic }}</text>
|
||||
<text class="vital-label">透前血压 mmHg</text>
|
||||
</view>
|
||||
<view v-if="record.post_bp_systolic != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.post_bp_systolic }}/{{ record.post_bp_diastolic }}</text>
|
||||
<text class="vital-label">透后血压 mmHg</text>
|
||||
</view>
|
||||
<view v-if="record.pre_weight != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.pre_weight }}</text>
|
||||
<text class="vital-label">透前体重 kg</text>
|
||||
</view>
|
||||
<view v-if="record.post_weight != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.post_weight }}</text>
|
||||
<text class="vital-label">透后体重 kg</text>
|
||||
</view>
|
||||
<view v-if="record.ultrafiltration_volume != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.ultrafiltration_volume }}</text>
|
||||
<text class="vital-label">超滤量 ml</text>
|
||||
</view>
|
||||
<view v-if="record.blood_flow_rate != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.blood_flow_rate }}</text>
|
||||
<text class="vital-label">血流量 ml/min</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 并发症 -->
|
||||
<view v-if="record.complication_notes" class="section-card">
|
||||
<text class="section-title">并发症记录</text>
|
||||
<view class="warning-block">
|
||||
<text class="warning-block__text">{{ record.complication_notes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view v-if="record.symptoms && Object.keys(record.symptoms).length > 0" class="section-card">
|
||||
<text class="section-title">备注</text>
|
||||
<text class="notes-text">{{ JSON.stringify(record.symptoms, null, 2) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 审核操作 -->
|
||||
<view v-if="record.status === 'pending'" class="action-card">
|
||||
<view
|
||||
:class="['action-btn', reviewing ? 'disabled' : '']"
|
||||
@tap="reviewing ? undefined : handleReview"
|
||||
>
|
||||
<text class="action-btn-text">{{ reviewing ? '处理中...' : '审核通过' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getDialysisRecordById, reviewDialysisRecord } from '@/services/doctor/dialysis'
|
||||
import type { DialysisRecord } from '@/services/dialysis'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
|
||||
const DIALYSIS_TYPE_MAP: Record<string, string> = {
|
||||
hemodialysis: '血液透析',
|
||||
peritoneal: '腹膜透析',
|
||||
hemofiltration: '血液滤过',
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const record = ref<DialysisRecord | null>(null)
|
||||
const recordPatientName = ref('')
|
||||
const pageLoading = ref(true)
|
||||
const error = ref(false)
|
||||
const reviewing = ref(false)
|
||||
let recordId = ''
|
||||
|
||||
function dialysisTypeLabel(type: string): string {
|
||||
return DIALYSIS_TYPE_MAP[type] || type
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!recordId) return
|
||||
pageLoading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const data = await getDialysisRecordById(recordId)
|
||||
record.value = data
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReview() {
|
||||
if (!record.value) return
|
||||
reviewing.value = true
|
||||
try {
|
||||
const updated = await reviewDialysisRecord(recordId, record.value.version)
|
||||
record.value = updated
|
||||
uni.showToast({ title: '审核通过', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '审核失败', icon: 'none' })
|
||||
} finally {
|
||||
reviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
recordId = query?.id || ''
|
||||
if (!recordId) { error.value = true; pageLoading.value = false; return }
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 120px; }
|
||||
|
||||
.info-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-size: var(--tk-font-title);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.status-tag__text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vitals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vital-item {
|
||||
background: $pri-l;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.warning-block {
|
||||
background: $wrn-l;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.warning-block__text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $wrn;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
.action-btn.disabled { opacity: 0.5; }
|
||||
|
||||
.action-btn-text {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
371
apps/miniprogram-uniapp/src/pages-sub/doctor/dialysis/index.vue
Normal file
371
apps/miniprogram-uniapp/src/pages-sub/doctor/dialysis/index.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading && records.length === 0" text="加载中..." />
|
||||
<scroll-view
|
||||
v-else
|
||||
scroll-y
|
||||
class="page-scroll"
|
||||
@scrolltolower="onLoadMore"
|
||||
>
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 状态筛选 -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="tab in STATUS_TABS"
|
||||
:key="tab.key"
|
||||
:class="['tab', activeStatus === tab.key ? 'tab--active' : '']"
|
||||
@tap="handleStatusChange(tab.key)"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 列表统计 -->
|
||||
<view v-if="records.length > 0" class="list-meta">
|
||||
<text class="list-meta__text">共 {{ total }} 条记录</text>
|
||||
</view>
|
||||
|
||||
<!-- 透析记录卡片 -->
|
||||
<EmptyState v-if="!pageLoading && records.length === 0" icon="💉" title="暂无透析记录" />
|
||||
<view v-else class="record-cards">
|
||||
<view
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
class="record-card"
|
||||
@tap="goDetail(record.id)"
|
||||
>
|
||||
<view class="record-card__header">
|
||||
<text class="record-card__patient">{{ record.patient_name || formatDate(record.dialysis_date, 'MM-DD') }}</text>
|
||||
<view
|
||||
class="record-card__status"
|
||||
:style="getStatusInlineStyle(record.status)"
|
||||
>
|
||||
<text class="record-card__status-text">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-card__body">
|
||||
<view class="record-card__info-row">
|
||||
<text class="record-card__label">透析日期</text>
|
||||
<text class="record-card__value">{{ formatDate(record.dialysis_date, 'YYYY-MM-DD') }}</text>
|
||||
</view>
|
||||
<view class="record-card__info-row">
|
||||
<text class="record-card__label">透析方式</text>
|
||||
<text class="record-card__value">{{ dialysisTypeLabel(record.dialysis_type) }}</text>
|
||||
</view>
|
||||
<view v-if="record.dialysis_duration" class="record-card__info-row">
|
||||
<text class="record-card__label">时长</text>
|
||||
<text class="record-card__value">{{ record.dialysis_duration }} 分钟</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-card__type-tag">
|
||||
<text class="record-card__type-tag-text">
|
||||
{{ dialysisTypeShort(record.dialysis_type) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="!loadingMore && records.length >= total && total > 0" class="load-hint-wrap">
|
||||
<text class="load-hint">没有更多了</text>
|
||||
</view>
|
||||
<Loading v-if="loadingMore" text="加载中..." />
|
||||
</view>
|
||||
|
||||
<!-- 新建按钮 -->
|
||||
<view class="fab" @tap="goCreate">
|
||||
<text class="fab__icon">+</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { onLoad, onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { listDialysisRecords } from '@/services/doctor/dialysis'
|
||||
import type { DialysisRecord } from '@/services/doctor/dialysis'
|
||||
|
||||
// 医生端透析列表后端返回的扩展字段
|
||||
interface DoctorDialysisRecord extends DialysisRecord {
|
||||
patient_name?: string
|
||||
}
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'in_progress', label: '进行中' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
{ key: 'cancelled', label: '已取消' },
|
||||
] as const
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const records = ref<DoctorDialysisRecord[]>([])
|
||||
const activeStatus = ref('')
|
||||
const pageLoading = ref(true)
|
||||
const loadingMore = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const patientId = ref('')
|
||||
|
||||
function dialysisTypeLabel(type: string): string {
|
||||
if (type === 'hemodialysis') return '血液透析'
|
||||
if (type === 'peritoneal') return '腹膜透析'
|
||||
return type
|
||||
}
|
||||
|
||||
function dialysisTypeShort(type: string): string {
|
||||
if (type === 'hemodialysis') return 'HD'
|
||||
if (type === 'peritoneal') return 'PD'
|
||||
return type
|
||||
}
|
||||
|
||||
async function loadRecords(pageNum: number, isRefresh = false) {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
if (isRefresh) {
|
||||
pageLoading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const params: { page: number; page_size: number; status?: string } = {
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
}
|
||||
if (activeStatus.value) {
|
||||
params.status = activeStatus.value
|
||||
}
|
||||
const res = await listDialysisRecords(patientId.value, params)
|
||||
const list = res.data || []
|
||||
if (isRefresh) {
|
||||
records.value = list
|
||||
} else {
|
||||
records.value = [...records.value, ...list]
|
||||
}
|
||||
total.value = res.total || 0
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
loadingMore.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(key: string) {
|
||||
activeStatus.value = key
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/dialysis/detail/index?id=${id}`,
|
||||
})
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/dialysis/create/index${patientId.value ? `?patientId=${patientId.value}` : ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
if (!isLoading.value && records.value.length < total.value) {
|
||||
loadRecords(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeStatus, () => {
|
||||
loadRecords(1, true)
|
||||
})
|
||||
|
||||
onLoad((query) => {
|
||||
patientId.value = query?.patientId || ''
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
loadRecords(1, true)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadRecords(1, true).finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
// ── 状态标签 ──
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
box-shadow: $shadow-sm;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
background: $pri-l;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 3px;
|
||||
background: $pri;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 列表统计 ──
|
||||
.list-meta {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 记录卡片 ──
|
||||
.record-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
@include card;
|
||||
position: relative;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__patient {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__status {
|
||||
@include status-inline;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__status-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__type-tag {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 28px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
&__type-tag-text {
|
||||
@include tag($pri-l, $pri);
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载提示 ──
|
||||
.load-hint-wrap {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.load-hint {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 浮动新建按钮 ──
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 100px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: $pri;
|
||||
@include flex-center;
|
||||
box-shadow: $shadow-lg;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $card;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<view :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- Loading -->
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
|
||||
<!-- Error -->
|
||||
<ErrorState v-else-if="error || !task" text="任务不存在" />
|
||||
|
||||
<template v-else>
|
||||
<!-- Task info card -->
|
||||
<view class="info-card">
|
||||
<view class="info-header">
|
||||
<text class="patient-name">{{ task.patient_name || '未知患者' }}</text>
|
||||
<text class="status-tag" :style="getStatusInlineStyle(task.status)">
|
||||
{{ getStatusLabel(task.status) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="info-row">
|
||||
<text class="info-label">随访方式</text>
|
||||
<text class="info-value">{{ getTypeLabel(task.follow_up_type) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="info-row">
|
||||
<text class="info-label">计划日期</text>
|
||||
<text class="info-value">{{ task.planned_date }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="task.content_template" class="info-desc">
|
||||
<text class="info-desc-label">随访内容</text>
|
||||
<text class="info-desc-text">{{ task.content_template }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- History records -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">历史记录</text>
|
||||
|
||||
<view v-if="records.length === 0" class="empty-records">
|
||||
<text class="empty-text">暂无随访记录</text>
|
||||
</view>
|
||||
|
||||
<view v-for="record in records" :key="record.id" class="record-item">
|
||||
<view class="record-date-row">
|
||||
<text class="record-date">{{ record.executed_date }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="record.result" class="record-field">
|
||||
<text class="record-field-label">随访结果</text>
|
||||
<text class="record-field-value">{{ record.result }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="record.patient_condition" class="record-field">
|
||||
<text class="record-field-label">患者状况</text>
|
||||
<text class="record-field-value">{{ record.patient_condition }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="record.medical_advice" class="record-field">
|
||||
<text class="record-field-label">医嘱建议</text>
|
||||
<text class="record-field-value">{{ record.medical_advice }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Submit form (only when can submit) -->
|
||||
<view v-if="canSubmit" class="submit-card">
|
||||
<!-- Start button when pending/overdue -->
|
||||
<view v-if="task.status === 'pending' || task.status === 'overdue'" class="start-btn-wrap">
|
||||
<view
|
||||
:class="['action-btn', startingTask ? 'disabled' : '']"
|
||||
@tap="startingTask ? undefined : handleStart"
|
||||
>
|
||||
<text class="action-btn-text">{{ startingTask ? '处理中...' : '开始随访' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Form when in_progress -->
|
||||
<template v-if="task.status === 'in_progress'">
|
||||
<text class="section-title">填写随访记录</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label"><text class="required">*</text> 随访结果</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="请输入随访结果"
|
||||
:value="formData.result"
|
||||
@input="(e: any) => formData.result = e.detail.value"
|
||||
:maxlength="1000"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">患者状况</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="请描述患者当前状况(选填)"
|
||||
:value="formData.patient_condition"
|
||||
@input="(e: any) => formData.patient_condition = e.detail.value"
|
||||
:maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">医嘱建议</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="请输入医嘱建议(选填)"
|
||||
:value="formData.medical_advice"
|
||||
@input="(e: any) => formData.medical_advice = e.detail.value"
|
||||
:maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">下次随访日期</text>
|
||||
<picker
|
||||
mode="date"
|
||||
:value="formData.next_follow_up_date"
|
||||
@change="(e: any) => formData.next_follow_up_date = e.detail.value"
|
||||
>
|
||||
<view class="date-picker">
|
||||
<text :class="['date-text', formData.next_follow_up_date ? '' : 'placeholder']">
|
||||
{{ formData.next_follow_up_date || '请选择日期' }}
|
||||
</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view
|
||||
:class="['action-btn', submitting ? 'disabled' : '']"
|
||||
@tap="submitting ? undefined : handleSubmit"
|
||||
>
|
||||
<text class="action-btn-text">{{ submitting ? '提交中...' : '提交记录' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor/followup'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
phone: '电话',
|
||||
visit: '门诊',
|
||||
online: '线上',
|
||||
home: '家访',
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const task = ref<doctorApi.FollowUpTask | null>(null)
|
||||
const records = ref<doctorApi.FollowUpRecord[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const startingTask = ref(false)
|
||||
const submitting = ref(false)
|
||||
let taskId = ''
|
||||
|
||||
const formData = reactive({
|
||||
result: '',
|
||||
patient_condition: '',
|
||||
medical_advice: '',
|
||||
next_follow_up_date: '',
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (!task.value) return false
|
||||
return ['pending', 'in_progress', 'overdue'].includes(task.value.status)
|
||||
})
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
return TYPE_MAP[type] || type
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const [taskData, recordsRes] = await Promise.all([
|
||||
doctorApi.getFollowUpTask(taskId),
|
||||
doctorApi.listFollowUpRecords({ task_id: taskId }),
|
||||
])
|
||||
task.value = taskData
|
||||
records.value = recordsRes.data || []
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
if (!task.value) return
|
||||
startingTask.value = true
|
||||
try {
|
||||
const updated = await doctorApi.updateFollowUpTask(
|
||||
taskId,
|
||||
{ status: 'in_progress' },
|
||||
task.value.version,
|
||||
)
|
||||
task.value = updated
|
||||
uni.showToast({ title: '已开始随访', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
startingTask.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.result.trim()) {
|
||||
uni.showToast({ title: '请输入随访结果', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await doctorApi.createFollowUpRecord(taskId, {
|
||||
result: formData.result.trim(),
|
||||
patient_condition: formData.patient_condition.trim() || undefined,
|
||||
medical_advice: formData.medical_advice.trim() || undefined,
|
||||
next_follow_up_date: formData.next_follow_up_date || undefined,
|
||||
})
|
||||
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||
formData.result = ''
|
||||
formData.patient_condition = ''
|
||||
formData.medical_advice = ''
|
||||
formData.next_follow_up_date = ''
|
||||
fetchDetail()
|
||||
} catch {
|
||||
uni.showToast({ title: '提交失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
taskId = query?.id || ''
|
||||
if (!taskId) { error.value = true; loading.value = false; return }
|
||||
fetchDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 120px; }
|
||||
|
||||
|
||||
// Info card
|
||||
.info-card {
|
||||
@include card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-size: var(--tk-font-title);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.info-desc {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.info-desc-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-desc-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// Section card
|
||||
.section-card {
|
||||
@include card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Records
|
||||
.empty-records {
|
||||
@include flex-center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.record-item:last-child { border-bottom: none; }
|
||||
|
||||
.record-date-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.record-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.record-field {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.record-field-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.record-field-value {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// Submit card
|
||||
.submit-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.start-btn-wrap {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
.action-btn.disabled { opacity: 0.5; }
|
||||
|
||||
.action-btn-text {
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Form
|
||||
.form-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.required { color: $dan; }
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 12px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 12px;
|
||||
@include flex-center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.date-text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.date-text.placeholder { color: $tx3; }
|
||||
</style>
|
||||
227
apps/miniprogram-uniapp/src/pages-sub/doctor/followup/index.vue
Normal file
227
apps/miniprogram-uniapp/src/pages-sub/doctor/followup/index.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<view :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<text class="page-title">随访任务</text>
|
||||
|
||||
<!-- Tab filter -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="tab in TABS" :key="tab.key"
|
||||
:class="['tab', activeTab === tab.key ? 'active' : '']"
|
||||
@tap="handleTabChange(tab.key)"
|
||||
>
|
||||
<text :class="['tab-text', activeTab === tab.key ? 'active' : '']">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Loading -->
|
||||
<Loading v-if="loading && tasks.length === 0" text="加载中..." />
|
||||
|
||||
<!-- Empty -->
|
||||
<EmptyState v-else-if="tasks.length === 0" icon="📋" title="暂无随访任务" />
|
||||
|
||||
<!-- Task list -->
|
||||
<scroll-view v-else scroll-y class="list-scroll" @scrolltolower="loadMore">
|
||||
<view
|
||||
v-for="item in tasks" :key="item.id"
|
||||
class="task-card"
|
||||
@tap="goDetail(item.id)"
|
||||
>
|
||||
<view class="card-header">
|
||||
<view class="type-badge" :style="getTypeStyle(item.follow_up_type)">
|
||||
<text class="type-text">{{ getTypeLabel(item.follow_up_type) }}</text>
|
||||
</view>
|
||||
<text :class="['status-tag', item.status]" :style="getStatusInlineStyle(item.status)">
|
||||
{{ getStatusLabel(item.status) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<text class="patient-name">{{ item.patient_name || '未知患者' }}</text>
|
||||
|
||||
<view class="card-footer">
|
||||
<text class="planned-date">计划日期:{{ item.planned_date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && tasks.length >= total && total > 0" class="no-more">
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor/followup'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'in_progress', label: '进行中' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
{ key: 'overdue', label: '已逾期' },
|
||||
]
|
||||
|
||||
const TYPE_MAP: Record<string, { label: string; bg: string; color: string }> = {
|
||||
phone: { label: '电话', bg: '#E8F0E8', color: '#5B7A5E' },
|
||||
visit: { label: '门诊', bg: '#F0DDD4', color: '#C4623A' },
|
||||
online: { label: '线上', bg: '#E0F0FF', color: '#3B82B8' },
|
||||
home: { label: '家访', bg: '#FFF3E0', color: '#C4873A' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const tasks = ref<doctorApi.FollowUpTask[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const activeTab = ref('')
|
||||
const loading = ref(false)
|
||||
let patientId = ''
|
||||
let loadingGuard = false
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
return TYPE_MAP[type]?.label || type
|
||||
}
|
||||
|
||||
function getTypeStyle(type: string): Record<string, string> {
|
||||
const info = TYPE_MAP[type]
|
||||
if (!info) return { background: '#F1F5F9', color: '#78716C' }
|
||||
return { background: info.bg, color: info.color }
|
||||
}
|
||||
|
||||
async function fetchTasks(pageNum: number, status: string, isRefresh = false) {
|
||||
if (loadingGuard) return
|
||||
loadingGuard = true
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = { page: pageNum, page_size: 20 }
|
||||
if (status) params.status = status
|
||||
if (patientId) params.patient_id = patientId
|
||||
const res = await doctorApi.listFollowUpTasks(params as Parameters<typeof doctorApi.listFollowUpTasks>[0])
|
||||
const list = res.data || []
|
||||
tasks.value = isRefresh ? list : [...tasks.value, ...list]
|
||||
total.value = res.total
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loadingGuard = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabChange(key: string) {
|
||||
activeTab.value = key
|
||||
fetchTasks(1, key, true)
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!loading.value && tasks.value.length < total.value) {
|
||||
fetchTasks(page.value + 1, activeTab.value)
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/doctor/followup/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
patientId = query?.patientId || ''
|
||||
fetchTasks(1, '', true)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
fetchTasks(1, activeTab.value, true).finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 0 120px; }
|
||||
.page-title { @include section-title; margin-left: 24px; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
padding: 0 24px 16px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 6px 16px;
|
||||
min-height: $touch-min;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tab.active { background: $pri; }
|
||||
|
||||
.tab-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.tab-text.active { color: $card; }
|
||||
|
||||
.list-scroll { height: calc(100vh - 160px); }
|
||||
|
||||
.task-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin: 0 24px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
.type-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.planned-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
451
apps/miniprogram-uniapp/src/pages-sub/doctor/index.vue
Normal file
451
apps/miniprogram-uniapp/src/pages-sub/doctor/index.vue
Normal file
@@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<scroll-view v-else scroll-y class="page-scroll">
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 顶部问候 -->
|
||||
<view class="header">
|
||||
<text class="header-title">医护工作台</text>
|
||||
<text class="header-greeting">{{ greeting }},{{ displayName }}</text>
|
||||
<text class="header-date">{{ todayStr }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 异常体征告警横幅 -->
|
||||
<view v-if="alertCount > 0" class="alert-banner" @tap="goAlerts">
|
||||
<text class="alert-icon">!</text>
|
||||
<text class="alert-text">{{ alertCount }} 位患者体征异常</text>
|
||||
<text class="alert-link">查看 ></text>
|
||||
</view>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名..."
|
||||
placeholder-class="search-placeholder"
|
||||
:focus="false"
|
||||
@focus="goPatients"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 工作概览 -->
|
||||
<view class="section">
|
||||
<text class="section-title">工作概览</text>
|
||||
<view class="grid-2">
|
||||
<view
|
||||
v-for="card in visibleCards"
|
||||
:key="card.key"
|
||||
class="overview-card"
|
||||
@tap="navigateTo(card.route)"
|
||||
>
|
||||
<text class="overview-card__initial">{{ card.initial }}</text>
|
||||
<text class="overview-card__num">{{ getValue(card.key) }}</text>
|
||||
<text class="overview-card__label">{{ card.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 健康审核 -->
|
||||
<view v-if="visibleHealthCards.length > 0" class="section">
|
||||
<text class="section-title">健康审核</text>
|
||||
<view class="grid-2">
|
||||
<view
|
||||
v-for="card in visibleHealthCards"
|
||||
:key="card.key"
|
||||
class="overview-card"
|
||||
@tap="navigateTo(card.route)"
|
||||
>
|
||||
<text class="overview-card__initial">{{ card.initial }}</text>
|
||||
<text class="overview-card__num">{{ getValue(card.key) }}</text>
|
||||
<text class="overview-card__label">{{ card.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<view class="section">
|
||||
<text class="section-title">快捷操作</text>
|
||||
<view class="grid-4">
|
||||
<view
|
||||
v-for="action in visibleQuickActions"
|
||||
:key="action.route"
|
||||
class="quick-action"
|
||||
@tap="navigateTo(action.route)"
|
||||
>
|
||||
<view class="quick-action__icon-wrap">
|
||||
<text class="quick-action__initial">{{ action.initial }}</text>
|
||||
<text
|
||||
v-if="action.label === '告警中心' && alertCount > 0"
|
||||
class="quick-action__badge"
|
||||
>{{ alertCount > 99 ? '99+' : alertCount }}</text>
|
||||
</view>
|
||||
<text class="quick-action__label">{{ action.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 退出登录 -->
|
||||
<view class="footer">
|
||||
<text class="logout-btn" @tap="handleLogout">退出登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getDashboard } from '@/services/doctor/dashboard'
|
||||
import type { DoctorDashboard } from '@/services/doctor/dashboard'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
interface CardConfig {
|
||||
key: keyof DoctorDashboard
|
||||
label: string
|
||||
initial: string
|
||||
route: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
const ALL_CARDS: CardConfig[] = [
|
||||
{ key: 'total_patients', label: '我的患者', initial: '患', route: '/pages-sub/doctor/patients/index' },
|
||||
{ key: 'unread_messages', label: '未读消息', initial: '消', route: '/pages-sub/doctor/consultation/index' },
|
||||
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages-sub/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages-sub/doctor/consultation/index', roles: ['doctor', 'health_manager'] },
|
||||
]
|
||||
|
||||
const ALL_HEALTH_CARDS: CardConfig[] = [
|
||||
{ key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages-sub/doctor/report/index', roles: ['doctor'] },
|
||||
{ key: 'today_appointments', label: '今日预约', initial: '约', route: '/pages-sub/doctor/patients/index' },
|
||||
]
|
||||
|
||||
interface QuickAction {
|
||||
label: string
|
||||
initial: string
|
||||
route: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
const ALL_QUICK_ACTIONS: QuickAction[] = [
|
||||
{ label: '化验审核', initial: '审', route: '/pages-sub/doctor/report/index', roles: ['doctor'] },
|
||||
{ label: '患者查询', initial: '查', route: '/pages-sub/doctor/patients/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '随访记录', initial: '随', route: '/pages-sub/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '告警中心', initial: '警', route: '/pages-sub/doctor/alerts/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '透析管理', initial: '透', route: '/pages-sub/doctor/dialysis/index', roles: ['doctor'] },
|
||||
{ label: '处方管理', initial: '方', route: '/pages-sub/doctor/prescription/index', roles: ['doctor'] },
|
||||
{ label: '行动收件箱', initial: '行', route: '/pages-sub/doctor/action-inbox/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
]
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
doctor: '医生',
|
||||
nurse: '护士',
|
||||
health_manager: '健康管理师',
|
||||
admin: '管理员',
|
||||
operator: '运营',
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const dashboard = ref<DoctorDashboard | null>(null)
|
||||
const alertCount = ref(0)
|
||||
const pageLoading = ref(true)
|
||||
|
||||
const displayName = computed(() => {
|
||||
const user = authStore.user
|
||||
const roles = authStore.roles
|
||||
if (user?.display_name) return user.display_name
|
||||
if (user?.username) return user.username
|
||||
const primary = roles.find(r => r !== 'admin')
|
||||
return primary ? (ROLE_LABELS[primary] || primary) : '医护'
|
||||
})
|
||||
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了'
|
||||
if (h < 12) return '早上好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const todayStr = computed(() => {
|
||||
return new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })
|
||||
})
|
||||
|
||||
function hasRole(allowed: string[] | undefined): boolean {
|
||||
if (!allowed) return true
|
||||
return authStore.roles.some(r => r === 'admin' || allowed.includes(r))
|
||||
}
|
||||
|
||||
const visibleCards = computed(() => ALL_CARDS.filter(c => hasRole(c.roles)))
|
||||
const visibleHealthCards = computed(() => ALL_HEALTH_CARDS.filter(c => hasRole(c.roles)))
|
||||
const visibleQuickActions = computed(() => ALL_QUICK_ACTIONS.filter(a => hasRole(a.roles)))
|
||||
|
||||
function getValue(key: keyof DoctorDashboard): number | string {
|
||||
if (!dashboard.value) return '-'
|
||||
return dashboard.value[key] ?? 0
|
||||
}
|
||||
|
||||
function navigateTo(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function goPatients() {
|
||||
uni.navigateTo({ url: '/pages-sub/doctor/patients/index' })
|
||||
}
|
||||
|
||||
function goAlerts() {
|
||||
uni.navigateTo({ url: '/pages-sub/doctor/alerts/index' })
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await getDashboard()
|
||||
dashboard.value = data
|
||||
const count = (data as Record<string, unknown>)?.abnormal_vital_count
|
||||
alertCount.value = typeof count === 'number' ? count : 0
|
||||
} catch {
|
||||
// 静默失败,显示占位
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboard()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
authStore.restore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 32px 24px 120px;
|
||||
}
|
||||
|
||||
// ── 顶部问候 ──
|
||||
.header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
@include section-title;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-greeting {
|
||||
display: block;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header-date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 告警横幅 ──
|
||||
.alert-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0 24px;
|
||||
padding: 16px 20px;
|
||||
min-height: $touch-min;
|
||||
background: $dan-l;
|
||||
border-radius: $r;
|
||||
border-left: 4px solid $dan;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
@include flex-center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: $dan;
|
||||
color: $white;
|
||||
text-align: center;
|
||||
line-height: 36px;
|
||||
font-weight: bold;
|
||||
font-size: var(--tk-font-body);
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
.alert-link {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $dan;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ── 搜索框 ──
|
||||
.search-bar {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $surface-alt;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 通用区块 ──
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
// ── 工作概览网格 ──
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px 24px;
|
||||
text-align: center;
|
||||
box-shadow: $shadow-md;
|
||||
transition: transform 0.15s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.overview-card__initial {
|
||||
display: inline-flex;
|
||||
@include flex-center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.overview-card__num {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.overview-card__label {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
// ── 快捷操作 ──
|
||||
.grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.quick-action {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
box-shadow: $shadow-md;
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-action__icon-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.quick-action__initial {
|
||||
@include flex-center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: $r;
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quick-action__badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -12px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
background: $dan;
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 700;
|
||||
border-radius: $r-pill;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.quick-action__label {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// ── 底部 ──
|
||||
.footer {
|
||||
margin-top: 60px;
|
||||
text-align: center;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
color: $dan;
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 16px 48px;
|
||||
min-height: $touch-min;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<view v-else-if="!patient" :class="['error-wrap', elderClass]">
|
||||
<text class="error-text">患者信息加载失败</text>
|
||||
</view>
|
||||
<scroll-view v-else scroll-y class="page-scroll">
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 基本信息 -->
|
||||
<view class="section">
|
||||
<text class="section-title">基本信息</text>
|
||||
<view class="info-grid">
|
||||
<view class="info-item">
|
||||
<text class="info-label">姓名</text>
|
||||
<text class="info-value">{{ patient.name }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">性别</text>
|
||||
<text class="info-value">{{ genderLabel(patient.gender) }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">年龄</text>
|
||||
<text class="info-value">{{ calcAge(patient.birth_date) }}岁</text>
|
||||
</view>
|
||||
<view v-if="patient.blood_type" class="info-item">
|
||||
<text class="info-label">血型</text>
|
||||
<text class="info-value">{{ patient.blood_type }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 医疗信息 -->
|
||||
<view v-if="patient.allergy_history || patient.medical_history_summary" class="section">
|
||||
<text class="section-title">医疗信息</text>
|
||||
<view v-if="patient.allergy_history" class="warning-card">
|
||||
<text class="warning-label">过敏史</text>
|
||||
<text class="warning-text">{{ patient.allergy_history }}</text>
|
||||
</view>
|
||||
<view v-if="patient.medical_history_summary" class="info-block">
|
||||
<text class="info-block-label">病史摘要</text>
|
||||
<text class="info-block-text">{{ patient.medical_history_summary }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 健康概览 -->
|
||||
<view v-if="summary" class="section">
|
||||
<text class="section-title">健康概览</text>
|
||||
<view v-if="summary.latest_vital_signs" class="vitals-grid">
|
||||
<view
|
||||
v-if="summary.latest_vital_signs.systolic_bp != null"
|
||||
class="vital-item"
|
||||
>
|
||||
<text class="vital-value">{{ summary.latest_vital_signs.systolic_bp }}/{{ summary.latest_vital_signs.diastolic_bp }}</text>
|
||||
<text class="vital-label">血压 mmHg</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="summary.latest_vital_signs.heart_rate != null"
|
||||
class="vital-item"
|
||||
>
|
||||
<text class="vital-value">{{ summary.latest_vital_signs.heart_rate }}</text>
|
||||
<text class="vital-label">心率 bpm</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="summary.latest_vital_signs.weight != null"
|
||||
class="vital-item"
|
||||
>
|
||||
<text class="vital-value">{{ summary.latest_vital_signs.weight }}</text>
|
||||
<text class="vital-label">体重 kg</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="summary.latest_vital_signs.blood_sugar != null"
|
||||
class="vital-item"
|
||||
>
|
||||
<text class="vital-value">{{ summary.latest_vital_signs.blood_sugar }}</text>
|
||||
<text class="vital-label">血糖 mmol/L</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="summary.pending_follow_ups != null && summary.pending_follow_ups > 0" class="stat-row">
|
||||
<text class="stat-label">待处理随访</text>
|
||||
<text class="stat-value stat-value--warn">{{ summary.pending_follow_ups }} 项</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 近期化验 -->
|
||||
<view v-if="summary?.latest_lab_report" class="section">
|
||||
<text class="section-title">近期化验</text>
|
||||
<view
|
||||
class="lab-item"
|
||||
@tap="goReportDetail(summary!.latest_lab_report!.id)"
|
||||
>
|
||||
<view class="lab-item__header">
|
||||
<text class="lab-item__type">{{ summary.latest_lab_report.report_type }}</text>
|
||||
<text class="lab-item__date">{{ summary.latest_lab_report.report_date }}</text>
|
||||
</view>
|
||||
<text
|
||||
v-if="(summary.latest_lab_report.abnormal_count ?? 0) > 0"
|
||||
class="lab-item__abnormal"
|
||||
>{{ summary.latest_lab_report.abnormal_count }} 项异常</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="section">
|
||||
<text class="section-title">操作</text>
|
||||
<view class="action-buttons">
|
||||
<view class="action-btn" @tap="goReports">
|
||||
<text class="action-btn__text">查看化验报告</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="goFollowups">
|
||||
<text class="action-btn__text">随访记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getPatient, getHealthSummary } from '@/services/doctor/patient'
|
||||
import type { PatientDetail, HealthSummary } from '@/services/doctor/patient'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const patient = ref<PatientDetail | null>(null)
|
||||
const summary = ref<HealthSummary | null>(null)
|
||||
const pageLoading = ref(true)
|
||||
const patientId = ref('')
|
||||
|
||||
function genderLabel(g?: string): string {
|
||||
if (g === 'male') return '男'
|
||||
if (g === 'female') return '女'
|
||||
return g || '-'
|
||||
}
|
||||
|
||||
function calcAge(bd?: string): string {
|
||||
if (!bd) return '-'
|
||||
const diff = Date.now() - new Date(bd).getTime()
|
||||
return String(Math.floor(diff / (365.25 * 24 * 3600 * 1000)))
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!patientId.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const [p, s] = await Promise.all([
|
||||
getPatient(patientId.value),
|
||||
getHealthSummary(patientId.value),
|
||||
])
|
||||
patient.value = p
|
||||
summary.value = s
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goReportDetail(reportId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/report/detail/index?patientId=${patientId.value}&id=${reportId}`,
|
||||
})
|
||||
}
|
||||
|
||||
function goReports() {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/report/index?patientId=${patientId.value}`,
|
||||
})
|
||||
}
|
||||
|
||||
function goFollowups() {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/followup/index?patientId=${patientId.value}`,
|
||||
})
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
patientId.value = query?.id || ''
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.error-wrap {
|
||||
@include flex-center;
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 通用区块 ──
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
// ── 信息网格 ──
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// ── 过敏警告卡 ──
|
||||
.warning-card {
|
||||
background: $wrn-l;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.warning-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $wrn;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri-d;
|
||||
}
|
||||
|
||||
// ── 病史摘要 ──
|
||||
.info-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.info-block-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-block-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// ── 体征网格 ──
|
||||
.vitals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vital-item {
|
||||
background: $pri-l;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
// ── 统计行 ──
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
|
||||
&--warn {
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 化验卡片 ──
|
||||
.lab-item {
|
||||
padding: 20px 0;
|
||||
min-height: $touch-min;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__abnormal {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $dan;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 操作按钮 ──
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
min-height: $touch-min;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $r;
|
||||
background: $pri;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
344
apps/miniprogram-uniapp/src/pages-sub/doctor/patients/index.vue
Normal file
344
apps/miniprogram-uniapp/src/pages-sub/doctor/patients/index.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading && patients.length === 0" text="加载中..." />
|
||||
<scroll-view v-else scroll-y class="page-scroll" @scrolltolower="onLoadMore">
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名/手机号"
|
||||
placeholder-class="search-placeholder"
|
||||
:value="search"
|
||||
confirm-type="search"
|
||||
@input="onSearchInput"
|
||||
@confirm="handleSearch"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 标签过滤 -->
|
||||
<scroll-view v-if="tags.length > 0" scroll-x class="tag-filter">
|
||||
<view
|
||||
:class="['tag-chip', { active: !activeTag }]"
|
||||
@tap="handleTagFilter('')"
|
||||
>
|
||||
<text>全部</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
:class="['tag-chip', { active: activeTag === tag.id }]"
|
||||
:style="activeTag === tag.id && tag.color ? `background: ${tag.color}; color: white` : ''"
|
||||
@tap="handleTagFilter(tag.id)"
|
||||
>
|
||||
<text>{{ tag.name }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 患者数量 -->
|
||||
<view class="patient-count">
|
||||
<text>共 {{ total }} 位患者</text>
|
||||
</view>
|
||||
|
||||
<!-- 患者卡片列表 -->
|
||||
<EmptyState v-if="patients.length === 0" icon="📋" title="暂无患者数据" />
|
||||
<view v-else class="patient-cards">
|
||||
<view
|
||||
v-for="p in patients"
|
||||
:key="p.id"
|
||||
class="patient-card"
|
||||
@tap="goDetail(p.id)"
|
||||
>
|
||||
<view class="patient-card__header">
|
||||
<text class="patient-card__name">{{ p.name }}</text>
|
||||
<text class="patient-card__meta">{{ genderLabel(p.gender) }} {{ calcAge(p.birth_date) }}</text>
|
||||
</view>
|
||||
<view v-if="p.tags && p.tags.length > 0" class="patient-card__tags">
|
||||
<view
|
||||
v-for="t in p.tags"
|
||||
:key="t.id"
|
||||
class="patient-tag"
|
||||
:style="t.color ? `background: ${t.color}20; color: ${t.color}` : ''"
|
||||
>
|
||||
<text class="patient-tag__text">{{ t.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="p.status" :class="['patient-card__status', `patient-card__status--${p.status}`]">
|
||||
{{ p.status === 'active' ? '活跃' : p.status === 'inactive' ? '非活跃' : p.status }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多提示 -->
|
||||
<view v-if="!loadingMore && patients.length >= total && total > 0" class="load-hint-wrap">
|
||||
<text class="load-hint">没有更多了</text>
|
||||
</view>
|
||||
<Loading v-if="loadingMore" text="加载中..." />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { listPatients, listPatientTags } from '@/services/doctor/patient'
|
||||
import type { PatientItem, PatientTag } from '@/services/doctor/patient'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const patients = ref<PatientItem[]>([])
|
||||
const tags = ref<PatientTag[]>([])
|
||||
const activeTag = ref('')
|
||||
const search = ref('')
|
||||
const pageLoading = ref(true)
|
||||
const loadingMore = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
function genderLabel(gender?: string): string {
|
||||
if (!gender) return ''
|
||||
if (gender === 'male') return '男'
|
||||
if (gender === 'female') return '女'
|
||||
return gender
|
||||
}
|
||||
|
||||
function calcAge(birthDate?: string): string {
|
||||
if (!birthDate) return ''
|
||||
const birth = new Date(birthDate)
|
||||
const now = new Date()
|
||||
let age = now.getFullYear() - birth.getFullYear()
|
||||
if (
|
||||
now.getMonth() < birth.getMonth() ||
|
||||
(now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate())
|
||||
) {
|
||||
age--
|
||||
}
|
||||
return `${age}岁`
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
try {
|
||||
const res = await listPatientTags()
|
||||
tags.value = res.data || []
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPatients(pageNum: number, isRefresh = false) {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
if (isRefresh) {
|
||||
pageLoading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const res = await listPatients({
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
search: search.value || undefined,
|
||||
tag_id: activeTag.value || undefined,
|
||||
})
|
||||
const list = res.data || []
|
||||
if (isRefresh) {
|
||||
patients.value = list
|
||||
} else {
|
||||
patients.value = [...patients.value, ...list]
|
||||
}
|
||||
total.value = res.total || 0
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
loadingMore.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchInput(e: { detail: { value: string } }) {
|
||||
search.value = e.detail.value
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
loadPatients(1, true)
|
||||
}
|
||||
|
||||
function handleTagFilter(tagId: string) {
|
||||
activeTag.value = tagId === activeTag.value ? '' : tagId
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/doctor/patients/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
if (!isLoading.value && patients.value.length < total.value) {
|
||||
loadPatients(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTag, () => {
|
||||
loadPatients(1, true)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTags()
|
||||
loadPatients(1, true)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadPatients(1, true).finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
// 从详情页返回时不需要重新加载,保留列表状态
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
// ── 搜索栏 ──
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 标签过滤 ──
|
||||
.tag-filter {
|
||||
white-space: nowrap;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 24px;
|
||||
min-height: $touch-min;
|
||||
border-radius: $r-pill;
|
||||
background: $bd-l;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-right: 16px;
|
||||
|
||||
&.active {
|
||||
background: $pri;
|
||||
color: $card;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 患者计数 ──
|
||||
.patient-count {
|
||||
margin-bottom: 16px;
|
||||
|
||||
text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 患者卡片 ──
|
||||
.patient-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.patient-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__status {
|
||||
@include tag($bg, $tx2);
|
||||
|
||||
&--active {
|
||||
@include tag($acc-l, $acc);
|
||||
}
|
||||
|
||||
&--inactive {
|
||||
@include tag($bd-l, $tx3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.patient-tag {
|
||||
padding: 4px 14px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载提示 ──
|
||||
.load-hint-wrap {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.load-hint {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,517 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- 患者选择 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">选择患者</text>
|
||||
<view class="patient-search">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名"
|
||||
:value="patientSearch"
|
||||
@input="(e: any) => { patientSearch = e.detail.value; searchPatients() }"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="searchingPatient" class="loading-hint">
|
||||
<text class="loading-hint__text">搜索中...</text>
|
||||
</view>
|
||||
<view v-else-if="patientResults.length > 0" class="patient-list">
|
||||
<view
|
||||
v-for="p in patientResults"
|
||||
:key="p.id"
|
||||
:class="['patient-item', form.patient_id === p.id ? 'patient-item--selected' : '']"
|
||||
@tap="selectPatient(p)"
|
||||
>
|
||||
<text class="patient-item__name">{{ p.name }}</text>
|
||||
<text class="patient-item__info">{{ p.gender === 'male' ? '男' : p.gender === 'female' ? '女' : '' }}</text>
|
||||
<view v-if="form.patient_id === p.id" class="patient-item__check">
|
||||
<text class="check-icon">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="patientSearch && !searchingPatient" class="empty-hint">
|
||||
<text class="empty-hint__text">未找到患者</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 透析方式与频率 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">透析方案</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">透析器型号</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如 F60S"
|
||||
:value="form.dialyzer_model"
|
||||
@input="(e: any) => form.dialyzer_model = e.detail.value"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">频率(次/周) <text class="required">*</text></text>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
placeholder="如 3"
|
||||
:value="form.frequency_per_week ?? ''"
|
||||
@input="(e: any) => updateNumericField('frequency_per_week', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">单次时长(分钟)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 240"
|
||||
:value="form.duration_minutes ?? ''"
|
||||
@input="(e: any) => updateNumericField('duration_minutes', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">生效日期</text>
|
||||
<picker mode="date" :value="form.effective_from" @change="(e: any) => form.effective_from = e.detail.value">
|
||||
<view :class="['picker-display', form.effective_from ? '' : 'placeholder']">
|
||||
{{ form.effective_from || '选择日期' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">失效日期</text>
|
||||
<picker mode="date" :value="form.effective_to" @change="(e: any) => form.effective_to = e.detail.value">
|
||||
<view :class="['picker-display', form.effective_to ? '' : 'placeholder']">
|
||||
{{ form.effective_to || '选择日期' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 透析参数 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">透析参数</text>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">血流量 (ml/min)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 300"
|
||||
:value="form.blood_flow_rate ?? ''"
|
||||
@input="(e: any) => updateNumericField('blood_flow_rate', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">透析液流量 (ml/min)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 500"
|
||||
:value="form.dialysate_flow_rate ?? ''"
|
||||
@input="(e: any) => updateNumericField('dialysate_flow_rate', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">抗凝方式</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如 肝素、低分子肝素"
|
||||
:value="form.anticoagulation_type"
|
||||
@input="(e: any) => form.anticoagulation_type = e.detail.value"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">抗凝剂量</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如 2000IU"
|
||||
:value="form.anticoagulation_dose"
|
||||
@input="(e: any) => form.anticoagulation_dose = e.detail.value"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">透析液钾 (mmol/L)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 2.0"
|
||||
:value="form.dialysate_potassium ?? ''"
|
||||
@input="(e: any) => updateNumericField('dialysate_potassium', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">透析液钙 (mmol/L)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 1.5"
|
||||
:value="form.dialysate_calcium ?? ''"
|
||||
@input="(e: any) => updateNumericField('dialysate_calcium', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">膜面积 (m2)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 1.8"
|
||||
:value="form.membrane_area ?? ''"
|
||||
@input="(e: any) => updateNumericField('membrane_area', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 目标参数 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">目标参数</text>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">干体重 (kg)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 65.0"
|
||||
:value="form.target_dry_weight ?? ''"
|
||||
@input="(e: any) => updateNumericField('target_dry_weight', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">超滤目标 (ml)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 2000"
|
||||
:value="form.target_ultrafiltration_ml ?? ''"
|
||||
@input="(e: any) => updateNumericField('target_ultrafiltration_ml', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 血管通路 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">血管通路</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">通路类型</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如 动静脉内瘘、中心静脉导管"
|
||||
:value="form.vascular_access_type"
|
||||
@input="(e: any) => form.vascular_access_type = e.detail.value"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">通路位置</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如 左前臂"
|
||||
:value="form.vascular_access_location"
|
||||
@input="(e: any) => form.vascular_access_location = e.detail.value"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">备注</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="处方备注(选填)"
|
||||
:value="form.notes"
|
||||
@input="(e: any) => form.notes = e.detail.value"
|
||||
:maxlength="1000"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 提交 -->
|
||||
<view class="submit-wrap">
|
||||
<view :class="['action-btn', submitting ? 'disabled' : '']" @tap="submitting ? undefined : handleSubmit">
|
||||
<text class="action-btn-text">{{ submitting ? '提交中...' : '创建处方' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { createDialysisPrescription } from '@/services/doctor/dialysis'
|
||||
import { listPatients } from '@/services/doctor/patient'
|
||||
import type { PatientItem } from '@/services/doctor/patient'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const form = reactive({
|
||||
patient_id: '',
|
||||
dialyzer_model: '',
|
||||
frequency_per_week: undefined as number | undefined,
|
||||
duration_minutes: undefined as number | undefined,
|
||||
blood_flow_rate: undefined as number | undefined,
|
||||
dialysate_flow_rate: undefined as number | undefined,
|
||||
anticoagulation_type: '',
|
||||
anticoagulation_dose: '',
|
||||
dialysate_potassium: undefined as number | undefined,
|
||||
dialysate_calcium: undefined as number | undefined,
|
||||
dialysate_bicarbonate: undefined as number | undefined,
|
||||
membrane_area: undefined as number | undefined,
|
||||
target_dry_weight: undefined as number | undefined,
|
||||
target_ultrafiltration_ml: undefined as number | undefined,
|
||||
vascular_access_type: '',
|
||||
vascular_access_location: '',
|
||||
effective_from: '',
|
||||
effective_to: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const patientSearch = ref('')
|
||||
const patientResults = ref<PatientItem[]>([])
|
||||
const searchingPatient = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function searchPatients() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (!patientSearch.value.trim()) {
|
||||
patientResults.value = []
|
||||
return
|
||||
}
|
||||
searchTimer = setTimeout(async () => {
|
||||
searchingPatient.value = true
|
||||
try {
|
||||
const res = await listPatients({ search: patientSearch.value.trim(), page_size: 10 })
|
||||
patientResults.value = res.data || []
|
||||
} catch {
|
||||
patientResults.value = []
|
||||
} finally {
|
||||
searchingPatient.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectPatient(p: PatientItem) {
|
||||
form.patient_id = form.patient_id === p.id ? '' : p.id
|
||||
}
|
||||
|
||||
function updateNumericField(field: keyof typeof form, raw: string) {
|
||||
const val = raw.trim() === '' ? undefined : Number(raw)
|
||||
;(form as any)[field] = isNaN(val as number) ? undefined : val
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.patient_id) {
|
||||
uni.showToast({ title: '请选择患者', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!form.frequency_per_week) {
|
||||
uni.showToast({ title: '请填写透析频率', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await createDialysisPrescription({
|
||||
patient_id: form.patient_id,
|
||||
dialyzer_model: form.dialyzer_model || undefined,
|
||||
frequency_per_week: form.frequency_per_week,
|
||||
duration_minutes: form.duration_minutes,
|
||||
blood_flow_rate: form.blood_flow_rate,
|
||||
dialysate_flow_rate: form.dialysate_flow_rate,
|
||||
anticoagulation_type: form.anticoagulation_type || undefined,
|
||||
anticoagulation_dose: form.anticoagulation_dose || undefined,
|
||||
dialysate_potassium: form.dialysate_potassium,
|
||||
dialysate_calcium: form.dialysate_calcium,
|
||||
dialysate_bicarbonate: form.dialysate_bicarbonate,
|
||||
membrane_area: form.membrane_area,
|
||||
target_dry_weight: form.target_dry_weight,
|
||||
target_ultrafiltration_ml: form.target_ultrafiltration_ml,
|
||||
vascular_access_type: form.vascular_access_type || undefined,
|
||||
vascular_access_location: form.vascular_access_location || undefined,
|
||||
effective_from: form.effective_from || undefined,
|
||||
effective_to: form.effective_to || undefined,
|
||||
notes: form.notes.trim() || undefined,
|
||||
})
|
||||
uni.showToast({ title: '创建成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 800)
|
||||
} catch {
|
||||
uni.showToast({ title: '创建失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 160px; }
|
||||
|
||||
.section-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Search
|
||||
.patient-search { margin-bottom: 12px; }
|
||||
|
||||
.search-input {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loading-hint, .empty-hint {
|
||||
@include flex-center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.loading-hint__text, .empty-hint__text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.patient-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.patient-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-radius: $r-xs;
|
||||
|
||||
&:active { background: $bd-l; }
|
||||
&--selected { background: $pri-l; }
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
&__check {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@include flex-center;
|
||||
background: $pri;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
|
||||
// Form
|
||||
.form-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-field--half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.required { color: $dan; }
|
||||
|
||||
.form-input {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.picker-display {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.picker-display.placeholder { color: $tx3; }
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 12px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
// Submit
|
||||
.submit-wrap { margin: 0 24px; }
|
||||
|
||||
.action-btn {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
.action-btn.disabled { opacity: 0.5; }
|
||||
|
||||
.action-btn-text {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<ErrorState v-else-if="error || !prescription" text="处方加载失败" :on-retry="loadData" />
|
||||
<scroll-view v-else scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- 处方信息 -->
|
||||
<view class="info-card">
|
||||
<view class="info-header">
|
||||
<text class="section-label">处方信息</text>
|
||||
<view class="status-tag" :style="getStatusInlineStyle(prescription.status)">
|
||||
<text class="status-tag__text">{{ getStatusLabel(prescription.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="info-row">
|
||||
<text class="info-label">透析方式</text>
|
||||
<text class="info-value">{{ prescription.dialyzer_model || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">频率</text>
|
||||
<text class="info-value">{{ prescription.frequency_per_week ? `${prescription.frequency_per_week} 次/周` : '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">单次时长</text>
|
||||
<text class="info-value">{{ prescription.duration_minutes ? `${prescription.duration_minutes} 分钟` : '-' }}</text>
|
||||
</view>
|
||||
<view v-if="prescription.effective_from" class="info-row">
|
||||
<text class="info-label">生效日期</text>
|
||||
<text class="info-value">{{ prescription.effective_from }}</text>
|
||||
</view>
|
||||
<view v-if="prescription.effective_to" class="info-row">
|
||||
<text class="info-label">失效日期</text>
|
||||
<text class="info-value">{{ prescription.effective_to }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 透析参数 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">透析参数</text>
|
||||
|
||||
<view v-if="prescription.blood_flow_rate != null" class="info-row">
|
||||
<text class="info-label">血流量</text>
|
||||
<text class="info-value">{{ prescription.blood_flow_rate }} ml/min</text>
|
||||
</view>
|
||||
<view v-if="prescription.dialysate_flow_rate != null" class="info-row">
|
||||
<text class="info-label">透析液流量</text>
|
||||
<text class="info-value">{{ prescription.dialysate_flow_rate }} ml/min</text>
|
||||
</view>
|
||||
<view v-if="prescription.dialysate_potassium != null" class="info-row">
|
||||
<text class="info-label">透析液钾浓度</text>
|
||||
<text class="info-value">{{ prescription.dialysate_potassium }} mmol/L</text>
|
||||
</view>
|
||||
<view v-if="prescription.dialysate_calcium != null" class="info-row">
|
||||
<text class="info-label">透析液钙浓度</text>
|
||||
<text class="info-value">{{ prescription.dialysate_calcium }} mmol/L</text>
|
||||
</view>
|
||||
<view v-if="prescription.dialysate_bicarbonate != null" class="info-row">
|
||||
<text class="info-label">透析液碳酸氢盐</text>
|
||||
<text class="info-value">{{ prescription.dialysate_bicarbonate }} mmol/L</text>
|
||||
</view>
|
||||
<view v-if="prescription.anticoagulation_type" class="info-row">
|
||||
<text class="info-label">抗凝方式</text>
|
||||
<text class="info-value">{{ prescription.anticoagulation_type }}</text>
|
||||
</view>
|
||||
<view v-if="prescription.anticoagulation_dose" class="info-row">
|
||||
<text class="info-label">抗凝剂量</text>
|
||||
<text class="info-value">{{ prescription.anticoagulation_dose }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 目标参数 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">目标参数</text>
|
||||
<view class="vitals-grid">
|
||||
<view v-if="prescription.target_dry_weight != null" class="vital-item">
|
||||
<text class="vital-value">{{ prescription.target_dry_weight }}</text>
|
||||
<text class="vital-label">干体重 kg</text>
|
||||
</view>
|
||||
<view v-if="prescription.target_ultrafiltration_ml != null" class="vital-item">
|
||||
<text class="vital-value">{{ prescription.target_ultrafiltration_ml }}</text>
|
||||
<text class="vital-label">超滤目标 ml</text>
|
||||
</view>
|
||||
<view v-if="prescription.membrane_area != null" class="vital-item">
|
||||
<text class="vital-value">{{ prescription.membrane_area }}</text>
|
||||
<text class="vital-label">膜面积 m2</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 血管通路 -->
|
||||
<view v-if="prescription.vascular_access_type" class="section-card">
|
||||
<text class="section-title">血管通路</text>
|
||||
<view class="info-row">
|
||||
<text class="info-label">通路类型</text>
|
||||
<text class="info-value">{{ prescription.vascular_access_type }}</text>
|
||||
</view>
|
||||
<view v-if="prescription.vascular_access_location" class="info-row">
|
||||
<text class="info-label">通路位置</text>
|
||||
<text class="info-value">{{ prescription.vascular_access_location }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view v-if="prescription.notes" class="section-card">
|
||||
<text class="section-title">备注</text>
|
||||
<text class="notes-text">{{ prescription.notes }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 操作 -->
|
||||
<view v-if="prescription.status === 'active'" class="action-card">
|
||||
<view
|
||||
:class="['action-btn--outline', deactivating ? 'disabled' : '']"
|
||||
@tap="deactivating ? undefined : handleDeactivate"
|
||||
>
|
||||
<text class="action-btn--outline__text">{{ deactivating ? '处理中...' : '停用处方' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getDialysisPrescriptionById, updateDialysisPrescription } from '@/services/doctor/dialysis'
|
||||
import type { DialysisPrescription } from '@/services/dialysis'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const prescription = ref<DialysisPrescription | null>(null)
|
||||
const pageLoading = ref(true)
|
||||
const error = ref(false)
|
||||
const deactivating = ref(false)
|
||||
let prescriptionId = ''
|
||||
|
||||
async function loadData() {
|
||||
if (!prescriptionId) return
|
||||
pageLoading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const data = await getDialysisPrescriptionById(prescriptionId)
|
||||
prescription.value = data
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeactivate() {
|
||||
if (!prescription.value) return
|
||||
deactivating.value = true
|
||||
try {
|
||||
const updated = await updateDialysisPrescription(
|
||||
prescriptionId,
|
||||
{ status: 'inactive' },
|
||||
prescription.value.version,
|
||||
)
|
||||
prescription.value = updated
|
||||
uni.showToast({ title: '已停用', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
deactivating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
prescriptionId = query?.id || ''
|
||||
if (!prescriptionId) { error.value = true; pageLoading.value = false; return }
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 120px; }
|
||||
|
||||
.info-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: var(--tk-font-title);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.status-tag__text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vitals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vital-item {
|
||||
background: $pri-l;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.action-btn--outline {
|
||||
@include btn-outline;
|
||||
|
||||
&.disabled { opacity: 0.5; }
|
||||
|
||||
&__text {
|
||||
color: $pri;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<!-- 搜索 -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名"
|
||||
:value="searchKeyword"
|
||||
@input="(e: any) => { searchKeyword = e.detail.value; debouncedSearch() }"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Tab 筛选 -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
:class="['tab', activeTab === t.key ? 'tab--active' : '']"
|
||||
@tap="handleTabChange(t.key)"
|
||||
>
|
||||
<text>{{ t.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载态 -->
|
||||
<Loading v-if="loading && prescriptions.length === 0" text="加载中..." />
|
||||
|
||||
<!-- 空态 -->
|
||||
<EmptyState v-else-if="prescriptions.length === 0" icon="📋" title="暂无透析处方" />
|
||||
|
||||
<!-- 处方列表 -->
|
||||
<view v-else class="prescription-list">
|
||||
<view
|
||||
v-for="p in prescriptions"
|
||||
:key="p.id"
|
||||
class="prescription-card"
|
||||
@tap="goDetail(p.id)"
|
||||
>
|
||||
<view class="prescription-card__top">
|
||||
<text class="prescription-card__patient">{{ patientNameMap[p.patient_id] || '未知患者' }}</text>
|
||||
<view class="prescription-card__status" :style="getStatusInlineStyle(p.status)">
|
||||
<text class="prescription-card__status-text">{{ getStatusLabel(p.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="prescription-card__meta">
|
||||
<text class="prescription-card__type">
|
||||
{{ dialysisTypeLabel(p.dialyzer_model) }}
|
||||
</text>
|
||||
<text v-if="p.frequency_per_week" class="prescription-card__freq">
|
||||
{{ p.frequency_per_week }}次/周
|
||||
</text>
|
||||
</view>
|
||||
<text class="prescription-card__date">{{ p.created_at?.substring(0, 10) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分页 -->
|
||||
<view v-if="total > pageSize" class="pagination">
|
||||
<text
|
||||
:class="['pagination__btn', page <= 1 ? 'disabled' : '']"
|
||||
@tap="page > 1 && (page = page - 1)"
|
||||
>上一页</text>
|
||||
<text class="pagination__info">{{ page }} / {{ totalPages }}</text>
|
||||
<text
|
||||
:class="['pagination__btn', page >= totalPages ? 'disabled' : '']"
|
||||
@tap="page < totalPages && (page = page + 1)"
|
||||
>下一页</text>
|
||||
</view>
|
||||
|
||||
<!-- 创建按钮 -->
|
||||
<view class="fab" @tap="goCreate">
|
||||
<text class="fab__icon">+</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { listDialysisPrescriptions } from '@/services/doctor/dialysis'
|
||||
import type { DialysisPrescription } from '@/services/dialysis'
|
||||
import { listPatients } from '@/services/doctor/patient'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'active', label: '生效中' },
|
||||
{ key: 'inactive', label: '已停用' },
|
||||
{ key: 'expired', label: '已过期' },
|
||||
] as const
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const pageSize = 20
|
||||
|
||||
const prescriptions = ref<DialysisPrescription[]>([])
|
||||
const patientNameMap = ref<Record<string, string>>({})
|
||||
const activeTab = ref('')
|
||||
const searchKeyword = ref('')
|
||||
const loading = ref(true)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
||||
|
||||
function handleTabChange(key: string) {
|
||||
activeTab.value = key
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function dialysisTypeLabel(model?: string): string {
|
||||
if (!model) return '透析处方'
|
||||
return model
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/doctor/prescription/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/pages-sub/doctor/prescription/create/index' })
|
||||
}
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function debouncedSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadPrescriptions()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function loadPrescriptions() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (activeTab.value) params.status = activeTab.value
|
||||
const res = await listDialysisPrescriptions(params)
|
||||
prescriptions.value = res.data || []
|
||||
total.value = res.total || 0
|
||||
// Resolve patient names
|
||||
const ids = [...new Set(prescriptions.value.map((p) => p.patient_id))]
|
||||
await resolvePatientNames(ids)
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePatientNames(ids: string[]) {
|
||||
const missing = ids.filter((id) => !patientNameMap.value[id])
|
||||
if (missing.length === 0) return
|
||||
try {
|
||||
// Load patients in batches to resolve names
|
||||
for (const id of missing) {
|
||||
try {
|
||||
const res = await listPatients({ page_size: 1 })
|
||||
// If we have data, try to find the patient
|
||||
const patient = res.data?.find((p) => p.id === id)
|
||||
if (patient) {
|
||||
patientNameMap.value = { ...patientNameMap.value, [id]: patient.name }
|
||||
}
|
||||
} catch { /* skip individual failures */ }
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
watch([page, activeTab], () => { loadPrescriptions() })
|
||||
|
||||
onShow(() => {
|
||||
loadPrescriptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
|
||||
.search-bar {
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-pill;
|
||||
padding: 0 24px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $bg;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: $card;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid $bd;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-list {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.prescription-card {
|
||||
@include card;
|
||||
|
||||
&:active { background: $bd-l; }
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__patient {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__status-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__type {
|
||||
@include tag($pri-l, $pri);
|
||||
}
|
||||
|
||||
&__freq {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
|
||||
&__btn {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
&.disabled { color: $tx3; }
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 100px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: $pri;
|
||||
border-radius: 50%;
|
||||
@include flex-center;
|
||||
box-shadow: $shadow-lg;
|
||||
|
||||
&:active { opacity: 0.85; }
|
||||
}
|
||||
|
||||
.fab__icon {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: 300;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<view :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- Loading -->
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
|
||||
<!-- Error -->
|
||||
<view v-else-if="!report" class="empty-wrap">
|
||||
<text class="empty-text">报告不存在</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- Report info card -->
|
||||
<view class="info-card">
|
||||
<view class="info-header">
|
||||
<text class="report-type">{{ report.report_type }}</text>
|
||||
<text class="status-tag" :style="getStatusInlineStyle(report.status)">
|
||||
{{ getStatusLabel(report.status) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="info-row">
|
||||
<text class="info-label">报告日期</text>
|
||||
<text class="info-value">{{ report.report_date }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="report.abnormal_count != null" class="info-row">
|
||||
<text class="info-label">异常指标</text>
|
||||
<text :class="['info-value', report.abnormal_count > 0 ? 'abnormal' : 'normal']">
|
||||
{{ report.abnormal_count }} 项
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Indicators list -->
|
||||
<view class="indicators-card">
|
||||
<text class="section-title">检查指标</text>
|
||||
|
||||
<view v-if="indicators.length === 0" class="empty-indicators">
|
||||
<text class="empty-text">暂无指标数据</text>
|
||||
</view>
|
||||
|
||||
<view v-for="(item, idx) in indicators" :key="idx" class="indicator-item">
|
||||
<view class="indicator-left">
|
||||
<text class="indicator-name">{{ item.name }}</text>
|
||||
<text class="indicator-value">{{ item.value }}{{ item.unit ? ` ${item.unit}` : '' }}</text>
|
||||
</view>
|
||||
<view class="indicator-right">
|
||||
<text v-if="item.reference_min != null && item.reference_max != null" class="indicator-ref">
|
||||
{{ item.reference_min }}~{{ item.reference_max }}
|
||||
</text>
|
||||
<text :class="['indicator-status', getIndicatorStatusClass(item)]">
|
||||
{{ getIndicatorStatusLabel(item) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Doctor interpretation -->
|
||||
<view v-if="report.doctor_notes" class="notes-card">
|
||||
<text class="section-title">医生解读</text>
|
||||
<text class="notes-text">{{ report.doctor_notes }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Review section (for doctor) -->
|
||||
<view v-if="canReview" class="review-card">
|
||||
<text class="section-title">审核报告</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">审核意见</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="请输入审核意见(选填)"
|
||||
:value="reviewNotes"
|
||||
@input="(e: any) => reviewNotes = e.detail.value"
|
||||
:maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view
|
||||
:class="['action-btn', reviewing ? 'disabled' : '']"
|
||||
@tap="reviewing ? undefined : handleReview"
|
||||
>
|
||||
<text class="action-btn-text">{{ reviewing ? '提交中...' : '确认审核' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor/labReport'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
interface IndicatorDisplay {
|
||||
name: string
|
||||
value: number
|
||||
unit?: string
|
||||
reference_min?: number
|
||||
reference_max?: number
|
||||
is_abnormal?: boolean
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const report = ref<doctorApi.LabReportDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const reviewing = ref(false)
|
||||
const reviewNotes = ref('')
|
||||
let patientId = ''
|
||||
let reportId = ''
|
||||
|
||||
const indicators = computed<IndicatorDisplay[]>(() => {
|
||||
if (!report.value?.items) return []
|
||||
return report.value.items
|
||||
})
|
||||
|
||||
const canReview = computed(() => {
|
||||
if (!report.value) return false
|
||||
return report.value.status !== 'reviewed' && report.value.status !== 'verified'
|
||||
})
|
||||
|
||||
function getIndicatorStatusClass(item: IndicatorDisplay): string {
|
||||
if (item.is_abnormal) {
|
||||
if (item.reference_min != null && item.value < item.reference_min) return 'low'
|
||||
return 'high'
|
||||
}
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function getIndicatorStatusLabel(item: IndicatorDisplay): string {
|
||||
if (item.is_abnormal) {
|
||||
if (item.reference_min != null && item.value < item.reference_min) return '偏低'
|
||||
return '偏高'
|
||||
}
|
||||
return '正常'
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
report.value = await doctorApi.getLabReport(patientId, reportId)
|
||||
reviewNotes.value = report.value?.doctor_notes || ''
|
||||
} catch {
|
||||
report.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReview() {
|
||||
if (!report.value) return
|
||||
reviewing.value = true
|
||||
try {
|
||||
const updated = await doctorApi.reviewLabReport(patientId, reportId, {
|
||||
doctor_notes: reviewNotes.value.trim() || undefined,
|
||||
version: report.value.version,
|
||||
})
|
||||
report.value = updated
|
||||
uni.showToast({ title: '审核完成', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '审核失败', icon: 'none' })
|
||||
} finally {
|
||||
reviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
patientId = query?.patientId || ''
|
||||
reportId = query?.reportId || ''
|
||||
if (!patientId || !reportId) { loading.value = false; return }
|
||||
fetchDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 120px; }
|
||||
|
||||
.empty-wrap { @include flex-center; padding: 120px 0; }
|
||||
.empty-text { font-size: var(--tk-font-body); color: $tx3; }
|
||||
|
||||
// Info card
|
||||
.info-card {
|
||||
@include card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.report-type {
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.info-value.abnormal { color: $dan; font-weight: 600; }
|
||||
.info-value.normal { color: $acc; }
|
||||
|
||||
// Indicators card
|
||||
.indicators-card {
|
||||
@include card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-indicators {
|
||||
@include flex-center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.indicator-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.indicator-item:last-child { border-bottom: none; }
|
||||
|
||||
.indicator-left { flex: 1; }
|
||||
|
||||
.indicator-name {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.indicator-value {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.indicator-right {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.indicator-ref {
|
||||
display: block;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.indicator-status {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.indicator-status.high { color: $wrn; }
|
||||
.indicator-status.low { color: $info; }
|
||||
.indicator-status.normal { color: $acc; }
|
||||
|
||||
// Notes card
|
||||
.notes-card {
|
||||
@include card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
// Review card
|
||||
.review-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.form-field { margin-bottom: 16px; }
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 12px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
.action-btn.disabled { opacity: 0.5; }
|
||||
|
||||
.action-btn-text {
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
220
apps/miniprogram-uniapp/src/pages-sub/doctor/report/index.vue
Normal file
220
apps/miniprogram-uniapp/src/pages-sub/doctor/report/index.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<view :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<text class="page-title">化验报告</text>
|
||||
|
||||
<!-- Search bar -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名"
|
||||
:value="searchText"
|
||||
@input="(e: any) => searchText = e.detail.value"
|
||||
@confirm="handleSearch"
|
||||
confirm-type="search"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Loading -->
|
||||
<Loading v-if="loading && reports.length === 0" text="加载中..." />
|
||||
|
||||
<!-- Empty -->
|
||||
<EmptyState v-else-if="reports.length === 0 && !loading" icon="📋" title="暂无化验报告" />
|
||||
|
||||
<!-- Report list -->
|
||||
<scroll-view v-else scroll-y class="list-scroll" @scrolltolower="loadMore">
|
||||
<view
|
||||
v-for="item in reports" :key="item.id"
|
||||
class="report-card"
|
||||
@tap="goDetail(item.id)"
|
||||
>
|
||||
<view class="card-header">
|
||||
<text class="report-type">{{ item.report_type }}</text>
|
||||
<text class="status-tag" :style="getStatusInlineStyle(item.status)">
|
||||
{{ getStatusLabel(item.status) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="card-body">
|
||||
<text class="report-date">报告日期:{{ item.report_date }}</text>
|
||||
|
||||
<view v-if="item.abnormal_count != null && item.abnormal_count > 0" class="abnormal-badge">
|
||||
<text class="abnormal-text">{{ item.abnormal_count }} 项异常</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && reports.length >= total && total > 0" class="no-more">
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor/labReport'
|
||||
import { listPatients } from '@/services/doctor/patient'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const reports = ref<doctorApi.LabReportItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
let patientId = ''
|
||||
let currentPatientId = ''
|
||||
let loadingGuard = false
|
||||
|
||||
async function fetchReports(pageNum: number, isRefresh = false) {
|
||||
if (loadingGuard || !currentPatientId) return
|
||||
loadingGuard = true
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await doctorApi.listLabReports(currentPatientId, {
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
})
|
||||
const list = res.data || []
|
||||
reports.value = isRefresh ? list : [...reports.value, ...list]
|
||||
total.value = res.total
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loadingGuard = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
const keyword = searchText.value.trim()
|
||||
if (!keyword) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listPatients({ search: keyword, page_size: 1 })
|
||||
const patients = res.data || []
|
||||
if (patients.length > 0) {
|
||||
currentPatientId = patients[0].id
|
||||
fetchReports(1, true)
|
||||
} else {
|
||||
uni.showToast({ title: '未找到该患者', icon: 'none' })
|
||||
loading.value = false
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '搜索失败', icon: 'none' })
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!loading.value && reports.value.length < total.value) {
|
||||
fetchReports(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(reportId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/report/detail/index?reportId=${reportId}&patientId=${currentPatientId}`,
|
||||
})
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
patientId = query?.patientId || ''
|
||||
if (patientId) {
|
||||
currentPatientId = patientId
|
||||
fetchReports(1, true)
|
||||
}
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
if (currentPatientId) {
|
||||
fetchReports(1, true).finally(() => uni.stopPullDownRefresh())
|
||||
} else {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 0 120px; }
|
||||
.page-title { @include section-title; margin-left: 24px; }
|
||||
|
||||
// Search bar
|
||||
.search-bar {
|
||||
padding: 0 24px 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
height: 48px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
// List
|
||||
.list-scroll { height: calc(100vh - 180px); }
|
||||
|
||||
.report-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin: 0 24px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.report-type {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.report-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.abnormal-badge {
|
||||
padding: 2px 10px;
|
||||
border-radius: $r-pill;
|
||||
background: $dan-l;
|
||||
}
|
||||
|
||||
.abnormal-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $dan;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
115
apps/miniprogram-uniapp/src/pages-sub/events/index.vue
Normal file
115
apps/miniprogram-uniapp/src/pages-sub/events/index.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['events-page', elderClass]">
|
||||
<view class="events-header">
|
||||
<text class="events-header__title">线下活动</text>
|
||||
<text class="events-header__subtitle">参加活动赢取积分</text>
|
||||
</view>
|
||||
|
||||
<view v-if="events.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="" title="暂无可报名的活动" />
|
||||
</view>
|
||||
|
||||
<view v-else class="event-list">
|
||||
<view v-for="event in events" :key="event.id" class="event-card">
|
||||
<view class="event-card__header">
|
||||
<view :class="['event-card__status', (STATUS_MAP[event.status] || { className: '' }).className]">
|
||||
<text>{{ (STATUS_MAP[event.status] || { label: event.status }).label }}</text>
|
||||
</view>
|
||||
<text class="event-card__points">+{{ event.points_reward }} 积分</text>
|
||||
</view>
|
||||
<text class="event-card__title">{{ event.title }}</text>
|
||||
<text v-if="event.description" class="event-card__desc">{{ event.description }}</text>
|
||||
<view class="event-card__info">
|
||||
<text class="event-card__date">{{ formatDate(event.event_date) }}</text>
|
||||
<text v-if="event.location" class="event-card__location">{{ event.location }}</text>
|
||||
</view>
|
||||
<view class="event-card__footer">
|
||||
<text class="event-card__participants">
|
||||
{{ event.current_participants }}{{ event.max_participants ? `/${event.max_participants}` : '' }} 人已报名
|
||||
</text>
|
||||
<view :class="['event-card__btn', isFull(event) ? 'event-card__btn--disabled' : '']" @tap="handleRegister(event)">
|
||||
<text class="event-card__btn-text">
|
||||
{{ registering === event.id ? '报名中...' : isFull(event) ? '已满' : '立即报名' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { listOfflineEvents, registerEvent, type OfflineEvent } from '@/services/points'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
published: { label: '报名中', className: 'event-card__status--published' },
|
||||
ongoing: { label: '进行中', className: 'event-card__status--ongoing' },
|
||||
completed: { label: '已结束', className: 'event-card__status--completed' },
|
||||
cancelled: { label: '已取消', className: 'event-card__status--cancelled' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const events = ref<OfflineEvent[]>([])
|
||||
const loading = ref(true)
|
||||
const registering = ref<string | null>(null)
|
||||
|
||||
const isFull = (event: OfflineEvent) => event.max_participants != null && event.current_participants >= event.max_participants
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
|
||||
const loadEvents = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listOfflineEvents({ page: 1, page_size: 50, status: 'published' })
|
||||
events.value = res.data || []
|
||||
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleRegister = async (event: OfflineEvent) => {
|
||||
if (isFull(event) || registering.value) return
|
||||
registering.value = event.id
|
||||
try {
|
||||
await registerEvent(event.id)
|
||||
uni.showToast({ title: '报名成功', icon: 'success' })
|
||||
loadEvents()
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '报名失败'
|
||||
uni.showToast({ title: msg.substring(0, 20), icon: 'none' })
|
||||
} finally { registering.value = null }
|
||||
}
|
||||
|
||||
onMounted(loadEvents)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.events-page { min-height: 100vh; background: $bg; }
|
||||
.events-header { padding: 32px 24px 16px; }
|
||||
.events-header__title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; }
|
||||
.events-header__subtitle { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; display: block; }
|
||||
.event-list { padding: 0 24px; }
|
||||
.event-card { @include card; margin-bottom: 16px; }
|
||||
.event-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.event-card__status { padding: 2px 10px; border-radius: 4px; }
|
||||
.event-card__status--published { background: rgba($pri, 0.1); }
|
||||
.event-card__status--ongoing { background: rgba(250,173,20,0.15); }
|
||||
.event-card__status--completed { background: rgba(0,0,0,0.05); }
|
||||
.event-card__status--cancelled { background: rgba(0,0,0,0.05); }
|
||||
.event-card__points { font-size: var(--tk-font-cap); color: $wrn; font-weight: 500; }
|
||||
.event-card__title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; display: block; }
|
||||
.event-card__desc { font-size: var(--tk-font-cap); color: $tx2; display: block; margin-top: 6px; line-height: 1.5; }
|
||||
.event-card__info { display: flex; gap: 16px; margin-top: 10px; }
|
||||
.event-card__date, .event-card__location { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.event-card__footer { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(0,0,0,0.05); }
|
||||
.event-card__participants { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.event-card__btn { padding: 6px 20px; min-height: $touch-min; display: flex; align-items: center; background: $pri; border-radius: $r; }
|
||||
.event-card__btn--disabled { background: rgba(0,0,0,0.1); }
|
||||
.event-card__btn-text { font-size: var(--tk-font-cap); color: $white; }
|
||||
.event-card__btn--disabled .event-card__btn-text { color: $tx3; }
|
||||
.empty-wrap { padding-top: 80px; }
|
||||
</style>
|
||||
109
apps/miniprogram-uniapp/src/pages-sub/followup/detail/index.vue
Normal file
109
apps/miniprogram-uniapp/src/pages-sub/followup/detail/index.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<ErrorState v-else-if="error || !task" text="任务不存在" />
|
||||
<template v-else>
|
||||
<view class="detail-card">
|
||||
<text class="detail-title">{{ task.follow_up_type }}</text>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">状态</text>
|
||||
<text :class="['detail-value', getStatusClass(task.status)]">{{ getStatusLabel(task.status) }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">截止日期</text>
|
||||
<text class="detail-value">{{ task.planned_date }}</text>
|
||||
</view>
|
||||
<view v-if="countdown" :class="['countdown', countdown.urgent ? 'countdown-urgent' : '']">
|
||||
<text class="countdown-text">{{ countdown.text }}</text>
|
||||
</view>
|
||||
<view v-if="task.content_template" class="detail-desc">
|
||||
<text class="detail-desc-text">{{ task.content_template }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="task.status !== 'completed'" class="submit-card">
|
||||
<text class="section-title">填写随访记录</text>
|
||||
<textarea class="submit-textarea" placeholder="请输入随访内容..." :value="content" @input="(e: any) => content = e.detail.value" :maxlength="500" />
|
||||
<view :class="['submit-btn', submitting ? 'disabled' : '']" @tap="submitting ? undefined : handleSubmit">
|
||||
<text class="submit-btn-text">{{ submitting ? '提交中...' : '提交' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getTaskDetail, submitRecord, type FollowUpTask } from '@/services/followup'
|
||||
import { trackEvent } from '@/services/analytics'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const task = ref<FollowUpTask | null>(null)
|
||||
const content = ref('')
|
||||
const submitting = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
let id = ''
|
||||
|
||||
const getStatusLabel = (status: string) => status === 'completed' ? '已完成' : status === 'overdue' ? '已过期' : '待完成'
|
||||
const getStatusClass = (status: string) => status === 'completed' ? 'status-completed' : status === 'overdue' ? 'status-overdue' : 'status-pending'
|
||||
|
||||
const countdown = computed(() => {
|
||||
if (!task.value || task.value.status === 'completed') return null
|
||||
const now = new Date()
|
||||
const due = new Date(task.value.planned_date)
|
||||
const diffMs = due.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
if (diffDays < 0) return { text: `已过期 ${Math.abs(diffDays)} 天`, urgent: true }
|
||||
if (diffDays === 0) return { text: '今天截止', urgent: true }
|
||||
if (diffDays <= 3) return { text: `还剩 ${diffDays} 天`, urgent: true }
|
||||
return { text: `还剩 ${diffDays} 天`, urgent: false }
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!content.value.trim()) { uni.showToast({ title: '请输入内容', icon: 'none' }); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
await submitRecord(id, { result: content.value.trim(), patient_condition: content.value.trim() })
|
||||
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||
trackEvent('followup_submit', { task_id: id })
|
||||
content.value = ''
|
||||
} catch { uni.showToast({ title: '提交失败', icon: 'none' }) }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
id = query?.id || ''
|
||||
if (!id) { error.value = true; loading.value = false; return }
|
||||
loading.value = true
|
||||
getTaskDetail(id).then(data => { task.value = data }).catch(() => { error.value = true }).finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.detail-card { @include card; margin-bottom: 16px; }
|
||||
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-bottom: 12px; }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; }
|
||||
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.detail-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.status-completed { color: $acc; }
|
||||
.status-overdue { color: $dan; }
|
||||
.status-pending { color: $pri; }
|
||||
.countdown { margin-top: 8px; padding: 8px 12px; border-radius: $r; background: rgba(250,173,20,0.08); }
|
||||
.countdown-urgent { background: rgba(255,77,79,0.08); }
|
||||
.countdown-text { font-size: var(--tk-font-cap); color: $wrn; }
|
||||
.countdown-urgent .countdown-text { color: $dan; }
|
||||
.detail-desc { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.05); }
|
||||
.detail-desc-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
|
||||
.submit-card { @include card; }
|
||||
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.submit-textarea { width: 100%; min-height: 120px; border: 1px solid rgba(0,0,0,0.08); border-radius: $r; padding: 12px; font-size: var(--tk-font-body); box-sizing: border-box; }
|
||||
.submit-btn { margin-top: 16px; height: $touch-min; background: $pri; border-radius: $r; @include flex-center; }
|
||||
.submit-btn.disabled { opacity: 0.5; }
|
||||
.submit-btn-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
|
||||
</style>
|
||||
129
apps/miniprogram-uniapp/src/pages-sub/mall/index.vue
Normal file
129
apps/miniprogram-uniapp/src/pages-sub/mall/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- 积分概览 -->
|
||||
<view class="points-card card">
|
||||
<text class="points-label">我的积分</text>
|
||||
<text class="points-value">{{ points }}</text>
|
||||
<view class="checkin-btn" @tap="doCheckin">
|
||||
{{ checkinDone ? '已签到' : '每日签到' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品列表 -->
|
||||
<text class="section-title">兑换商品</text>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<EmptyState v-else-if="products.length === 0" icon="🎁" title="暂无可兑换商品" />
|
||||
<view v-else class="product-grid">
|
||||
<view v-for="item in products" :key="item.id" class="product-card">
|
||||
<text class="product-name">{{ item.name }}</text>
|
||||
<text class="product-points">{{ item.points }} 积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/services/request'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const points = ref(0)
|
||||
const products = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const checkinDone = ref(false)
|
||||
|
||||
async function fetchPoints() {
|
||||
try {
|
||||
const res = await api.get<any>('/health/points')
|
||||
if (res) points.value = res.points || 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function fetchProducts() {
|
||||
loading.value = true
|
||||
try { products.value = await api.get<any[]>('/health/points/products') || [] }
|
||||
catch { products.value = [] }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function doCheckin() {
|
||||
if (checkinDone.value) return
|
||||
try {
|
||||
await api.post('/health/points/checkin')
|
||||
checkinDone.value = true
|
||||
uni.showToast({ title: '签到成功', icon: 'success' })
|
||||
await fetchPoints()
|
||||
} catch {
|
||||
uni.showToast({ title: '签到失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { fetchPoints(); fetchProducts() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
|
||||
.card { @include card; }
|
||||
|
||||
.points-card {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: var(--tk-font-display);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
@include serif-number;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.checkin-btn {
|
||||
@include btn-primary;
|
||||
width: auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
|
||||
.section-title { @include section-title; }
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-points {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<view :class="['alerts-page', elderClass]">
|
||||
<template v-if="!authStore.currentPatient">
|
||||
<view class="alerts-empty">
|
||||
<text class="alerts-empty-text">请先完善个人档案</text>
|
||||
<view class="alerts-empty-action" @tap="goAddFamily">
|
||||
<text class="alerts-empty-action-text">去建档</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="alerts-tabs">
|
||||
<view
|
||||
v-for="tab in STATUS_TABS" :key="tab.key"
|
||||
:class="['alerts-tab', status === tab.key ? 'active' : '']"
|
||||
@tap="handleTabChange(tab.key)"
|
||||
>
|
||||
<text :class="['alerts-tab-text', status === tab.key ? 'active' : '']">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="alerts.length === 0 && !loading" class="alerts-empty">
|
||||
<text class="alerts-empty-text">暂无告警记录</text>
|
||||
<text class="alerts-empty-hint">您的各项指标正常</text>
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="alerts-scroll" @scrolltolower="loadMore">
|
||||
<view class="alert-card" v-for="item in alerts" :key="item.id">
|
||||
<view class="alert-header">
|
||||
<view :class="['alert-badge', (SEVERITY_MAP[item.severity] || SEVERITY_MAP.warning).className]">
|
||||
<text class="alert-badge-text">{{ (SEVERITY_MAP[item.severity] || SEVERITY_MAP.warning).label }}</text>
|
||||
</view>
|
||||
<text class="alert-time">{{ formatDate(item.created_at) }}</text>
|
||||
</view>
|
||||
<text class="alert-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && alerts.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listPatientAlerts, type Alert } from '@/services/alert'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||
info: { label: '提示', className: 'sev-info' },
|
||||
warning: { label: '警告', className: 'sev-warning' },
|
||||
critical: { label: '严重', className: 'sev-critical' },
|
||||
urgent: { label: '紧急', className: 'sev-urgent' },
|
||||
}
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'acknowledged', label: '已确认' },
|
||||
{ key: 'resolved', label: '已恢复' },
|
||||
]
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const alerts = ref<Alert[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const status = ref('')
|
||||
const loading = ref(false)
|
||||
let loadingGuard = false
|
||||
|
||||
const fetchAlerts = async (pageNum: number, s: string, isRefresh = false) => {
|
||||
if (!authStore.currentPatient || loadingGuard) return
|
||||
loadingGuard = true
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listPatientAlerts(authStore.currentPatient.id, { page: pageNum, page_size: 20, status: s || undefined })
|
||||
const list = res.data || []
|
||||
alerts.value = isRefresh ? list : [...alerts.value, ...list]
|
||||
total.value = res.total
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loadingGuard = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => { status.value = key; fetchAlerts(1, key, true) }
|
||||
const loadMore = () => { if (!loading.value && alerts.value.length < total.value) fetchAlerts(page.value + 1, status.value) }
|
||||
const goAddFamily = () => uni.navigateTo({ url: '/pages-sub/pkg-profile/family-add/index' })
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString()
|
||||
|
||||
onShow(() => { fetchAlerts(1, status.value, true) })
|
||||
onPullDownRefresh(() => { fetchAlerts(1, status.value, true).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.alerts-page { min-height: 100vh; background: $bg; }
|
||||
.alerts-tabs { display: flex; padding: 12px 24px; gap: 8px; background: $card; }
|
||||
.alerts-tab { padding: 6px 16px; min-height: $touch-min; display: flex; align-items: center; border-radius: 20px; background: rgba(0,0,0,0.04); }
|
||||
.alerts-tab.active { background: $pri; }
|
||||
.alerts-tab-text { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.alerts-tab-text.active { color: $white; }
|
||||
.alerts-scroll { height: calc(100vh - 52px); }
|
||||
.alert-card { @include card; margin: 0 24px 12px; }
|
||||
.alert-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.alert-badge { padding: 2px 8px; border-radius: 4px; }
|
||||
.sev-info { background: rgba(0,0,0,0.05); }
|
||||
.sev-warning { background: rgba(250,173,20,0.15); }
|
||||
.sev-critical { background: rgba(255,77,79,0.15); }
|
||||
.sev-urgent { background: rgba(114,46,209,0.15); }
|
||||
.alert-badge-text { font-size: var(--tk-font-micro); color: $tx2; }
|
||||
.alert-time { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.alert-title { font-size: var(--tk-font-body); color: $tx; line-height: 1.5; }
|
||||
.alerts-empty { @include flex-center; flex-direction: column; padding: 80px 40px; }
|
||||
.alerts-empty-text { font-size: var(--tk-font-body); color: $tx2; }
|
||||
.alerts-empty-hint { font-size: var(--tk-font-cap); color: $tx3; margin-top: 8px; }
|
||||
.alerts-empty-action { margin-top: 20px; padding: 8px 24px; min-height: $touch-min; display: flex; align-items: center; background: $pri; border-radius: $r; }
|
||||
.alerts-empty-action-text { color: $white; font-size: var(--tk-font-body); }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['dm-page', elderClass]">
|
||||
<view class="dm-hero">
|
||||
<view class="dm-hero-icon"><text class="dm-hero-icon-text">记</text></view>
|
||||
<text class="dm-hero-title">日常监测</text>
|
||||
<text class="dm-hero-sub">每日健康数据上报</text>
|
||||
</view>
|
||||
|
||||
<view class="dm-card">
|
||||
<view class="dm-card-header">
|
||||
<text class="dm-card-title">记录日期</text>
|
||||
<text v-if="isToday" class="dm-card-badge">今日</text>
|
||||
</view>
|
||||
<picker mode="selector" :range="dateList" :value="dateIdx" @change="(e: any) => dateIdx = Number(e.detail.value)">
|
||||
<view class="dm-date-row">
|
||||
<text class="dm-date-value">{{ recordDate }}</text>
|
||||
<text class="dm-date-arrow">V</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 晨间体征 -->
|
||||
<view :class="['dm-group', collapsed.morning ? 'dm-group-collapsed' : '']">
|
||||
<view class="dm-group-header" @tap="toggleSection('morning')">
|
||||
<text class="dm-group-title">晨间体征</text>
|
||||
<text :class="['dm-group-arrow', collapsed.morning ? '' : 'dm-group-arrow-open']">▸</text>
|
||||
</view>
|
||||
<view v-if="!collapsed.morning" class="dm-group-body">
|
||||
<view class="dm-bp-group">
|
||||
<view class="dm-bp-field">
|
||||
<text class="dm-field-label">收缩压</text>
|
||||
<input type="digit" :class="['dm-input-box', morningSysAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 120" :value="morningSystolic" @input="(e: any) => morningSystolic = e.detail.value" />
|
||||
<text v-if="morningSysAbnormal.abnormal" :class="['dm-field-warning', morningSysAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
|
||||
{{ morningSysAbnormal.direction === 'high' ? '偏高' : '偏低' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="dm-bp-divider">
|
||||
<view class="dm-bp-line" /><text class="dm-bp-slash">/</text><view class="dm-bp-line" />
|
||||
</view>
|
||||
<view class="dm-bp-field">
|
||||
<text class="dm-field-label">舒张压</text>
|
||||
<input type="digit" :class="['dm-input-box', morningDiaAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 80" :value="morningDiastolic" @input="(e: any) => morningDiastolic = e.detail.value" />
|
||||
<text v-if="morningDiaAbnormal.abnormal" :class="['dm-field-warning', morningDiaAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
|
||||
{{ morningDiaAbnormal.direction === 'high' ? '偏高' : '偏低' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="dm-field-unit">mmHg</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 晚间体征 -->
|
||||
<view :class="['dm-group', collapsed.evening ? 'dm-group-collapsed' : '']">
|
||||
<view class="dm-group-header" @tap="toggleSection('evening')">
|
||||
<text class="dm-group-title">晚间体征</text>
|
||||
<text :class="['dm-group-arrow', collapsed.evening ? '' : 'dm-group-arrow-open']">▸</text>
|
||||
</view>
|
||||
<view v-if="!collapsed.evening" class="dm-group-body">
|
||||
<view class="dm-bp-group">
|
||||
<view class="dm-bp-field">
|
||||
<text class="dm-field-label">收缩压</text>
|
||||
<input type="digit" :class="['dm-input-box', eveningSysAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 120" :value="eveningSystolic" @input="(e: any) => eveningSystolic = e.detail.value" />
|
||||
<text v-if="eveningSysAbnormal.abnormal" :class="['dm-field-warning', eveningSysAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
|
||||
{{ eveningSysAbnormal.direction === 'high' ? '偏高' : '偏低' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="dm-bp-divider">
|
||||
<view class="dm-bp-line" /><text class="dm-bp-slash">/</text><view class="dm-bp-line" />
|
||||
</view>
|
||||
<view class="dm-bp-field">
|
||||
<text class="dm-field-label">舒张压</text>
|
||||
<input type="digit" :class="['dm-input-box', eveningDiaAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 80" :value="eveningDiastolic" @input="(e: any) => eveningDiastolic = e.detail.value" />
|
||||
<text v-if="eveningDiaAbnormal.abnormal" :class="['dm-field-warning', eveningDiaAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
|
||||
{{ eveningDiaAbnormal.direction === 'high' ? '偏高' : '偏低' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="dm-field-unit">mmHg</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 其他指标 -->
|
||||
<view :class="['dm-group', collapsed.other ? 'dm-group-collapsed' : '']">
|
||||
<view class="dm-group-header" @tap="toggleSection('other')">
|
||||
<text class="dm-group-title">其他指标</text>
|
||||
<text :class="['dm-group-arrow', collapsed.other ? '' : 'dm-group-arrow-open']">▸</text>
|
||||
</view>
|
||||
<view v-if="!collapsed.other" class="dm-group-body">
|
||||
<view class="dm-inner-field">
|
||||
<text class="dm-field-label">体重</text>
|
||||
<view class="dm-single-row">
|
||||
<input type="digit" class="dm-input-box dm-input-flex" placeholder="如 65.0" :value="weight" @input="(e: any) => weight = e.detail.value" />
|
||||
<text class="dm-unit-inline">kg</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="dm-inner-field">
|
||||
<text class="dm-field-label">血糖</text>
|
||||
<view class="dm-single-row">
|
||||
<input type="digit" :class="['dm-input-box', 'dm-input-flex', bloodSugarAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 5.6" :value="bloodSugar" @input="(e: any) => bloodSugar = e.detail.value" />
|
||||
<text class="dm-unit-inline">mmol/L</text>
|
||||
</view>
|
||||
<text v-if="bloodSugarAbnormal.abnormal" :class="['dm-field-warning', bloodSugarAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
|
||||
{{ bloodSugarAbnormal.direction === 'high' ? '偏高' : '偏低' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="dm-inner-field">
|
||||
<text class="dm-field-label">饮水量</text>
|
||||
<view class="dm-single-row">
|
||||
<input type="digit" class="dm-input-box dm-input-flex" placeholder="如 2000" :value="fluidIntake" @input="(e: any) => fluidIntake = e.detail.value" />
|
||||
<text class="dm-unit-inline">ml</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="dm-inner-field">
|
||||
<text class="dm-field-label">尿量</text>
|
||||
<view class="dm-single-row">
|
||||
<input type="digit" class="dm-input-box dm-input-flex" placeholder="如 1500" :value="urineOutput" @input="(e: any) => urineOutput = e.detail.value" />
|
||||
<text class="dm-unit-inline">ml</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="dm-inner-field">
|
||||
<text class="dm-field-label">备注</text>
|
||||
<input class="dm-input-box dm-input-full" placeholder="如:头晕、乏力等(可选)" :value="notes" @input="(e: any) => notes = e.detail.value" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view :class="['dm-submit', submitting ? 'dm-submit-disabled' : '']" @tap="submitting ? undefined : handleSubmit">
|
||||
<text class="dm-submit-text">{{ submitting ? '提交中...' : '提交上报' }}</text>
|
||||
</view>
|
||||
<view class="dm-reset" @tap="resetForm"><text class="dm-reset-text">清空表单</text></view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { validateNum } from '@/utils/validate'
|
||||
import { createDailyMonitoring } from '@/services/health'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useHealthStore } from '@/stores/health'
|
||||
import { usePointsStore } from '@/stores/points'
|
||||
import { clearRequestCache } from '@/services/request'
|
||||
import { trackEvent } from '@/services/analytics'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const BP_RANGE = { min: 30, minMsg: '血压值不能低于30', max: 300, maxMsg: '血压值不能高于300', optional: true }
|
||||
const WEIGHT_RANGE = { min: 1, minMsg: '体重不能低于1kg', max: 500, maxMsg: '体重不能高于500kg', optional: true }
|
||||
const SUGAR_RANGE = { min: 0.1, minMsg: '血糖值不能低于0.1', max: 50, maxMsg: '血糖值不能高于50', optional: true }
|
||||
const VOLUME_RANGE = { min: 0, minMsg: '数值不能为负', max: 10000, maxMsg: '数值超出合理范围', optional: true }
|
||||
|
||||
const REFERENCE_RANGES: Record<string, { min: number; max: number } | null> = {
|
||||
systolic: { min: 90, max: 140 }, diastolic: { min: 60, max: 90 },
|
||||
bloodSugar: { min: 3.9, max: 6.1 }, weight: null, fluidIntake: null, urineOutput: null,
|
||||
}
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
morningSystolic: '晨间收缩压', morningDiastolic: '晨间舒张压',
|
||||
eveningSystolic: '晚间收缩压', eveningDiastolic: '晚间舒张压', bloodSugar: '血糖',
|
||||
}
|
||||
|
||||
const checkAbnormal = (value: string, field: string): { abnormal: boolean; direction: 'high' | 'low' | null } => {
|
||||
const ref = REFERENCE_RANGES[field]
|
||||
if (!value || !ref) return { abnormal: false, direction: null }
|
||||
const n = parseFloat(value)
|
||||
if (isNaN(n)) return { abnormal: false, direction: null }
|
||||
if (n > ref.max) return { abnormal: true, direction: 'high' }
|
||||
if (n < ref.min) return { abnormal: true, direction: 'low' }
|
||||
return { abnormal: false, direction: null }
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const healthStore = useHealthStore()
|
||||
const pointsStore = usePointsStore()
|
||||
|
||||
const today = formatDate(new Date())
|
||||
const dateList = ref(Array.from({ length: 30 }, (_, i) => { const d = new Date(); d.setDate(d.getDate() - i); return formatDate(d) }))
|
||||
const dateIdx = ref(0)
|
||||
const recordDate = computed(() => dateList.value[dateIdx.value])
|
||||
const isToday = computed(() => recordDate.value === today)
|
||||
|
||||
const morningSystolic = ref('')
|
||||
const morningDiastolic = ref('')
|
||||
const eveningSystolic = ref('')
|
||||
const eveningDiastolic = ref('')
|
||||
const weight = ref('')
|
||||
const bloodSugar = ref('')
|
||||
const fluidIntake = ref('')
|
||||
const urineOutput = ref('')
|
||||
const notes = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
type SectionKey = 'morning' | 'evening' | 'other'
|
||||
const collapsed = ref<Record<SectionKey, boolean>>({ morning: false, evening: false, other: true })
|
||||
const toggleSection = (key: SectionKey) => { collapsed.value = { ...collapsed.value, [key]: !collapsed.value[key] } }
|
||||
|
||||
const morningSysAbnormal = computed(() => checkAbnormal(morningSystolic.value, 'systolic'))
|
||||
const morningDiaAbnormal = computed(() => checkAbnormal(morningDiastolic.value, 'diastolic'))
|
||||
const eveningSysAbnormal = computed(() => checkAbnormal(eveningSystolic.value, 'systolic'))
|
||||
const eveningDiaAbnormal = computed(() => checkAbnormal(eveningDiastolic.value, 'diastolic'))
|
||||
const bloodSugarAbnormal = computed(() => checkAbnormal(bloodSugar.value, 'bloodSugar'))
|
||||
|
||||
const resetForm = () => {
|
||||
morningSystolic.value = ''; morningDiastolic.value = ''
|
||||
eveningSystolic.value = ''; eveningDiastolic.value = ''
|
||||
weight.value = ''; bloodSugar.value = ''; fluidIntake.value = ''; urineOutput.value = ''; notes.value = ''
|
||||
}
|
||||
|
||||
const gatherAbnormalFields = (): string[] => {
|
||||
const checks: [string, string][] = [
|
||||
['morningSystolic', morningSystolic.value], ['morningDiastolic', morningDiastolic.value],
|
||||
['eveningSystolic', eveningSystolic.value], ['eveningDiastolic', eveningDiastolic.value],
|
||||
['bloodSugar', bloodSugar.value],
|
||||
]
|
||||
return checks.filter(([field, value]) => checkAbnormal(value, field).abnormal).map(([field]) => FIELD_LABELS[field])
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!authStore.currentPatient) { uni.showToast({ title: '请先选择就诊人', icon: 'none' }); return }
|
||||
const hasData = morningSystolic.value || morningDiastolic.value || eveningSystolic.value || eveningDiastolic.value || weight.value || bloodSugar.value || fluidIntake.value || urineOutput.value
|
||||
if (!hasData) { uni.showToast({ title: '请至少填写一项数据', icon: 'none' }); return }
|
||||
if ((morningSystolic.value && !morningDiastolic.value) || (!morningSystolic.value && morningDiastolic.value)) { uni.showToast({ title: '晨起血压请同时填写收缩压和舒张压', icon: 'none' }); return }
|
||||
if ((eveningSystolic.value && !eveningDiastolic.value) || (!eveningSystolic.value && eveningDiastolic.value)) { uni.showToast({ title: '晚间血压请同时填写收缩压和舒张压', icon: 'none' }); return }
|
||||
|
||||
const parseNum = (v: string) => v ? parseFloat(v) : undefined
|
||||
const fields = {
|
||||
morningSystolic: parseNum(morningSystolic.value), morningDiastolic: parseNum(morningDiastolic.value),
|
||||
eveningSystolic: parseNum(eveningSystolic.value), eveningDiastolic: parseNum(eveningDiastolic.value),
|
||||
weight: parseNum(weight.value), bloodSugar: parseNum(bloodSugar.value),
|
||||
fluidIntake: parseNum(fluidIntake.value), urineOutput: parseNum(urineOutput.value),
|
||||
}
|
||||
const validations: [number | undefined, string, typeof BP_RANGE][] = [
|
||||
[fields.morningSystolic, '晨起收缩压', BP_RANGE], [fields.morningDiastolic, '晨起舒张压', BP_RANGE],
|
||||
[fields.eveningSystolic, '晚间收缩压', BP_RANGE], [fields.eveningDiastolic, '晚间舒张压', BP_RANGE],
|
||||
[fields.weight, '体重', WEIGHT_RANGE], [fields.bloodSugar, '血糖', SUGAR_RANGE],
|
||||
[fields.fluidIntake, '饮水量', VOLUME_RANGE], [fields.urineOutput, '尿量', VOLUME_RANGE],
|
||||
]
|
||||
for (const [value, label, range] of validations) {
|
||||
const err = validateNum(value, label, range)
|
||||
if (err) { uni.showToast({ title: err, icon: 'none' }); return }
|
||||
}
|
||||
|
||||
const abnormalFields = gatherAbnormalFields()
|
||||
if (abnormalFields.length > 0) {
|
||||
const confirmed = await uni.showModal({ title: '数值异常提醒', content: `以下指标超出正常范围:${abnormalFields.join('、')}。确认提交?`, confirmText: '确认提交', cancelText: '返回修改' })
|
||||
if (!confirmed.confirm) return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await createDailyMonitoring({
|
||||
patient_id: authStore.currentPatient.id, record_date: recordDate.value,
|
||||
morning_bp_systolic: fields.morningSystolic, morning_bp_diastolic: fields.morningDiastolic,
|
||||
evening_bp_systolic: fields.eveningSystolic, evening_bp_diastolic: fields.eveningDiastolic,
|
||||
weight: fields.weight, blood_sugar: fields.bloodSugar,
|
||||
fluid_intake: fields.fluidIntake, urine_output: fields.urineOutput,
|
||||
notes: notes.value || undefined,
|
||||
})
|
||||
trackEvent('daily_monitoring_submit', { date: recordDate.value })
|
||||
healthStore.clearCache(); clearRequestCache('/health/'); pointsStore.invalidate()
|
||||
uni.showToast({ title: '上报成功', icon: 'success' })
|
||||
setTimeout(() => uni.showToast({ title: '+10 健康积分', icon: 'none', duration: 1500 }), 1600)
|
||||
setTimeout(() => uni.navigateBack(), 3200)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '上报失败'
|
||||
if (msg.includes('已有记录') || msg.includes('already exists')) {
|
||||
uni.showModal({ title: '提示', content: '该日期已有监测记录,请选择其他日期', showCancel: false })
|
||||
} else {
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => { uni.setNavigationBarTitle({ title: '日常监测上报' }) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dm-page { min-height: 100vh; background: $bg; padding: 0 24px 160px; }
|
||||
.dm-hero { @include flex-center; flex-direction: column; padding: 40px 0 24px; }
|
||||
.dm-hero-icon { width: 56px; height: 56px; border-radius: 50%; background: $pri; @include flex-center; margin-bottom: 12px; }
|
||||
.dm-hero-icon-text { color: $white; font-size: var(--tk-font-body); font-weight: 600; }
|
||||
.dm-hero-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
|
||||
.dm-hero-sub { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; }
|
||||
.dm-card { @include card; margin-bottom: 16px; }
|
||||
.dm-card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.dm-card-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
|
||||
.dm-card-badge { font-size: var(--tk-font-micro); color: $pri; background: rgba($pri, 0.1); padding: 2px 8px; border-radius: 4px; }
|
||||
.dm-date-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-top: 1px solid rgba(0,0,0,0.05); margin-top: 8px; }
|
||||
.dm-date-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.dm-date-arrow { color: $tx3; font-size: var(--tk-font-micro); }
|
||||
.dm-group { @include card; margin-bottom: 12px; }
|
||||
.dm-group-header { display: flex; justify-content: space-between; align-items: center; min-height: $touch-min; }
|
||||
.dm-group-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
|
||||
.dm-group-arrow { font-size: var(--tk-font-cap); color: $tx3; transition: transform 0.2s; }
|
||||
.dm-group-arrow-open { transform: rotate(90deg); }
|
||||
.dm-group-body { padding-top: 12px; border-top: 1px solid rgba(0,0,0,0.05); margin-top: 10px; }
|
||||
.dm-bp-group { display: flex; align-items: flex-end; gap: 8px; }
|
||||
.dm-bp-field { flex: 1; }
|
||||
.dm-bp-divider { display: flex; flex-direction: column; align-items: center; gap: 4px; padding-bottom: 10px; }
|
||||
.dm-bp-line { width: 1px; height: 12px; background: rgba(0,0,0,0.1); }
|
||||
.dm-bp-slash { color: $tx3; font-size: var(--tk-font-cap); }
|
||||
.dm-field-label { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-bottom: 6px; }
|
||||
.dm-input-box { height: 44px; border: 1px solid rgba(0,0,0,0.08); border-radius: $r; padding: 0 12px; font-size: var(--tk-font-body); width: 100%; box-sizing: border-box; }
|
||||
.dm-input-abnormal { border-color: $wrn; }
|
||||
.dm-input-flex { flex: 1; }
|
||||
.dm-input-full { width: 100%; }
|
||||
.dm-field-unit { font-size: var(--tk-font-cap); color: $tx3; margin-top: 6px; display: block; }
|
||||
.dm-field-warning { font-size: var(--tk-font-micro); color: $wrn; margin-top: 4px; display: block; }
|
||||
.dm-field-warning-low { color: $info; }
|
||||
.dm-inner-field { margin-bottom: 16px; }
|
||||
.dm-single-row { display: flex; align-items: center; gap: 8px; }
|
||||
.dm-unit-inline { font-size: var(--tk-font-cap); color: $tx3; white-space: nowrap; }
|
||||
.dm-submit { margin-top: 24px; height: 48px; background: $pri; border-radius: $r; @include flex-center; }
|
||||
.dm-submit-disabled { opacity: 0.5; }
|
||||
.dm-submit-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
|
||||
.dm-reset { margin-top: 12px; @include flex-center; min-height: $touch-min; }
|
||||
.dm-reset-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
242
apps/miniprogram-uniapp/src/pages-sub/pkg-health/input/index.vue
Normal file
242
apps/miniprogram-uniapp/src/pages-sub/pkg-health/input/index.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<view :class="['input-page', elderClass]">
|
||||
<view class="input-hero">
|
||||
<view class="input-hero-icon">
|
||||
<text class="input-hero-icon-text">录</text>
|
||||
</view>
|
||||
<text class="input-hero-title">体征录入</text>
|
||||
<text class="input-hero-sub">记录今日健康数据</text>
|
||||
</view>
|
||||
|
||||
<view class="input-sync-entry" @tap="goDeviceSync">
|
||||
<text class="input-sync-entry-text">从设备同步</text>
|
||||
<text class="input-sync-entry-hint">蓝牙连接设备自动获取数据</text>
|
||||
</view>
|
||||
|
||||
<view class="input-card">
|
||||
<view class="input-card-header">
|
||||
<view class="input-card-indicator">
|
||||
<text class="input-card-indicator-char">{{ indicatorInitial }}</text>
|
||||
</view>
|
||||
<text class="input-card-label">指标类型</text>
|
||||
</view>
|
||||
<picker mode="selector" :range="indicatorLabels" :value="indicatorIdx" @change="onIndicatorChange">
|
||||
<view class="input-picker-row">
|
||||
<text class="input-picker-value">{{ INDICATORS[indicatorIdx].label }}</text>
|
||||
<text class="input-picker-arrow">V</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="isBpIndicator" class="input-card">
|
||||
<text class="input-section-title">血压数值</text>
|
||||
<view class="input-bp-group">
|
||||
<view class="input-bp-field">
|
||||
<text class="input-field-label">收缩压</text>
|
||||
<input type="digit" class="input-field-box" placeholder="如 120" :value="systolic" @input="(e: any) => systolic = e.detail.value" />
|
||||
</view>
|
||||
<view class="input-bp-divider">
|
||||
<view class="input-bp-line" />
|
||||
<text class="input-bp-slash">/</text>
|
||||
<view class="input-bp-line" />
|
||||
</view>
|
||||
<view class="input-bp-field">
|
||||
<text class="input-field-label">舒张压</text>
|
||||
<input type="digit" class="input-field-box" placeholder="如 80" :value="diastolic" @input="(e: any) => diastolic = e.detail.value" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="input-field-unit">mmHg</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="input-card">
|
||||
<text class="input-section-title">检测数值</text>
|
||||
<input type="digit" class="input-field-box input-field-full" placeholder="请输入数值" :value="val" @input="(e: any) => val = e.detail.value" />
|
||||
<text class="input-field-unit">{{ unitLabel }}</text>
|
||||
</view>
|
||||
|
||||
<view class="input-card">
|
||||
<text class="input-section-title">备注</text>
|
||||
<input class="input-field-box input-field-full" placeholder="如:饭后2小时(可选)" :value="note" @input="(e: any) => note = e.detail.value" />
|
||||
</view>
|
||||
|
||||
<view :class="['input-submit', submitting ? 'input-submit-disabled' : '']" @tap="submitting ? undefined : handleSubmit">
|
||||
<text class="input-submit-text">{{ submitting ? '提交中...' : '提交录入' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { num, validateStr } from '@/utils/validate'
|
||||
import { inputVitalSign, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } from '@/services/health'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useHealthStore } from '@/stores/health'
|
||||
import { usePointsStore } from '@/stores/points'
|
||||
import { clearRequestCache } from '@/services/request'
|
||||
import { trackEvent } from '@/services/analytics'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const INDICATORS = [
|
||||
{ value: 'blood_pressure', label: '晨间血压 (mmHg)' },
|
||||
{ value: 'blood_pressure_evening', label: '晚间血压 (mmHg)' },
|
||||
{ value: 'heart_rate', label: '心率 (bpm)' },
|
||||
{ value: 'blood_sugar_fasting', label: '空腹血糖 (mmol/L)' },
|
||||
{ value: 'blood_sugar_postprandial', label: '餐后血糖 (mmol/L)' },
|
||||
{ value: 'weight', label: '体重 (kg)' },
|
||||
{ value: 'temperature', label: '体温 (℃)' },
|
||||
]
|
||||
const BP_INDICATORS = ['blood_pressure', 'blood_pressure_evening']
|
||||
|
||||
const valueCheck = num({ posMsg: '请输入有效数值' })
|
||||
const systolicCheck = num({ min: 60, minMsg: '收缩压过低', max: 250, maxMsg: '收缩压过高,请及时就医', optional: true })
|
||||
const diastolicCheck = num({ min: 40, minMsg: '舒张压过低', max: 150, maxMsg: '舒张压过高,请及时就医', optional: true })
|
||||
|
||||
function getWarnForIndicator(thresholds: HealthThreshold[], indicator: string) {
|
||||
const isBp = BP_INDICATORS.includes(indicator)
|
||||
const high = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'high')
|
||||
const low = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'low')
|
||||
if (!high && !low) return null
|
||||
const warningMap: Record<string, string> = {
|
||||
blood_pressure: '收缩压偏高,建议及时就医',
|
||||
blood_pressure_evening: '收缩压偏高,建议及时就医',
|
||||
heart_rate: '心率异常,请注意休息',
|
||||
blood_sugar_fasting: '血糖偏高,建议就医检查',
|
||||
blood_sugar_postprandial: '血糖偏高,建议就医检查',
|
||||
}
|
||||
return { max: high?.threshold_value, min: low?.threshold_value, warning: warningMap[indicator] ?? '数值异常,请关注' }
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const healthStore = useHealthStore()
|
||||
const pointsStore = usePointsStore()
|
||||
|
||||
const indicatorIdx = ref(0)
|
||||
const thresholds = ref<HealthThreshold[]>(DEFAULT_THRESHOLDS)
|
||||
const val = ref('')
|
||||
const systolic = ref('')
|
||||
const diastolic = ref('')
|
||||
const note = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const indicatorLabels = INDICATORS.map(i => i.label)
|
||||
const isBpIndicator = computed(() => BP_INDICATORS.includes(INDICATORS[indicatorIdx.value].value))
|
||||
const indicatorInitial = computed(() => INDICATORS[indicatorIdx.value].label.charAt(0))
|
||||
const unitLabel = computed(() => INDICATORS[indicatorIdx.value].label.match(/\((.+)\)/)?.[1] || '')
|
||||
|
||||
const onIndicatorChange = (e: any) => { indicatorIdx.value = Number(e.detail.value) }
|
||||
|
||||
const goDeviceSync = () => {
|
||||
uni.navigateTo({ url: '/pages-sub/device-sync/index?returnTo=input' })
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
getHealthThresholds().then(t => { if (t.length > 0) thresholds.value = t })
|
||||
try {
|
||||
const raw = uni.getStorageSync('device_sync_result')
|
||||
if (!raw) return
|
||||
uni.removeStorageSync('device_sync_result')
|
||||
const syncData: Record<string, number> = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
if (syncData.systolic != null && syncData.diastolic != null) {
|
||||
indicatorIdx.value = 0
|
||||
systolic.value = String(syncData.systolic)
|
||||
diastolic.value = String(syncData.diastolic)
|
||||
} else if (syncData.blood_sugar != null) {
|
||||
indicatorIdx.value = 3
|
||||
val.value = String(syncData.blood_sugar)
|
||||
} else if (syncData.heart_rate != null) {
|
||||
indicatorIdx.value = 2
|
||||
val.value = String(syncData.heart_rate)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const patient = authStore.currentPatient
|
||||
if (!patient) { uni.showToast({ title: '请先选择就诊人', icon: 'none' }); return }
|
||||
|
||||
const currentIndicator = INDICATORS[indicatorIdx.value].value
|
||||
if (BP_INDICATORS.includes(currentIndicator)) {
|
||||
if (!systolic.value || !diastolic.value) { uni.showToast({ title: '请填写收缩压和舒张压', icon: 'none' }); return }
|
||||
} else {
|
||||
if (!val.value) { uni.showToast({ title: '请输入数值', icon: 'none' }); return }
|
||||
}
|
||||
|
||||
const input = BP_INDICATORS.includes(currentIndicator)
|
||||
? { indicator_type: currentIndicator as 'blood_pressure' | 'blood_pressure_evening', value: parseFloat(systolic.value), extra: { systolic: parseFloat(systolic.value), diastolic: parseFloat(diastolic.value) } }
|
||||
: { indicator_type: currentIndicator as any, value: parseFloat(val.value) }
|
||||
|
||||
const valueResult = valueCheck.safeParse(input.value)
|
||||
if (!valueResult.ok) { uni.showToast({ title: valueResult.message, icon: 'none' }); return }
|
||||
if (input.extra?.systolic !== undefined) {
|
||||
const r = systolicCheck.safeParse(input.extra.systolic)
|
||||
if (!r.ok) { uni.showToast({ title: r.message, icon: 'none' }); return }
|
||||
}
|
||||
if (input.extra?.diastolic !== undefined) {
|
||||
const r = diastolicCheck.safeParse(input.extra.diastolic)
|
||||
if (!r.ok) { uni.showToast({ title: r.message, icon: 'none' }); return }
|
||||
}
|
||||
if (note.value) {
|
||||
const err = validateStr(note.value, 200, '备注')
|
||||
if (err) { uni.showToast({ title: err, icon: 'none' }); return }
|
||||
}
|
||||
|
||||
const threshold = getWarnForIndicator(thresholds.value, currentIndicator)
|
||||
if (threshold) {
|
||||
const v = input.value
|
||||
if ((threshold.max && v > threshold.max) || (threshold.min && v < threshold.min)) {
|
||||
await uni.showModal({ title: '健康提示', content: threshold.warning, showCancel: false })
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await inputVitalSign(patient.id, { ...input, note: note.value || undefined })
|
||||
healthStore.clearCache()
|
||||
clearRequestCache('/health/')
|
||||
pointsStore.invalidate()
|
||||
uni.showToast({ title: '录入成功', icon: 'success' })
|
||||
trackEvent('health_data_input', { type: currentIndicator })
|
||||
setTimeout(() => uni.navigateBack(), 1000)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '录入失败'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.input-page { min-height: 100vh; background: $bg; padding: 0 24px 120px; }
|
||||
.input-hero { @include flex-center; flex-direction: column; padding: 40px 0 24px; }
|
||||
.input-hero-icon { width: 56px; height: 56px; border-radius: 50%; background: $pri; @include flex-center; margin-bottom: 12px; }
|
||||
.input-hero-icon-text { color: $white; font-size: var(--tk-font-body); font-weight: 600; }
|
||||
.input-hero-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
|
||||
.input-hero-sub { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; }
|
||||
.input-sync-entry { @include card; display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.input-sync-entry-text { font-size: var(--tk-font-body); color: $pri; font-weight: 500; }
|
||||
.input-sync-entry-hint { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.input-card { @include card; margin-bottom: 16px; }
|
||||
.input-card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.input-card-indicator { width: 32px; height: 32px; border-radius: 8px; background: rgba($pri, 0.1); @include flex-center; }
|
||||
.input-card-indicator-char { color: $pri; font-size: var(--tk-font-cap); font-weight: 600; }
|
||||
.input-card-label { font-size: var(--tk-font-body); color: $tx; font-weight: 500; }
|
||||
.input-picker-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-top: 1px solid rgba(0,0,0,0.05); }
|
||||
.input-picker-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.input-picker-arrow { color: $tx3; font-size: var(--tk-font-micro); }
|
||||
.input-section-title { font-size: var(--tk-font-cap); color: $tx2; margin-bottom: 10px; display: block; }
|
||||
.input-bp-group { display: flex; align-items: flex-end; gap: 8px; }
|
||||
.input-bp-field { flex: 1; }
|
||||
.input-field-label { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-bottom: 6px; }
|
||||
.input-field-box { height: 44px; border: 1px solid rgba(0,0,0,0.08); border-radius: $r; padding: 0 12px; font-size: var(--tk-font-body); width: 100%; box-sizing: border-box; }
|
||||
.input-field-full { width: 100%; }
|
||||
.input-bp-divider { display: flex; flex-direction: column; align-items: center; gap: 4px; padding-bottom: 10px; }
|
||||
.input-bp-line { width: 1px; height: 12px; background: rgba(0,0,0,0.1); }
|
||||
.input-bp-slash { color: $tx3; font-size: var(--tk-font-cap); }
|
||||
.input-field-unit { font-size: var(--tk-font-cap); color: $tx3; margin-top: 6px; display: block; }
|
||||
.input-submit { margin-top: 24px; height: 48px; background: $pri; border-radius: $r; @include flex-center; }
|
||||
.input-submit-disabled { opacity: 0.5; }
|
||||
.input-submit-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
|
||||
</style>
|
||||
167
apps/miniprogram-uniapp/src/pages-sub/pkg-health/trend/index.vue
Normal file
167
apps/miniprogram-uniapp/src/pages-sub/pkg-health/trend/index.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<text class="page-title">健康趋势</text>
|
||||
|
||||
<!-- 指标切换 -->
|
||||
<view class="tab-group">
|
||||
<view v-for="tab in tabs" :key="tab.key"
|
||||
:class="['tab-item', { active: activeTab === tab.key }]"
|
||||
@tap="switchTab(tab.key)"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 趋势图(纯 CSS 柱状图) -->
|
||||
<view class="chart-card card">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<EmptyState v-else-if="trendData.length === 0" icon="📊" title="暂无趋势数据" />
|
||||
<view v-else class="bar-chart">
|
||||
<view v-for="(item, idx) in trendData" :key="idx" class="bar-group">
|
||||
<view class="bar" :style="{ height: getBarHeight(item.value) + '%' }" />
|
||||
<text class="bar-label">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<view v-if="trendData.length > 0" class="data-list card">
|
||||
<view v-for="(item, idx) in trendData" :key="idx" class="data-row">
|
||||
<text class="data-date">{{ item.label }}</text>
|
||||
<text class="data-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getTrend } from '@/services/health'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const tabs = [
|
||||
{ key: 'heart_rate', name: '心率' },
|
||||
{ key: 'blood_pressure', name: '血压' },
|
||||
{ key: 'blood_sugar', name: '血糖' },
|
||||
{ key: 'weight', name: '体重' },
|
||||
]
|
||||
|
||||
const activeTab = ref('heart_rate')
|
||||
const loading = ref(false)
|
||||
const trendData = ref<{ label: string; value: number }[]>([])
|
||||
|
||||
function switchTab(key: string) {
|
||||
activeTab.value = key
|
||||
fetchTrend()
|
||||
}
|
||||
|
||||
function getBarHeight(value: number): number {
|
||||
const max = Math.max(...trendData.value.map(d => d.value), 1)
|
||||
return (value / max) * 80
|
||||
}
|
||||
|
||||
async function fetchTrend() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTrend(activeTab.value, '7d')
|
||||
trendData.value = (res?.data_points || []).map((item) => ({
|
||||
label: item.date || '',
|
||||
value: Number(item.value) || 0,
|
||||
}))
|
||||
} catch {
|
||||
trendData.value = []
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onLoad(() => { fetchTrend() })
|
||||
onMounted(fetchTrend)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
.page-title { @include section-title; }
|
||||
.card { @include card; }
|
||||
|
||||
.tab-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 12px 24px;
|
||||
min-height: $touch-min;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: $r-pill;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
background: $card;
|
||||
white-space: nowrap;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&.active {
|
||||
background: $pri;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-around;
|
||||
height: 240px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.bar-group {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 32px;
|
||||
background: $pri;
|
||||
border-radius: 8px 8px 0 0;
|
||||
min-height: 4px;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.data-list { margin-top: 16px; }
|
||||
|
||||
.data-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.data-date {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
@include serif-number;
|
||||
}
|
||||
</style>
|
||||
142
apps/miniprogram-uniapp/src/pages-sub/pkg-mall/detail/index.vue
Normal file
142
apps/miniprogram-uniapp/src/pages-sub/pkg-mall/detail/index.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<view class="balance-card">
|
||||
<text class="balance-label">当前积分</text>
|
||||
<text class="balance-value">{{ balance.toLocaleString() }}</text>
|
||||
<view class="balance-stats">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value stat-earn">{{ (pointsStore.account?.total_earned ?? 0).toLocaleString() }}</text>
|
||||
<text class="stat-label">累计获得</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat-item">
|
||||
<text class="stat-value stat-spend">{{ (pointsStore.account?.total_spent ?? 0).toLocaleString() }}</text>
|
||||
<text class="stat-label">累计消费</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat-item">
|
||||
<text class="stat-value stat-expired">{{ (pointsStore.account?.total_expired ?? 0).toLocaleString() }}</text>
|
||||
<text class="stat-label">已过期</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="type-tabs">
|
||||
<view v-for="tab in TYPE_TABS" :key="tab.key"
|
||||
:class="['type-tab', activeTab === tab.key ? 'active' : '']"
|
||||
@tap="handleTabChange(tab.key)">
|
||||
<text class="type-tab-text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="transactions.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="" title="暂无积分记录" hint="签到或兑换后将显示记录" />
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="tx-scroll" @scrolltolower="loadMore">
|
||||
<view class="transaction-item" v-for="tx in transactions" :key="tx.id">
|
||||
<view :class="['tx-badge', `tx-badge-${getTypeClass(tx.type)}`]">
|
||||
<text class="tx-badge-text">{{ getTypeLabel(tx.type) }}</text>
|
||||
</view>
|
||||
<view class="tx-info">
|
||||
<text class="tx-desc">{{ tx.description || (tx.type === 'earn' ? '积分收入' : tx.type === 'spend' ? '积分消费' : '积分过期') }}</text>
|
||||
<text class="tx-date">{{ formatDate(tx.created_at) }}</text>
|
||||
</view>
|
||||
<view class="tx-amount-col">
|
||||
<text :class="['tx-amount', `tx-amount-${tx.type === 'earn' ? 'positive' : 'negative'}`]">{{ formatAmount(tx) }}</text>
|
||||
<text class="tx-remaining">余额 {{ tx.balance_after.toLocaleString() }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && transactions.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listMyTransactions, type PointsTransaction } from '@/services/points'
|
||||
import { usePointsStore } from '@/stores/points'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const TYPE_TABS = [{ key: '', label: '全部' }, { key: 'earn', label: '收入' }, { key: 'spend', label: '支出' }]
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const pointsStore = usePointsStore()
|
||||
const transactions = ref<PointsTransaction[]>([])
|
||||
const activeTab = ref('')
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
let loadingGuard = false
|
||||
|
||||
const balance = computed(() => pointsStore.account?.balance ?? 0)
|
||||
const getTypeLabel = (type: string) => type === 'earn' ? '收' : type === 'spend' ? '支' : '过'
|
||||
const getTypeClass = (type: string) => type === 'earn' ? 'earn' : type === 'spend' ? 'spend' : 'expired'
|
||||
const formatAmount = (tx: PointsTransaction) => tx.type === 'earn' ? `+${tx.amount.toLocaleString()}` : `-${tx.amount.toLocaleString()}`
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const fetchTransactions = async (pageNum: number, type: string, isRefresh = false) => {
|
||||
if (loadingGuard) return
|
||||
loadingGuard = true; loading.value = true
|
||||
try {
|
||||
const res = await listMyTransactions({ page: pageNum, page_size: 10 })
|
||||
let list = res.data || []
|
||||
if (type) list = list.filter(t => t.type === type)
|
||||
transactions.value = isRefresh ? list : [...transactions.value, ...list]
|
||||
total.value = res.total; page.value = pageNum
|
||||
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
finally { loadingGuard = false; loading.value = false }
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => { activeTab.value = key; fetchTransactions(1, key, true) }
|
||||
const loadMore = () => { if (!loading.value && transactions.value.length < total.value) fetchTransactions(page.value + 1, activeTab.value) }
|
||||
|
||||
onShow(() => { uni.setNavigationBarTitle({ title: '积分明细' }); Promise.all([pointsStore.refresh(), fetchTransactions(1, activeTab.value, true)]) })
|
||||
onPullDownRefresh(() => { Promise.all([pointsStore.refresh(), fetchTransactions(1, activeTab.value, true)]).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; }
|
||||
.balance-card { background: linear-gradient(135deg, $pri, darken($pri, 10%)); padding: 24px; margin: 0; }
|
||||
.balance-label { font-size: var(--tk-font-cap); color: rgba(255,255,255,0.8); display: block; }
|
||||
.balance-value { font-size: var(--tk-font-num); font-weight: 700; color: $white; display: block; margin: 8px 0 20px; }
|
||||
.balance-stats { display: flex; align-items: center; }
|
||||
.stat-item { flex: 1; text-align: center; }
|
||||
.stat-value { font-size: var(--tk-font-body); font-weight: 500; display: block; }
|
||||
.stat-earn { color: rgba(255,255,255,0.95); }
|
||||
.stat-spend { color: rgba(255,255,255,0.95); }
|
||||
.stat-expired { color: rgba(255,255,255,0.95); }
|
||||
.stat-label { font-size: var(--tk-font-cap); color: rgba(255,255,255,0.6); display: block; margin-top: 4px; }
|
||||
.stat-divider { width: 1px; height: 24px; background: rgba(255,255,255,0.2); }
|
||||
.type-tabs { display: flex; padding: 12px 24px; gap: 8px; background: $card; }
|
||||
.type-tab { padding: 6px 16px; min-height: $touch-min; display: flex; align-items: center; border-radius: 20px; background: rgba(0,0,0,0.04); }
|
||||
.type-tab.active { background: $pri; }
|
||||
.type-tab-text { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.type-tab.active .type-tab-text { color: $white; }
|
||||
.tx-scroll { height: calc(100vh - 200px); padding: 16px 24px; }
|
||||
.transaction-item { @include card; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.tx-badge { width: 36px; height: 36px; border-radius: 50%; @include flex-center; flex-shrink: 0; }
|
||||
.tx-badge-earn { background: rgba(82,196,26,0.1); }
|
||||
.tx-badge-spend { background: rgba(250,84,28,0.1); }
|
||||
.tx-badge-expired { background: rgba(0,0,0,0.05); }
|
||||
.tx-badge-text { font-size: var(--tk-font-micro); font-weight: 500; }
|
||||
.tx-info { flex: 1; min-width: 0; }
|
||||
.tx-desc { font-size: var(--tk-font-body); color: $tx; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tx-date { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 2px; }
|
||||
.tx-amount-col { text-align: right; flex-shrink: 0; }
|
||||
.tx-amount { font-size: var(--tk-font-body); font-weight: 500; display: block; }
|
||||
.tx-amount-positive { color: $acc; }
|
||||
.tx-amount-negative { color: $wrn; }
|
||||
.tx-remaining { font-size: var(--tk-font-micro); color: $tx3; display: block; margin-top: 2px; }
|
||||
.empty-wrap { padding-top: 60px; }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<view :class="['exchange-page', elderClass]">
|
||||
<template v-if="loading">
|
||||
<Loading text="加载中..." />
|
||||
</template>
|
||||
<template v-else-if="product">
|
||||
<view class="product-card">
|
||||
<view :class="['product-icon-wrap', iconCls]">
|
||||
<text class="product-icon-char">{{ initial }}</text>
|
||||
</view>
|
||||
<view class="product-meta">
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<text class="product-type-tag">{{ typeLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-section">
|
||||
<text class="detail-section-title">兑换明细</text>
|
||||
<view class="detail-card">
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">所需积分</text>
|
||||
<text class="detail-value detail-cost">{{ cost.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">当前余额</text>
|
||||
<text :class="['detail-value', insufficient ? 'detail-insufficient' : 'detail-sufficient']">{{ balance.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view v-if="insufficient" class="detail-row">
|
||||
<text class="detail-label">差额</text>
|
||||
<text class="detail-value detail-insufficient">-{{ (cost - balance).toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="detail-row last">
|
||||
<text class="detail-label">库存</text>
|
||||
<text class="detail-value">{{ product.stock > 0 ? `剩余 ${product.stock} 件` : '已兑完' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="notice-section">
|
||||
<text class="notice-title">温馨提示</text>
|
||||
<text class="notice-text">兑换成功后将生成核销码,请凭核销码到前台核销领取。</text>
|
||||
<text class="notice-text">积分一经兑换不可退回。</text>
|
||||
</view>
|
||||
|
||||
<view class="exchange-footer">
|
||||
<view class="footer-cost">
|
||||
<text class="footer-cost-label">合计</text>
|
||||
<text class="footer-cost-num">{{ cost.toLocaleString() }}</text>
|
||||
<text class="footer-cost-unit">积分</text>
|
||||
</view>
|
||||
<view :class="['confirm-btn', insufficient || product.stock <= 0 || submitting ? 'disabled' : '']" @tap="handleConfirm">
|
||||
<text class="confirm-btn-text">
|
||||
{{ submitting ? '兑换中...' : insufficient ? '积分不足' : product.stock <= 0 ? '已兑完' : '确认兑换' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { listProducts, exchangeProduct, type PointsProduct } from '@/services/points'
|
||||
import { usePointsStore } from '@/stores/points'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_INITIAL: Record<string, string> = { physical: '物', service: '券', privilege: '权' }
|
||||
const TYPE_LABEL: Record<string, string> = { physical: '实物商品', service: '服务券', privilege: '权益卡' }
|
||||
const TYPE_CLASS: Record<string, string> = { physical: 'product-icon-wrap--physical', service: 'product-icon-wrap--service', privilege: 'product-icon-wrap--privilege' }
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const pointsStore = usePointsStore()
|
||||
const product = ref<PointsProduct | null>(null)
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
let productId = ''
|
||||
|
||||
const balance = computed(() => pointsStore.account?.balance ?? 0)
|
||||
const cost = computed(() => product.value?.points_cost ?? 0)
|
||||
const insufficient = computed(() => balance.value < cost.value)
|
||||
const productType = computed(() => product.value?.product_type || 'physical')
|
||||
const initial = computed(() => TYPE_INITIAL[productType.value] || '礼')
|
||||
const typeLabel = computed(() => TYPE_LABEL[productType.value] || '商品')
|
||||
const iconCls = computed(() => TYPE_CLASS[productType.value] || 'product-icon-wrap--service')
|
||||
|
||||
const loadData = async () => {
|
||||
const instance = getCurrentPages()
|
||||
const page = instance[instance.length - 1] as any
|
||||
productId = page?.$page?.options?.product_id || page?.options?.product_id || ''
|
||||
if (!productId) {
|
||||
uni.showToast({ title: '参数错误', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const [productRes] = await Promise.all([listProducts({ page: 1, page_size: 100 }), pointsStore.refresh()])
|
||||
const found = productRes.data.find(p => p.id === productId)
|
||||
if (!found) { uni.showToast({ title: '商品不存在', icon: 'none' }); setTimeout(() => uni.navigateBack(), 1500); return }
|
||||
product.value = found
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!product.value || submitting.value || insufficient.value || product.value.stock <= 0) return
|
||||
const modalRes = await uni.showModal({ title: '确认兑换', content: `确定花费 ${cost.value} 积分兑换「${product.value.name}」吗?` })
|
||||
if (!modalRes.confirm) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const order = await exchangeProduct(product.value.id)
|
||||
uni.showToast({ title: '兑换成功', icon: 'success', duration: 2000 })
|
||||
setTimeout(() => {
|
||||
uni.showModal({ title: '兑换成功', content: `核销码: ${order.qr_code}\n请凭此码到前台核销`, showCancel: false, confirmText: '查看订单', success: () => uni.navigateTo({ url: '/pages-sub/pkg-mall/orders/index' }) })
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '兑换失败'
|
||||
if (msg.includes('余额不足') || msg.includes('insufficient')) uni.showToast({ title: '积分不足', icon: 'none' })
|
||||
else uni.showToast({ title: msg, icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => { uni.setNavigationBarTitle({ title: '确认兑换' }); loadData() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.exchange-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.product-card { @include card; display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }
|
||||
.product-icon-wrap { width: 48px; height: 48px; border-radius: 12px; @include flex-center; }
|
||||
.product-icon-wrap--physical { background: rgba(250,173,20,0.15); }
|
||||
.product-icon-wrap--service { background: rgba($pri, 0.1); }
|
||||
.product-icon-wrap--privilege { background: rgba(114,46,209,0.15); }
|
||||
.product-icon-char { font-size: var(--tk-font-cap); font-weight: 600; }
|
||||
.product-meta { flex: 1; }
|
||||
.product-name { font-size: var(--tk-font-body); font-weight: 500; color: $tx; display: block; }
|
||||
.product-type-tag { font-size: var(--tk-font-cap); color: $tx3; margin-top: 4px; display: block; }
|
||||
.detail-section { @include card; margin-bottom: 16px; }
|
||||
.detail-section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.detail-card { }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
|
||||
.detail-row.last { border-bottom: none; }
|
||||
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.detail-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.detail-cost { color: $wrn; font-weight: 500; }
|
||||
.detail-sufficient { color: $acc; }
|
||||
.detail-insufficient { color: $wrn; }
|
||||
.notice-section { @include card; margin-bottom: 16px; }
|
||||
.notice-title { font-size: var(--tk-font-cap); font-weight: 500; color: $tx2; margin-bottom: 8px; display: block; }
|
||||
.notice-text { font-size: var(--tk-font-cap); color: $tx3; line-height: 1.6; display: block; }
|
||||
.exchange-footer { position: fixed; bottom: 0; left: 0; right: 0; display: flex; align-items: center; padding: 12px 24px; background: $card; box-shadow: $shadow-sm; gap: 16px; }
|
||||
.footer-cost { flex: 1; display: flex; align-items: baseline; gap: 4px; }
|
||||
.footer-cost-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.footer-cost-num { font-size: var(--tk-font-body); font-weight: 600; color: $wrn; }
|
||||
.footer-cost-unit { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.confirm-btn { height: $touch-min; padding: 0 32px; background: $pri; border-radius: $r; @include flex-center; }
|
||||
.confirm-btn.disabled { opacity: 0.5; }
|
||||
.confirm-btn-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
|
||||
</style>
|
||||
130
apps/miniprogram-uniapp/src/pages-sub/pkg-mall/orders/index.vue
Normal file
130
apps/miniprogram-uniapp/src/pages-sub/pkg-mall/orders/index.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<view :class="['orders-page', elderClass]">
|
||||
<view class="status-tabs">
|
||||
<view v-for="tab in STATUS_TABS" :key="tab.key"
|
||||
:class="['status-tab', activeTab === tab.key ? 'active' : '']"
|
||||
@tap="handleTabChange(tab.key)">
|
||||
<text class="status-tab-text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="orders.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="" title="暂无订单" hint="去商城兑换心仪商品吧" />
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="orders-scroll" @scrolltolower="loadMore">
|
||||
<view class="order-card" v-for="order in orders" :key="order.id">
|
||||
<view class="order-header">
|
||||
<text class="order-product">商品 {{ order.product_id.slice(0, 8) }}</text>
|
||||
<view :class="['order-status-tag', getStatusConfig(order.status).cls]">
|
||||
<text class="order-status-text">{{ getStatusConfig(order.status).label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="order-body">
|
||||
<view class="order-row">
|
||||
<text class="order-row-label">消耗积分</text>
|
||||
<text class="order-row-value order-cost">{{ order.points_cost.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="order-row">
|
||||
<text class="order-row-label">兑换时间</text>
|
||||
<text class="order-row-value">{{ formatDate(order.created_at) }}</text>
|
||||
</view>
|
||||
<view v-if="order.status === 'pending'" class="order-qrcode" @tap="handleShowQrCode(order.qr_code)">
|
||||
<text class="qrcode-label">核销码</text>
|
||||
<text class="qrcode-value">{{ order.qr_code }}</text>
|
||||
<text class="qrcode-tap">查看</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && orders.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listMyOrders, type PointsOrder } from '@/services/points'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' }, { key: 'pending', label: '待核销' },
|
||||
{ key: 'verified', label: '已核销' }, { key: 'expired', label: '已过期' },
|
||||
]
|
||||
const STATUS_CONFIG: Record<string, { label: string; cls: string }> = {
|
||||
pending: { label: '待核销', cls: 'order-status-tag--pending' },
|
||||
verified: { label: '已核销', cls: 'order-status-tag--verified' },
|
||||
cancelled: { label: '已取消', cls: 'order-status-tag--cancelled' },
|
||||
expired: { label: '已过期', cls: 'order-status-tag--expired' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const orders = ref<PointsOrder[]>([])
|
||||
const activeTab = ref('')
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
let loadingGuard = false
|
||||
|
||||
const getStatusConfig = (status: string) => STATUS_CONFIG[status] || { label: status, cls: 'order-status-tag--expired' }
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const fetchOrders = async (pageNum: number, status: string, isRefresh = false) => {
|
||||
if (loadingGuard) return
|
||||
loadingGuard = true; loading.value = true
|
||||
try {
|
||||
const res = await listMyOrders({ page: pageNum, page_size: 10 })
|
||||
let list = res.data || []
|
||||
if (status) list = list.filter(o => o.status === status)
|
||||
orders.value = isRefresh ? list : [...orders.value, ...list]
|
||||
total.value = res.total; page.value = pageNum
|
||||
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
finally { loadingGuard = false; loading.value = false }
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => { activeTab.value = key; fetchOrders(1, key, true) }
|
||||
const loadMore = () => { if (!loading.value && orders.value.length < total.value) fetchOrders(page.value + 1, activeTab.value) }
|
||||
const handleShowQrCode = (qrCode: string) => uni.showModal({ title: '核销码', content: qrCode, showCancel: false, confirmText: '知道了' })
|
||||
|
||||
onShow(() => { uni.setNavigationBarTitle({ title: '我的订单' }); fetchOrders(1, activeTab.value, true) })
|
||||
onPullDownRefresh(() => { fetchOrders(1, activeTab.value, true).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.orders-page { min-height: 100vh; background: $bg; }
|
||||
.status-tabs { display: flex; padding: 12px 24px; gap: 8px; background: $card; }
|
||||
.status-tab { padding: 6px 16px; min-height: $touch-min; display: flex; align-items: center; border-radius: 20px; background: rgba(0,0,0,0.04); }
|
||||
.status-tab.active { background: $pri; }
|
||||
.status-tab-text { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.status-tab.active .status-tab-text { color: $white; }
|
||||
.orders-scroll { height: calc(100vh - 52px); padding: 16px 24px; }
|
||||
.order-card { @include card; margin-bottom: 12px; }
|
||||
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.order-product { font-size: var(--tk-font-body); color: $tx; font-weight: 500; }
|
||||
.order-status-tag { padding: 2px 10px; border-radius: 4px; }
|
||||
.order-status-tag--pending { background: rgba($pri, 0.1); }
|
||||
.order-status-tag--verified { background: rgba(82,196,26,0.1); }
|
||||
.order-status-tag--cancelled { background: rgba(0,0,0,0.05); }
|
||||
.order-status-tag--expired { background: rgba(0,0,0,0.05); }
|
||||
.order-status-text { font-size: var(--tk-font-micro); color: $tx2; }
|
||||
.order-body { }
|
||||
.order-row { display: flex; justify-content: space-between; padding: 6px 0; }
|
||||
.order-row-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.order-row-value { font-size: var(--tk-font-cap); color: $tx; }
|
||||
.order-cost { color: $wrn; font-weight: 500; }
|
||||
.order-qrcode { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 8px 12px; background: rgba($pri, 0.05); border-radius: $r; }
|
||||
.qrcode-label { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.qrcode-value { flex: 1; font-size: var(--tk-font-cap); color: $pri; font-weight: 500; }
|
||||
.qrcode-tap { font-size: var(--tk-font-cap); color: $pri; }
|
||||
.empty-wrap { padding-top: 120px; }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<view :class="['consents-page', elderClass]">
|
||||
<text class="page-title">知情同意</text>
|
||||
|
||||
<view class="consent-list">
|
||||
<view v-for="c in consents" :key="c.id" class="consent-card">
|
||||
<view class="consent-card__header">
|
||||
<text class="consent-card__type">{{ CONSENT_TYPE_MAP[c.consent_type] || c.consent_type }}</text>
|
||||
<text :class="['status-tag', (STATUS_MAP[c.status] || { cls: '' }).cls]">
|
||||
{{ (STATUS_MAP[c.status] || { label: c.status }).label }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="consent-card__scope">范围: {{ c.consent_scope }}</text>
|
||||
<text v-if="c.granted_at" class="consent-card__date">签署时间: {{ c.granted_at }}</text>
|
||||
<text v-if="c.revoked_at" class="consent-card__date">撤回时间: {{ c.revoked_at }}</text>
|
||||
<text v-if="c.expiry_date" class="consent-card__expiry">有效期至: {{ c.expiry_date }}</text>
|
||||
<view
|
||||
v-if="c.status === 'granted'"
|
||||
:class="['revoke-btn', revoking === c.id ? 'revoke-btn--disabled' : '']"
|
||||
@tap="handleRevoke(c)"
|
||||
>
|
||||
<text class="revoke-btn__text">{{ revoking === c.id ? '处理中...' : '撤回同意' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState
|
||||
v-if="consents.length === 0 && !loading"
|
||||
:text="authStore.currentPatient ? '暂无知情同意记录' : '请先在就诊人管理中选择就诊人'"
|
||||
/>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listConsents, revokeConsent, type Consent } from '@/services/consent'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const CONSENT_TYPE_MAP: Record<string, string> = {
|
||||
data_processing: '数据处理同意',
|
||||
health_data_collection: '健康数据采集',
|
||||
research_use: '科研使用',
|
||||
third_party_share: '第三方共享',
|
||||
genetic_testing: '基因检测',
|
||||
telemedicine: '远程医疗',
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
granted: { label: '已签署', cls: 'granted' },
|
||||
revoked: { label: '已撤回', cls: 'revoked' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const consents = ref<Consent[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const revoking = ref<string | null>(null)
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient) {
|
||||
consents.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listConsents(authStore.currentPatient.id, { page: p, page_size: 20 })
|
||||
const list = res.data || []
|
||||
consents.value = append ? [...consents.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && consents.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (consent: Consent) => {
|
||||
const res = await uni.showModal({
|
||||
title: '确认撤回',
|
||||
content: `确定要撤回「${CONSENT_TYPE_MAP[consent.consent_type] || consent.consent_type}」的同意吗?`,
|
||||
})
|
||||
if (!res.confirm) return
|
||||
revoking.value = consent.id
|
||||
try {
|
||||
const updated = await revokeConsent(consent.id, consent.version)
|
||||
consents.value = consents.value.map((c) => c.id === updated.id ? updated : c)
|
||||
uni.showToast({ title: '已撤回', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '撤回失败', icon: 'none' })
|
||||
} finally {
|
||||
revoking.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.consents-page { min-height: 100vh; background: $bg; padding: 32px 24px; padding-bottom: 40px; }
|
||||
.page-title { @include section-title; padding-left: 4px; }
|
||||
.consent-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.consent-card { background: $card; border-radius: $r; padding: 28px; box-shadow: $shadow-sm; }
|
||||
.consent-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.consent-card__type { font-size: var(--tk-font-body-lg); font-weight: bold; color: $tx; }
|
||||
.status-tag {
|
||||
@include tag($bd-l, $tx3);
|
||||
&.granted { @include tag($acc-l, $acc); }
|
||||
&.revoked { @include tag($dan-l, $dan); }
|
||||
}
|
||||
.consent-card__scope,
|
||||
.consent-card__date,
|
||||
.consent-card__expiry {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.revoke-btn {
|
||||
margin-top: 16px;
|
||||
padding: 12px 0;
|
||||
min-height: $touch-min;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
border-radius: $r-sm;
|
||||
border: 1px solid $dan;
|
||||
&:active { background: $dan-l; }
|
||||
&--disabled { opacity: 0.5; }
|
||||
}
|
||||
.revoke-btn__text { font-size: var(--tk-font-h2); color: $dan; font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<view :class="['diagnoses-page', elderClass]">
|
||||
<text class="page-title">诊断记录</text>
|
||||
|
||||
<scroll-view scroll-y class="diagnosis-scroll" @scrolltolower="loadMore">
|
||||
<view class="diagnosis-list">
|
||||
<view v-for="d in records" :key="d.id" class="diagnosis-card">
|
||||
<view class="diagnosis-card__header">
|
||||
<text class="diagnosis-card__name">{{ d.diagnosis_name }}</text>
|
||||
<text :class="['diagnosis-card__status', (STATUS_MAP[d.status] || { cls: '' }).cls]">
|
||||
{{ (STATUS_MAP[d.status] || { label: d.status }).label }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="diagnosis-card__meta">
|
||||
<text :class="['diagnosis-card__type', (TYPE_MAP[d.diagnosis_type] || { cls: '' }).cls]">
|
||||
{{ (TYPE_MAP[d.diagnosis_type] || { label: d.diagnosis_type }).label }}
|
||||
</text>
|
||||
<text class="diagnosis-card__code">{{ d.icd_code }}</text>
|
||||
</view>
|
||||
<text class="diagnosis-card__date">诊断日期:{{ d.diagnosed_date }}</text>
|
||||
<text v-if="d.notes" class="diagnosis-card__notes">{{ d.notes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState
|
||||
v-if="records.length === 0 && !loading"
|
||||
:text="authStore.currentPatient ? '暂无诊断记录' : '请先在就诊人管理中选择就诊人'"
|
||||
/>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listDiagnoses, type Diagnosis } from '@/services/health-record'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_MAP: Record<string, { label: string; cls: string }> = {
|
||||
primary: { label: '主要', cls: 'primary' },
|
||||
secondary: { label: '次要', cls: 'secondary' },
|
||||
comorbid: { label: '合并症', cls: 'comorbid' },
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '活动', cls: 'active' },
|
||||
resolved: { label: '已解决', cls: 'resolved' },
|
||||
chronic: { label: '慢性', cls: 'chronic' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const records = ref<Diagnosis[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient) {
|
||||
records.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listDiagnoses(authStore.currentPatient.id, { page: p, page_size: 20 })
|
||||
const list = res.data || []
|
||||
records.value = append ? [...records.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && records.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.diagnoses-page { min-height: 100vh; background: $bg; padding: 32px 24px 0; }
|
||||
.page-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.diagnosis-scroll { height: calc(100vh - 80px); }
|
||||
.diagnosis-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.diagnosis-card { background: $card; border-radius: $r; padding: 28px; box-shadow: $shadow-sm; }
|
||||
.diagnosis-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.diagnosis-card__name { font-size: var(--tk-font-body-lg); font-weight: bold; color: $tx; flex: 1; margin-right: 12px; }
|
||||
.diagnosis-card__status {
|
||||
@include tag($bd-l, $tx3);
|
||||
&.active { @include tag($acc-l, $acc); }
|
||||
&.resolved { @include tag($acc-l, $acc); }
|
||||
&.chronic { @include tag($wrn-l, $wrn); }
|
||||
}
|
||||
.diagnosis-card__meta { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.diagnosis-card__type {
|
||||
@include tag($pri-l, $pri-d);
|
||||
&.secondary { @include tag($bd-l, $tx2); }
|
||||
&.comorbid { @include tag($wrn-l, $wrn); }
|
||||
}
|
||||
.diagnosis-card__code { font-size: var(--tk-font-body); color: $tx3; font-variant-numeric: tabular-nums; }
|
||||
.diagnosis-card__date { font-size: var(--tk-font-body); color: $tx2; display: block; }
|
||||
.diagnosis-card__notes { font-size: var(--tk-font-body); color: $tx2; display: block; margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<EmptyState v-else-if="!rx" icon="📋" title="处方不存在" />
|
||||
<template v-else>
|
||||
<view class="detail-card header-card">
|
||||
<view class="header-row">
|
||||
<text class="detail-title">{{ rx.dialyzer_model || '透析处方' }}</text>
|
||||
<text :class="['status-tag', statusInfo(rx.status).cls]">{{ statusInfo(rx.status).label }}</text>
|
||||
</view>
|
||||
<text v-if="rx.effective_from || rx.effective_to" class="header-sub">
|
||||
{{ rx.effective_from || '...' }} ~ {{ rx.effective_to || '...' }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="detail-card">
|
||||
<text class="section-title">基本参数</text>
|
||||
<view v-if="rx.dialyzer_model" class="detail-row"><text class="detail-label">透析器型号</text><text class="detail-value">{{ rx.dialyzer_model }}</text></view>
|
||||
<view v-if="rx.membrane_area != null" class="detail-row"><text class="detail-label">膜面积</text><text class="detail-value">{{ rx.membrane_area }} m²</text></view>
|
||||
<view v-if="rx.blood_flow_rate != null" class="detail-row"><text class="detail-label">血流速</text><text class="detail-value">{{ rx.blood_flow_rate }} ml/min</text></view>
|
||||
<view v-if="rx.dialysate_flow_rate != null" class="detail-row"><text class="detail-label">透析液流量</text><text class="detail-value">{{ rx.dialysate_flow_rate }} ml/min</text></view>
|
||||
<view v-if="rx.frequency_per_week != null" class="detail-row"><text class="detail-label">频率</text><text class="detail-value">{{ rx.frequency_per_week }} 次/周</text></view>
|
||||
<view v-if="rx.duration_minutes != null" class="detail-row"><text class="detail-label">每次时长</text><text class="detail-value">{{ rx.duration_minutes }} 分钟</text></view>
|
||||
</view>
|
||||
|
||||
<view class="detail-card">
|
||||
<text class="section-title">透析液配比</text>
|
||||
<view v-if="rx.dialysate_potassium != null" class="detail-row"><text class="detail-label">钾浓度</text><text class="detail-value">{{ rx.dialysate_potassium }} mmol/L</text></view>
|
||||
<view v-if="rx.dialysate_calcium != null" class="detail-row"><text class="detail-label">钙浓度</text><text class="detail-value">{{ rx.dialysate_calcium }} mmol/L</text></view>
|
||||
<view v-if="rx.dialysate_bicarbonate != null" class="detail-row"><text class="detail-label">碳酸氢盐浓度</text><text class="detail-value">{{ rx.dialysate_bicarbonate }} mmol/L</text></view>
|
||||
</view>
|
||||
|
||||
<view class="detail-card">
|
||||
<text class="section-title">抗凝方案</text>
|
||||
<view v-if="rx.anticoagulation_type" class="detail-row"><text class="detail-label">抗凝类型</text><text class="detail-value">{{ rx.anticoagulation_type }}</text></view>
|
||||
<view v-if="rx.anticoagulation_dose" class="detail-row"><text class="detail-label">抗凝剂量</text><text class="detail-value">{{ rx.anticoagulation_dose }}</text></view>
|
||||
</view>
|
||||
|
||||
<view v-if="rx.vascular_access_type || rx.vascular_access_location" class="detail-card">
|
||||
<text class="section-title">血管通路</text>
|
||||
<view v-if="rx.vascular_access_type" class="detail-row"><text class="detail-label">通路类型</text><text class="detail-value">{{ rx.vascular_access_type }}</text></view>
|
||||
<view v-if="rx.vascular_access_location" class="detail-row"><text class="detail-label">通路位置</text><text class="detail-value">{{ rx.vascular_access_location }}</text></view>
|
||||
</view>
|
||||
|
||||
<view v-if="rx.target_ultrafiltration_ml != null || rx.target_dry_weight != null" class="detail-card">
|
||||
<text class="section-title">超滤目标</text>
|
||||
<view v-if="rx.target_ultrafiltration_ml != null" class="detail-row"><text class="detail-label">目标超滤量</text><text class="detail-value">{{ rx.target_ultrafiltration_ml }} ml</text></view>
|
||||
<view v-if="rx.target_dry_weight != null" class="detail-row"><text class="detail-label">目标干体重</text><text class="detail-value">{{ rx.target_dry_weight }} kg</text></view>
|
||||
</view>
|
||||
|
||||
<view v-if="rx.notes" class="detail-card">
|
||||
<text class="section-title">备注</text>
|
||||
<text class="notes-text">{{ rx.notes }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getDialysisPrescription, type DialysisPrescription } from '@/services/dialysis'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '生效中', cls: 'active' },
|
||||
inactive: { label: '已停用', cls: 'inactive' },
|
||||
expired: { label: '已过期', cls: 'expired' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const rx = ref<DialysisPrescription | null>(null)
|
||||
const loading = ref(true)
|
||||
let id = ''
|
||||
|
||||
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
|
||||
|
||||
onLoad((query) => {
|
||||
id = query?.id || ''
|
||||
if (!id) { loading.value = false; return }
|
||||
loading.value = true
|
||||
getDialysisPrescription(id)
|
||||
.then(data => { rx.value = data })
|
||||
.catch(() => uni.showToast({ title: '加载失败', icon: 'none' }))
|
||||
.finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.detail-card { @include card; margin-bottom: 16px; }
|
||||
.header-card { border-left: 4px solid $pri; }
|
||||
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
|
||||
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
|
||||
&.active { background: rgba(82,196,26,0.1); color: $acc; }
|
||||
&.inactive { background: rgba(0,0,0,0.04); color: $tx3; }
|
||||
&.expired { background: rgba(255,77,79,0.1); color: $dan; }
|
||||
}
|
||||
.header-sub { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
|
||||
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; }
|
||||
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.detail-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.notes-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<view :class="['dialysis-prescriptions-page', elderClass]">
|
||||
<text class="page-title">透析处方</text>
|
||||
|
||||
<template v-if="!authStore.currentPatient">
|
||||
<EmptyState icon="📋" title="请先在就诊人管理中选择就诊人" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<view v-if="prescriptions.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="📋" title="暂无透析处方" />
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="prescription-scroll" @scrolltolower="loadMore">
|
||||
<view
|
||||
v-for="p in prescriptions" :key="p.id"
|
||||
class="prescription-card"
|
||||
@tap="goDetail(p.id)"
|
||||
>
|
||||
<view class="prescription-card-top">
|
||||
<text class="prescription-model">{{ p.dialyzer_model || '未指定型号' }}</text>
|
||||
<text :class="['status-tag', statusInfo(p.status).cls]">{{ statusInfo(p.status).label }}</text>
|
||||
</view>
|
||||
<view class="prescription-meta">
|
||||
<text v-if="p.frequency_per_week != null" class="meta-item">{{ p.frequency_per_week }}次/周</text>
|
||||
<text v-if="p.duration_minutes != null" class="meta-item">每次{{ p.duration_minutes }}分钟</text>
|
||||
</view>
|
||||
<text v-if="p.effective_from || p.effective_to" class="prescription-date">
|
||||
{{ p.effective_from || '...' }} ~ {{ p.effective_to || '...' }}
|
||||
</text>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && prescriptions.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listDialysisPrescriptions, type DialysisPrescription } from '@/services/dialysis'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '生效中', cls: 'active' },
|
||||
inactive: { label: '已停用', cls: 'inactive' },
|
||||
expired: { label: '已过期', cls: 'expired' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const prescriptions = ref<DialysisPrescription[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
let loadingGuard = false
|
||||
|
||||
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient || loadingGuard) return
|
||||
loadingGuard = true
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listDialysisPrescriptions({ patient_id: authStore.currentPatient.id, page: p, page_size: 20 })
|
||||
const list = res.data || []
|
||||
prescriptions.value = append ? [...prescriptions.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loadingGuard = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && prescriptions.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (id: string) => {
|
||||
uni.navigateTo({ url: `/pages-sub/pkg-profile/dialysis-prescriptions/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dialysis-prescriptions-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.page-title { @include section-title; }
|
||||
.prescription-scroll { height: calc(100vh - 80px); }
|
||||
.prescription-card { @include card; margin-bottom: 12px; }
|
||||
.prescription-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.prescription-model { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
|
||||
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
|
||||
&.active { background: rgba(82,196,26,0.1); color: $acc; }
|
||||
&.inactive { background: rgba(0,0,0,0.04); color: $tx3; }
|
||||
&.expired { background: rgba(255,77,79,0.1); color: $dan; }
|
||||
}
|
||||
.prescription-meta { display: flex; gap: 16px; margin-top: 4px; }
|
||||
.meta-item { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.prescription-date { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.empty-wrap { padding-top: 40px; }
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<EmptyState v-else-if="!record" icon="📋" title="记录不存在" />
|
||||
<template v-else>
|
||||
<view class="detail-card header-card">
|
||||
<view class="header-row">
|
||||
<text class="detail-title">{{ record.dialysis_date }}</text>
|
||||
<text :class="['status-tag', statusInfo(record.status).cls]">{{ statusInfo(record.status).label }}</text>
|
||||
</view>
|
||||
<text class="header-sub">{{ TYPE_MAP[record.dialysis_type] || record.dialysis_type }}</text>
|
||||
<text v-if="record.reviewed_at" class="review-info">审核时间 {{ record.reviewed_at }}</text>
|
||||
</view>
|
||||
|
||||
<view class="detail-card">
|
||||
<text class="section-title">基本信息</text>
|
||||
<view v-if="record.start_time" class="detail-row"><text class="detail-label">开始时间</text><text class="detail-value">{{ record.start_time }}</text></view>
|
||||
<view v-if="record.end_time" class="detail-row"><text class="detail-label">结束时间</text><text class="detail-value">{{ record.end_time }}</text></view>
|
||||
<view v-if="record.dialysis_duration != null" class="detail-row"><text class="detail-label">透析时长</text><text class="detail-value">{{ record.dialysis_duration }} 分钟</text></view>
|
||||
<view v-if="record.blood_flow_rate != null" class="detail-row"><text class="detail-label">血流速</text><text class="detail-value">{{ record.blood_flow_rate }} ml/min</text></view>
|
||||
<view v-if="record.ultrafiltration_volume != null" class="detail-row"><text class="detail-label">超滤量</text><text class="detail-value">{{ record.ultrafiltration_volume }} ml</text></view>
|
||||
</view>
|
||||
|
||||
<view class="detail-card">
|
||||
<text class="section-title">体重与血压</text>
|
||||
<view v-if="record.dry_weight != null" class="detail-row"><text class="detail-label">干体重</text><text class="detail-value">{{ record.dry_weight }} kg</text></view>
|
||||
<view v-if="record.pre_weight != null" class="detail-row"><text class="detail-label">透前体重</text><text class="detail-value">{{ record.pre_weight }} kg</text></view>
|
||||
<view v-if="record.post_weight != null" class="detail-row"><text class="detail-label">透后体重</text><text class="detail-value">{{ record.post_weight }} kg</text></view>
|
||||
<view v-if="record.pre_bp_systolic != null && record.pre_bp_diastolic != null" class="detail-row"><text class="detail-label">透前血压</text><text class="detail-value">{{ record.pre_bp_systolic }}/{{ record.pre_bp_diastolic }} mmHg</text></view>
|
||||
<view v-if="record.post_bp_systolic != null && record.post_bp_diastolic != null" class="detail-row"><text class="detail-label">透后血压</text><text class="detail-value">{{ record.post_bp_systolic }}/{{ record.post_bp_diastolic }} mmHg</text></view>
|
||||
<view v-if="record.pre_heart_rate != null" class="detail-row"><text class="detail-label">透前心率</text><text class="detail-value">{{ record.pre_heart_rate }} bpm</text></view>
|
||||
<view v-if="record.post_heart_rate != null" class="detail-row"><text class="detail-label">透后心率</text><text class="detail-value">{{ record.post_heart_rate }} bpm</text></view>
|
||||
</view>
|
||||
|
||||
<view v-if="record.symptoms || record.complication_notes" class="detail-card">
|
||||
<text class="section-title">症状与并发症</text>
|
||||
<view v-if="record.symptoms && Object.keys(record.symptoms).length > 0" class="detail-row"><text class="detail-label">症状</text><text class="detail-value">{{ JSON.stringify(record.symptoms) }}</text></view>
|
||||
<view v-if="record.complication_notes" class="detail-row"><text class="detail-label">并发症备注</text><text class="detail-value">{{ record.complication_notes }}</text></view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getDialysisRecord, type DialysisRecord } from '@/services/dialysis'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
draft: { label: '草稿', cls: 'draft' },
|
||||
completed: { label: '已完成', cls: 'completed' },
|
||||
reviewed: { label: '已审核', cls: 'reviewed' },
|
||||
}
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
HD: '血液透析',
|
||||
HDF: '血液透析滤过',
|
||||
HF: '血液滤过',
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const record = ref<DialysisRecord | null>(null)
|
||||
const loading = ref(true)
|
||||
let id = ''
|
||||
|
||||
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
|
||||
|
||||
onLoad((query) => {
|
||||
id = query?.id || ''
|
||||
if (!id) { loading.value = false; return }
|
||||
loading.value = true
|
||||
getDialysisRecord(id)
|
||||
.then(data => { record.value = data })
|
||||
.catch(() => uni.showToast({ title: '加载失败', icon: 'none' }))
|
||||
.finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.detail-card { @include card; margin-bottom: 16px; }
|
||||
.header-card { border-left: 4px solid $pri; }
|
||||
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
|
||||
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
|
||||
&.draft { background: rgba(0,0,0,0.04); color: $tx3; }
|
||||
&.completed { background: rgba(82,196,26,0.1); color: $acc; }
|
||||
&.reviewed { background: rgba(22,119,255,0.1); color: $info; }
|
||||
}
|
||||
.header-sub { font-size: var(--tk-font-body); color: $tx2; display: block; margin-top: 4px; }
|
||||
.review-info { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
|
||||
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; }
|
||||
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.detail-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<view :class="['dialysis-records-page', elderClass]">
|
||||
<text class="page-title">透析记录</text>
|
||||
|
||||
<template v-if="!authStore.currentPatient">
|
||||
<EmptyState icon="📋" title="请先在就诊人管理中选择就诊人" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<view v-if="records.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="📋" title="暂无透析记录" />
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="record-scroll" @scrolltolower="loadMore">
|
||||
<view
|
||||
v-for="r in records" :key="r.id"
|
||||
class="record-card"
|
||||
@tap="goDetail(r.id)"
|
||||
>
|
||||
<view class="record-card-top">
|
||||
<text :class="['type-tag', typeInfo(r.dialysis_type).cls]">{{ typeInfo(r.dialysis_type).label }}</text>
|
||||
<text :class="['status-tag', statusInfo(r.status).cls]">{{ statusInfo(r.status).label }}</text>
|
||||
</view>
|
||||
<text class="record-date">{{ r.dialysis_date }}</text>
|
||||
<view v-if="r.pre_weight || r.post_weight" class="weight-row">
|
||||
<text v-if="r.pre_weight" class="weight-item">透前 {{ r.pre_weight }}kg</text>
|
||||
<text v-if="r.post_weight" class="weight-item">透后 {{ r.post_weight }}kg</text>
|
||||
</view>
|
||||
<text v-if="r.dialysis_duration" class="record-meta">时长 {{ r.dialysis_duration }}分钟</text>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && records.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listDialysisRecords, type DialysisRecord } from '@/services/dialysis'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const TYPE_MAP: Record<string, { label: string; cls: string }> = {
|
||||
HD: { label: 'HD', cls: 'hd' },
|
||||
HDF: { label: 'HDF', cls: 'hdf' },
|
||||
HF: { label: 'HF', cls: 'hf' },
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
draft: { label: '草稿', cls: 'draft' },
|
||||
completed: { label: '已完成', cls: 'completed' },
|
||||
reviewed: { label: '已审核', cls: 'reviewed' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const records = ref<DialysisRecord[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
let loadingGuard = false
|
||||
|
||||
const typeInfo = (t: string) => TYPE_MAP[t] || { label: t, cls: '' }
|
||||
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient || loadingGuard) return
|
||||
loadingGuard = true
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listDialysisRecords(authStore.currentPatient.id, { page: p, page_size: 20 })
|
||||
const list = res.data || []
|
||||
records.value = append ? [...records.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loadingGuard = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && records.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (id: string) => {
|
||||
uni.navigateTo({ url: `/pages-sub/pkg-profile/dialysis-records/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dialysis-records-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.page-title { @include section-title; }
|
||||
.record-scroll { height: calc(100vh - 80px); }
|
||||
.record-card { @include card; margin-bottom: 12px; }
|
||||
.record-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.type-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap); font-weight: 500;
|
||||
&.hd { background: rgba(22,119,255,0.1); color: $info; }
|
||||
&.hdf { background: rgba(114,46,209,0.1); color: #722ed1; }
|
||||
&.hf { background: rgba(250,173,20,0.1); color: $wrn; }
|
||||
}
|
||||
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
|
||||
&.draft { background: rgba(0,0,0,0.04); color: $tx3; }
|
||||
&.completed { background: rgba(82,196,26,0.1); color: $acc; }
|
||||
&.reviewed { background: rgba(22,119,255,0.1); color: $info; }
|
||||
}
|
||||
.record-date { font-size: var(--tk-font-body); color: $tx; display: block; margin-bottom: 4px; }
|
||||
.weight-row { display: flex; gap: 16px; margin-top: 4px; }
|
||||
.weight-item { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.record-meta { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.empty-wrap { padding-top: 40px; }
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<view :class="['elder-mode-page', elderClass]">
|
||||
<view class="elder-mode-card">
|
||||
<view class="elder-mode-header">
|
||||
<text class="elder-mode-icon">老</text>
|
||||
<view class="elder-mode-info">
|
||||
<text class="elder-mode-title">长辈模式</text>
|
||||
<text class="elder-mode-desc">放大字体和按钮,更易阅读和操作</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="elder-mode-status">
|
||||
<text class="elder-mode-status-text">
|
||||
当前状态:{{ uiStore.elderMode ? '已开启' : '已关闭' }}
|
||||
</text>
|
||||
<view
|
||||
:class="['elder-mode-switch', { 'elder-mode-switch--on': uiStore.elderMode }]"
|
||||
@tap="handleToggle"
|
||||
>
|
||||
<view class="elder-mode-switch-thumb" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="elder-mode-preview">
|
||||
<text class="elder-mode-preview-title">效果预览</text>
|
||||
<view class="elder-mode-preview-card">
|
||||
<text :class="['elder-mode-preview-sample', { 'elder-mode-preview-sample--large': uiStore.elderMode }]">
|
||||
{{ uiStore.elderMode ? '长辈模式字体示例' : '标准模式字体示例' }}
|
||||
</text>
|
||||
<text class="elder-mode-preview-note">
|
||||
{{ uiStore.elderMode ? '字号放大 1.3 倍,间距放大 1.2 倍' : '正常字号和间距' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const uiStore = useUIStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
function handleToggle() {
|
||||
uiStore.toggleElderMode()
|
||||
uni.showToast({
|
||||
title: uiStore.elderMode ? '已开启长辈模式' : '已关闭长辈模式',
|
||||
icon: 'none',
|
||||
duration: 1500,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.elder-mode-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.elder-mode-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-md;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.elder-mode-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.elder-mode-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r-sm;
|
||||
background: $acc-l;
|
||||
@include flex-center;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 700;
|
||||
color: $acc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.elder-mode-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.elder-mode-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.elder-mode-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
.elder-mode-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0 0;
|
||||
border-top: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.elder-mode-status-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.elder-mode-switch {
|
||||
width: 52px;
|
||||
height: 30px;
|
||||
border-radius: $r-pill;
|
||||
background: $bd;
|
||||
position: relative;
|
||||
transition: background 0.25s;
|
||||
transition: background 0.25s;
|
||||
|
||||
&--on {
|
||||
background: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.elder-mode-switch-thumb {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: $r-pill;
|
||||
background: $card;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.25s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.elder-mode-switch--on & {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
}
|
||||
|
||||
.elder-mode-preview {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.elder-mode-preview-title {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.elder-mode-preview-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.elder-mode-preview-sample {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
transition: font-size 0.25s;
|
||||
|
||||
&--large {
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
}
|
||||
|
||||
.elder-mode-preview-note {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<view :class="['family-add-page', elderClass]">
|
||||
<text class="page-title">{{ editId ? '编辑就诊人' : '添加就诊人' }}</text>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-item">
|
||||
<text class="form-label">姓名</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="请输入姓名"
|
||||
placeholder-class="form-placeholder"
|
||||
:value="name"
|
||||
@input="name = ($event as any).detail.value"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="form-label">关系</text>
|
||||
<picker
|
||||
mode="selector"
|
||||
:range="RELATION_OPTIONS"
|
||||
:value="relationIdx"
|
||||
@change="onRelationChange"
|
||||
>
|
||||
<view class="form-picker">
|
||||
<text class="form-picker-text">{{ RELATION_OPTIONS[relationIdx] }}</text>
|
||||
<text class="form-picker-arrow">></text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="form-label">性别</text>
|
||||
<picker
|
||||
mode="selector"
|
||||
:range="GENDER_OPTIONS"
|
||||
:value="genderIdx"
|
||||
@change="onGenderChange"
|
||||
>
|
||||
<view class="form-picker">
|
||||
<text class="form-picker-text">{{ GENDER_OPTIONS[genderIdx] }}</text>
|
||||
<text class="form-picker-arrow">></text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="form-label">出生日期</text>
|
||||
<picker
|
||||
mode="date"
|
||||
:value="birthDate || '2000-01-01'"
|
||||
@change="onDateChange"
|
||||
>
|
||||
<view class="form-picker">
|
||||
<text :class="['form-picker-text', { placeholder: !birthDate }]">
|
||||
{{ birthDate || '请选择' }}
|
||||
</text>
|
||||
<text class="form-picker-arrow">></text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
:class="['submit-btn', { disabled: submitting }]"
|
||||
@tap="submitting ? undefined : handleSubmit()"
|
||||
>
|
||||
<text class="submit-text">{{ submitting ? '提交中...' : editId ? '保存修改' : '确认添加' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { createPatient, updatePatient, Patient } from '@/services/patient'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const RELATION_OPTIONS = ['本人', '配偶', '父母', '子女', '其他']
|
||||
const GENDER_OPTIONS = ['男', '女']
|
||||
|
||||
const editId = ref('')
|
||||
const editData = ref<Patient | null>(null)
|
||||
|
||||
const name = ref('')
|
||||
const relationIdx = ref(0)
|
||||
const genderIdx = ref(0)
|
||||
const birthDate = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
function onRelationChange(e: any) {
|
||||
relationIdx.value = Number(e.detail.value)
|
||||
}
|
||||
|
||||
function onGenderChange(e: any) {
|
||||
genderIdx.value = Number(e.detail.value)
|
||||
}
|
||||
|
||||
function onDateChange(e: any) {
|
||||
birthDate.value = e.detail.value
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!name.value.trim()) {
|
||||
uni.showToast({ title: '请输入姓名', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const gender = GENDER_OPTIONS[genderIdx.value] === '男' ? 'male' : 'female'
|
||||
if (editId.value && editData.value) {
|
||||
await updatePatient(editId.value, {
|
||||
name: name.value.trim(),
|
||||
gender,
|
||||
birth_date: birthDate.value || undefined,
|
||||
relation: RELATION_OPTIONS[relationIdx.value],
|
||||
}, editData.value.version)
|
||||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||
} else {
|
||||
await createPatient({
|
||||
name: name.value.trim(),
|
||||
gender,
|
||||
birth_date: birthDate.value || undefined,
|
||||
})
|
||||
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||
}
|
||||
setTimeout(() => uni.navigateBack(), 1000)
|
||||
} catch {
|
||||
uni.showToast({ title: editId.value ? '修改失败' : '添加失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
if (query?.id) {
|
||||
editId.value = query.id
|
||||
const stored = uni.getStorageSync('edit_patient') as Patient | null
|
||||
if (stored) {
|
||||
editData.value = stored
|
||||
name.value = stored.name || ''
|
||||
relationIdx.value = stored.relation ? RELATION_OPTIONS.indexOf(stored.relation) : 0
|
||||
genderIdx.value = stored.gender === 'female' ? 1 : 0
|
||||
birthDate.value = stored.birth_date || ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
uni.removeStorageSync('edit_patient')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.family-add-page {
|
||||
padding: 32px 24px;
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include section-title;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 4px 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
flex-shrink: 0;
|
||||
width: 140px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
text-align: right;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.form-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-picker-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
margin-right: 10px;
|
||||
|
||||
&.placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.form-picker-arrow {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: $pri;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<view :class="['family-page', elderClass]">
|
||||
<text class="family-page-title">就诊人管理</text>
|
||||
|
||||
<view class="family-list">
|
||||
<view
|
||||
v-for="p in patients"
|
||||
:key="p.id"
|
||||
:class="['family-item', { active: authStore.currentPatient?.id === p.id }]"
|
||||
@tap="handleSelect(p)"
|
||||
>
|
||||
<view class="family-avatar">
|
||||
<text class="family-avatar-text">{{ relationInitial(p.relation || '本人') }}</text>
|
||||
</view>
|
||||
<view class="family-info">
|
||||
<view class="family-name-row">
|
||||
<text class="family-name">{{ p.name }}</text>
|
||||
<text v-if="authStore.currentPatient?.id === p.id" class="family-current-tag">当前</text>
|
||||
</view>
|
||||
<view class="family-meta">
|
||||
<text class="family-relation-tag">{{ p.relation || '本人' }}</text>
|
||||
<text class="family-gender">{{ genderText(p.gender) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="family-edit" @tap.stop="goToEdit(p)">
|
||||
<text class="family-edit-text">编辑</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState v-if="patients.length === 0 && !loading" text="暂无就诊人" />
|
||||
|
||||
<view class="family-add-btn" @tap="goToAdd">
|
||||
<text class="family-add-text">添加就诊人</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { listPatients, Patient } from '@/services/patient'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const patients = ref<Patient[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchPatients() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listPatients()
|
||||
patients.value = res.data || []
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(patient: Patient) {
|
||||
authStore.setCurrentPatient({
|
||||
id: patient.id,
|
||||
name: patient.name,
|
||||
gender: patient.gender,
|
||||
birth_date: patient.birth_date,
|
||||
relation: patient.relation || '本人',
|
||||
})
|
||||
uni.showToast({ title: `已切换为 ${patient.name}`, icon: 'success' })
|
||||
}
|
||||
|
||||
function goToAdd() {
|
||||
uni.navigateTo({ url: '/pages-sub/pkg-profile/family-add/index' })
|
||||
}
|
||||
|
||||
function goToEdit(patient: Patient) {
|
||||
uni.setStorageSync('edit_patient', patient)
|
||||
uni.navigateTo({ url: `/pages-sub/pkg-profile/family-add/index?id=${patient.id}` })
|
||||
}
|
||||
|
||||
function genderText(g?: string) {
|
||||
if (g === 'male') return '男'
|
||||
if (g === 'female') return '女'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
function relationInitial(relation: string) {
|
||||
return relation ? relation.charAt(0) : '本'
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
fetchPatients()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.family-page {
|
||||
padding: 32px 24px;
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
|
||||
.family-page-title {
|
||||
@include section-title;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.family-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.family-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
&.active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.family-avatar {
|
||||
@include flex-center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
flex-shrink: 0;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.family-avatar-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $pri-d;
|
||||
}
|
||||
|
||||
.family-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.family-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.family-name {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.family-current-tag {
|
||||
@include tag($pri, $white);
|
||||
font-size: var(--tk-font-body-sm);
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.family-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.family-relation-tag {
|
||||
@include tag($pri-l, $pri-d);
|
||||
font-size: var(--tk-font-body);
|
||||
padding: 2px 12px;
|
||||
}
|
||||
|
||||
.family-gender {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.family-edit {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
padding: 14px 24px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-pill;
|
||||
min-height: 48px;
|
||||
@include flex-center;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
}
|
||||
|
||||
.family-edit-text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.family-add-btn {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: $pri;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
|
||||
}
|
||||
|
||||
.family-add-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<view :class="['my-followups-page', elderClass]">
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="tab in TABS" :key="tab.key"
|
||||
:class="['tab-item', activeTab === tab.key ? 'active' : '']"
|
||||
@tap="handleTabChange(tab.key)"
|
||||
>
|
||||
<text :class="['tab-text', activeTab === tab.key ? 'active' : '']">{{ tab.label }}</text>
|
||||
<view v-if="activeTab === tab.key" class="tab-indicator" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="task-list">
|
||||
<view
|
||||
v-for="t in tasks" :key="t.id"
|
||||
class="task-card"
|
||||
@tap="goToDetail(t.id)"
|
||||
>
|
||||
<view class="task-top">
|
||||
<text class="task-name">{{ t.follow_up_type }}</text>
|
||||
<text :class="['task-status', getStatusClass(t.status)]">
|
||||
{{ getStatusLabel(t.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="task-desc">{{ t.content_template }}</text>
|
||||
<text class="task-due">截止: {{ t.planned_date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState
|
||||
v-if="tasks.length === 0 && !loading"
|
||||
:text="'暂无' + (TABS.find(t => t.key === activeTab)?.label || '') + '任务'"
|
||||
/>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { listTasks, type FollowUpTask } from '@/services/followup'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'pending', label: '待完成' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
{ key: 'overdue', label: '已过期' },
|
||||
]
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const activeTab = ref('pending')
|
||||
const tasks = ref<FollowUpTask[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchTasks = async (status: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const patientId = authStore.currentPatient?.id
|
||||
const res = await listTasks(patientId, status)
|
||||
tasks.value = res.data || []
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
activeTab.value = key
|
||||
fetchTasks(key)
|
||||
}
|
||||
|
||||
const goToDetail = (id: string) => {
|
||||
uni.navigateTo({ url: `/pages-sub/followup/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
if (status === 'completed') return 'completed'
|
||||
if (status === 'overdue') return 'overdue'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
if (status === 'completed') return '已完成'
|
||||
if (status === 'overdue') return '已过期'
|
||||
return '待完成'
|
||||
}
|
||||
|
||||
onShow(() => { fetchTasks(activeTab.value) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.my-followups-page { min-height: 100vh; background: $bg; }
|
||||
.tab-bar { display: flex; background: $card; padding: 0; box-shadow: $shadow-sm; }
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px 0 20px;
|
||||
position: relative;
|
||||
}
|
||||
.tab-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
&.active { color: $pri; font-weight: bold; }
|
||||
}
|
||||
.tab-indicator { width: 32px; height: 4px; background: $pri; border-radius: $r-xs; }
|
||||
.task-list { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.task-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
&:active { box-shadow: $shadow-md; }
|
||||
}
|
||||
.task-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.task-name {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
.task-status {
|
||||
@include tag($bd-l, $tx2);
|
||||
&.pending { @include tag($wrn-l, $wrn); }
|
||||
&.completed { @include tag($acc-l, $acc); }
|
||||
&.overdue { @include tag($dan-l, $dan); }
|
||||
}
|
||||
.task-desc {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-due {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<view :class="['health-records-page', elderClass]">
|
||||
<text class="page-title">健康记录</text>
|
||||
|
||||
<view class="record-list">
|
||||
<view v-for="r in records" :key="r.id" class="record-card">
|
||||
<view class="record-card__header">
|
||||
<text class="record-card__type">{{ TYPE_MAP[r.record_type] || r.record_type }}</text>
|
||||
<text class="record-card__date">{{ r.record_date }}</text>
|
||||
</view>
|
||||
<text v-if="r.overall_assessment" class="record-card__assessment">{{ r.overall_assessment }}</text>
|
||||
<text v-if="r.source" class="record-card__source">来源:{{ r.source }}</text>
|
||||
<text v-if="r.notes" class="record-card__notes">{{ r.notes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState
|
||||
v-if="records.length === 0 && !loading"
|
||||
:text="authStore.currentPatient ? '暂无健康记录' : '请先在就诊人管理中选择就诊人'"
|
||||
/>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listHealthRecords, type HealthRecord } from '@/services/health-record'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
checkup: '体检',
|
||||
follow_up: '复查',
|
||||
referral: '转诊',
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const records = ref<HealthRecord[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient) {
|
||||
records.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listHealthRecords(authStore.currentPatient.id, { page: p, page_size: 20 })
|
||||
const list = res.data || []
|
||||
records.value = append ? [...records.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && records.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.health-records-page { min-height: 100vh; background: $bg; padding: 32px 24px; padding-bottom: 40px; }
|
||||
.page-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.record-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.record-card { background: $card; border-radius: $r; padding: 28px; box-shadow: $shadow-sm; }
|
||||
.record-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.record-card__type { font-size: var(--tk-font-body-lg); font-weight: bold; color: $tx; }
|
||||
.record-card__date { font-size: var(--tk-font-h2); color: $tx2; font-variant-numeric: tabular-nums; }
|
||||
.record-card__assessment { font-size: var(--tk-font-h2); color: $tx; display: block; margin-bottom: 4px; }
|
||||
.record-card__source { font-size: var(--tk-font-body); color: $tx3; display: block; margin-bottom: 4px; }
|
||||
.record-card__notes { font-size: var(--tk-font-body); color: $tx2; display: block; margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<view :class="['medication-page', elderClass]">
|
||||
<text class="page-title">用药提醒</text>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
|
||||
<template v-else>
|
||||
<view class="reminder-list">
|
||||
<view
|
||||
v-for="r in reminders" :key="r.id"
|
||||
:class="['reminder-card', !r.is_active ? 'disabled' : '']"
|
||||
>
|
||||
<view class="reminder-avatar">
|
||||
<text class="reminder-avatar-text">{{ nameInitial(r.medication_name) }}</text>
|
||||
</view>
|
||||
<view class="reminder-info">
|
||||
<text class="reminder-name">{{ r.medication_name }}</text>
|
||||
<text class="reminder-dosage">
|
||||
{{ r.dosage || '-' }} | {{ r.reminder_times?.join(', ') || '-' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="reminder-actions">
|
||||
<view
|
||||
:class="['toggle', r.is_active ? 'on' : 'off']"
|
||||
@tap="handleToggle(r)"
|
||||
>
|
||||
<view class="toggle-dot" />
|
||||
</view>
|
||||
<text class="delete-btn" @tap="handleDelete(r)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState v-if="reminders.length === 0" text="暂无用药提醒" />
|
||||
|
||||
<view v-if="showForm" class="form-card">
|
||||
<text class="form-card-title">添加提醒</text>
|
||||
<view class="form-item">
|
||||
<text class="form-label">药品名称</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="请输入药品名称"
|
||||
placeholder-class="form-placeholder"
|
||||
:value="formName"
|
||||
@input="formName = ($event as any).detail.value"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">剂量</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如: 1片、10ml"
|
||||
placeholder-class="form-placeholder"
|
||||
:value="formDosage"
|
||||
@input="formDosage = ($event as any).detail.value"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">提醒时间</text>
|
||||
<picker mode="time" :value="formTime" @change="formTime = ($event as any).detail.value">
|
||||
<view class="time-picker-wrap">
|
||||
<text class="time-value">{{ formTime }}</text>
|
||||
<text class="time-modify">修改</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-actions">
|
||||
<view class="form-cancel" @tap="showForm = false">
|
||||
<text class="form-cancel-text">取消</text>
|
||||
</view>
|
||||
<view class="form-confirm" @tap="handleAdd">
|
||||
<text class="form-confirm-text">确认</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!showForm" class="add-btn" @tap="showForm = true">
|
||||
<text class="add-text">添加提醒</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import {
|
||||
listReminders,
|
||||
createReminder,
|
||||
updateReminder,
|
||||
deleteReminder,
|
||||
type MedicationReminder,
|
||||
} from '@/services/medication-reminder'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const reminders = ref<MedicationReminder[]>([])
|
||||
const loading = ref(true)
|
||||
const showForm = ref(false)
|
||||
const formName = ref('')
|
||||
const formDosage = ref('')
|
||||
const formTime = ref('08:00')
|
||||
|
||||
const fetchReminders = async () => {
|
||||
try {
|
||||
const res = await listReminders()
|
||||
reminders.value = res.data ?? []
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (r: MedicationReminder) => {
|
||||
try {
|
||||
await updateReminder(r.id, { is_active: !r.is_active, version: r.version })
|
||||
fetchReminders()
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (r: MedicationReminder) => {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个提醒吗?',
|
||||
}).then(async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await deleteReminder(r.id, r.version)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
fetchReminders()
|
||||
} catch {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!formName.value.trim()) {
|
||||
uni.showToast({ title: '请输入药品名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const patientId = authStore.currentPatient?.id
|
||||
if (!patientId) {
|
||||
uni.showToast({ title: '请先绑定患者档案', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await createReminder({
|
||||
patient_id: patientId,
|
||||
medication_name: formName.value.trim(),
|
||||
dosage: formDosage.value.trim() || undefined,
|
||||
reminder_times: [formTime.value],
|
||||
is_active: true,
|
||||
})
|
||||
formName.value = ''
|
||||
formDosage.value = ''
|
||||
formTime.value = '08:00'
|
||||
showForm.value = false
|
||||
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||
fetchReminders()
|
||||
} catch {
|
||||
uni.showToast({ title: '添加失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const nameInitial = (name: string) => {
|
||||
return name ? name.charAt(0) : '药'
|
||||
}
|
||||
|
||||
onMounted(() => { fetchReminders() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.medication-page { min-height: 100vh; background: $bg; padding: 32px 24px; padding-bottom: 160px; }
|
||||
.page-title { @include section-title; padding-left: 4px; }
|
||||
.reminder-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.reminder-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
&.disabled { opacity: 0.55; }
|
||||
}
|
||||
.reminder-avatar {
|
||||
@include flex-center;
|
||||
width: 72px; height: 72px;
|
||||
border-radius: $r;
|
||||
background: $acc-l;
|
||||
flex-shrink: 0;
|
||||
margin-right: 20px;
|
||||
}
|
||||
.reminder-avatar-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $acc;
|
||||
}
|
||||
.reminder-info { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.reminder-name {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.reminder-dosage {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
.reminder-actions { display: flex; align-items: center; gap: 16px; flex-shrink: 0; margin-left: 12px; }
|
||||
.toggle {
|
||||
width: 84px; height: 48px;
|
||||
border-radius: $r-pill;
|
||||
padding: 4px;
|
||||
position: relative;
|
||||
transition: background 0.3s;
|
||||
&.on { background: $pri; }
|
||||
&.off { background: $bd; }
|
||||
}
|
||||
.toggle-dot {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
background: $card;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
transition: left 0.3s;
|
||||
.toggle.on & { left: 40px; }
|
||||
.toggle.off & { left: 4px; }
|
||||
}
|
||||
.delete-btn {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $dan;
|
||||
padding: 14px 16px;
|
||||
min-height: 48px;
|
||||
@include flex-center;
|
||||
}
|
||||
.form-card { background: $card; border-radius: $r; padding: 28px; margin-top: 24px; box-shadow: $shadow-sm; }
|
||||
.form-card-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
&:last-of-type { border-bottom: none; }
|
||||
}
|
||||
.form-label { font-size: var(--tk-font-body-lg); color: $tx; flex-shrink: 0; width: 160px; }
|
||||
.form-input { flex: 1; font-size: var(--tk-font-body-lg); color: $tx; text-align: right; border: none; background: transparent; outline: none; }
|
||||
.form-placeholder { color: $tx3; }
|
||||
.time-picker-wrap { flex: 1; display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
|
||||
.time-value { @include serif-number; font-size: var(--tk-font-body-lg); color: $tx; }
|
||||
.time-modify { font-size: var(--tk-font-h2); color: $pri; }
|
||||
.form-actions { display: flex; gap: 16px; margin-top: 24px; }
|
||||
.form-cancel { flex: 1; background: $bd-l; border-radius: $r-sm; padding: 20px; text-align: center; }
|
||||
.form-cancel-text { font-size: var(--tk-font-body-lg); color: $tx2; }
|
||||
.form-confirm { flex: 1; background: $pri; border-radius: $r-sm; padding: 20px; text-align: center; }
|
||||
.form-confirm-text { font-size: var(--tk-font-body-lg); color: $white; font-weight: bold; }
|
||||
.add-btn {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
background: $pri;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
|
||||
}
|
||||
.add-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<view :class="['my-reports-page', elderClass]">
|
||||
<text class="page-title">检查报告</text>
|
||||
|
||||
<view class="report-list">
|
||||
<view
|
||||
v-for="r in reports" :key="r.id"
|
||||
class="report-card"
|
||||
@tap="goToDetail(r.id)"
|
||||
>
|
||||
<view class="report-card-top">
|
||||
<view class="report-type-row">
|
||||
<view class="report-avatar">
|
||||
<text class="report-avatar-text">{{ typeInitial(r.report_type) }}</text>
|
||||
</view>
|
||||
<text class="report-type">{{ r.report_type }}</text>
|
||||
</view>
|
||||
<text :class="['report-status', formatStatus(r)]">
|
||||
{{ formatStatus(r) === 'normal' ? '正常' : formatStatus(r) === 'abnormal' ? '异常' : '未知' }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="report-date">{{ r.report_date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState
|
||||
v-if="reports.length === 0 && !loading"
|
||||
:text="authStore.currentPatient ? '暂无报告记录' : '请先在就诊人管理中选择就诊人'"
|
||||
/>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listReports, type LabReport } from '@/services/report'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const reports = ref<LabReport[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient) {
|
||||
reports.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listReports(authStore.currentPatient.id, p)
|
||||
const list = res.data || []
|
||||
reports.value = append ? [...reports.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && reports.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
const goToDetail = (id: string) => {
|
||||
uni.navigateTo({ url: `/pages-sub/report/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
const formatStatus = (report: LabReport) => {
|
||||
const indicators = report.indicators
|
||||
if (!indicators || typeof indicators !== 'object') return 'unknown'
|
||||
const vals = Object.values(indicators) as Array<{ status?: string }>
|
||||
const hasAbnormal = vals.some((v) => v.status === 'high' || v.status === 'low')
|
||||
return hasAbnormal ? 'abnormal' : 'normal'
|
||||
}
|
||||
|
||||
const typeInitial = (type: string) => {
|
||||
return type ? type.charAt(0) : '报'
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.my-reports-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 32px 24px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
.page-title { @include section-title; padding-left: 4px; }
|
||||
.report-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.report-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
&:active { box-shadow: $shadow-md; }
|
||||
}
|
||||
.report-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.report-type-row { display: flex; align-items: center; }
|
||||
.report-avatar {
|
||||
@include flex-center;
|
||||
width: 56px; height: 56px;
|
||||
border-radius: $r-sm;
|
||||
background: $pri-l;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.report-avatar-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $pri-d;
|
||||
}
|
||||
.report-type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
.report-status {
|
||||
@include tag($bd-l, $tx2);
|
||||
&.normal { @include tag($acc-l, $acc); }
|
||||
&.abnormal { @include tag($dan-l, $dan); }
|
||||
}
|
||||
.report-date {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
padding-left: 72px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<view :class="['settings-page', elderClass]">
|
||||
<text class="page-title">设置</text>
|
||||
|
||||
<view class="settings-group">
|
||||
<view class="settings-item" @tap="handleClearCache">
|
||||
<view class="settings-icon">
|
||||
<text class="settings-icon-text">缓</text>
|
||||
</view>
|
||||
<text class="settings-label">清除缓存</text>
|
||||
<text class="settings-arrow">></text>
|
||||
</view>
|
||||
<view class="settings-item" @tap="handleAbout">
|
||||
<view class="settings-icon">
|
||||
<text class="settings-icon-text">关</text>
|
||||
</view>
|
||||
<text class="settings-label">关于我们</text>
|
||||
<text class="settings-arrow">></text>
|
||||
</view>
|
||||
<view class="settings-item" @tap="handlePrivacy">
|
||||
<view class="settings-icon">
|
||||
<text class="settings-icon-text">隐</text>
|
||||
</view>
|
||||
<text class="settings-label">隐私政策</text>
|
||||
<text class="settings-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="settings-group">
|
||||
<view class="settings-item logout-item" @tap="handleLogout">
|
||||
<text class="settings-label logout-label">退出登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { clearRequestCache } from '@/services/request'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
function handleClearCache() {
|
||||
uni.showModal({
|
||||
title: '清除缓存',
|
||||
content: '确定要清除本地缓存数据吗?不会影响账号信息。',
|
||||
success: (res: any) => {
|
||||
if (res.confirm) {
|
||||
const preservedKeys = [
|
||||
'access_token', 'refresh_token', 'user_data', 'user_roles',
|
||||
'tenant_id', 'wechat_openid', 'current_patient', 'current_patient_id',
|
||||
]
|
||||
const preservedData: Record<string, unknown> = {}
|
||||
for (const key of preservedKeys) {
|
||||
const val = uni.getStorageSync(key)
|
||||
if (val) preservedData[key] = val
|
||||
}
|
||||
|
||||
try { uni.clearStorageSync() } catch { /* ignore */ }
|
||||
|
||||
for (const [key, val] of Object.entries(preservedData)) {
|
||||
uni.setStorageSync(key, val)
|
||||
}
|
||||
|
||||
clearRequestCache()
|
||||
uni.showToast({ title: '缓存已清除', icon: 'success' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleAbout() {
|
||||
uni.showModal({
|
||||
title: '关于我们',
|
||||
content: 'HMS 健康管理平台 v1.0.0\n为您的健康保驾护航',
|
||||
showCancel: false,
|
||||
})
|
||||
}
|
||||
|
||||
function handlePrivacy() {
|
||||
uni.navigateTo({ url: '/pages/legal/privacy-policy' })
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res: any) => {
|
||||
if (res.confirm) {
|
||||
authStore.logout()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include section-title;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28px 24px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.logout-item {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
@include flex-center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r-sm;
|
||||
background: $pri-l;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-icon-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: bold;
|
||||
color: $pri-d;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.logout-label {
|
||||
color: $dan;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.settings-arrow {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-else-if="!report" class="empty-wrap"><text class="empty-text">报告不存在</text></view>
|
||||
<template v-else>
|
||||
<view class="detail-card">
|
||||
<text class="detail-title">{{ report.report_type }}</text>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">报告日期</text>
|
||||
<text class="detail-value">{{ report.report_date }}</text>
|
||||
</view>
|
||||
<view v-if="report.doctor_interpretation" class="detail-row">
|
||||
<text class="detail-label">医生解读</text>
|
||||
<text class="detail-value">{{ report.doctor_interpretation }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="indicators-card">
|
||||
<text class="section-title">检查指标</text>
|
||||
<view v-for="item in indicators" :key="item.name" class="indicator-item">
|
||||
<view class="indicator-left">
|
||||
<text class="indicator-name">{{ item.name }}</text>
|
||||
<text class="indicator-value">{{ item.value }}{{ item.unit ? ` ${item.unit}` : '' }}</text>
|
||||
</view>
|
||||
<view class="indicator-right">
|
||||
<text v-if="item.reference_min != null && item.reference_max != null" class="indicator-ref">
|
||||
{{ item.reference_min }}~{{ item.reference_max }}
|
||||
</text>
|
||||
<text :class="['indicator-status', getStatusInfo(item.status).className]">
|
||||
{{ getStatusInfo(item.status).text }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getReportDetail, type LabReport } from '@/services/report'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
interface IndicatorItem { name: string; value: number; unit?: string; reference_min?: number; reference_max?: number; status?: string }
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const report = ref<LabReport | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const indicators = computed<IndicatorItem[]>(() => {
|
||||
if (!report.value?.indicators || typeof report.value.indicators !== 'object') return []
|
||||
return Object.entries(report.value.indicators).map(([name, val]) => ({ name, value: val.value, unit: val.unit, reference_min: val.reference_min, reference_max: val.reference_max, status: val.status }))
|
||||
})
|
||||
|
||||
const getStatusInfo = (status?: string) => {
|
||||
if (status === 'high') return { text: '偏高', className: 'high' }
|
||||
if (status === 'low') return { text: '偏低', className: 'low' }
|
||||
return { text: '正常', className: 'normal' }
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
const id = query?.id || ''
|
||||
const patientId = uni.getStorageSync('current_patient_id') || ''
|
||||
if (!id || !patientId) { loading.value = false; return }
|
||||
getReportDetail(patientId, id).then(data => { report.value = data }).catch(() => uni.showToast({ title: '加载失败', icon: 'none' })).finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.empty-wrap { @include flex-center; padding: 120px 0; }
|
||||
.empty-text { font-size: var(--tk-font-body); color: $tx3; }
|
||||
.detail-card { @include card; margin-bottom: 16px; }
|
||||
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-bottom: 12px; }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.detail-value { font-size: var(--tk-font-body); color: $tx; flex: 1; text-align: right; }
|
||||
.indicators-card { @include card; }
|
||||
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.indicator-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
|
||||
.indicator-item:last-child { border-bottom: none; }
|
||||
.indicator-left { flex: 1; }
|
||||
.indicator-name { font-size: var(--tk-font-cap); color: $tx2; display: block; }
|
||||
.indicator-value { font-size: var(--tk-font-body); color: $tx; display: block; margin-top: 2px; }
|
||||
.indicator-right { text-align: right; flex-shrink: 0; }
|
||||
.indicator-ref { font-size: var(--tk-font-micro); color: $tx3; display: block; }
|
||||
.indicator-status { font-size: var(--tk-font-cap); display: block; margin-top: 2px; }
|
||||
.indicator-status.high { color: $wrn; }
|
||||
.indicator-status.low { color: $info; }
|
||||
.indicator-status.normal { color: $acc; }
|
||||
</style>
|
||||
150
apps/miniprogram-uniapp/src/pages.json
Normal file
150
apps/miniprogram-uniapp/src/pages.json
Normal file
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"pages": [
|
||||
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "健康管理" } },
|
||||
{ "path": "pages/login/index", "style": { "navigationBarTitleText": "登录" } },
|
||||
{ "path": "pages/health/index", "style": { "navigationBarTitleText": "健康数据" } },
|
||||
{ "path": "pages/messages/index", "style": { "navigationBarTitleText": "消息" } },
|
||||
{ "path": "pages/profile/index", "style": { "navigationBarTitleText": "我的" } },
|
||||
{ "path": "pages/legal/user-agreement", "style": { "navigationBarTitleText": "用户协议" } },
|
||||
{ "path": "pages/legal/privacy-policy", "style": { "navigationBarTitleText": "隐私政策" } }
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "pages-sub/consultation",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "咨询列表" } },
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "咨询详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/mall",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "积分商城" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/appointment",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "预约列表" } },
|
||||
{ "path": "create/index", "style": { "navigationBarTitleText": "创建预约" } },
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "预约详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/pkg-health",
|
||||
"pages": [
|
||||
{ "path": "trend/index", "style": { "navigationBarTitleText": "健康趋势" } },
|
||||
{ "path": "input/index", "style": { "navigationBarTitleText": "健康录入" } },
|
||||
{ "path": "daily-monitoring/index", "style": { "navigationBarTitleText": "每日监测" } },
|
||||
{ "path": "alerts/index", "style": { "navigationBarTitleText": "健康告警" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/article",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "健康文章" } },
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "文章详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/doctor",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "医生工作台" } },
|
||||
{ "path": "patients/index", "style": { "navigationBarTitleText": "患者列表" } },
|
||||
{ "path": "patients/detail/index", "style": { "navigationBarTitleText": "患者详情" } },
|
||||
{ "path": "consultation/index", "style": { "navigationBarTitleText": "咨询管理" } },
|
||||
{ "path": "consultation/detail/index", "style": { "navigationBarTitleText": "咨询详情" } },
|
||||
{ "path": "followup/index", "style": { "navigationBarTitleText": "随访列表" } },
|
||||
{ "path": "followup/detail/index", "style": { "navigationBarTitleText": "随访详情" } },
|
||||
{ "path": "report/index", "style": { "navigationBarTitleText": "报告列表" } },
|
||||
{ "path": "report/detail/index", "style": { "navigationBarTitleText": "报告详情" } },
|
||||
{ "path": "alerts/index", "style": { "navigationBarTitleText": "告警列表" } },
|
||||
{ "path": "alerts/detail/index", "style": { "navigationBarTitleText": "告警详情" } },
|
||||
{ "path": "action-inbox/index", "style": { "navigationBarTitleText": "待办事项" } },
|
||||
{ "path": "dialysis/index", "style": { "navigationBarTitleText": "透析列表" } },
|
||||
{ "path": "dialysis/detail/index", "style": { "navigationBarTitleText": "透析详情" } },
|
||||
{ "path": "dialysis/create/index", "style": { "navigationBarTitleText": "创建透析" } },
|
||||
{ "path": "prescription/index", "style": { "navigationBarTitleText": "处方列表" } },
|
||||
{ "path": "prescription/detail/index", "style": { "navigationBarTitleText": "处方详情" } },
|
||||
{ "path": "prescription/create/index", "style": { "navigationBarTitleText": "创建处方" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/pkg-mall",
|
||||
"pages": [
|
||||
{ "path": "exchange/index", "style": { "navigationBarTitleText": "积分兑换" } },
|
||||
{ "path": "orders/index", "style": { "navigationBarTitleText": "我的订单" } },
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "商品详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/pkg-profile",
|
||||
"pages": [
|
||||
{ "path": "family/index", "style": { "navigationBarTitleText": "家庭成员" } },
|
||||
{ "path": "family-add/index", "style": { "navigationBarTitleText": "添加成员" } },
|
||||
{ "path": "reports/index", "style": { "navigationBarTitleText": "报告列表" } },
|
||||
{ "path": "followups/index", "style": { "navigationBarTitleText": "随访记录" } },
|
||||
{ "path": "medication/index", "style": { "navigationBarTitleText": "用药管理" } },
|
||||
{ "path": "settings/index", "style": { "navigationBarTitleText": "设置" } },
|
||||
{ "path": "dialysis-records/index", "style": { "navigationBarTitleText": "透析记录" } },
|
||||
{ "path": "dialysis-records/detail/index", "style": { "navigationBarTitleText": "透析详情" } },
|
||||
{ "path": "dialysis-prescriptions/index", "style": { "navigationBarTitleText": "透析处方" } },
|
||||
{ "path": "dialysis-prescriptions/detail/index", "style": { "navigationBarTitleText": "处方详情" } },
|
||||
{ "path": "consents/index", "style": { "navigationBarTitleText": "知情同意书" } },
|
||||
{ "path": "health-records/index", "style": { "navigationBarTitleText": "健康档案" } },
|
||||
{ "path": "diagnoses/index", "style": { "navigationBarTitleText": "诊断记录" } },
|
||||
{ "path": "elder-mode/index", "style": { "navigationBarTitleText": "长者模式" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/ai-report",
|
||||
"pages": [
|
||||
{ "path": "list/index", "style": { "navigationBarTitleText": "AI 分析" } },
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "分析详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/report",
|
||||
"pages": [
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "报告详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/followup",
|
||||
"pages": [
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "随访详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/events",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "活动列表" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/device-sync",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "设备同步" } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
"color": "#A8A29E",
|
||||
"selectedColor": "#C4623A",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{ "pagePath": "pages/index/index", "text": "首页", "iconPath": "static/tabbar/home.png", "selectedIconPath": "static/tabbar/home-active.png" },
|
||||
{ "pagePath": "pages/health/index", "text": "健康", "iconPath": "static/tabbar/health.png", "selectedIconPath": "static/tabbar/health-active.png" },
|
||||
{ "pagePath": "pages/messages/index", "text": "消息", "iconPath": "static/tabbar/message.png", "selectedIconPath": "static/tabbar/message-active.png" },
|
||||
{ "pagePath": "pages/profile/index", "text": "我的", "iconPath": "static/tabbar/profile.png", "selectedIconPath": "static/tabbar/profile-active.png" }
|
||||
]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "健康管理",
|
||||
"backgroundColor": "#F5F0EB",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
}
|
||||
156
apps/miniprogram-uniapp/src/pages/health/index.vue
Normal file
156
apps/miniprogram-uniapp/src/pages/health/index.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="health-scroll">
|
||||
<view :class="['health-page', elderClass]">
|
||||
<GuestGuard>
|
||||
<!-- 健康数据页 -->
|
||||
<text class="page-title">健康数据</text>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
|
||||
<!-- 体征录入卡片 -->
|
||||
<view v-if="!loading" class="card input-card">
|
||||
<text class="section-title">今日体征</text>
|
||||
<view class="vital-grid">
|
||||
<view v-for="item in vitalItems" :key="item.key" class="vital-item" @tap="goInput(item.key)">
|
||||
<text class="vital-icon">{{ item.icon }}</text>
|
||||
<text class="vital-name">{{ item.name }}</text>
|
||||
<text class="vital-value">{{ latestVitals[item.key] || '--' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 健康趋势入口 -->
|
||||
<view v-if="!loading" class="card" @tap="navigateTo('/pages-sub/pkg-health/trend/index')">
|
||||
<view class="trend-entry">
|
||||
<text class="trend-label">查看健康趋势</text>
|
||||
<text class="trend-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</GuestGuard>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getTodaySummary } from '@/services/health'
|
||||
import GuestGuard from '@/components/GuestGuard.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const vitalItems = [
|
||||
{ key: 'heart_rate', name: '心率', icon: '❤️' },
|
||||
{ key: 'blood_pressure', name: '血压', icon: '🩸' },
|
||||
{ key: 'blood_sugar', name: '血糖', icon: '🍬' },
|
||||
{ key: 'temperature', name: '体温', icon: '🌡️' },
|
||||
{ key: 'weight', name: '体重', icon: '⚖️' },
|
||||
{ key: 'oxygen', name: '血氧', icon: '🫁' },
|
||||
]
|
||||
|
||||
const latestVitals = reactive<Record<string, string>>({})
|
||||
|
||||
function navigateTo(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function goInput(key: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/pkg-health/input/index?type=${key}` })
|
||||
}
|
||||
|
||||
async function fetchLatestVitals() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getTodaySummary()
|
||||
if (data) {
|
||||
Object.assign(latestVitals, data)
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchLatestVitals)
|
||||
onShow(() => { authStore.restore() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.health-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.health-page {
|
||||
padding: 28px 24px 120px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.input-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vital-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vital-item {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
background: $pri-surface;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.vital-icon {
|
||||
font-size: var(--tk-font-h2);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vital-name {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
@include serif-number;
|
||||
}
|
||||
|
||||
.trend-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: $touch-min;
|
||||
}
|
||||
|
||||
.trend-label {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.trend-arrow {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx3;
|
||||
}
|
||||
</style>
|
||||
317
apps/miniprogram-uniapp/src/pages/index/index.vue
Normal file
317
apps/miniprogram-uniapp/src/pages/index/index.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="home-scroll" @scrolltolower="onLoadMore">
|
||||
<view :class="['home-page', elderClass]">
|
||||
<!-- 已登录模式 -->
|
||||
<template v-if="authStore.user">
|
||||
<!-- 用户问候 -->
|
||||
<view class="greeting-section">
|
||||
<text class="greeting-text">{{ greeting }},{{ authStore.user.display_name || authStore.user.username }}</text>
|
||||
<text class="greeting-date">{{ today }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 快捷功能 -->
|
||||
<view class="quick-actions">
|
||||
<view class="action-item" @tap="navigateTo('/pages-sub/appointment/create/index')">
|
||||
<text class="action-icon">📅</text>
|
||||
<text class="action-label">预约</text>
|
||||
</view>
|
||||
<view class="action-item" @tap="navigateTo('/pages-sub/consultation/index')">
|
||||
<text class="action-icon">💬</text>
|
||||
<text class="action-label">咨询</text>
|
||||
</view>
|
||||
<view class="action-item" @tap="navigateTo('/pages-sub/pkg-health/trend/index')">
|
||||
<text class="action-icon">📊</text>
|
||||
<text class="action-label">趋势</text>
|
||||
</view>
|
||||
<view class="action-item" @tap="navigateTo('/pages-sub/article/index')">
|
||||
<text class="action-icon">📰</text>
|
||||
<text class="action-label">文章</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 健康概览卡片 -->
|
||||
<view class="health-summary card">
|
||||
<text class="section-title">健康概览</text>
|
||||
<view v-if="healthSummary" class="summary-grid">
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ healthSummary.heart_rate || '--' }}</text>
|
||||
<text class="summary-label">心率</text>
|
||||
</view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ healthSummary.blood_pressure || '--' }}</text>
|
||||
<text class="summary-label">血压</text>
|
||||
</view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ healthSummary.blood_sugar || '--' }}</text>
|
||||
<text class="summary-label">血糖</text>
|
||||
</view>
|
||||
</view>
|
||||
<Loading v-else-if="summaryLoading" text="加载中..." />
|
||||
<EmptyState v-else icon="📋" title="暂无健康数据" action-text="录入数据" @action="switchTab('/pages/health/index')" />
|
||||
</view>
|
||||
|
||||
<!-- 最近文章 -->
|
||||
<view class="articles-section card">
|
||||
<text class="section-title">健康文章</text>
|
||||
<Loading v-if="articlesLoading" text="加载中..." />
|
||||
<template v-else-if="articles.length > 0">
|
||||
<view v-for="article in articles" :key="article.id" class="article-entry" @tap="goArticle(article.id)">
|
||||
<text class="article-title">{{ article.title }}</text>
|
||||
<text class="article-date">{{ formatDate(article.created_at) }}</text>
|
||||
</view>
|
||||
</template>
|
||||
<EmptyState v-else icon="📰" title="暂无文章" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 访客模式 -->
|
||||
<template v-else>
|
||||
<view class="guest-page">
|
||||
<text class="guest-hero-icon">🏥</text>
|
||||
<text class="guest-hero-title">健康管理</text>
|
||||
<text class="guest-hero-desc">您的专属健康管家</text>
|
||||
<view class="guest-login-btn" @tap="navigateTo('/pages/login/index')">
|
||||
登录 / 注册
|
||||
</view>
|
||||
<text class="guest-browse">浏览健康资讯</text>
|
||||
|
||||
<!-- 访客也展示文章 -->
|
||||
<view class="articles-section card">
|
||||
<text class="section-title">健康文章</text>
|
||||
<Loading v-if="articlesLoading" text="加载中..." />
|
||||
<template v-else-if="articles.length > 0">
|
||||
<view v-for="article in articles" :key="article.id" class="article-entry" @tap="goArticle(article.id)">
|
||||
<text class="article-title">{{ article.title }}</text>
|
||||
<text class="article-date">{{ formatDate(article.created_at) }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getArticles } from '@/services/article'
|
||||
import { getTodaySummary } from '@/services/health'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const articlesLoading = ref(false)
|
||||
const summaryLoading = ref(false)
|
||||
const articles = ref<any[]>([])
|
||||
const healthSummary = ref<any>(null)
|
||||
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了'
|
||||
if (h < 12) return '早上好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const today = computed(() => formatDate(new Date(), 'YYYY年MM月DD日'))
|
||||
|
||||
function navigateTo(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function switchTab(url: string) {
|
||||
uni.switchTab({ url })
|
||||
}
|
||||
|
||||
function goArticle(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/article/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
// 分页加载预留
|
||||
}
|
||||
|
||||
async function fetchArticles() {
|
||||
articlesLoading.value = true
|
||||
try {
|
||||
const res = await getArticles({ limit: 5 })
|
||||
articles.value = res || []
|
||||
} catch {
|
||||
articles.value = []
|
||||
}
|
||||
articlesLoading.value = false
|
||||
}
|
||||
|
||||
async function fetchHealthSummary() {
|
||||
if (!authStore.user) return
|
||||
summaryLoading.value = true
|
||||
try {
|
||||
healthSummary.value = await getTodaySummary()
|
||||
} catch {
|
||||
healthSummary.value = null
|
||||
}
|
||||
summaryLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchArticles()
|
||||
fetchHealthSummary()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
authStore.restore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.home-page {
|
||||
padding: 28px 24px 120px;
|
||||
}
|
||||
|
||||
.greeting-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.greeting-text {
|
||||
display: block;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.greeting-date {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
padding: 20px 0;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: var(--tk-font-hero);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
@include serif-number;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.article-entry {
|
||||
padding: 16px 0;
|
||||
min-height: $touch-min;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.article-title {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// 访客模式
|
||||
.guest-page {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.guest-hero-icon {
|
||||
font-size: var(--tk-font-display);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.guest-hero-title {
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guest-hero-desc {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.guest-login-btn {
|
||||
@include btn-primary;
|
||||
width: 280px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.guest-browse {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $pri;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user